From 28d4242df0307477b581a96cfaa7f6478f11a9fa Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 19:14:13 -0600 Subject: [PATCH] Route Daytona sandboxes through the sandbox driver The `daytona` provider kind now maps onto the sandbox-driver Daytona provider instead of fabro's own SDK client. Fabro keeps what is its own: the HMAC-named snapshot built from the environment's image or Dockerfile, the explicit 120 minute auto-stop default, the managed labels that gate destructive operations, the clone decision and layout, and push credentials. The driver creates the sandbox, clones natively, and serves exec, files, search, terminal, SSH, preview, and VNC through its facets. - `daytona.rs` builds the driver `SandboxSpec` (snapshot source, `/home/daytona/workspace`, labels, timers, network policy, run name), ensures the snapshot through the driver `SnapshotProvider`, attaches by persisted id with fabro's label guard, and probes credentials through the provider health check under fabro's 20 second budget. - `DriverSandbox` gains a create plan that settles the spec right before the provider call, records the snapshot a sandbox came from, and reports the provider console URL on `Ready`. - Terminals use the driver `Pty` facet; the server's SSH, preview, and VNC endpoints use the `SshAccess`, `PreviewUrls`, and `Vnc` facets through the driver-typed reconnect. The preview endpoint now answers for every provider with a preview facet, so the local sandbox returns its loopback URL. - Daytona credentials travel as `DaytonaCredentials` built from the vault key plus configured URL and organization; nothing reads the process environment implicitly. The inventory registry uses the shared `DriverInventoryProvider`. - The SDK-based `daytona/mod.rs`, `provider/daytona.rs`, the Daytona terminal, the `daytona` cargo feature, and the direct daytona-sdk, git2 (in fabro-sandbox), tungstenite, and rustls dependencies are gone. The live Daytona tests run against the driver-backed sandbox. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 12 - Cargo.toml | 2 - docs/public/reference/sdk.mdx | 3 +- lib/apps/fabro-cli/Cargo.toml | 5 +- lib/apps/fabro-cli/src/shared/repo.rs | 61 +- .../fabro-cli/tests/it/cmd/sandbox_preview.rs | 11 +- lib/apps/fabro-server/Cargo.toml | 2 +- lib/apps/fabro-server/src/diagnostics.rs | 2 +- lib/apps/fabro-server/src/install.rs | 17 +- lib/apps/fabro-server/src/run_files.rs | 7 +- lib/apps/fabro-server/src/run_manifest.rs | 14 +- lib/apps/fabro-server/src/server.rs | 84 +- .../src/server/handler/sandbox.rs | 391 +- .../src/server/handler/sessions.rs | 7 +- lib/apps/fabro-server/src/server/tests.rs | 5 +- lib/apps/fabro-server/tests/it/api/install.rs | 5 +- lib/components/fabro-sandbox/Cargo.toml | 15 - lib/components/fabro-sandbox/src/daytona.rs | 871 +++ .../fabro-sandbox/src/daytona/mod.rs | 5095 ----------------- lib/components/fabro-sandbox/src/details.rs | 398 +- lib/components/fabro-sandbox/src/docker.rs | 51 +- lib/components/fabro-sandbox/src/driver.rs | 17 + .../fabro-sandbox/src/driver_sandbox.rs | 117 +- .../fabro-sandbox/src/from_environment.rs | 11 +- lib/components/fabro-sandbox/src/lib.rs | 9 +- .../fabro-sandbox/src/managed_labels.rs | 50 +- lib/components/fabro-sandbox/src/provider.rs | 2 - .../fabro-sandbox/src/provider/daytona.rs | 132 - .../fabro-sandbox/src/provider/driver.rs | 11 +- lib/components/fabro-sandbox/src/reconnect.rs | 72 +- lib/components/fabro-sandbox/src/sandbox.rs | 271 - .../fabro-sandbox/src/sandbox_spec.rs | 33 +- lib/components/fabro-sandbox/src/terminal.rs | 606 +- .../tests/daytona_streaming_live.rs | 63 +- lib/components/fabro-workflow/Cargo.toml | 4 +- .../fabro-workflow/src/operations/start.rs | 10 +- .../fabro-workflow/src/pipeline/initialize.rs | 17 +- .../tests/it/daytona_integration.rs | 137 +- 38 files changed, 1617 insertions(+), 7003 deletions(-) create mode 100644 lib/components/fabro-sandbox/src/daytona.rs delete mode 100644 lib/components/fabro-sandbox/src/daytona/mod.rs delete mode 100644 lib/components/fabro-sandbox/src/provider/daytona.rs diff --git a/Cargo.lock b/Cargo.lock index eb5a02dbb..d2922c3e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2426,7 +2426,6 @@ dependencies = [ "cli-table", "console 0.15.11", "core-foundation 0.9.4", - "daytona-sdk", "dialoguer", "dirs", "dotenvy", @@ -2985,11 +2984,7 @@ dependencies = [ "async-trait", "base64", "chrono", - "daytona-api-client", - "daytona-sdk", - "fabro-config", "fabro-github", - "fabro-http", "fabro-proc", "fabro-redact", "fabro-static", @@ -2997,15 +2992,9 @@ dependencies = [ "fabro-types", "fabro-util", "futures", - "futures-util", - "git2", "hex", "hmac 0.12.1", - "httpmock", - "rand 0.9.4", "reqwest 0.13.2", - "reqwest-middleware", - "rustls", "sandbox-driver", "sandbox-driver-daytona", "sandbox-driver-daytona-config", @@ -3020,7 +3009,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", "tokio-util", "toml 0.8.23", "tracing", diff --git a/Cargo.toml b/Cargo.toml index af72dc197..430e334c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,8 +95,6 @@ twin-openai = { path = "test/twin/openai" } twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "5e86990418e21f4288ce537c9852dfdf78768abc", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "5e86990418e21f4288ce537c9852dfdf78768abc", package = "daytona-api-client" } # sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and # Daytona providers link in-process; third-party providers run as stdio # plugins through sandbox-driver-protocol. Pinned by rev like the Daytona SDK. diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 614e2e063..a2c040300 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -161,8 +161,7 @@ pub trait Sandbox: Send + Sync { |---|---| | `local_sandbox(...)` | Executes directly on the local filesystem through the sandbox driver Host provider. | | `docker_sandbox(...)` | Runs inside a Docker container through the sandbox driver. | - -The `DaytonaSandbox` implementation (feature-gated: `daytona`) runs inside a Daytona cloud sandbox. +| `daytona_sandbox(...)` | Runs inside a Daytona cloud sandbox through the sandbox driver. | ### Provider profiles diff --git a/lib/apps/fabro-cli/Cargo.toml b/lib/apps/fabro-cli/Cargo.toml index fa16a7375..71002b63c 100644 --- a/lib/apps/fabro-cli/Cargo.toml +++ b/lib/apps/fabro-cli/Cargo.toml @@ -34,7 +34,7 @@ fabro-mcp = { path = "../../components/fabro-mcp" } fabro-mcp-server = { path = "../fabro-mcp-server" } fabro-manifest = { path = "../../components/fabro-manifest" } fabro-proc = { path = "../../foundation/fabro-proc" } -fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona"] } +fabro-sandbox = { path = "../../components/fabro-sandbox" } fabro-checkpoint = { path = "../../components/fabro-checkpoint" } fabro-graphviz = { path = "../../components/fabro-graphviz" } fabro-validate = { path = "../../components/fabro-validate" } @@ -57,7 +57,6 @@ clap_complete.workspace = true cli-table.workspace = true console.workspace = true indicatif.workspace = true -daytona-sdk.workspace = true anyhow.workspace = true miette.workspace = true dotenvy.workspace = true @@ -104,7 +103,7 @@ nix = { version = "0.30", features = ["fs"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = { version = "0.9", optional = true } -# Vendor openssl only for musl targets. daytona-sdk transitively pulls +# Vendor openssl only for musl targets. Transitive dependencies pull # native-tls via reqwest, which needs libssl. On glibc runners the system # libssl is used; on musl runners we compile openssl from source. [target.'cfg(target_env = "musl")'.dependencies] diff --git a/lib/apps/fabro-cli/src/shared/repo.rs b/lib/apps/fabro-cli/src/shared/repo.rs index 44dd0ad99..9f89eeccf 100644 --- a/lib/apps/fabro-cli/src/shared/repo.rs +++ b/lib/apps/fabro-cli/src/shared/repo.rs @@ -1,5 +1,29 @@ -use anyhow::{Result, bail}; -use fabro_sandbox::daytona::detect_repo_info; +use std::path::Path; + +use anyhow::{Context as _, Result, bail}; + +/// Detect the git remote URL and current branch from a local repository. +/// +/// Uses `git2` to discover the repo at `path`, reads the `origin` remote URL +/// and the HEAD branch name. +pub(crate) fn detect_repo_info(path: &Path) -> Result<(String, Option)> { + let repo = git2::Repository::discover(path) + .with_context(|| format!("Failed to discover git repo at {}", path.display()))?; + + let url = repo + .find_remote("origin") + .context("Failed to find 'origin' remote")? + .url() + .context("origin remote URL is not valid UTF-8")? + .to_string(); + + let branch = repo + .head() + .ok() + .and_then(|head| head.shorthand().map(String::from)); + + Ok((url, branch)) +} pub(crate) fn ensure_matching_repo_origin( expected_origin_url: Option<&str>, @@ -28,10 +52,41 @@ pub(crate) fn ensure_matching_repo_origin( #[cfg(test)] mod tests { - use super::ensure_matching_repo_origin; + use super::{detect_repo_info, ensure_matching_repo_origin}; #[test] fn missing_expected_origin_skips_guard() { ensure_matching_repo_origin(None, "fork").unwrap(); } + + #[test] + fn detect_git_remote_from_repo() { + let dir = tempfile::tempdir().unwrap(); + let repo = git2::Repository::init(dir.path()).unwrap(); + repo.remote("origin", "https://github.com/org/repo.git") + .unwrap(); + + let (url, _branch) = detect_repo_info(dir.path()).unwrap(); + assert_eq!(url, "https://github.com/org/repo.git"); + } + + #[test] + fn detect_repo_info_returns_worktree_branch() { + let dir = tempfile::tempdir().unwrap(); + let repo = git2::Repository::init(dir.path()).unwrap(); + let sig = git2::Signature::now("Test", "test@test.com").unwrap(); + let tree_id = repo.index().unwrap().write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let commit = repo + .commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .unwrap(); + repo.remote("origin", "https://github.com/org/repo.git") + .unwrap(); + let commit_obj = repo.find_commit(commit).unwrap(); + repo.branch("fabro/run/ABC", &commit_obj, false).unwrap(); + repo.set_head("refs/heads/fabro/run/ABC").unwrap(); + + let (_, branch) = detect_repo_info(dir.path()).unwrap(); + assert_eq!(branch, Some("fabro/run/ABC".into())); + } } diff --git a/lib/apps/fabro-cli/tests/it/cmd/sandbox_preview.rs b/lib/apps/fabro-cli/tests/it/cmd/sandbox_preview.rs index b3ffdd7c8..b3641c6cf 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/sandbox_preview.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/sandbox_preview.rs @@ -50,19 +50,22 @@ fn help() { "); } +/// Preview URLs come from whichever provider facet the run's sandbox +/// exposes. The local provider runs on the server host, so its preview is +/// the loopback address for the port. #[test] -fn sandbox_preview_rejects_non_daytona_run() { +fn sandbox_preview_uses_the_local_provider_loopback_url() { let context = test_context!(); let setup = setup_local_sandbox_run(&context); let mut cmd = context.preview(); cmd.args([&setup.run.run_id, "3000"]); fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 + success: true + exit_code: 0 ----- stdout ----- + http://127.0.0.1:3000 ----- stderr ----- - × Sandbox provider does not support this capability. "); } diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index d21d7c515..14f2844c1 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -34,7 +34,7 @@ fabro-slack = { path = "../../components/fabro-slack" } fabro-workflow = { path = "../../components/fabro-workflow" } fabro-workflow-version = { path = "../../components/fabro-workflow-version" } fabro-validate = { path = "../../components/fabro-validate" } -fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona"] } +fabro-sandbox = { path = "../../components/fabro-sandbox" } fabro-github = { path = "../../components/fabro-github" } fabro-agent = { path = "../../components/fabro-agent" } fabro-llm = { path = "../../components/fabro-llm" } diff --git a/lib/apps/fabro-server/src/diagnostics.rs b/lib/apps/fabro-server/src/diagnostics.rs index 1e1add25e..4484f9f74 100644 --- a/lib/apps/fabro-server/src/diagnostics.rs +++ b/lib/apps/fabro-server/src/diagnostics.rs @@ -674,7 +674,7 @@ fn cloud_sandbox_probe_check(probe: anyhow::Result) -> Ok(check) if check.ok() => CheckResult { name: "Cloud Sandbox".to_string(), status: CheckStatus::Pass, - summary: format!("Daytona configured ({})", check.key_name), + summary: "Daytona configured".to_string(), details: Vec::new(), remediation: None, }, diff --git a/lib/apps/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs index 0907faa6b..460f401be 100644 --- a/lib/apps/fabro-server/src/install.rs +++ b/lib/apps/fabro-server/src/install.rs @@ -29,6 +29,7 @@ use fabro_llm::generate::{GenerateParams, generate}; use fabro_model::catalog::CatalogProvider; use fabro_model::{Catalog, ProviderId}; use fabro_sandbox::daytona; +use fabro_sandbox::driver::DaytonaCredentials; use fabro_static::EnvVars; use fabro_store::ArtifactStore; use fabro_types::settings::server::ObjectStoreSettings; @@ -1008,14 +1009,14 @@ async fn check_install_daytona_api_key( state: &InstallAppState, api_key: String, ) -> anyhow::Result { - let base_url = state - .upstreams - .daytona_api_base_url - .as_deref() - .unwrap_or(daytona::DEFAULT_DAYTONA_API_URL); - let organization_id = state.upstreams.daytona_organization_id.as_deref(); - let http_client = fabro_http::http_client().context("failed to build HTTP client")?; - daytona::check_daytona_api_key_with(base_url, organization_id, api_key, http_client).await + let credentials = DaytonaCredentials { + api_key, + api_url: state.upstreams.daytona_api_base_url.clone(), + organization_id: state.upstreams.daytona_organization_id.clone(), + target: None, + http_client: Some(fabro_http::http_client().context("failed to build HTTP client")?), + }; + daytona::check_daytona_api_key(&credentials, daytona::DAYTONA_CREDENTIAL_PROBE_TIMEOUT).await } async fn put_install_sandbox( diff --git a/lib/apps/fabro-server/src/run_files.rs b/lib/apps/fabro-server/src/run_files.rs index ff7b9c201..8c17fb745 100644 --- a/lib/apps/fabro-server/src/run_files.rs +++ b/lib/apps/fabro-server/src/run_files.rs @@ -37,7 +37,6 @@ use fabro_api::types::{ }; use fabro_sandbox::reconnect::reconnect_for_run; use fabro_sandbox::shell_quote; -use fabro_static::EnvVars; use fabro_types::RunId; use fabro_workflow::sandbox_git::{ DiffError, DiffNumstat, RawDiffEntry, SubmoduleChange, SymlinkChange, list_changed_files_raw, @@ -1209,11 +1208,11 @@ async fn reconnect_run_sandbox( .and_then(fabro_types::RunSandbox::instance) .cloned() .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Run sandbox was not created."))?; - let daytona_api_key = state - .vault_secret(EnvVars::DAYTONA_API_KEY) + let daytona = state + .vault_daytona_credentials() .await .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; - let sandbox = reconnect_for_run(&record, daytona_api_key, Some(*run_id)) + let sandbox = reconnect_for_run(&record, daytona, Some(*run_id)) .await .map_err(|err| ApiError::new(StatusCode::CONFLICT, err.to_string()))?; sandbox diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 7b9c722ad..9c63e780f 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -23,7 +23,7 @@ use fabro_sandbox::from_environment::{ local_working_directory_from_environment, }; use fabro_sandbox::redact::redact_auth_url; -use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxSpec}; +use fabro_sandbox::{DaytonaCredentials, DockerSandboxOptions, Sandbox, SandboxSpec}; use fabro_static::EnvVars; use fabro_types::settings::ModelRef; use fabro_types::settings::cli::OutputVerbosity; @@ -484,14 +484,14 @@ async fn build_preflight_report( None }; - let daytona_api_key = state.vault_secret(EnvVars::DAYTONA_API_KEY).await?; + let daytona = state.vault_daytona_credentials().await?; let sandbox_ok = run_sandbox_check( &mut checks, &sandbox_provider, prepared, &resolved_run, github_app.clone(), - daytona_api_key, + daytona, ) .await; let repository_access_ok = run_repository_access_check( @@ -919,7 +919,7 @@ fn preflight_sandbox_spec( prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, - daytona_api_key: Option, + daytona: Option, ) -> std::result::Result { let clone_origin_url = prepared .git @@ -959,7 +959,7 @@ fn preflight_sandbox_spec( clone_branch, clone_tag: None, clone_commit_sha: None, - api_key: daytona_api_key, + credentials: daytona, } } None => { @@ -976,14 +976,14 @@ async fn run_sandbox_check( prepared: &PreparedManifest, resolved_run: &RunNamespace, github_app: Option, - daytona_api_key: Option, + daytona: Option, ) -> bool { let spec = match preflight_sandbox_spec( sandbox_provider, prepared, resolved_run, github_app.clone(), - daytona_api_key, + daytona, ) { Ok(spec) => spec, Err(err) => { diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 6f8778461..3a88bdc8f 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -67,13 +67,12 @@ use fabro_mcp_store::McpServerStore; use fabro_model::catalog::LlmCatalogSettings; use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderId}; use fabro_redact::redact_jsonl_line; -use fabro_sandbox::daytona::{self, DaytonaSandbox}; use fabro_sandbox::details::sandbox_details; -use fabro_sandbox::driver::ProviderConnectOptions; +use fabro_sandbox::driver::{DaytonaCredentials, ProviderConnectOptions}; use fabro_sandbox::reconnect::reconnect_for_run; use fabro_sandbox::{ - DaytonaSandboxProvider, DriverInventoryProvider, LocalSandboxProvider, Sandbox, - SandboxProvider, SandboxProviderRegistry, + DriverInventoryProvider, LocalSandboxProvider, Sandbox, SandboxProvider, + SandboxProviderRegistry, daytona, }; use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient}; use fabro_slack::config::{ @@ -1472,6 +1471,32 @@ impl AppState { (self.env_lookup)(name) } + /// Daytona credentials for `api_key`: the key from the vault, the + /// control-plane URL and organization from server configuration, and + /// the server's HTTP client. The process environment is consulted only + /// through the configured lookup. + pub(crate) fn daytona_credentials(&self, api_key: String) -> DaytonaCredentials { + DaytonaCredentials { + api_key, + api_url: self + .config_env_lookup(EnvVars::DAYTONA_API_URL) + .or_else(|| self.config_env_lookup(EnvVars::DAYTONA_SERVER_URL)), + organization_id: self.config_env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID), + target: None, + http_client: self.http_client().ok(), + } + } + + /// Daytona credentials from the vault, `None` when no key is stored. + pub(crate) async fn vault_daytona_credentials( + &self, + ) -> Result, SecretStoreError> { + Ok(self + .vault_secret(EnvVars::DAYTONA_API_KEY) + .await? + .map(|api_key| self.daytona_credentials(api_key))) + } + pub(crate) async fn check_daytona_api_key( &self, api_key: String, @@ -1485,21 +1510,7 @@ impl AppState { api_key: String, probe_timeout: Duration, ) -> anyhow::Result { - let base_url = self - .config_env_lookup(EnvVars::DAYTONA_API_URL) - .or_else(|| self.config_env_lookup(EnvVars::DAYTONA_SERVER_URL)) - .unwrap_or_else(|| daytona::DEFAULT_DAYTONA_API_URL.to_string()); - let org_id = self.config_env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID); - - let http_client = fabro_http::http_client().context("failed to build HTTP client")?; - daytona::check_daytona_api_key_with_timeout( - &base_url, - org_id.as_deref(), - api_key, - http_client, - probe_timeout, - ) - .await + daytona::check_daytona_api_key(&self.daytona_credentials(api_key), probe_timeout).await } /// Borrow the persistent store so sibling modules can open run readers @@ -2351,16 +2362,25 @@ fn build_sandbox_provider_registry( } } - if provider_settings.is_enabled(&SandboxProviderKind::DAYTONA) && daytona_api_key.is_some() { - let api_url = env_lookup(EnvVars::DAYTONA_API_URL) - .or_else(|| env_lookup(EnvVars::DAYTONA_SERVER_URL)); - let organization_id = env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID); - providers.push(Arc::new(DaytonaSandboxProvider::new( - daytona_api_key, - api_url, - organization_id, - http_client, - ))); + if let Some(daytona) = provider_settings.get(&SandboxProviderKind::DAYTONA) { + if let Some(api_key) = daytona_api_key.filter(|_| daytona.enabled) { + let credentials = DaytonaCredentials { + api_key, + api_url: env_lookup(EnvVars::DAYTONA_API_URL) + .or_else(|| env_lookup(EnvVars::DAYTONA_SERVER_URL)), + organization_id: env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID), + target: None, + http_client, + }; + providers.push(Arc::new(DriverInventoryProvider::lazy( + SandboxProviderKind::DAYTONA, + daytona.clone(), + ProviderConnectOptions { + host_registry_root: None, + daytona: Some(credentials), + }, + ))); + } } SandboxProviderRegistry::new(providers) @@ -2770,11 +2790,11 @@ async fn delete_run_sandbox_resource( })); } - let daytona_api_key = state - .vault_secret(EnvVars::DAYTONA_API_KEY) + let daytona = state + .vault_daytona_credentials() .await .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; - let sandbox = match reconnect_for_run(&record, daytona_api_key, Some(id)).await { + let sandbox = match reconnect_for_run(&record, daytona, Some(id)).await { Ok(sandbox) => sandbox, Err(err) if force || delete_started => { tracing::warn!( diff --git a/lib/apps/fabro-server/src/server/handler/sandbox.rs b/lib/apps/fabro-server/src/server/handler/sandbox.rs index 8a072bd54..b906c9255 100644 --- a/lib/apps/fabro-server/src/server/handler/sandbox.rs +++ b/lib/apps/fabro-server/src/server/handler/sandbox.rs @@ -2,29 +2,30 @@ use std::collections::BTreeMap; use std::net::{Ipv4Addr, Ipv6Addr}; use std::num::NonZeroU64; use std::sync::Arc; +use std::time::Duration; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; -use fabro_sandbox::{TerminalSize, open_terminal_for_run}; +use fabro_sandbox::{DriverSandbox, TerminalSize, open_terminal_for_run, reconnect_driver_for_run}; use fabro_types::{ - BundledProvider, RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, - SandboxServiceListMeta, + RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta, }; use futures_util::FutureExt; use futures_util::future::BoxFuture; use super::super::{ - ApiError, AppState, Bytes, DaytonaSandbox, EnvVars, HeaderMap, IntoResponse, Json, - NamedTempFile, Path, PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, - Router, RunId, Sandbox, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, - SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State, - StatusCode, VncPreviewResponse, collect_causes, fs, get, octet_stream_response, - parse_run_id_path, post, reconnect_for_run, reject_if_archived, render_with_causes, - sandbox_details, + ApiError, AppState, Bytes, DaytonaCredentials, HeaderMap, IntoResponse, Json, NamedTempFile, + Path, PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId, + Sandbox, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService, + SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State, StatusCode, + VncPreviewResponse, collect_causes, fs, get, octet_stream_response, parse_run_id_path, post, + reject_if_archived, render_with_causes, sandbox_details, }; const MAX_TERMINAL_CONTROL_BYTES: usize = 4096; const DEFAULT_VNC_NO_VNC_PORT: u16 = 6080; const DEFAULT_VNC_TTL_SECS: i32 = 3600; +/// Header a Daytona unsigned preview needs; surfaced as the response token. +const PREVIEW_TOKEN_HEADER: &str = "x-daytona-preview-token"; const LIST_SANDBOX_SERVICES_COMMAND: &str = r#"if command -v ss >/dev/null 2>&1; then ss -H -ltnp && exit 0 fi @@ -46,37 +47,24 @@ const VNC_VIEWER_PATH: &str = "/vnc.html"; const VNC_VIEWER_AUTOCONNECT: (&str, &str) = ("autoconnect", "true"); const VNC_VIEWER_RESIZE: (&str, &str) = ("resize", "scale"); +/// The provider-side steps behind a VNC preview, so the response shaping can +/// be tested without a sandbox. trait VncSandbox { - fn start_computer_use(&self) -> BoxFuture<'_, fabro_sandbox::Result<()>>; - fn signed_preview_url( - &self, - port: u16, - expires_in_secs: i32, - ) -> BoxFuture<'_, fabro_sandbox::Result>; + /// Starts the desktop and returns the signed viewer URL the provider + /// hands out for it. + fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result>; } -impl VncSandbox for DaytonaSandbox { - fn start_computer_use(&self) -> BoxFuture<'_, fabro_sandbox::Result<()>> { +impl VncSandbox for DriverSandbox { + fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result> { async move { - let computer_use = self.computer_use().await?; - computer_use - .start() + let vnc = self.handle()?.vnc().ok_or_else(|| { + fabro_sandbox::Error::message("Sandbox provider does not support VNC previews.") + })?; + vnc.vnc_connection() .await - .map_err(|err| fabro_sandbox::Error::context("Failed to start Computer Use", err)) - .map(|_| ()) - } - .boxed() - } - - fn signed_preview_url( - &self, - port: u16, - expires_in_secs: i32, - ) -> BoxFuture<'_, fabro_sandbox::Result> { - async move { - self.get_signed_preview_url(port, Some(expires_in_secs)) - .await - .map(|preview| preview.url) + .map(|connection| connection.url) + .map_err(|err| fabro_sandbox::Error::context("Failed to open a VNC preview", err)) } .boxed() } @@ -110,12 +98,11 @@ async fn retrieve_run_sandbox( Ok(record) => record, Err(response) => return response, }; - let daytona_api_key = match load_daytona_api_key(&state).await { + let daytona = match load_daytona_credentials(&state).await { Ok(value) => value, Err(response) => return response, }; - let daytona_organization_id = state.config_env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID); - match sandbox_details(&record, daytona_api_key, daytona_organization_id, Some(id)).await { + match sandbox_details(&record, daytona, Some(id)).await { Ok(details) => Json::(details).into_response(), Err(err) => { let detail = format!("{err:#}"); @@ -232,7 +219,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run return; } }; - let daytona_api_key = match load_daytona_api_key(&state).await { + let daytona = match load_daytona_credentials(&state).await { Ok(value) => value, Err(response) => { let _ = socket @@ -245,27 +232,19 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: Run return; } }; - let daytona_organization_id = state.config_env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID); - let session = match open_terminal_for_run( - &record, - daytona_api_key, - daytona_organization_id, - Some(id), - TerminalSize::default(), - ) - .await - { - Ok(session) => session, - Err(err) => { - let _ = socket - .send(terminal_server_text( - "error", - Some(&err.display_with_causes()), - )) - .await; - return; - } - }; + let session = + match open_terminal_for_run(&record, daytona, Some(id), TerminalSize::default()).await { + Ok(session) => session, + Err(err) => { + let _ = socket + .send(terminal_server_text( + "error", + Some(&err.display_with_causes()), + )) + .await; + return; + } + }; if socket .send(terminal_server_text("ready", None)) @@ -364,18 +343,35 @@ async fn generate_preview_url( let Ok(port) = u16::try_from(request.port) else { return ApiError::bad_request("Port must fit in a u16.").into_response(); }; - let Ok(expires_in_secs) = i32::try_from(request.expires_in_secs.get()) else { + if i32::try_from(request.expires_in_secs.get()).is_err() { return ApiError::bad_request("Preview expiry exceeds supported range.").into_response(); - }; + } - let sandbox = match reconnect_daytona_sandbox(&state, &id).await { + let record = match load_run_sandbox_instance(&state, &id).await { + Ok(record) => record, + Err(response) => return response, + }; + let sandbox = match reconnect_driver_sandbox_instance(&state, &id, &record).await { Ok(sandbox) => sandbox, Err(response) => return response, }; + let handle = match sandbox.handle() { + Ok(handle) => handle, + Err(err) => { + return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(); + } + }; + let Some(previews) = handle.preview_urls() else { + return ApiError::new( + StatusCode::CONFLICT, + "Sandbox provider does not support preview URLs.", + ) + .into_response(); + }; let response = if request.signed { - match sandbox - .get_signed_preview_url(port, Some(expires_in_secs)) + match previews + .signed_preview_url(port, Duration::from_secs(request.expires_in_secs.get())) .await { Ok(preview) => PreviewUrlResponse { @@ -383,19 +379,17 @@ async fn generate_preview_url( url: preview.url, }, Err(err) => { - return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()) - .into_response(); + return ApiError::new(StatusCode::CONFLICT, err.to_string()).into_response(); } } } else { - match sandbox.get_preview_link(port).await { + match previews.preview_url(port).await { Ok(preview) => PreviewUrlResponse { - token: Some(preview.token), + token: preview.headers.get(PREVIEW_TOKEN_HEADER).cloned(), url: preview.url, }, Err(err) => { - return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()) - .into_response(); + return ApiError::new(StatusCode::CONFLICT, err.to_string()).into_response(); } } }; @@ -418,45 +412,43 @@ async fn create_ssh_access( Err(response) => return response, }; - match record.provider.bundled() { - Some(BundledProvider::Daytona) => { - let sandbox = match reconnect_daytona_sandbox_instance(&state, &record).await { - Ok(sandbox) => sandbox, - Err(response) => return response, - }; - match sandbox.create_ssh_access(Some(request.ttl_minutes)).await { - Ok(command) => { - (StatusCode::CREATED, Json(SshAccessResponse { command })).into_response() - } - Err(err) => { - ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() - } - } + if record.provider == SandboxProviderKind::LOCAL { + return ApiError::new( + StatusCode::CONFLICT, + "Sandbox provider does not support access commands.", + ) + .into_response(); + } + let sandbox = match reconnect_driver_sandbox_instance(&state, &id, &record).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; + let handle = match sandbox.handle() { + Ok(handle) => handle, + Err(err) => { + return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(); } - Some(BundledProvider::Docker) | None => { - let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await { - Ok(sandbox) => sandbox, - Err(response) => return response, - }; - match sandbox.ssh_access_command().await { - Ok(Some(command)) => { - (StatusCode::CREATED, Json(SshAccessResponse { command })).into_response() - } - Ok(None) => ApiError::new( - StatusCode::CONFLICT, - "Sandbox provider does not support access commands.", - ) - .into_response(), - Err(err) => { - ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() - } - } + }; + // Providers with a leased SSH gateway honor the requested lifetime; + // providers with a fixed local command return it as is. + let result = match handle.ssh() { + Some(ssh) => ssh + .ssh_access(Some(Duration::from_secs_f64(request.ttl_minutes * 60.0))) + .await + .map(|access| Some(access.command)) + .map_err(|err| fabro_sandbox::Error::context("Failed to create SSH access", err)), + None => sandbox.ssh_access_command().await, + }; + match result { + Ok(Some(command)) => { + (StatusCode::CREATED, Json(SshAccessResponse { command })).into_response() } - Some(BundledProvider::Local) => ApiError::new( + Ok(None) => ApiError::new( StatusCode::CONFLICT, "Sandbox provider does not support access commands.", ) .into_response(), + Err(err) => ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(), } } @@ -480,29 +472,24 @@ async fn create_sandbox_vnc_preview( ) .into_response(); } - let sandbox = match reconnect_daytona_sandbox_instance(&state, &record).await { + let sandbox = match reconnect_driver_sandbox_instance(&state, &id, &record).await { Ok(sandbox) => sandbox, Err(response) => return response, }; - match build_vnc_preview_response(&sandbox).await { + match build_vnc_preview_response(&record.provider, &sandbox).await { Ok(response) => (StatusCode::CREATED, Json(response)).into_response(), Err(response) => response, } } async fn build_vnc_preview_response( + provider: &SandboxProviderKind, sandbox: &impl VncSandbox, ) -> Result { - sandbox.start_computer_use().await.map_err(|err| { + let url = sandbox.vnc_viewer_url().await.map_err(|err| { ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() })?; - let signed = sandbox - .signed_preview_url(DEFAULT_VNC_NO_VNC_PORT, DEFAULT_VNC_TTL_SECS) - .await - .map_err(|err| { - ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() - })?; - let url = vnc_viewer_url(&signed).map_err(|err| { + let url = vnc_viewer_url(&url).map_err(|err| { ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() })?; Ok(VncPreviewResponse { @@ -512,11 +499,14 @@ async fn build_vnc_preview_response( .expect("default VNC TTL should be nonzero"), port: NonZeroU64::new(u64::from(DEFAULT_VNC_NO_VNC_PORT)) .expect("default VNC port should be nonzero"), - provider: "daytona".to_string(), + provider: provider.to_string(), url, }) } +/// Pins the viewer URL to the noVNC page with autoconnect and scaling. The +/// provider already points at the viewer; this makes the query idempotent +/// so a URL that already carries the viewer parameters is not duplicated. fn vnc_viewer_url(signed_url: &str) -> fabro_sandbox::Result { // Internal URL manipulation, not logging — `DisplaySafeUrl` is for // logging/error boundaries. The signed URL may carry a credential, so @@ -527,10 +517,22 @@ fn vnc_viewer_url(signed_url: &str) -> fabro_sandbox::Result { )] let mut url = url::Url::parse(signed_url) .map_err(|err| fabro_sandbox::Error::context("Failed to parse signed VNC URL", err))?; + let preserved: Vec<(String, String)> = url + .query_pairs() + .filter(|(key, _)| key != VNC_VIEWER_AUTOCONNECT.0 && key != VNC_VIEWER_RESIZE.0) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); url.set_path(VNC_VIEWER_PATH); - url.query_pairs_mut() - .append_pair(VNC_VIEWER_AUTOCONNECT.0, VNC_VIEWER_AUTOCONNECT.1) - .append_pair(VNC_VIEWER_RESIZE.0, VNC_VIEWER_RESIZE.1); + url.set_query(None); + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in &preserved { + pairs.append_pair(key, value); + } + pairs + .append_pair(VNC_VIEWER_AUTOCONNECT.0, VNC_VIEWER_AUTOCONNECT.1) + .append_pair(VNC_VIEWER_RESIZE.0, VNC_VIEWER_RESIZE.1); + } Ok(url.into()) } @@ -878,8 +880,20 @@ async fn reconnect_run_sandbox_instance( run_id: &RunId, record: &RunSandboxInstance, ) -> Result, Response> { - let daytona_api_key = load_daytona_api_key(state).await?; - let sandbox = reconnect_for_run(record, daytona_api_key, Some(*run_id)) + let sandbox = reconnect_driver_sandbox_instance(state, run_id, record).await?; + Ok(Box::new(sandbox)) +} + +/// Reconnects a run's sandbox as the driver-backed type, for endpoints that +/// reach a driver facet fabro's `Sandbox` trait does not carry (VNC, signed +/// previews, leased SSH). +async fn reconnect_driver_sandbox_instance( + state: &Arc, + run_id: &RunId, + record: &RunSandboxInstance, +) -> Result { + let daytona = load_daytona_credentials(state).await?; + let sandbox = reconnect_driver_for_run(record, daytona, Some(*run_id), None) .await .map_err(|err| { let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref())); @@ -891,64 +905,17 @@ async fn reconnect_run_sandbox_instance( Ok(sandbox) } -async fn reconnect_daytona_sandbox( - state: &Arc, - run_id: &RunId, -) -> Result { - let record = load_run_sandbox_instance(state, run_id).await?; - reconnect_daytona_sandbox_instance(state, &record).await -} - -async fn reconnect_daytona_sandbox_instance( - state: &Arc, - record: &RunSandboxInstance, -) -> Result { - if record.provider != SandboxProviderKind::DAYTONA { - return Err(ApiError::new( - StatusCode::CONFLICT, - "Sandbox provider does not support this capability.", +async fn load_daytona_credentials( + state: &AppState, +) -> Result, Response> { + state.vault_daytona_credentials().await.map_err(|err| { + tracing::error!(error = ?err, "Loading Daytona API key failed"); + ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "secret store operation failed", ) - .into_response()); - } - let runtime = &record.runtime; - let Some(repo_cloned) = runtime.repo_cloned else { - return Err(ApiError::new( - StatusCode::CONFLICT, - "Sandbox record is missing clone metadata.", - ) - .into_response()); - }; - let daytona_api_key = load_daytona_api_key(state).await?; - let sandbox = DaytonaSandbox::reconnect( - &runtime.id, - daytona_api_key, - repo_cloned, - runtime.working_directory.clone(), - runtime.clone_origin_url.clone(), - runtime.clone_branch.clone(), - ) - .await - .map_err(|err| { - ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() - })?; - sandbox.activate().await.map_err(|err| { - ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response() - })?; - Ok(sandbox) -} - -async fn load_daytona_api_key(state: &AppState) -> Result, Response> { - state - .vault_secret(EnvVars::DAYTONA_API_KEY) - .await - .map_err(|err| { - tracing::error!(error = ?err, "Loading Daytona API key failed"); - ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "secret store operation failed", - ) - .into_response() - }) + .into_response() + }) } async fn load_run_sandbox_instance( @@ -1215,35 +1182,18 @@ FABRO_PROC_NET_TCP /proc/net/tcp6 } struct FakeVncSandbox { - start_error: Option<&'static str>, - signed_url_error: Option<&'static str>, - signed_url: &'static str, + error: Option<&'static str>, + viewer_url: &'static str, } impl VncSandbox for FakeVncSandbox { - fn start_computer_use( + fn vnc_viewer_url( &self, - ) -> futures_util::future::BoxFuture<'_, fabro_sandbox::Result<()>> { - async move { - match self.start_error { - Some(message) => Err(fabro_sandbox::Error::message(message)), - None => Ok(()), - } - } - .boxed() - } - - fn signed_preview_url( - &self, - port: u16, - expires_in_secs: i32, ) -> futures_util::future::BoxFuture<'_, fabro_sandbox::Result> { async move { - assert_eq!(port, DEFAULT_VNC_NO_VNC_PORT); - assert_eq!(expires_in_secs, DEFAULT_VNC_TTL_SECS); - match self.signed_url_error { + match self.error { Some(message) => Err(fabro_sandbox::Error::message(message)), - None => Ok(self.signed_url.to_string()), + None => Ok(self.viewer_url.to_string()), } } .boxed() @@ -1253,12 +1203,13 @@ FABRO_PROC_NET_TCP /proc/net/tcp6 #[tokio::test] async fn vnc_preview_response_uses_daytona_defaults() { let sandbox = FakeVncSandbox { - start_error: None, - signed_url_error: None, - signed_url: "https://preview.example.test/sandbox/6080", + error: None, + viewer_url: "https://preview.example.test/vnc.html?autoconnect=true&resize=scale", }; - let response = build_vnc_preview_response(&sandbox).await.unwrap(); + let response = build_vnc_preview_response(&SandboxProviderKind::DAYTONA, &sandbox) + .await + .unwrap(); assert_eq!( response.url, @@ -1299,29 +1250,29 @@ FABRO_PROC_NET_TCP /proc/net/tcp6 } #[tokio::test] - async fn vnc_preview_response_maps_computer_use_start_failure_to_conflict() { + async fn vnc_preview_response_maps_provider_failure_to_conflict() { let sandbox = FakeVncSandbox { - start_error: Some("computer use failed"), - signed_url_error: None, - signed_url: "https://preview.example.test/sandbox/6080", + error: Some("computer use failed"), + viewer_url: "https://preview.example.test/sandbox/6080", }; - let response = build_vnc_preview_response(&sandbox).await.unwrap_err(); + let response = build_vnc_preview_response(&SandboxProviderKind::DAYTONA, &sandbox) + .await + .unwrap_err(); assert_eq!(response.status(), StatusCode::CONFLICT); } - #[tokio::test] - async fn vnc_preview_response_maps_signed_preview_failure_to_conflict() { - let sandbox = FakeVncSandbox { - start_error: None, - signed_url_error: Some("preview failed"), - signed_url: "https://preview.example.test/sandbox/6080", - }; - - let response = build_vnc_preview_response(&sandbox).await.unwrap_err(); - - assert_eq!(response.status(), StatusCode::CONFLICT); + #[test] + fn vnc_viewer_url_does_not_duplicate_viewer_parameters() { + let url = super::vnc_viewer_url( + "https://6080-preview.example.test/vnc.html?token=abc&autoconnect=true&resize=scale", + ) + .expect("parse"); + assert_eq!( + url, + "https://6080-preview.example.test/vnc.html?token=abc&autoconnect=true&resize=scale" + ); } } diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index a43405ba5..6b299f1e1 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -23,7 +23,6 @@ use fabro_api::types::{ use fabro_llm::types::ToolDefinition; use fabro_model::{AgentProfileKind, Catalog, ModelSelectionError, ProviderId, catalog}; use fabro_sandbox::reconnect::reconnect_for_run; -use fabro_static::EnvVars; use fabro_store::{ EventPayload, ProjectedRunSession, RunDatabase, project_run_session, project_run_sessions, }; @@ -715,11 +714,11 @@ async fn build_agent_session( let sandbox_instance = sandbox_record.instance().ok_or_else(|| { AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!("run sandbox was not created")) })?; - let daytona_api_key = state - .vault_secret(EnvVars::DAYTONA_API_KEY) + let daytona = state + .vault_daytona_credentials() .await .map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))?; - let sandbox = reconnect_for_run(sandbox_instance, daytona_api_key, Some(run_id)) + let sandbox = reconnect_for_run(sandbox_instance, daytona, Some(run_id)) .await .map_err(AskFabroBuildError::SandboxUnavailable)?; sandbox diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index b0bb62491..98a0be86b 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -1644,9 +1644,8 @@ async fn create_secret_rejects_under_scoped_daytona_api_key_and_leaves_vault_unc assert_eq!( body["errors"][0]["detail"], - "API key 'delete-only' is missing required Daytona scopes: \ - write:snapshots, write:sandboxes. Regenerate the key with all \ - snapshot and sandbox scopes." + "Daytona API key is missing required scopes: write:snapshots, write:sandboxes. \ + Regenerate the key with all snapshot and sandbox scopes." ); assert_eq!( state diff --git a/lib/apps/fabro-server/tests/it/api/install.rs b/lib/apps/fabro-server/tests/it/api/install.rs index f05dea863..ed8520298 100644 --- a/lib/apps/fabro-server/tests/it/api/install.rs +++ b/lib/apps/fabro-server/tests/it/api/install.rs @@ -2790,9 +2790,8 @@ async fn sandbox_daytona_test_endpoint_rejects_under_scoped_api_key() { assert_eq!( body["errors"][0]["detail"], - "API key 'delete-only' is missing required Daytona scopes: \ - write:snapshots, write:sandboxes. Regenerate the key with all \ - snapshot and sandbox scopes." + "Daytona API key is missing required scopes: write:snapshots, write:sandboxes. \ + Regenerate the key with all snapshot and sandbox scopes." ); auth.assert_async().await; current_key.assert_async().await; diff --git a/lib/components/fabro-sandbox/Cargo.toml b/lib/components/fabro-sandbox/Cargo.toml index dde9d5471..ef2869795 100644 --- a/lib/components/fabro-sandbox/Cargo.toml +++ b/lib/components/fabro-sandbox/Cargo.toml @@ -9,7 +9,6 @@ description = "Sandbox trait and implementations for Fabro agent execution envir [features] default = ["local"] local = [] -daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-config", "dep:fabro-http", "dep:reqwest-middleware", "dep:rand", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls"] test-support = [] [lib] @@ -57,29 +56,15 @@ fabro-redact.workspace = true futures = { workspace = true } -# daytona -fabro-config = { path = "../../foundation/fabro-config", optional = true } fabro-github = { path = "../fabro-github" } fabro-types = { path = "../../foundation/fabro-types" } chrono = { workspace = true } -# daytona -rand = { workspace = true, optional = true } -daytona-sdk = { workspace = true, optional = true } -daytona-api-client = { workspace = true, optional = true } -git2 = { workspace = true, optional = true } -fabro-http = { workspace = true, optional = true } -reqwest-middleware = { version = "0.5", features = ["json", "multipart", "form", "query"], optional = true } -tokio-tungstenite = { workspace = true, optional = true } -futures-util = { workspace = true, optional = true } -rustls = { version = "0.23", default-features = false, features = ["std", "ring"], optional = true } - [dev-dependencies] fabro-github = { path = "../fabro-github", features = ["test-support"] } tokio = { workspace = true, features = ["test-util", "macros"] } tempfile = "3" serde_json.workspace = true toml.workspace = true -httpmock = "0.8" fabro-test.workspace = true diff --git a/lib/components/fabro-sandbox/src/daytona.rs b/lib/components/fabro-sandbox/src/daytona.rs new file mode 100644 index 000000000..9322dece3 --- /dev/null +++ b/lib/components/fabro-sandbox/src/daytona.rs @@ -0,0 +1,871 @@ +//! The `daytona` provider kind: fabro's environment mapping onto the +//! sandbox-driver Daytona provider. +//! +//! Fabro decides the snapshot (built from the environment's image or +//! Dockerfile and named by an HMAC of its inputs), the lifecycle timers, +//! labels, network policy, and workspace layout; the driver creates and +//! drives the sandbox. The run works in `/home/daytona/workspace`, with a +//! cloned repository checked out under `/home/daytona/repos` and linked into +//! the workspace. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use fabro_github::GitHubCredentials; +use fabro_types::settings::server::ServerSandboxProviderSettings; +use fabro_types::{RunId, SandboxProviderKind}; +use sandbox_driver::{ + HealthStatus, LifecycleTimers, NetworkPolicy, Resources, SandboxId, SandboxProvider, + SandboxSource, SandboxSpec as DriverSpec, SnapshotFilter, SnapshotId, SnapshotProvider, + SnapshotSource, SnapshotSpec, SnapshotState, +}; +use tokio::time; + +pub use crate::config::{ + DaytonaNetwork, DaytonaSettings as DaytonaConfig, + DaytonaSnapshotSettings as DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource, +}; +pub use crate::driver::DaytonaCredentials; +use crate::driver::{ProviderConnectOptions, connect_provider}; +use crate::driver_sandbox::{ + CreatePlan, DriverSandbox, PreparedCreate, RepoWorkspace, WorkspaceLayout, +}; +use crate::managed_labels; +use crate::sandbox::SandboxEvent; + +pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; +pub(crate) const REPOS_ROOT: &str = "/home/daytona/repos"; +const DEFAULT_SNAPSHOT: &str = "daytona-medium"; +pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api"; +/// Budget for the credential probe `fabro doctor` and the install flow run. +pub const DAYTONA_CREDENTIAL_PROBE_TIMEOUT: Duration = Duration::from_secs(20); +/// Budget for a custom snapshot to reach Daytona's active state. +const DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT: Duration = Duration::from_mins(30); +/// Auto-stop applied when `lifecycle.auto_stop` is unset. Omitting the timer +/// would inherit Daytona's server-side default of 15 idle minutes, which is +/// shorter than a single long inference call and stops the sandbox mid-run; +/// 120 minutes clears any realistic call while still reclaiming sandboxes +/// leaked by a dead worker. An explicit `0` disables auto-stop entirely. +const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120; + +/// Scopes a Daytona API key needs for fabro's snapshot and sandbox flow, in +/// the order the remediation text lists them. +pub const REQUIRED_DAYTONA_SCOPES: &[&str] = &[ + "write:snapshots", + "delete:snapshots", + "write:sandboxes", + "delete:sandboxes", +]; + +pub mod snapshot_identity { + use hmac::{Hmac, Mac}; + use serde::Serialize; + use sha2::{Digest, Sha256}; + use uuid::Uuid; + + use super::{DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource}; + + const IDENTITY_VERSION: u8 = 1; + const PROVIDER: &str = "daytona"; + const TENANT: &str = "single-tenant"; + + type HmacSha256 = Hmac; + + /// The snapshot source as it appears in the identity manifest. Each + /// variant flattens into a single `"": ""` entry. + #[derive(Serialize)] + #[serde(rename_all = "snake_case")] + enum SourceManifest<'a> { + DockerfileSha256(String), + Image(&'a str), + } + + #[derive(Serialize)] + struct SnapshotManifest<'a> { + identity_version: u8, + provider: &'static str, + tenant: &'static str, + #[serde(flatten)] + source: SourceManifest<'a>, + cpu: Option, + memory_gb: Option, + disk_gb: Option, + /// Nothing sets an entrypoint yet. The field stays because removing + /// it would rename every existing snapshot under `IDENTITY_VERSION` 1. + entrypoint: Option<&'static str>, + } + + /// The name of the snapshot built from `config`: a UUIDv8 derived from an + /// HMAC of the build inputs keyed by the API key, so the same inputs reuse + /// the same snapshot and a rotated key never collides with another + /// tenant's. + pub fn snapshot_name(api_key: &str, config: &DaytonaSnapshotConfig) -> crate::Result { + let manifest = canonical_manifest(config)?; + let mut mac = HmacSha256::new_from_slice(api_key.as_bytes()) + .expect("HMAC-SHA256 accepts keys of any length"); + mac.update(&manifest); + let digest = mac.finalize().into_bytes(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + Ok(format!("fabro-{}", Uuid::new_v8(bytes))) + } + + fn canonical_manifest(config: &DaytonaSnapshotConfig) -> crate::Result> { + let source = match &config.source { + DaytonaSnapshotSource::Image(image) => SourceManifest::Image(image), + DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(text)) => { + SourceManifest::DockerfileSha256(hex::encode(Sha256::digest(text.as_bytes()))) + } + DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { .. }) => { + return Err(crate::Error::message( + "Daytona snapshot dockerfile path should have been resolved to inline content before sandbox creation", + )); + } + }; + let manifest = SnapshotManifest { + identity_version: IDENTITY_VERSION, + provider: PROVIDER, + tenant: TENANT, + source, + cpu: config.cpu, + memory_gb: config.memory, + disk_gb: config.disk, + entrypoint: None, + }; + serde_json::to_vec(&manifest).map_err(|err| { + crate::Error::context("Failed to serialize Daytona snapshot identity", err) + }) + } +} + +/// Outcome of probing a Daytona credential through the provider's health +/// check. +#[derive(Debug)] +pub struct DaytonaKeyCheck { + /// Scopes the key lacks, in Daytona's wire names. + pub missing: Vec, +} + +#[derive(Debug, thiserror::Error)] +#[error("Daytona credential probe timed out after {timeout:?}")] +pub struct DaytonaCredentialProbeTimeout { + timeout: Duration, +} + +impl DaytonaCredentialProbeTimeout { + #[must_use] + pub const fn new(timeout: Duration) -> Self { + Self { timeout } + } + + #[must_use] + pub const fn timeout(&self) -> Duration { + self.timeout + } +} + +impl DaytonaKeyCheck { + #[must_use] + pub fn ok(&self) -> bool { + self.missing.is_empty() + } + + #[must_use] + pub fn missing_display(&self) -> String { + self.missing.join(", ") + } + + #[must_use] + pub fn missing_message(&self) -> String { + format!( + "Daytona API key is missing required scopes: {}. Regenerate the key with all \ + snapshot and sandbox scopes.", + self.missing_display() + ) + } +} + +#[must_use] +pub fn required_perms_display() -> String { + REQUIRED_DAYTONA_SCOPES.join(", ") +} + +/// Whether `credentials` reach Daytona, are accepted, and carry the scopes +/// fabro needs. Reachability and authentication failures are errors; a key +/// that authenticates but lacks scopes is an `Ok` check that is not `ok()`. +pub async fn check_daytona_api_key( + credentials: &DaytonaCredentials, + probe_timeout: Duration, +) -> anyhow::Result { + let probe = async { + let provider = connect(credentials).await?; + let health = provider + .health() + .await + .map_err(|error| anyhow::Error::new(error).context("Daytona health check failed"))?; + match health.status { + HealthStatus::Ok | HealthStatus::Unknown => Ok(DaytonaKeyCheck { + missing: Vec::new(), + }), + HealthStatus::Unauthorized if !health.missing_permissions.is_empty() => { + Ok(DaytonaKeyCheck { + missing: ordered_scopes(&health.missing_permissions), + }) + } + HealthStatus::Unauthorized => Err(anyhow::anyhow!( + "failed to authenticate with Daytona: {}", + health + .message + .unwrap_or_else(|| "the credential was rejected".to_string()) + )), + _ => Err(anyhow::anyhow!( + "failed to reach Daytona: {}", + health + .message + .unwrap_or_else(|| "the control plane did not answer".to_string()) + )), + } + }; + match time::timeout(probe_timeout, probe).await { + Ok(result) => result, + Err(_) => Err(anyhow::Error::new(DaytonaCredentialProbeTimeout::new( + probe_timeout, + ))), + } +} + +/// The scopes fabro requires, in fabro's documented order, followed by any +/// other scope the provider reported missing. +fn ordered_scopes(missing: &[String]) -> Vec { + let mut ordered: Vec = REQUIRED_DAYTONA_SCOPES + .iter() + .filter(|scope| missing.iter().any(|reported| reported == *scope)) + .map(|scope| (*scope).to_string()) + .collect(); + for scope in missing { + if !ordered.contains(scope) { + ordered.push(scope.clone()); + } + } + ordered +} + +async fn connect(credentials: &DaytonaCredentials) -> anyhow::Result> { + connect_provider( + &SandboxProviderKind::DAYTONA, + &ServerSandboxProviderSettings::default(), + &ProviderConnectOptions { + host_registry_root: None, + daytona: Some(credentials.clone()), + }, + ) + .await + .map(|connected| connected.provider) + .map_err(|error| anyhow::Error::new(error).context("Failed to connect to Daytona")) +} + +/// The workspace layout every Daytona sandbox uses. +pub(crate) fn layout() -> WorkspaceLayout { + WorkspaceLayout { + workspace_root: WORKING_DIRECTORY.to_string(), + repos_root: REPOS_ROOT.to_string(), + } +} + +/// The driver spec for a fabro Daytona sandbox created from `snapshot`. +pub(crate) fn driver_spec( + config: &DaytonaConfig, + run_id: Option<&RunId>, + snapshot: &SnapshotId, +) -> DriverSpec { + let mut spec = DriverSpec::new(SandboxSource::Snapshot { + id: snapshot.clone(), + }) + .working_directory(WORKING_DIRECTORY) + .network(match &config.network { + Some(DaytonaNetwork::Block) => NetworkPolicy::Block, + Some(DaytonaNetwork::AllowAll) => NetworkPolicy::AllowAll, + Some(DaytonaNetwork::AllowList(cidrs)) => NetworkPolicy::CidrAllowList { + cidrs: cidrs.clone(), + }, + None => NetworkPolicy::ProviderDefault, + }); + if let Some(run_id) = run_id { + spec = spec.name(format!("fabro-{run_id}")); + } + let mut labels: Vec<(String, String)> = + managed_labels::merge_for_run(config.labels.as_ref(), run_id) + .into_iter() + .collect(); + labels.sort(); + for (key, value) in labels { + spec = spec.label(key, value); + } + let mut timers = LifecycleTimers::default(); + // An explicit zero disables auto-stop; the driver encodes + // `Duration::ZERO` as that wire value. + timers.auto_stop_after_idle = Some(minutes_to_duration( + config + .auto_stop_interval + .unwrap_or(DEFAULT_AUTO_STOP_INTERVAL_MINUTES), + )); + // Run sandboxes are never deleted on stop: the run record may need + // them again on resume, and `fabro system prune` reclaims them. + timers.auto_delete_after_stop = Some(Duration::ZERO); + spec.timers(timers) +} + +fn minutes_to_duration(minutes: i32) -> Duration { + Duration::from_mins(u64::try_from(minutes).unwrap_or(0)) +} + +/// Ensures the snapshot `config` describes exists and is active, building +/// it when Daytona does not have it. Returns the snapshot to create +/// sandboxes from. +async fn ensure_snapshot( + provider: &dyn SandboxProvider, + api_key: &str, + config: &DaytonaSnapshotConfig, + emit: &(dyn Fn(SandboxEvent) + Send + Sync), +) -> crate::Result<(SnapshotId, String)> { + let name = snapshot_identity::snapshot_name(api_key, config)?; + let snapshots = provider.snapshots().ok_or_else(|| { + crate::Error::message("The Daytona provider does not expose snapshot management") + })?; + let mut filter = SnapshotFilter::default(); + filter.name = Some(name.clone()); + let existing = snapshots + .list(&filter) + .await + .map_err(|error| { + crate::Error::context(format!("Failed to look up snapshot '{name}'"), error) + })? + .into_iter() + .find(|status| status.name.as_deref() == Some(name.as_str())); + let id = if let Some(status) = existing { + match status.state { + SnapshotState::Active => return Ok((status.id, name)), + SnapshotState::Error => { + return Err(crate::Error::message(format!( + "Snapshot '{name}' is in an error state: {}", + status.error_reason.unwrap_or_default() + ))); + } + SnapshotState::Inactive => { + emit(SandboxEvent::SnapshotCreating { name: name.clone() }); + snapshots + .activate(&status.id, None) + .await + .map_err(|error| { + crate::Error::context( + format!("Failed to activate snapshot '{name}'"), + error, + ) + })?; + status.id + } + _ => { + emit(SandboxEvent::SnapshotCreating { name: name.clone() }); + status.id + } + } + } else { + emit(SandboxEvent::SnapshotCreating { name: name.clone() }); + let spec = snapshot_spec(&name, config)?; + snapshots.create(&spec, None).await.map_err(|error| { + crate::Error::context(format!("Failed to create snapshot '{name}'"), error) + })? + }; + wait_for_active_snapshot(snapshots, &id, &name).await?; + Ok((id, name)) +} + +fn snapshot_spec(name: &str, config: &DaytonaSnapshotConfig) -> crate::Result { + let source = match &config.source { + DaytonaSnapshotSource::Image(image) => SnapshotSource::Image { + reference: image.clone(), + }, + DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(content)) => { + SnapshotSource::Dockerfile { + content: content.clone(), + } + } + DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { .. }) => { + return Err(crate::Error::message(format!( + "Snapshot '{name}': dockerfile path should have been resolved to inline content before sandbox creation" + ))); + } + }; + let mut resources = Resources::default(); + resources.cpu_cores = config.cpu.and_then(|cpu| u32::try_from(cpu).ok()); + resources.memory_mb = config + .memory + .and_then(|gb| u64::try_from(gb).ok()) + .map(|gb| gb * 1024); + resources.disk_mb = config + .disk + .and_then(|gb| u64::try_from(gb).ok()) + .map(|gb| gb * 1024); + Ok(SnapshotSpec::new(source).name(name).resources(resources)) +} + +/// Polls a snapshot until it is active, with exponential back-off, or fails +/// when it errors or the budget runs out. +async fn wait_for_active_snapshot( + snapshots: &dyn SnapshotProvider, + id: &SnapshotId, + name: &str, +) -> crate::Result<()> { + let mut delay = Duration::from_secs(2); + let max_delay = Duration::from_secs(30); + let deadline = time::Instant::now() + DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT; + while time::Instant::now() < deadline { + time::sleep(delay).await; + let status = snapshots.get(id).await.map_err(|error| { + crate::Error::context(format!("Failed to poll snapshot '{name}'"), error) + })?; + match status.state { + SnapshotState::Active => return Ok(()), + SnapshotState::Error | SnapshotState::Deleting => { + return Err(crate::Error::message(format!( + "Snapshot '{name}' failed: {}", + status.error_reason.unwrap_or_default() + ))); + } + _ => delay = (delay * 2).min(max_delay), + } + } + Err(crate::Error::message(format!( + "Timed out waiting for snapshot '{name}' to become active" + ))) +} + +/// Prepares a Daytona create: the snapshot first, then the spec naming it. +struct DaytonaCreatePlan { + provider: Arc, + api_key: String, + config: DaytonaConfig, + run_id: Option, +} + +#[async_trait] +impl CreatePlan for DaytonaCreatePlan { + async fn prepare( + &self, + emit: &(dyn Fn(SandboxEvent) + Send + Sync), + ) -> crate::Result { + let (snapshot_id, snapshot_name) = match &self.config.snapshot { + Some(snapshot) => { + let started = time::Instant::now(); + let result = + ensure_snapshot(self.provider.as_ref(), &self.api_key, snapshot, emit).await; + match result { + Ok((id, name)) => { + emit(SandboxEvent::SnapshotReady { + name: name.clone(), + duration_ms: u64::try_from(started.elapsed().as_millis()) + .unwrap_or(u64::MAX), + }); + (id, name) + } + Err(error) => { + let name = snapshot_identity::snapshot_name(&self.api_key, snapshot) + .unwrap_or_default(); + emit(SandboxEvent::SnapshotFailed { + name, + error: error.to_string(), + causes: error.causes(), + }); + return Err(error); + } + } + } + None => ( + SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("the default snapshot name is valid"), + DEFAULT_SNAPSHOT.to_string(), + ), + }; + Ok(PreparedCreate { + spec: driver_spec(&self.config, self.run_id.as_ref(), &snapshot_id), + source: Some(snapshot_name.clone()), + snapshot: Some(snapshot_name), + }) + } +} + +/// A Daytona sandbox for a run. The sandbox is created by `initialize`; +/// construction validates the clone request and connects the provider, so +/// a bad spec or missing credential fails before any control-plane call. +#[expect( + clippy::too_many_arguments, + reason = "mirrors SandboxSpec::Daytona; clone inputs are validated together" +)] +pub async fn daytona_sandbox( + config: DaytonaConfig, + github_app: Option<&GitHubCredentials>, + run_id: Option, + clone_origin_url: Option, + clone_branch: Option, + clone_tag: Option, + clone_commit_sha: Option, + credentials: &DaytonaCredentials, +) -> crate::Result { + let workspace = RepoWorkspace::plan( + layout(), + config.skip_clone, + clone_origin_url.as_deref(), + clone_branch.as_deref(), + clone_tag.as_deref(), + clone_commit_sha.as_deref(), + config + .clone_depth + .and_then(|depth| u32::try_from(depth).ok()), + github_app, + )?; + let provider = connect(credentials) + .await + .map_err(|error| crate::Error::context_anyhow("Failed to connect to Daytona", error))?; + let plan = DaytonaCreatePlan { + provider: Arc::clone(&provider), + api_key: credentials.api_key.clone(), + config, + run_id, + }; + Ok(DriverSandbox::pending_with_plan( + SandboxProviderKind::DAYTONA, + provider, + Box::new(plan), + workspace, + )) +} + +/// Reattach to a run's Daytona sandbox by its persisted id. +/// +/// The sandbox must carry fabro's managed label and, when a run id is +/// known, the matching run label: fabro never operates on a sandbox it did +/// not create, even inside its own organization. +pub async fn attach_daytona( + sandbox_id: &str, + repo_cloned: bool, + working_directory: String, + clone_origin_url: Option, + run_id: Option, + credentials: &DaytonaCredentials, +) -> crate::Result { + let provider = connect(credentials) + .await + .map_err(|error| crate::Error::context_anyhow("Failed to connect to Daytona", error))?; + let id = SandboxId::try_new(sandbox_id) + .map_err(|error| crate::Error::context("Invalid Daytona sandbox id", error))?; + let handle = provider.attach(&id, None).await.map_err(|error| { + crate::Error::context( + format!("Failed to reconnect Daytona sandbox '{sandbox_id}'"), + error, + ) + })?; + let status = handle.describe().await?; + managed_labels::verify_managed( + &SandboxProviderKind::DAYTONA, + sandbox_id, + &status.labels, + run_id.as_ref(), + )?; + let workspace = + RepoWorkspace::attached(layout(), repo_cloned, working_directory, clone_origin_url); + let sandbox = DriverSandbox::attached(SandboxProviderKind::DAYTONA, handle, workspace); + if let Some(snapshot) = status.source { + sandbox.set_snapshot(snapshot); + } + Ok(sandbox) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn run_id() -> RunId { + "01HY0000000000000000000000".parse().unwrap() + } + + #[test] + fn daytona_config_defaults() { + let config = DaytonaConfig::default(); + assert!(config.snapshot.is_none()); + assert!(config.auto_stop_interval.is_none()); + assert!(config.labels.is_none()); + assert!(config.clone_depth.is_none()); + } + + #[test] + fn driver_spec_names_the_run_and_carries_fabro_labels_and_timers() { + let config = DaytonaConfig { + labels: Some(HashMap::from([( + "team".to_string(), + "platform".to_string(), + )])), + network: Some(DaytonaNetwork::AllowList(vec!["10.0.0.0/8".to_string()])), + ..DaytonaConfig::default() + }; + let snapshot = SnapshotId::try_new("snap-1").unwrap(); + let spec = driver_spec(&config, Some(&run_id()), &snapshot); + + assert!(matches!(&spec.source, SandboxSource::Snapshot { id } if id == &snapshot)); + assert_eq!( + spec.name.as_deref(), + Some("fabro-01HY0000000000000000000000") + ); + assert_eq!(spec.working_directory.as_deref(), Some(WORKING_DIRECTORY)); + assert_eq!( + spec.labels.get("sh.fabro.managed").map(String::as_str), + Some("true") + ); + assert_eq!( + spec.labels.get("sh.fabro.run_id").map(String::as_str), + Some("01HY0000000000000000000000") + ); + assert_eq!( + spec.labels.get("team").map(String::as_str), + Some("platform") + ); + assert!(matches!( + &spec.network, + NetworkPolicy::CidrAllowList { cidrs } if cidrs == &["10.0.0.0/8".to_string()] + )); + assert_eq!( + spec.timers.auto_stop_after_idle, + Some(Duration::from_hours(2)), + "an unset auto-stop gets fabro's explicit default, never Daytona's 15 minutes" + ); + assert_eq!(spec.timers.auto_delete_after_stop, Some(Duration::ZERO)); + assert!(!spec.ephemeral); + } + + #[test] + fn driver_spec_passes_explicit_auto_stop_through_and_zero_disables() { + let snapshot = SnapshotId::try_new(DEFAULT_SNAPSHOT).unwrap(); + let explicit = driver_spec( + &DaytonaConfig { + auto_stop_interval: Some(45), + network: Some(DaytonaNetwork::Block), + ..DaytonaConfig::default() + }, + None, + &snapshot, + ); + assert_eq!( + explicit.timers.auto_stop_after_idle, + Some(Duration::from_mins(45)) + ); + assert!(matches!(explicit.network, NetworkPolicy::Block)); + assert!(explicit.name.is_none()); + assert!(!explicit.labels.contains_key("sh.fabro.run_id")); + + let disabled = driver_spec( + &DaytonaConfig { + auto_stop_interval: Some(0), + ..DaytonaConfig::default() + }, + None, + &snapshot, + ); + assert_eq!(disabled.timers.auto_stop_after_idle, Some(Duration::ZERO)); + } + + #[test] + fn snapshot_spec_maps_sources_and_gigabyte_resources() { + let config = DaytonaSnapshotConfig { + cpu: Some(2), + memory: Some(4), + disk: Some(10), + source: DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()), + }; + let spec = snapshot_spec("fabro-x", &config).unwrap(); + assert_eq!(spec.name.as_deref(), Some("fabro-x")); + assert!(matches!( + &spec.source, + SnapshotSource::Image { reference } if reference == "ubuntu:24.04" + )); + assert_eq!(spec.resources.cpu_cores, Some(2)); + assert_eq!(spec.resources.memory_mb, Some(4096)); + assert_eq!(spec.resources.disk_mb, Some(10_240)); + + let dockerfile = snapshot_spec("fabro-y", &DaytonaSnapshotConfig { + source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( + "FROM ubuntu".to_string(), + )), + ..config.clone() + }) + .unwrap(); + assert!(matches!( + &dockerfile.source, + SnapshotSource::Dockerfile { content } if content == "FROM ubuntu" + )); + + let unresolved = snapshot_spec("fabro-z", &DaytonaSnapshotConfig { + source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { + path: "Dockerfile".to_string(), + }), + ..config + }) + .unwrap_err(); + assert!( + unresolved + .to_string() + .contains("resolved to inline content") + ); + } + + #[test] + fn computed_snapshot_identity_is_deterministic_and_keyed() { + let config = DaytonaSnapshotConfig { + cpu: Some(2), + memory: Some(4), + disk: Some(10), + source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( + "FROM ubuntu:24.04\nRUN apt-get update".to_string(), + )), + }; + + let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); + let second = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); + let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &config).unwrap(); + + assert_eq!(first, second); + assert_eq!(first, "fabro-e607185f-c7ab-88c9-bf9d-d70addba9298"); + assert_ne!(first, rotated_key); + let uuid = first + .strip_prefix("fabro-") + .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) + .expect("snapshot name should be fabro-"); + assert_eq!(uuid.get_version_num(), 8); + assert_eq!(uuid.get_variant(), uuid::Variant::RFC4122); + } + + #[test] + fn computed_snapshot_identity_changes_for_generation_inputs() { + let base = DaytonaSnapshotConfig { + cpu: Some(2), + memory: Some(4), + disk: Some(10), + source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( + "FROM ubuntu:24.04".to_string(), + )), + }; + let base_name = snapshot_identity::snapshot_name("dtn_secret", &base).unwrap(); + + let cases = [ + DaytonaSnapshotConfig { + source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( + "FROM ubuntu:24.04\n# roll cache".to_string(), + )), + ..base.clone() + }, + DaytonaSnapshotConfig { + cpu: Some(4), + ..base.clone() + }, + DaytonaSnapshotConfig { + memory: Some(8), + ..base.clone() + }, + DaytonaSnapshotConfig { + disk: Some(20), + ..base.clone() + }, + ]; + + for changed in cases { + let changed_name = snapshot_identity::snapshot_name("dtn_secret", &changed).unwrap(); + assert_ne!(base_name, changed_name); + } + } + + #[test] + fn computed_snapshot_identity_excludes_raw_dockerfile_and_key_material() { + let config = DaytonaSnapshotConfig { + cpu: None, + memory: None, + disk: None, + source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( + "FROM private.example.com/secret-image\nRUN echo raw-secret".to_string(), + )), + }; + + let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &config).unwrap(); + + assert!(name.starts_with("fabro-")); + assert!(!name.contains("private.example.com")); + assert!(!name.contains("raw-secret")); + assert!(!name.contains("dtn_super_secret_key")); + } + + #[test] + fn computed_snapshot_identity_changes_for_image_reference() { + let config = DaytonaSnapshotConfig { + cpu: Some(2), + memory: Some(4), + disk: Some(10), + source: DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()), + }; + let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); + let changed = snapshot_identity::snapshot_name("dtn_secret", &DaytonaSnapshotConfig { + source: DaytonaSnapshotSource::Image("ubuntu:24.10".to_string()), + ..config + }) + .unwrap(); + + assert_eq!(first, "fabro-5d23a023-d7ff-8d68-b3ca-e6286f4211d9"); + assert_ne!(first, changed); + } + + #[test] + fn missing_scopes_render_in_documented_order() { + let check = DaytonaKeyCheck { + missing: ordered_scopes(&[ + "write:sandboxes".to_string(), + "write:snapshots".to_string(), + "manage:secrets".to_string(), + ]), + }; + assert!(!check.ok()); + assert_eq!( + check.missing_display(), + "write:snapshots, write:sandboxes, manage:secrets" + ); + assert_eq!( + check.missing_message(), + "Daytona API key is missing required scopes: write:snapshots, write:sandboxes, \ + manage:secrets. Regenerate the key with all snapshot and sandbox scopes." + ); + assert_eq!( + required_perms_display(), + "write:snapshots, delete:snapshots, write:sandboxes, delete:sandboxes" + ); + } + + #[tokio::test] + async fn credential_probe_reports_configured_timeout() { + let credentials = DaytonaCredentials { + api_key: "dtn_test".to_string(), + // A non-routable address: the probe cannot finish within the budget. + api_url: Some("http://10.255.255.1:1/api".to_string()), + organization_id: None, + target: None, + http_client: None, + }; + let err = check_daytona_api_key(&credentials, Duration::from_millis(1)) + .await + .expect_err("probe should time out"); + let timeout = err + .downcast_ref::() + .expect("timeout should preserve its type"); + assert_eq!(timeout.timeout(), Duration::from_millis(1)); + assert_eq!( + err.to_string(), + "Daytona credential probe timed out after 1ms" + ); + } +} diff --git a/lib/components/fabro-sandbox/src/daytona/mod.rs b/lib/components/fabro-sandbox/src/daytona/mod.rs deleted file mode 100644 index 0f637e8d8..000000000 --- a/lib/components/fabro-sandbox/src/daytona/mod.rs +++ /dev/null @@ -1,5095 +0,0 @@ -use std::collections::HashMap; -use std::fmt::Write; -use std::future::Future; -use std::path::Path; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant}; - -use anyhow::Context as _; -use async_trait::async_trait; -use daytona_api_client::apis::api_keys_api; -use daytona_api_client::apis::configuration::Configuration; -use daytona_api_client::models::SandboxState; -use daytona_api_client::models::api_key_list::Permissions; -use daytona_sdk::api_types::SignedPortPreviewUrl; -use daytona_sdk::toolbox_types::Command as SessionCommandResult; -use daytona_sdk::{DaytonaError, GitCloneOptions, SessionCommandLogsResult}; -use fabro_github::GitHubCredentials; -use fabro_github::token_source::InstallationTokenSource; -use fabro_static::EnvVars; -use fabro_types::{CommandOutputStream, CommandTermination, RunId, SandboxProviderKind}; -use fabro_util::time::elapsed_ms; -use rand::Rng; -use tokio::runtime::Handle; -use tokio::sync::{Mutex, OnceCell}; -use tokio::task::JoinHandle; -use tokio::{fs, time}; -use tokio_util::sync::CancellationToken; - -use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason, PinnedRevision}; -use crate::git_retry::{self, CredentialContext, GitRetryReason}; -use crate::push_credentials::{self, PushCredentialState}; -use crate::redact::redact_auth_url; -use crate::sandbox::{ - self, BASH_ENV_VAR, BASH_PROBE_MARKER, BASH_PROBE_SCRIPT, BASH_PROBE_TIMEOUT_MS, - OutputCaptureBuffer, OutputCaptureStats, REMOTE_BASH, REMOTE_WALK_TIMEOUT_MS, RefreshOutcome, - optional_timeout, resolve_path, validate_bash_probe, -}; -use crate::{ - CommandOutputCallback, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult, - GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StdioProcess, - WalkOptions, managed_labels, shell_quote, -}; - -/// Remediation shown when a Daytona sandbox has no usable Bash. -const DAYTONA_BASH_REMEDIATION: &str = "Daytona sandboxes require /bin/bash for every command, with no `sh` fallback. Use the \ - built-in Daytona snapshot, or a custom snapshot whose image provides bash."; - -/// Remediation shown when the session transport reaches Bash but never -/// completes. -/// -/// The direct transport is probed first, so a sandbox that reaches this failure -/// already has a usable Bash. What is left is the session contract: Daytona -/// sources the submitted command inside a wrapper that resumes afterward to -/// drain the log labelers and persist the exit code, so the command has to -/// return control to it. -const DAYTONA_BASH_SESSION_REMEDIATION: &str = "Daytona ran the direct command transport but not its streaming session transport. \ - A session command must leave Daytona's wrapper shell in place so the provider can \ - record the exit code; replacing that shell reports no completion and stalls every \ - streaming command until its timeout."; - -pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; -pub(crate) const REPOS_ROOT: &str = "/home/daytona/repos"; -// Beneath the system tmp dir so any sandbox user can create it; the -// trailing `runtime` component is load-bearing — materialized blobs at -// `runtime/blobs/{hash}.json` are recognized as managed blob references and -// normalized back to `blob://` in durable context. -pub(crate) const RUNTIME_DIRECTORY: &str = "/tmp/fabro/runtime"; -const DEFAULT_SNAPSHOT: &str = "daytona-medium"; -pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api"; -pub(crate) const DAYTONA_DASHBOARD_SANDBOXES_URL: &str = - "https://app.daytona.io/dashboard/sandboxes"; -const FABRO_SANDBOX_USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION")); -pub const DAYTONA_CREDENTIAL_PROBE_TIMEOUT: Duration = Duration::from_secs(20); -const DAYTONA_BASH_SESSION_PROBE_TIMEOUT: Duration = Duration::from_secs(20); -/// Shared budget for required setup after Daytona's native clone returns. -/// -/// The native clone has its own provider lifecycle. Starting this deadline -/// afterward prevents a slow successful clone from consuming the budget for -/// required branch attachment and workspace linking. -const DAYTONA_POST_CLONE_SETUP_TIMEOUT: Duration = Duration::from_mins(5); -/// Best-effort push-credential setup should not consume or extend the required -/// post-clone setup budget. -const DAYTONA_CREDENTIAL_SETUP_TIMEOUT: Duration = Duration::from_secs(10); -/// Budget for a custom snapshot to reach Daytona's active state. -const DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT: Duration = Duration::from_mins(30); -/// Grace for the toolbox to return the server-side command timeout response. -/// The SDK truncates the server timeout to whole seconds, so the server can -/// fire up to one second before the shared deadline; this additional grace -/// lets that response reach the client afterward. -const DAYTONA_CLIENT_TIMEOUT_GRACE: Duration = Duration::from_secs(1); -/// The Daytona SDK serializes command timeouts as whole seconds. Do not send a -/// zero-second timeout when the shared deadline is nearly exhausted. -const DAYTONA_MIN_SERVER_TIMEOUT: Duration = Duration::from_secs(1); -/// Upper bound on explicit and Drop-triggered Daytona cleanup calls (session -/// deletion, temporary stdin files) so a stalled REST call cannot block -/// cancellation/timeout paths indefinitely. -const DAYTONA_CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); -/// Budget for waiting out an in-flight Daytona lifecycle transition (for -/// example an auto-stop racing an activation) before giving up. Transitions -/// normally finish within seconds; the budget only bounds a wedged sandbox. -const DAYTONA_STATE_CHANGE_TIMEOUT: Duration = Duration::from_mins(2); -/// Poll interval while waiting out an in-flight Daytona lifecycle transition. -const DAYTONA_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_secs(1); -/// Auto-stop applied when `lifecycle.auto_stop` is unset. Omitting the field -/// would inherit Daytona's server-side default of 15 idle minutes, which is -/// shorter than a single long inference call and stops the sandbox mid-run; -/// 120 minutes clears any realistic call while still reclaiming sandboxes -/// leaked by a dead worker. An explicit `0` disables auto-stop entirely. -const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120; - -/// The ref Daytona's native clone checks out. A pinned tag is fetched by its -/// fully-qualified ref so a same-named branch is never consulted; with an -/// exact commit, `commit_id` drives the checkout and the branch is only a name. -fn git_clone_selector(branch: Option<&str>, pin: Option<&PinnedRevision>) -> Option { - match pin { - Some(PinnedRevision::Tag(tag)) => Some(clone_source::tag_ref(tag)), - Some(PinnedRevision::Commit(_)) | None => branch.map(str::to_string), - } -} - -pub(crate) fn daytona_not_found(err: &DaytonaError) -> bool { - matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404) -} - -/// Permissions a Daytona API key needs for Fabro's snapshot and sandbox flow. -pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[ - Permissions::WRITE_SNAPSHOTS, - Permissions::DELETE_SNAPSHOTS, - Permissions::WRITE_SANDBOXES, - Permissions::DELETE_SANDBOXES, -]; - -pub use crate::config::{ - DaytonaNetwork, DaytonaSettings as DaytonaConfig, - DaytonaSnapshotSettings as DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource, -}; - -pub mod snapshot_identity { - use hmac::{Hmac, Mac}; - use serde::Serialize; - use sha2::{Digest, Sha256}; - use uuid::Uuid; - - use super::{DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource}; - - const IDENTITY_VERSION: u8 = 1; - const PROVIDER: &str = "daytona"; - const TENANT: &str = "single-tenant"; - - type HmacSha256 = Hmac; - - /// The snapshot source as it appears in the identity manifest. Each - /// variant flattens into a single `"": ""` entry. - #[derive(Serialize)] - #[serde(rename_all = "snake_case")] - enum SourceManifest<'a> { - DockerfileSha256(String), - Image(&'a str), - } - - #[derive(Serialize)] - struct SnapshotManifest<'a> { - identity_version: u8, - provider: &'static str, - tenant: &'static str, - #[serde(flatten)] - source: SourceManifest<'a>, - cpu: Option, - memory_gb: Option, - disk_gb: Option, - /// Nothing sets an entrypoint yet. The field stays because removing - /// it would rename every existing snapshot under `IDENTITY_VERSION` 1. - entrypoint: Option<&'static str>, - } - - pub fn snapshot_name(api_key: &str, config: &DaytonaSnapshotConfig) -> crate::Result { - let manifest = canonical_manifest(config)?; - let mut mac = HmacSha256::new_from_slice(api_key.as_bytes()) - .expect("HMAC-SHA256 accepts keys of any length"); - mac.update(&manifest); - let digest = mac.finalize().into_bytes(); - let mut bytes = [0_u8; 16]; - bytes.copy_from_slice(&digest[..16]); - Ok(format!("fabro-{}", Uuid::new_v8(bytes))) - } - - fn canonical_manifest(config: &DaytonaSnapshotConfig) -> crate::Result> { - let source = match &config.source { - DaytonaSnapshotSource::Image(image) => SourceManifest::Image(image), - DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(text)) => { - SourceManifest::DockerfileSha256(hex::encode(Sha256::digest(text.as_bytes()))) - } - DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { .. }) => { - return Err(crate::Error::message( - "Daytona snapshot dockerfile path should have been resolved to inline content before sandbox creation", - )); - } - }; - let manifest = SnapshotManifest { - identity_version: IDENTITY_VERSION, - provider: PROVIDER, - tenant: TENANT, - source, - cpu: config.cpu, - memory_gb: config.memory, - disk_gb: config.disk, - entrypoint: None, - }; - serde_json::to_vec(&manifest).map_err(|err| { - crate::Error::context("Failed to serialize Daytona snapshot identity", err) - }) - } -} - -fn create_snapshot_params( - name: &str, - config: &DaytonaSnapshotConfig, -) -> crate::Result { - let image = match &config.source { - DaytonaSnapshotSource::Image(image) => daytona_sdk::ImageSource::Name(image.clone()), - DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline(dockerfile)) => { - daytona_sdk::ImageSource::Custom(daytona_sdk::DockerImage::from_dockerfile(dockerfile)) - } - DaytonaSnapshotSource::Dockerfile(DockerfileSource::Path { .. }) => { - return Err(crate::Error::message(format!( - "Snapshot '{name}': dockerfile path should have been resolved to inline content before sandbox creation" - ))); - } - }; - - Ok(daytona_sdk::CreateSnapshotParams { - name: name.to_string(), - image, - resources: Some(daytona_sdk::Resources { - cpu: config.cpu, - memory: config.memory, - disk: config.disk, - ..Default::default() - }), - entrypoint: None, - region_id: None, - sandbox_class: None, - }) -} - -#[derive(Debug)] -pub struct DaytonaKeyCheck { - pub key_name: String, - pub missing: Vec, -} - -#[derive(Debug, thiserror::Error)] -#[error("Daytona credential probe timed out after {timeout:?}")] -pub struct DaytonaCredentialProbeTimeout { - timeout: Duration, -} - -impl DaytonaCredentialProbeTimeout { - #[must_use] - pub const fn new(timeout: Duration) -> Self { - Self { timeout } - } - - #[must_use] - pub const fn timeout(&self) -> Duration { - self.timeout - } -} - -impl DaytonaKeyCheck { - pub fn ok(&self) -> bool { - self.missing.is_empty() - } - - pub fn missing_display(&self) -> String { - join_perms(&self.missing) - } - - pub fn missing_message(&self) -> String { - format!( - "API key '{}' is missing required Daytona scopes: {}. \ - Regenerate the key with all snapshot and sandbox scopes.", - self.key_name, - self.missing_display() - ) - } -} - -pub fn required_perms_display() -> String { - join_perms(REQUIRED_DAYTONA_PERMISSIONS) -} - -fn join_perms(perms: &[Permissions]) -> String { - perms - .iter() - .copied() - .map(perm_wire_str) - .collect::>() - .join(", ") -} - -fn perm_wire_str(permission: Permissions) -> &'static str { - match permission { - Permissions::WRITE_SNAPSHOTS => "write:snapshots", - Permissions::DELETE_SNAPSHOTS => "delete:snapshots", - Permissions::WRITE_SANDBOXES => "write:sandboxes", - Permissions::DELETE_SANDBOXES => "delete:sandboxes", - _ => "unknown", - } -} - -/// Build a [`daytona_sdk::Client`], forwarding an optional API key from the -/// vault so the SDK doesn't have to rely on `DAYTONA_API_KEY` being in the -/// process environment. -async fn build_daytona_client( - api_key: Option, -) -> Result { - build_daytona_client_with(api_key, None, None, None).await -} - -#[expect( - clippy::disallowed_methods, - reason = "Standalone Daytona sandbox construction falls back to the documented process env var." -)] -fn resolve_daytona_api_key(api_key: Option) -> Option { - api_key.filter(|key| !key.is_empty()).or_else(|| { - std::env::var(EnvVars::DAYTONA_API_KEY) - .ok() - .filter(|key| !key.is_empty()) - }) -} - -pub(crate) async fn build_daytona_client_with( - api_key: Option, - api_url: Option, - organization_id: Option, - http_client: Option, -) -> Result { - let sdk_config = daytona_sdk::DaytonaConfig { - api_key, - api_url, - organization_id, - http_client, - ..Default::default() - }; - daytona_sdk::Client::new_with_config(sdk_config).await -} - -#[expect( - clippy::disallowed_methods, - reason = "This is the production env-resolving Daytona credential probe facade." -)] -pub async fn check_daytona_api_key(api_key: String) -> anyhow::Result { - let base_url = std::env::var(EnvVars::DAYTONA_API_URL) - .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) - .unwrap_or_else(|_| DEFAULT_DAYTONA_API_URL.to_string()); - let org_id = std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(); - let http_client = fabro_http::http_client().context("failed to build HTTP client")?; - check_daytona_api_key_with(&base_url, org_id.as_deref(), api_key, http_client).await -} - -pub async fn check_daytona_api_key_with( - base_url: &str, - org_id: Option<&str>, - api_key: String, - http_client: fabro_http::HttpClient, -) -> anyhow::Result { - check_daytona_api_key_with_timeout( - base_url, - org_id, - api_key, - http_client, - DAYTONA_CREDENTIAL_PROBE_TIMEOUT, - ) - .await -} - -pub async fn check_daytona_api_key_with_timeout( - base_url: &str, - org_id: Option<&str>, - api_key: String, - http_client: fabro_http::HttpClient, - probe_timeout: Duration, -) -> anyhow::Result { - let work = async { - let client = build_daytona_client_with( - Some(api_key.clone()), - Some(base_url.to_string()), - org_id.map(str::to_string), - Some(http_client.clone()), - ) - .await - .map_err(anyhow::Error::new) - .context("failed to construct Daytona client")?; - client - .list(None, Some(1), Some(1)) - .await - .map_err(anyhow::Error::new) - .context("failed to authenticate with Daytona")?; - - let api_config = build_api_keys_configuration(base_url, &api_key, http_client); - let info = api_keys_api::get_current_api_key(&api_config, org_id) - .await - .map_err(anyhow::Error::new) - .context("failed to read current Daytona API key")?; - let missing = REQUIRED_DAYTONA_PERMISSIONS - .iter() - .copied() - .filter(|permission| !info.permissions.contains(permission)) - .collect(); - - Ok::<_, anyhow::Error>(DaytonaKeyCheck { - key_name: info.name, - missing, - }) - }; - - daytona_credential_probe_with_timeout(work, probe_timeout).await -} - -async fn daytona_credential_probe_with_timeout( - probe: F, - probe_timeout: Duration, -) -> anyhow::Result -where - F: Future>, -{ - match time::timeout(probe_timeout, probe).await { - Ok(result) => result, - Err(_) => Err(anyhow::Error::new(DaytonaCredentialProbeTimeout::new( - probe_timeout, - ))), - } -} - -fn build_api_keys_configuration( - base_url: &str, - api_key: &str, - http_client: fabro_http::HttpClient, -) -> Configuration { - Configuration { - base_path: base_url.to_string(), - user_agent: Some(FABRO_SANDBOX_USER_AGENT.to_string()), - client: reqwest_middleware::ClientBuilder::new(http_client).build(), - basic_auth: None, - oauth_access_token: None, - bearer_access_token: Some(api_key.to_string()), - api_key: None, - } -} - -fn command_kind(command: &str) -> &'static str { - match command.split_whitespace().next().unwrap_or_default() { - "git" => "git", - "sh" | "/bin/sh" => "sh", - "bash" | "/bin/bash" => "bash", - "rg" => "rg", - "grep" => "grep", - "find" => "find", - "cat" => "cat", - "ls" => "ls", - "mkdir" => "mkdir", - "rm" => "rm", - "printf" => "printf", - _ => "other", - } -} - -#[derive(Clone, Copy, strum::Display)] -#[strum(serialize_all = "lowercase")] -enum DaytonaLifecycleAction { - Start, - Stop, -} - -impl DaytonaLifecycleAction { - async fn execute( - self, - client: &daytona_sdk::Client, - sandbox_name: &str, - ) -> Result<(), DaytonaError> { - match self { - Self::Start => client.start(sandbox_name).await.map(drop), - Self::Stop => client.stop(sandbox_name).await.map(drop), - } - } - - fn is_complete(self, state: Option) -> bool { - match self { - Self::Start => state == Some(SandboxState::Started), - Self::Stop => matches!(state, Some(SandboxState::Stopped | SandboxState::Destroyed)), - } - } -} - -/// Sandbox that runs all operations inside a Daytona cloud sandbox. -pub struct DaytonaSandbox { - config: DaytonaConfig, - client: daytona_sdk::Client, - api_key: Option, - push_credentials: PushCredentialState, - sandbox: OnceCell, - snapshot_name: OnceCell, - rg_available: OnceCell, - event_callback: Option, - /// HTTPS origin URL stored after clone so we can refresh push credentials - /// later. - origin_url: OnceCell, - repo_cloned: OnceCell, - working_directory: OnceCell, - run_id: Option, - clone_origin_url: Option, - /// Explicit branch to clone. When set, overrides the branch detected by - /// the submitted run spec. - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, -} - -impl DaytonaSandbox { - /// Create a new `DaytonaSandbox`, creating the Daytona client internally. - /// - /// `api_key` is the Daytona API key, typically resolved from the vault. - /// When `None`, the SDK falls back to the `DAYTONA_API_KEY` env var. - pub async fn new( - config: DaytonaConfig, - github_app: Option, - run_id: Option, - clone_origin_url: Option, - clone_branch: Option, - clone_tag: Option, - clone_commit_sha: Option, - api_key: Option, - ) -> crate::Result { - if clone_tag.is_some() || clone_commit_sha.is_some() { - clone_source::decide_clone( - config.skip_clone, - clone_origin_url.as_deref(), - clone_branch.as_deref(), - clone_tag.as_deref(), - clone_commit_sha.as_deref(), - )?; - } - let api_key = resolve_daytona_api_key(api_key); - let client = build_daytona_client(api_key.clone()) - .await - .map_err(|e| crate::Error::context("Failed to create Daytona client", e))?; - let push_credentials = PushCredentialState::new(push_credentials::build_token_source( - github_app.as_ref(), - clone_origin_url.as_deref(), - )?); - Ok(Self { - config, - client, - api_key, - push_credentials, - sandbox: OnceCell::new(), - snapshot_name: OnceCell::new(), - rg_available: OnceCell::const_new(), - event_callback: None, - origin_url: OnceCell::new(), - repo_cloned: OnceCell::new(), - working_directory: OnceCell::new(), - run_id, - clone_origin_url, - clone_branch, - clone_tag, - clone_commit_sha, - }) - } - - /// Reconnect to an existing Daytona sandbox by name. - /// - /// Creates the client internally and fetches the sandbox, replacing the old - /// `from_existing()` + manual client/get boilerplate at call sites. - pub async fn reconnect( - sandbox_name: &str, - api_key: Option, - repo_cloned: bool, - working_directory: String, - clone_origin_url: Option, - clone_branch: Option, - ) -> crate::Result { - let api_key = resolve_daytona_api_key(api_key); - let client = build_daytona_client(api_key.clone()) - .await - .map_err(|e| crate::Error::context("Failed to create Daytona client", e))?; - let sdk_sandbox = client.get(sandbox_name).await.map_err(|e| { - crate::Error::context( - format!("Failed to reconnect to Daytona sandbox '{sandbox_name}'"), - e, - ) - })?; - let sandbox_cell = OnceCell::new(); - let _ = sandbox_cell.set(sdk_sandbox); - let origin_url = OnceCell::new(); - if repo_cloned { - if let Some(origin) = clone_origin_url.as_ref() { - let _ = origin_url.set(origin.clone()); - } - } - let repo_cloned_cell = OnceCell::new(); - let _ = repo_cloned_cell.set(repo_cloned); - let working_directory_cell = OnceCell::new(); - let _ = working_directory_cell.set(working_directory); - Ok(Self { - config: DaytonaConfig::default(), - client, - api_key, - push_credentials: PushCredentialState::new(None), - sandbox: sandbox_cell, - snapshot_name: OnceCell::new(), - rg_available: OnceCell::const_new(), - event_callback: None, - origin_url, - repo_cloned: repo_cloned_cell, - working_directory: working_directory_cell, - run_id: None, - clone_origin_url, - clone_branch, - clone_tag: None, - clone_commit_sha: None, - }) - } - - pub fn set_event_callback(&mut self, cb: SandboxEventCallback) { - self.event_callback = Some(cb); - } - - /// Get the `ComputerUseService` for this sandbox. - /// - /// Requires the sandbox to be initialized first. - pub async fn computer_use(&self) -> crate::Result { - let sandbox = self.sandbox()?; - sandbox - .computer_use() - .await - .map_err(|e| crate::Error::context("Failed to get computer use service", e)) - } - - /// Create SSH access and return the connection command string. - pub async fn create_ssh_access(&self, ttl_minutes: Option) -> crate::Result { - let sandbox = self.sandbox()?; - let dto = sandbox - .create_ssh_access(ttl_minutes) - .await - .map_err(|e| crate::Error::context("Failed to create SSH access", e))?; - Ok(dto.ssh_command) - } - - /// Get a preview link (URL + token) for a port on this sandbox. - pub async fn get_preview_link(&self, port: u16) -> crate::Result { - let sandbox = self.sandbox()?; - sandbox.get_preview_link(port).await.map_err(|e| { - crate::Error::context(format!("Failed to get preview link for port {port}"), e) - }) - } - - /// Get a signed preview URL for a port on this sandbox. - pub async fn get_signed_preview_url( - &self, - port: u16, - expires_in_seconds: Option, - ) -> crate::Result { - let sandbox = self.sandbox()?; - sandbox - .get_signed_preview_url(i32::from(port), expires_in_seconds) - .await - .map_err(|e| { - crate::Error::context( - format!("Failed to get signed preview URL for port {port}"), - e, - ) - }) - } - - fn emit(&self, event: SandboxEvent) { - event.trace(); - if let Some(ref cb) = self.event_callback { - cb(event); - } - } - - fn report_clone_failure(&self, origin_url: &str, err: crate::Error) -> crate::Error { - self.emit(SandboxEvent::GitCloneFailed { - url: origin_url.to_string(), - error: err.to_string(), - causes: err.causes(), - }); - err - } - - /// Report a clone/setup failure, clean up the created sandbox, and emit - /// the matching initialization failure. - async fn fail_clone_initialization( - &self, - sandbox: daytona_sdk::Sandbox, - origin_url: &str, - init_start: Instant, - err: crate::Error, - ) -> crate::Error { - let err = self.report_clone_failure(origin_url, err); - let err = self.finish_failed_initialization(sandbox, err).await; - self.fail_init(init_start, err) - } - - /// Point the admitted branch at the pinned revision and verify the - /// resulting HEAD. - /// - /// Daytona's native clone honors `commit_id`, but leaves the workspace on - /// whatever ref its own checkout produced (a detached tag, or the exact - /// commit). Re-pointing the branch keeps the admitted branch name readable - /// back out of the workspace, matching what the Docker provider produces - /// for the same inputs. - async fn attach_pinned_branch( - process_svc: &daytona_sdk::ProcessService, - checkout_path: &str, - branch: &str, - pin: &PinnedRevision, - deadline: time::Instant, - ) -> crate::Result<()> { - // An exact commit is named directly; a tag clone is already sitting on - // the tag, so peel whatever HEAD points at to its commit. - let revision = pin.expected_sha().unwrap_or("HEAD^{commit}"); - Self::run_required_post_clone_command( - process_svc, - &clone_source::exact_branch_checkout_command(checkout_path, branch, revision), - "/", - "git checkout pinned revision", - deadline, - ) - .await?; - let head = Self::run_required_post_clone_command( - process_svc, - &clone_source::exact_head_revision_command(checkout_path), - "/", - "git rev-parse HEAD after pinned checkout", - deadline, - ) - .await?; - pin.verify_head(&head)?; - Ok(()) - } - - /// Execute one post-clone command under the shared setup deadline. - /// - /// The SDK timeout asks Daytona to terminate the remote process. The outer - /// timeout is a transport backstop in case the toolbox never returns that - /// result. Callers must not retry after a timeout because the remote - /// process may still be winding down. - async fn execute_post_clone_command( - process_svc: &daytona_sdk::ProcessService, - command: &str, - cwd: &str, - label: &'static str, - deadline: time::Instant, - ) -> crate::Result { - let remaining = deadline.saturating_duration_since(time::Instant::now()); - if remaining < DAYTONA_MIN_SERVER_TIMEOUT { - return Err(crate::Error::message(format!( - "Daytona post-clone setup deadline expired before {label}" - ))); - } - - let wrapped = wrap_bash_command(command); - let options = daytona_sdk::ExecuteCommandOptions { - cwd: Some(cwd.to_string()), - timeout: Some(remaining), - ..Default::default() - }; - let execution = process_svc.execute_command(&wrapped, options); - time::timeout( - remaining.saturating_add(DAYTONA_CLIENT_TIMEOUT_GRACE), - execution, - ) - .await - .map_err(|_| { - crate::Error::message(format!( - "Daytona post-clone setup timed out while running {label}" - )) - })? - .map_err(|e| crate::Error::context(format!("Failed to run {label}"), e)) - } - - /// Run one required local step after the native clone and return stdout. - async fn run_required_post_clone_command( - process_svc: &daytona_sdk::ProcessService, - command: &str, - cwd: &str, - label: &'static str, - deadline: time::Instant, - ) -> crate::Result { - let start = Instant::now(); - let result = - Self::execute_post_clone_command(process_svc, command, cwd, label, deadline).await?; - if result.exit_code != 0 { - return Err(crate::Error::exec(label, ExecResult { - stdout: result.result, - stderr: String::new(), - exit_code: Some(result.exit_code), - termination: CommandTermination::Exited, - duration_ms: elapsed_ms(start), - })); - } - Ok(result.result) - } - - fn fail_init(&self, init_start: Instant, err: crate::Error) -> crate::Error { - let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::InitializeFailed { - provider: "daytona".into(), - error: err.to_string(), - causes: err.causes(), - duration_ms, - }); - err - } - - fn resolve_path(&self, path: &str) -> String { - resolve_path(path, self.working_directory()) - } - - async fn upload_file_content(&self, resolved_path: &str, content: &str) -> crate::Result<()> { - let sandbox = self.sandbox()?; - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - - fs_svc - .upload_file_bytes(resolved_path, content.as_bytes()) - .await - .map_err(|e| crate::Error::context(format!("Failed to write file {resolved_path}"), e)) - } - - /// Verify a Daytona sandbox evaluates commands as non-login Bash. - /// - /// Runs on a freshly created sandbox before any Fabro-owned setup, and - /// again after a reconnected sandbox starts, so a snapshot without Bash - /// fails at the lifecycle boundary rather than on some later command. - /// - /// Both transports are probed. They build different requests — a direct - /// process exec and a toolbox session — so neither is evidence for the - /// other, and a session transport that never yields an exit code otherwise - /// surfaces as every streaming command timing out rather than as an init - /// failure. The direct probe runs first because it isolates "no usable - /// Bash" from "Bash runs but the session contract is broken". - async fn probe_bash(sandbox: &daytona_sdk::Sandbox) -> crate::Result<()> { - Self::probe_bash_exec(sandbox).await?; - Self::probe_bash_session(sandbox).await - } - - /// Create the run-scoped Fabro runtime directory outside the repository - /// checkout, with owner-private permissions on each created level. - async fn create_runtime_directory(sandbox: &daytona_sdk::Sandbox) -> crate::Result<()> { - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get Daytona fs service", e))?; - let runtime_parent = Path::new(RUNTIME_DIRECTORY) - .parent() - .map(|parent| parent.to_string_lossy().to_string()); - if let Some(runtime_parent) = runtime_parent { - fs_svc - .create_folder(&runtime_parent, Some("0700")) - .await - .map_err(|e| { - wrap_fs_error( - "Failed to create Daytona runtime parent directory", - &runtime_parent, - e, - ) - })?; - } - fs_svc - .create_folder(RUNTIME_DIRECTORY, Some("0700")) - .await - .map_err(|e| { - wrap_fs_error( - "Failed to create Daytona runtime directory", - RUNTIME_DIRECTORY, - e, - ) - })?; - Ok(()) - } - - /// Probe Bash over the direct process-exec transport. - async fn probe_bash_exec(sandbox: &daytona_sdk::Sandbox) -> crate::Result<()> { - let start = Instant::now(); - let execution = time::timeout(Duration::from_millis(BASH_PROBE_TIMEOUT_MS), async { - let process_svc = sandbox - .process() - .await - .map_err(|e| crate::Error::context("Failed to get Daytona process service", e))?; - let result = process_svc - .execute_command( - &wrap_bash_command(BASH_PROBE_SCRIPT), - daytona_sdk::ExecuteCommandOptions { - cwd: Some("/".to_string()), - timeout: Some(Duration::from_millis(BASH_PROBE_TIMEOUT_MS)), - ..Default::default() - }, - ) - .await - .map_err(|e| crate::Error::context("Failed to run Daytona Bash check", e))?; - Ok(ExecResult { - stdout: result.result, - stderr: String::new(), - exit_code: Some(result.exit_code), - termination: CommandTermination::Exited, - duration_ms: elapsed_ms(start), - }) - }) - .await; - let execution = match execution { - Ok(result) => result, - Err(_) => Err(crate::Error::message(format!( - "Daytona Bash check timed out after {BASH_PROBE_TIMEOUT_MS}ms" - ))), - }; - - daytona_bash_probe_outcome(execution) - } - - /// Probe Bash over the streaming toolbox-session transport. - /// - /// Builds, submits, and awaits the command exactly the way - /// [`Sandbox::exec_command_streaming`] does, so the provider's exit-code - /// bookkeeping is part of what passes or fails here. The probe script's - /// `BASH_ENV` assertion is vacuous on this path — the generated session - /// script blanks `BASH_ENV` itself — but the interpreter, non-login, - /// non-POSIX, and completion assertions all hold. - /// - /// Costs one session round trip plus a single status poll per sandbox - /// lifecycle transition. `DAYTONA_BASH_SESSION_PROBE_TIMEOUT` is the outer - /// backstop for a stalled REST call; the inner [`BASH_PROBE_TIMEOUT_MS`] is - /// the deadline for the command itself. Session cleanup runs outside that - /// deadline under its own bounded timeout. - async fn probe_bash_session(sandbox: &daytona_sdk::Sandbox) -> crate::Result<()> { - let deadline = time::Instant::now() + DAYTONA_BASH_SESSION_PROBE_TIMEOUT; - let timeout_error = || { - crate::Error::message(format!( - "Daytona Bash session check timed out after {}s", - DAYTONA_BASH_SESSION_PROBE_TIMEOUT.as_secs() - )) - }; - let mut session = match time::timeout_at(deadline, DaytonaSession::create(sandbox)).await { - Ok(Ok(session)) => session, - Ok(Err(err)) => return daytona_bash_session_probe_outcome(Err(err)), - Err(_) => return daytona_bash_session_probe_outcome(Err(timeout_error())), - }; - - let execution = - match time::timeout_at(deadline, Self::run_bash_session_probe(&session)).await { - Ok(result) => result, - Err(_) => Err(timeout_error()), - }; - let close_reason = if execution.is_ok() { - "bash session probe finished" - } else { - "bash session probe failure" - }; - session.close(close_reason).await; - daytona_bash_session_probe_outcome(execution) - } - - /// Run one probe command through a Daytona session and collect its result. - async fn run_bash_session_probe(session: &DaytonaSession) -> crate::Result { - let start = Instant::now(); - let session_exec = session - .execute( - &build_bash_session_command(BASH_PROBE_SCRIPT, "/", None), - true, - true, - ) - .await - .map_err(|err| { - crate::Error::context("Failed to execute Daytona session command", err) - })?; - - if let Some(exit_code) = session_exec.exit_code { - return Ok(ExecResult { - stdout: session_exec - .stdout - .or(session_exec.output) - .unwrap_or_default(), - stderr: session_exec.stderr.unwrap_or_default(), - exit_code: Some(exit_code), - termination: CommandTermination::Exited, - duration_ms: elapsed_ms(start), - }); - } - let command_id = session_exec.cmd_id; - - let outcome = wait_for_completion( - session, - &command_id, - None, - Some(BASH_PROBE_TIMEOUT_MS), - CancellationToken::new(), - ) - .await?; - - let logs = match outcome.final_logs { - Some(logs) => Some(logs), - None => session.fetch_logs(&command_id).await, - }; - let (stdout, stderr) = logs - .map(|logs| (logs.stdout, logs.stderr)) - .unwrap_or_default(); - - Ok(ExecResult { - stdout, - stderr, - exit_code: outcome.exit_code, - termination: outcome.termination, - duration_ms: elapsed_ms(start), - }) - } - - /// Discard a sandbox whose initialization failed after creation. - /// - /// A failed cleanup is logged rather than returned: the initialization - /// failure is what the operator needs to act on. The SDK handle is - /// returned only when the caller should retain it for a lifecycle-level - /// cleanup retry. - async fn cleanup_failed_initialization_sandbox( - sandbox: daytona_sdk::Sandbox, - initialization_error: crate::Error, - ) -> (crate::Error, Option) { - match Self::delete_daytona_sandbox(&sandbox).await { - Ok(()) => (initialization_error, None), - Err(cleanup_error) => { - tracing::warn!( - error = %crate::display_for_log(&cleanup_error), - sandbox = %sandbox.name, - "Failed to delete Daytona sandbox after initialization failed" - ); - (initialization_error, Some(sandbox)) - } - } - } - - async fn delete_daytona_sandbox(sandbox: &daytona_sdk::Sandbox) -> crate::Result<()> { - match time::timeout(DAYTONA_CLEANUP_TIMEOUT, sandbox.delete()).await { - Ok(Ok(())) => Ok(()), - Ok(Err(err)) if daytona_not_found(&err) => Ok(()), - Ok(Err(err)) => Err(crate::Error::context( - "Failed to delete Daytona sandbox", - err, - )), - Err(_) => Err(crate::Error::message(format!( - "Timed out deleting Daytona sandbox after {}s", - DAYTONA_CLEANUP_TIMEOUT.as_secs() - ))), - } - } - - async fn finish_failed_initialization( - &self, - sandbox: daytona_sdk::Sandbox, - initialization_error: crate::Error, - ) -> crate::Error { - let (initialization_error, retry_sandbox) = - Self::cleanup_failed_initialization_sandbox(sandbox, initialization_error).await; - if let Some(sandbox) = retry_sandbox { - if self.sandbox.set(sandbox).is_err() { - tracing::warn!( - "Failed to retain Daytona sandbox handle after initialization cleanup \ - failed" - ); - } - } - initialization_error - } - - /// Get the sandbox, returning an error if not yet initialized. - fn sandbox(&self) -> crate::Result<&daytona_sdk::Sandbox> { - self.sandbox.get().ok_or_else(|| { - crate::Error::message("Daytona sandbox not initialized — call initialize() first") - }) - } - - /// Read-only access to the SDK sandbox once initialized. Returns `None` - /// before `initialize()` or `reconnect()` has populated the cell. - pub fn sandbox_handle(&self) -> Option<&daytona_sdk::Sandbox> { - self.sandbox.get() - } - - pub(crate) fn daytona_id(&self) -> crate::Result<&str> { - Ok(&self.sandbox()?.id) - } - - fn repo_cloned(&self) -> bool { - self.repo_cloned.get().copied().unwrap_or(false) - } - - fn set_working_directory(&self, working_directory: impl Into) -> crate::Result<()> { - self.working_directory - .set(working_directory.into()) - .map_err(|_| crate::Error::message("Daytona working directory already initialized")) - } - - /// Build `SandboxBaseParams` from config, generating a unique sandbox name. - fn base_params(&self) -> daytona_sdk::SandboxBaseParams { - let name = if let Some(ref id) = self.run_id { - format!("fabro-{id}") - } else { - format!( - "fabro-{}-{:04x}", - chrono::Utc::now().format("%Y%m%d-%H%M%S"), - rand::rng().random_range(0..0x10000u32), - ) - }; - let (network_block_all, network_allow_list) = match &self.config.network { - Some(DaytonaNetwork::Block) => (Some(true), None), - Some(DaytonaNetwork::AllowAll) => (Some(false), None), - Some(DaytonaNetwork::AllowList(cidrs)) => (None, Some(cidrs.clone())), - None => (None, None), - }; - daytona_sdk::SandboxBaseParams { - name: Some(name), - env_vars: Some(clean_bash_env(None)), - auto_stop_interval: self - .config - .auto_stop_interval - .or(Some(DEFAULT_AUTO_STOP_INTERVAL_MINUTES)), - labels: Some(managed_labels::merge_for_run( - self.config.labels.as_ref(), - self.run_id.as_ref(), - )), - auto_delete_interval: Some(-1), - ephemeral: Some(false), - network_block_all, - network_allow_list, - ..Default::default() - } - } - - /// Ensure the named snapshot exists and is active. - /// - /// If the snapshot doesn't exist and an image source is provided, creates - /// it and polls until it reaches `Active` state. Returns an error if - /// the snapshot is in a terminal failure state. - async fn ensure_snapshot( - &self, - name: &str, - snap_cfg: &DaytonaSnapshotConfig, - ) -> crate::Result<()> { - match self.client.snapshot.get(name).await { - Ok(dto) => { - use daytona_api_client::models::SnapshotState; - match dto.state { - SnapshotState::Active => return Ok(()), - SnapshotState::Error | SnapshotState::BuildFailed => { - return Err(crate::Error::message(format!( - "Snapshot '{}' is in state '{}': {}", - name, - dto.state, - dto.error_reason.unwrap_or_default() - ))); - } - _ => { - // Building/Pending/Pulling — fall through to poll - self.emit(SandboxEvent::SnapshotCreating { - name: name.to_string(), - }); - } - } - } - Err(daytona_sdk::DaytonaError::NotFound { .. }) => { - self.emit(SandboxEvent::SnapshotCreating { - name: name.to_string(), - }); - - let params = create_snapshot_params(name, snap_cfg)?; - self.client.snapshot.create(¶ms).await.map_err(|e| { - crate::Error::context(format!("Failed to create snapshot '{name}'"), e) - })?; - } - Err(e) => { - return Err(crate::Error::context( - format!("Failed to get snapshot '{name}'"), - e, - )); - } - } - - // Poll until Active (or terminal failure). - self.poll_snapshot_active(name).await - } - - /// Poll a snapshot until it reaches `Active` state, with exponential - /// back-off. - async fn poll_snapshot_active(&self, name: &str) -> crate::Result<()> { - use daytona_api_client::models::SnapshotState; - let mut delay = std::time::Duration::from_secs(2); - let max_delay = std::time::Duration::from_secs(30); - let deadline = Instant::now() + DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT; - - while Instant::now() < deadline { - time::sleep(delay).await; - let dto = self.client.snapshot.get(name).await.map_err(|e| { - crate::Error::context(format!("Failed to poll snapshot '{name}'"), e) - })?; - - match dto.state { - SnapshotState::Active => return Ok(()), - SnapshotState::Error | SnapshotState::BuildFailed => { - return Err(crate::Error::message(format!( - "Snapshot '{name}' failed ({}): {}", - dto.state, - dto.error_reason.unwrap_or_default() - ))); - } - _ => { - delay = (delay * 2).min(max_delay); - } - } - } - - Err(crate::Error::message(format!( - "Timed out waiting for snapshot '{name}' to become active" - ))) - } - - async fn wait_for_stable_state( - &self, - sandbox_name: &str, - ) -> Result, DaytonaError> { - loop { - time::sleep(DAYTONA_STATE_CHANGE_POLL_INTERVAL).await; - let state = self.client.get(sandbox_name).await?.state; - if !is_transitional_state(state) { - return Ok(state); - } - } - } - - async fn run_lifecycle_action( - &self, - sandbox_name: &str, - action: DaytonaLifecycleAction, - deadline: time::Instant, - ) -> crate::Result<()> { - loop { - let request = time::timeout_at(deadline, action.execute(&self.client, sandbox_name)); - match request.await { - Ok(Ok(())) => return Ok(()), - Ok(Err(source)) if is_state_change_in_progress(&source) => { - tracing::debug!( - action = %action, - sandbox = sandbox_name, - "Daytona lifecycle request rejected during state change" - ); - match time::timeout_at(deadline, self.wait_for_stable_state(sandbox_name)).await - { - Ok(Ok(state)) if action.is_complete(state) => return Ok(()), - Ok(Ok(_)) => {} - Ok(Err(wait_source)) => { - return Err(crate::Error::context( - format!( - "Failed to inspect Daytona sandbox while waiting to {action}" - ), - wait_source, - )); - } - Err(_) => { - return Err(crate::Error::context( - format!("Timed out waiting to {action} Daytona sandbox"), - source, - )); - } - } - } - Ok(Err(source)) => { - return Err(crate::Error::context( - format!("Failed to {action} Daytona sandbox"), - source, - )); - } - Err(_) => { - return Err(crate::Error::message(format!( - "Timed out waiting to {action} Daytona sandbox" - ))); - } - } - } - } - - fn start_error(&self, error: crate::Error) -> crate::Result<()> { - self.emit(SandboxEvent::StartFailed { - provider: "daytona".into(), - error: error.to_string(), - causes: error.causes(), - }); - Err(error) - } - - fn stop_error(&self, error: crate::Error) -> crate::Result<()> { - self.emit(SandboxEvent::StopFailed { - provider: "daytona".into(), - error: error.to_string(), - causes: error.causes(), - }); - Err(error) - } - - async fn start_with_deadline(&self, deadline: time::Instant) -> crate::Result<()> { - self.emit(SandboxEvent::StartStarted { - provider: "daytona".into(), - }); - let start = Instant::now(); - let result = async { - let sandbox = self.sandbox()?; - self.run_lifecycle_action(&sandbox.name, DaytonaLifecycleAction::Start, deadline) - .await?; - Self::probe_bash(sandbox).await - } - .await; - if let Err(error) = result { - return self.start_error(error); - } - self.emit(SandboxEvent::StartCompleted { - provider: "daytona".into(), - duration_ms: elapsed_ms(start), - }); - Ok(()) - } - - async fn stop_with_deadline(&self, deadline: time::Instant) -> crate::Result<()> { - self.emit(SandboxEvent::StopStarted { - provider: "daytona".into(), - }); - let start = Instant::now(); - let result = async { - let sandbox = self.sandbox()?; - self.run_lifecycle_action(&sandbox.name, DaytonaLifecycleAction::Stop, deadline) - .await - } - .await; - if let Err(error) = result { - return self.stop_error(error); - } - self.emit(SandboxEvent::StopCompleted { - provider: "daytona".into(), - duration_ms: elapsed_ms(start), - }); - Ok(()) - } -} - -fn is_state_change_in_progress(err: &DaytonaError) -> bool { - err.status_code() == Some(400) - && err - .message() - .to_ascii_lowercase() - .contains("state change in progress") -} - -fn is_transitional_state(state: Option) -> bool { - matches!( - state, - Some( - SandboxState::Creating - | SandboxState::Restoring - | SandboxState::Destroying - | SandboxState::Starting - | SandboxState::Stopping - | SandboxState::PendingBuild - | SandboxState::BuildingSnapshot - | SandboxState::PullingSnapshot - | SandboxState::Archiving - | SandboxState::Resizing - ) - ) -} - -/// Detect the git remote URL and current branch from a local repository. -/// -/// Uses `git2` to discover the repo at `path`, reads the `origin` remote URL -/// and the HEAD branch name. -pub fn detect_repo_info(path: &Path) -> crate::Result<(String, Option)> { - let repo = git2::Repository::discover(path).map_err(|e| { - crate::Error::context( - format!("Failed to discover git repo at {}", path.display()), - e, - ) - })?; - - let url = repo - .find_remote("origin") - .map_err(|e| crate::Error::context("Failed to find 'origin' remote", e))? - .url() - .ok_or_else(|| crate::Error::message("origin remote URL is not valid UTF-8"))? - .to_string(); - - let branch = repo - .head() - .ok() - .and_then(|head| head.shorthand().map(String::from)); - - Ok((url, branch)) -} - -#[async_trait] -impl Sandbox for DaytonaSandbox { - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &Path, - ) -> crate::Result<()> { - let sandbox = self.sandbox()?; - let resolved = self.resolve_path(remote_path); - - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - - let bytes = fs_svc - .download_file(&resolved) - .await - .map_err(|e| crate::Error::context(format!("Failed to download file {resolved}"), e))?; - - if let Some(parent) = local_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| crate::Error::context("Failed to create parent dirs", e))?; - } - fs::write(local_path, &bytes).await.map_err(|e| { - crate::Error::context(format!("Failed to write {}", local_path.display()), e) - })?; - - Ok(()) - } - - async fn upload_file_from_local( - &self, - local_path: &Path, - remote_path: &str, - ) -> crate::Result<()> { - let sandbox = self.sandbox()?; - let resolved = self.resolve_path(remote_path); - - // Ensure parent directory exists - if let Some(parent) = Path::new(&resolved).parent() { - let parent_str = parent.to_string_lossy(); - if parent_str != "/" { - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - let _ = fs_svc.create_folder(&parent_str, None).await; - } - } - - let bytes = fs::read(local_path).await.map_err(|e| { - crate::Error::context(format!("Failed to read {}", local_path.display()), e) - })?; - - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - - fs_svc - .upload_file_bytes(&resolved, &bytes) - .await - .map_err(|e| crate::Error::context(format!("Failed to upload file {resolved}"), e))?; - - Ok(()) - } - - async fn initialize(&self) -> crate::Result<()> { - self.emit(SandboxEvent::Initializing { - provider: "daytona".into(), - }); - let init_start = Instant::now(); - - let params = if let Some(snap_cfg) = self.config.snapshot.as_ref() { - let api_key = self.api_key.as_deref().ok_or_else(|| { - self.fail_init( - init_start, - crate::Error::message(format!( - "{} is required to compute Daytona snapshot identity", - EnvVars::DAYTONA_API_KEY - )), - ) - })?; - let snapshot_name = match snapshot_identity::snapshot_name(api_key, snap_cfg) { - Ok(name) => name, - Err(err) => return Err(self.fail_init(init_start, err)), - }; - let snap_start = Instant::now(); - if let Err(e) = self.ensure_snapshot(&snapshot_name, snap_cfg).await { - self.emit(SandboxEvent::SnapshotFailed { - name: snapshot_name.clone(), - error: e.to_string(), - causes: e.causes(), - }); - return Err(self.fail_init(init_start, e)); - } - let snap_duration = u64::try_from(snap_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::SnapshotReady { - name: snapshot_name.clone(), - duration_ms: snap_duration, - }); - let _ = self.snapshot_name.set(snapshot_name.clone()); - - daytona_sdk::CreateParams::Snapshot(daytona_sdk::SnapshotParams { - base: self.base_params(), - snapshot: snapshot_name, - }) - } else { - let _ = self.snapshot_name.set(DEFAULT_SNAPSHOT.to_string()); - daytona_sdk::CreateParams::Snapshot(daytona_sdk::SnapshotParams { - base: self.base_params(), - snapshot: DEFAULT_SNAPSHOT.to_string(), - }) - }; - - tracing::info!("Creating Daytona sandbox"); - let sandbox = self - .client - .create(params, daytona_sdk::CreateSandboxOptions::default()) - .await - .map_err(|e| { - self.fail_init( - init_start, - crate::Error::context("Failed to create Daytona sandbox", e), - ) - })?; - - if let Err(bash_error) = Self::probe_bash(&sandbox).await { - let err = self.finish_failed_initialization(sandbox, bash_error).await; - return Err(self.fail_init(init_start, err)); - } - - if let Err(runtime_error) = Self::create_runtime_directory(&sandbox).await { - let err = self - .finish_failed_initialization(sandbox, runtime_error) - .await; - return Err(self.fail_init(init_start, err)); - } - - let clone_decision = clone_source::decide_clone( - self.config.skip_clone, - self.clone_origin_url.as_deref(), - self.clone_branch.as_deref(), - self.clone_tag.as_deref(), - self.clone_commit_sha.as_deref(), - ) - .map_err(|e| self.fail_init(init_start, e))?; - - match clone_decision { - CloneDecision::EmptyWorkspace { reason } => { - if matches!(reason, EmptyWorkspaceReason::MissingOrigin) { - tracing::warn!( - provider = "daytona", - reason = reason.message(), - "Clone source missing for clone-based sandbox" - ); - } - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get Daytona fs service", e))?; - fs_svc - .create_folder(WORKING_DIRECTORY, None) - .await - .map_err(|e| crate::Error::context("Failed to create working directory", e))?; - let _ = self.repo_cloned.set(false); - self.set_working_directory(WORKING_DIRECTORY) - .map_err(|err| self.fail_init(init_start, err))?; - } - CloneDecision::GitHub { - origin_url, - branch, - tag, - commit_sha, - } => { - let layout = - clone_source::github_repo_layout(&origin_url, WORKING_DIRECTORY, REPOS_ROOT) - .map_err(|err| self.fail_init(init_start, err))?; - self.emit(SandboxEvent::GitCloneStarted { - url: origin_url.clone(), - branch: branch.clone(), - }); - let clone_start = Instant::now(); - - // The clone mints its own token (never a warm-cache reuse) and - // seeds the shared source, so the first refresh compares - // against the clone token instead of believing nothing was - // ever embedded. - let resolved_token = match self.push_credentials.source() { - Some(source) => Some(source.mint_for_clone().await.map_err(|source| { - let err = crate::Error::context_anyhow( - "Failed to get GitHub App credentials for clone", - source, - ); - self.emit(SandboxEvent::GitCloneFailed { - url: origin_url.clone(), - error: err.to_string(), - causes: err.causes(), - }); - self.fail_init(init_start, err) - })?), - None => None, - }; - let clone_credential_context = CredentialContext::from_snapshot( - resolved_token.as_ref().map(|token| &token.snapshot), - ); - let (username, password) = match &resolved_token { - Some(token) => ( - Some("x-access-token".to_string()), - Some(token.token.expose().to_string()), - ), - None => (None, None), - }; - - let fs_svc = sandbox.fs().await.map_err(|e| { - let err = crate::Error::context("Failed to get Daytona fs service", e); - self.emit(SandboxEvent::GitCloneFailed { - url: origin_url.clone(), - error: err.to_string(), - causes: err.causes(), - }); - self.fail_init(init_start, err) - })?; - fs_svc - .create_folder(WORKING_DIRECTORY, None) - .await - .map_err(|e| { - let err = wrap_fs_error( - "Failed to create Daytona workspace root", - WORKING_DIRECTORY, - e, - ); - self.emit(SandboxEvent::GitCloneFailed { - url: origin_url.clone(), - error: err.to_string(), - causes: err.causes(), - }); - self.fail_init(init_start, err) - })?; - fs_svc.create_folder(REPOS_ROOT, None).await.map_err(|e| { - let err = wrap_fs_error("Failed to create Daytona repos root", REPOS_ROOT, e); - self.emit(SandboxEvent::GitCloneFailed { - url: origin_url.clone(), - error: err.to_string(), - causes: err.causes(), - }); - self.fail_init(init_start, err) - })?; - fs_svc - .create_folder(&layout.repos_owner_path, None) - .await - .map_err(|e| { - let err = wrap_fs_error( - "Failed to create Daytona repos owner directory", - &layout.repos_owner_path, - e, - ); - self.emit(SandboxEvent::GitCloneFailed { - url: origin_url.clone(), - error: err.to_string(), - causes: err.causes(), - }); - self.fail_init(init_start, err) - })?; - - let git_svc = sandbox.git().await.map_err(|e| { - let err = crate::Error::context("Failed to get Daytona git service", e); - let err = self.report_clone_failure(&origin_url, err); - self.fail_init(init_start, err) - })?; - - let pin = PinnedRevision::from_selectors(tag.as_deref(), commit_sha.as_deref()); - let clone_selector = git_clone_selector(branch.as_deref(), pin.as_ref()); - let clone_plan = git_retry::RetryPlan::clone_default(None); - let clone_result = git_retry::retry_git_operation( - SandboxProviderKind::DAYTONA, - "clone", - &clone_plan, - |_attempt| { - let git_svc = &git_svc; - let origin = origin_url.as_str(); - let target = layout.primary_repo_path.as_str(); - let options = GitCloneOptions { - branch: clone_selector.clone(), - commit_id: commit_sha.clone(), - username: username.clone(), - password: password.clone(), - depth: self.config.clone_depth, - ..GitCloneOptions::default() - }; - async move { git_svc.clone(origin, target, options).await } - }, - |err: &DaytonaError| classify_clone_failure(err, clone_credential_context), - ) - .await; - - match clone_result { - Ok(()) => {} - Err(e) if self.push_credentials.source().is_none() => { - let err = crate::Error::context( - "Git clone failed. If this is a private repository, configure a \ - GitHub App with `fabro install` and install it for your organization.", - e, - ); - return Err(self - .fail_clone_initialization(sandbox, &origin_url, init_start, err) - .await); - } - Err(e) => { - let err = - crate::Error::context("Failed to clone repo into Daytona sandbox", e); - return Err(self - .fail_clone_initialization(sandbox, &origin_url, init_start, err) - .await); - } - } - - let post_clone_deadline = time::Instant::now() + DAYTONA_POST_CLONE_SETUP_TIMEOUT; - let process_svc = match sandbox.process().await { - Ok(process_svc) => process_svc, - Err(e) => { - let err = crate::Error::context("Failed to get Daytona process service", e); - return Err(self - .fail_clone_initialization(sandbox, &origin_url, init_start, err) - .await); - } - }; - - if let Some(pin) = &pin { - let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty()) - else { - let err = crate::Error::message(format!( - "{} requires a repository branch", - pin.label() - )); - return Err(self - .fail_clone_initialization(sandbox, &origin_url, init_start, err) - .await); - }; - if let Err(err) = Self::attach_pinned_branch( - &process_svc, - &layout.primary_repo_path, - branch, - pin, - post_clone_deadline, - ) - .await - { - return Err(self - .fail_clone_initialization(sandbox, &origin_url, init_start, err) - .await); - } - } - - let symlink_cmd = clone_source::repo_symlink_command(&layout); - if let Err(err) = Self::run_required_post_clone_command( - &process_svc, - &symlink_cmd, - "/", - "create Daytona workspace repo symlink", - post_clone_deadline, - ) - .await - { - return Err(self - .fail_clone_initialization(sandbox, &origin_url, init_start, err) - .await); - } - - let clone_duration = - u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::GitCloneCompleted { - url: origin_url.clone(), - duration_ms: clone_duration, - }); - - let _ = self.repo_cloned.set(true); - let _ = self.origin_url.set(origin_url.clone()); - self.set_working_directory(layout.execution_directory.clone()) - .map_err(|err| self.fail_init(init_start, err))?; - if let Some(resolved) = resolved_token { - match fabro_github::embed_token_in_url(&origin_url, resolved.token.expose()) { - Ok(auth_url) => { - let credential_deadline = - time::Instant::now() + DAYTONA_CREDENTIAL_SETUP_TIMEOUT; - let cmd = format!( - "git -c maintenance.auto=0 remote set-url origin {}", - shell_quote(auth_url.as_raw_url().as_str()), - ); - match Self::execute_post_clone_command( - &process_svc, - &cmd, - &layout.execution_directory, - "git remote set-url origin (Daytona post-clone)", - credential_deadline, - ) - .await - { - Ok(r) if r.exit_code != 0 => { - let err = crate::Error::exec( - "git remote set-url origin (Daytona post-clone)", - ExecResult { - stdout: String::new(), - stderr: redact_auth_url( - &r.result, - Some(&auth_url), - ), - exit_code: Some(r.exit_code), - termination: CommandTermination::Exited, - duration_ms: 0, - }, - ); - tracing::warn!( - error = %crate::display_for_log(&err), - "Failed to set Daytona sandbox push credentials \ - on origin — subsequent git push from this \ - sandbox will fail" - ); - } - Ok(_) => { - // Origin now carries this token; record it so refreshes compare - // against the clone generation. - self.push_credentials.record_embedded(resolved).await; - } - Err(_) => { - tracing::warn!( - error_class = "daytona_set_url_exec_failed", - "Daytona exec failed while setting push credentials \ - on origin — subsequent git push from this \ - sandbox will fail" - ); - } - } - } - Err(e) => { - tracing::warn!( - origin = %fabro_redact::redacted_url_for_log(&origin_url), - error = %e, - "Failed to build authenticated origin URL — \ - subsequent git push from this sandbox will fail" - ); - } - } - } - } - } - - let sandbox_name = sandbox.name.clone(); - let sandbox_cpu = sandbox.cpu; - let sandbox_memory = sandbox.memory; - self.sandbox - .set(sandbox) - .map_err(|_| crate::Error::message("Daytona sandbox already initialized"))?; - tracing::info!("Daytona sandbox ready"); - - let init_duration = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::Ready { - provider: "daytona".into(), - duration_ms: init_duration, - name: Some(sandbox_name), - cpu: Some(sandbox_cpu), - memory: Some(sandbox_memory), - url: Some(DAYTONA_DASHBOARD_SANDBOXES_URL.into()), - }); - - Ok(()) - } - - async fn start(&self) -> crate::Result<()> { - self.start_with_deadline(time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT) - .await - } - - async fn activate(&self) -> crate::Result<()> { - let sandbox = self.sandbox()?; - let deadline = time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT; - let current = time::timeout_at(deadline, self.client.get(&sandbox.name)) - .await - .map_err(|_| { - crate::Error::message("Timed out inspecting Daytona sandbox before activation") - })? - .map_err(|e| { - crate::Error::context("Failed to inspect Daytona sandbox before activation", e) - })?; - let state = if is_transitional_state(current.state) { - time::timeout_at(deadline, self.wait_for_stable_state(&sandbox.name)) - .await - .map_err(|_| { - crate::Error::message( - "Timed out waiting for Daytona sandbox state change before activation", - ) - })? - .map_err(|e| { - crate::Error::context( - "Failed to wait for Daytona sandbox state change before activation", - e, - ) - })? - } else { - current.state - }; - if state == Some(SandboxState::Started) { - return Ok(()); - } - self.start_with_deadline(deadline).await - } - - async fn stop(&self) -> crate::Result<()> { - self.stop_with_deadline(time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT) - .await - } - - async fn delete(&self) -> crate::Result<()> { - self.emit(SandboxEvent::DeleteStarted { - provider: "daytona".into(), - }); - let start = Instant::now(); - if let Some(sandbox) = self.sandbox.get() { - tracing::info!("Deleting Daytona sandbox"); - if let Err(err) = Self::delete_daytona_sandbox(sandbox).await { - self.emit(SandboxEvent::DeleteFailed { - provider: "daytona".into(), - error: err.to_string(), - causes: err.causes(), - }); - return Err(err); - } - } - let duration_ms = elapsed_ms(start); - self.emit(SandboxEvent::DeleteCompleted { - provider: "daytona".into(), - duration_ms, - }); - Ok(()) - } - - async fn cleanup(&self) -> crate::Result<()> { - self.delete().await - } - - fn working_directory(&self) -> &str { - self.working_directory - .get() - .map_or(WORKING_DIRECTORY, String::as_str) - } - - fn runtime_directory(&self) -> Option<&str> { - Some(RUNTIME_DIRECTORY) - } - - fn platform(&self) -> &'static str { - "linux" - } - - fn os_version(&self) -> String { - "Linux (Daytona)".to_string() - } - - fn sandbox_info(&self) -> String { - self.sandbox - .get() - .map(|s| s.name.clone()) - .unwrap_or_default() - } - - fn snapshot_info(&self) -> Option { - self.snapshot_name.get().cloned() - } - - async fn setup_git( - &self, - intent: &crate::GitSetupIntent, - ) -> crate::Result> { - if !self.repo_cloned() { - return Ok(None); - } - crate::setup_git_via_exec(self, intent).await.map(Some) - } - - fn resume_setup_commands(&self, run_branch: &str) -> Vec { - if !self.repo_cloned() { - return Vec::new(); - } - vec![format!( - "git fetch origin {} && git checkout {}", - shell_quote(run_branch), - shell_quote(run_branch) - )] - } - - async fn git_push_ref( - &self, - refspec: &str, - plan: &crate::RetryPlan, - ) -> Result { - if !self.repo_cloned() { - return Ok(crate::PushReport::default()); - } - let credentials = self - .origin_url - .get() - .map(|origin_url| (&self.push_credentials, origin_url.as_str())); - sandbox::git_push_via_exec(self, credentials, refspec, plan).await - } - - async fn ssh_access_command(&self) -> crate::Result> { - self.create_ssh_access(Some(60.0)).await.map(Some) - } - - fn origin_url(&self) -> Option<&str> { - if !self.repo_cloned() { - return None; - } - self.origin_url.get().map(String::as_str) - } - - async fn get_preview_url( - &self, - port: u16, - ) -> crate::Result)>> { - let sandbox = self.sandbox()?; - let preview = sandbox.get_preview_link(port).await.map_err(|e| { - crate::Error::context(format!("Failed to get preview link for port {port}"), e) - })?; - let mut headers = HashMap::new(); - if !preview.token.is_empty() { - headers.insert("x-daytona-preview-token".to_string(), preview.token); - } - headers.insert( - "X-Daytona-Skip-Preview-Warning".to_string(), - "true".to_string(), - ); - Ok(Some((preview.url, headers))) - } - - #[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))] - async fn refresh_push_credentials(&self) -> crate::Result { - if !self.repo_cloned() { - return Ok(RefreshOutcome::none()); - } - let Some(origin_url) = self.origin_url.get() else { - return Ok(RefreshOutcome::none()); // no authenticated origin — nothing to refresh - }; - self.push_credentials - .refresh(origin_url, |auth_url| { - push_credentials::set_auth_url_via_exec(self, auth_url) - }) - .await - } - - fn push_token_source(&self) -> Option> { - self.push_credentials.source().cloned() - } - - async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> { - let sandbox_id = self.sandbox()?.id.clone(); - let mut sandbox = - self.client.get(&sandbox_id).await.map_err(|e| { - crate::Error::context("Failed to get sandbox for autostop update", e) - })?; - sandbox - .set_autostop_interval(minutes) - .await - .map_err(|e| crate::Error::context("Failed to set autostop interval", e)) - } - - async fn read_file_bytes(&self, path: &str) -> crate::Result> { - let sandbox = self.sandbox()?; - let resolved = self.resolve_path(path); - - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - - let bytes = fs_svc - .download_file(&resolved) - .await - .map_err(|e| crate::Error::context(format!("Failed to read file {resolved}"), e))?; - - Ok(bytes) - } - - async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> { - let sandbox = self.sandbox()?; - let resolved = self.resolve_path(path); - - // Ensure parent directory exists - if let Some(parent) = Path::new(&resolved).parent() { - let parent_str = parent.to_string_lossy(); - if parent_str != "/" { - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - let _ = fs_svc.create_folder(&parent_str, None).await; - } - } - - self.upload_file_content(&resolved, content).await - } - - async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> { - let resolved = self.resolve_path(path); - self.upload_file_content(&resolved, content).await - } - - async fn delete_file(&self, path: &str) -> crate::Result<()> { - let sandbox = self.sandbox()?; - let resolved = self.resolve_path(path); - - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - - fs_svc - .delete_file(&resolved, false) - .await - .map_err(|e| crate::Error::context(format!("Failed to delete file {resolved}"), e))?; - - Ok(()) - } - - async fn file_exists(&self, path: &str) -> crate::Result { - let sandbox = self.sandbox()?; - let resolved = self.resolve_path(path); - - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - - match fs_svc.get_file_info(&resolved).await { - Ok(_) => Ok(true), - Err(daytona_sdk::DaytonaError::NotFound { .. }) => Ok(false), - Err(e) => Err(crate::Error::context( - format!("Failed to check file existence {resolved}"), - e, - )), - } - } - - async fn list_directory( - &self, - path: &str, - _depth: Option, - ) -> crate::Result> { - let sandbox = self.sandbox()?; - let resolved = self.resolve_path(path); - - let fs_svc = sandbox - .fs() - .await - .map_err(|e| crate::Error::context("Failed to get fs service", e))?; - - let files = fs_svc.list_files(&resolved).await.map_err(|e| { - crate::Error::context(format!("Failed to list directory {resolved}"), e) - })?; - - Ok(files - .into_iter() - .map(|f| DirEntry { - name: f.name, - is_dir: f.is_dir, - size: if f.size > 0 { - u64::try_from(f.size).ok() - } else { - None - }, - }) - .collect()) - } - - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&HashMap>, - cancel_token: Option, - ) -> crate::Result { - tracing::info!( - timeout_ms, - command_kind = command_kind(command), - command_len = command.len(), - "exec_command: entered" - ); - - let sandbox = self.sandbox()?; - let start = Instant::now(); - - let cwd = working_dir.map_or_else( - || self.working_directory().to_string(), - |d| self.resolve_path(d), - ); - - let process_svc = sandbox - .process() - .await - .map_err(|e| crate::Error::context("Failed to get process service", e))?; - - tracing::info!( - elapsed_ms = elapsed_ms(start), - "exec_command: process service acquired, starting select" - ); - - let clean_env = clean_bash_env(env_vars); - let options = daytona_sdk::ExecuteCommandOptions { - cwd: Some(cwd), - env: Some(clean_env.clone()), - timeout: Some(std::time::Duration::from_millis(timeout_ms)), - }; - - // The Daytona toolbox's /process/execute endpoint does not yet - // process the `envs` field (not in its OpenAPI spec), so we also - // prepend `export` statements as a fallback until server support - // lands. The SDK sends `envs` too for forward compatibility. - let command_with_env = format!("{}\n{command}", bash_export_lines(&clean_env).join("\n")); - - // Wrap with `bash -c` so pipes, env vars, and shell features work. - // The Daytona API uses direct exec, not a shell. - let wrapped = wrap_bash_command(&command_with_env); - - let timeout_duration = std::time::Duration::from_millis(timeout_ms + 2000); // 2s grace period - let token = cancel_token.unwrap_or_default(); - let exec_future = process_svc.execute_command(&wrapped, options); - - let result = tokio::select! { - res = exec_future => { - tracing::info!( - elapsed_ms = elapsed_ms(start), - ok = res.is_ok(), - "exec_command: HTTP response received" - ); - res.map_err(|e| crate::Error::context("Failed to execute command", e))? - } - () = time::sleep(timeout_duration) => { - tracing::info!( - elapsed_ms = elapsed_ms(start), - timeout_ms, - "exec_command: client-side timeout fired" - ); - return Ok(ExecResult { - stdout: String::new(), - stderr: "Command timed out locally".to_string(), - exit_code: None, - termination: CommandTermination::TimedOut, - duration_ms: elapsed_ms(start), - }); - } - () = token.cancelled() => { - tracing::info!( - elapsed_ms = elapsed_ms(start), - "exec_command: cancelled via token" - ); - return Ok(ExecResult { - stdout: String::new(), - stderr: "Command cancelled".to_string(), - exit_code: None, - termination: CommandTermination::Cancelled, - duration_ms: elapsed_ms(start), - }); - } - }; - - let duration_ms = elapsed_ms(start); - - // The Daytona SDK returns combined output in `result` field. - // Separate stderr isn't available in the simple execute_command API. - Ok(ExecResult { - stdout: result.result.clone(), - stderr: String::new(), - exit_code: Some(result.exit_code), - termination: CommandTermination::Exited, - duration_ms, - }) - } - - async fn exec_command_streaming( - &self, - request: ExecStreamingRequest<'_>, - ) -> crate::Result { - let ExecStreamingRequest { - command, - timeout_ms, - working_dir, - env_vars, - cancel_token, - stdin, - output_callback, - stream_output_bytes_cap, - } = request; - let sandbox = self.sandbox()?; - let start = Instant::now(); - let cwd = working_dir.map_or_else( - || self.working_directory().to_string(), - |d| self.resolve_path(d), - ); - let stdin_upload = async { - match stdin { - Some(stdin) => DaytonaStdinFile::create(sandbox, &stdin).await.map(Some), - None => Ok(None), - } - }; - let (mut stdin_file, mut session) = - tokio::try_join!(stdin_upload, DaytonaSession::create(sandbox))?; - let command_with_stdin = stdin_file - .as_ref() - .map(|stdin_file| stdin_file.redirect(command)); - let command = command_with_stdin.as_deref().unwrap_or(command); - - let session_command = build_bash_session_command(command, &cwd, env_vars); - let session_exec = match session.execute(&session_command, true, true).await { - Ok(result) => result, - Err(err) => { - session.close("command start failure").await; - return Err(crate::Error::context( - "Failed to execute Daytona session command", - err, - )); - } - }; - let command_id = session_exec.cmd_id; - - let stream_process_svc = match sandbox.process().await { - Ok(process_svc) => process_svc, - Err(err) => { - session.close("stream setup failure").await; - return Err(crate::Error::context("Failed to get process service", err)); - } - }; - let stdout_seen = Arc::new(Mutex::new(OutputCaptureBuffer::new( - stream_output_bytes_cap, - ))); - let stderr_seen = Arc::new(Mutex::new(OutputCaptureBuffer::new( - stream_output_bytes_cap, - ))); - let saw_live_chunk = Arc::new(AtomicBool::new(false)); - - let stream_session_id = session.id().to_string(); - let stream_command_id = command_id.clone(); - let stdout_callback = output_callback.clone(); - let stderr_callback = output_callback.clone(); - let stdout_seen_for_stream = Arc::clone(&stdout_seen); - let stderr_seen_for_stream = Arc::clone(&stderr_seen); - let stdout_live = Arc::clone(&saw_live_chunk); - let stderr_live = Arc::clone(&saw_live_chunk); - let mut stream_task = tokio::spawn(async move { - stream_process_svc - .get_session_command_logs_stream( - &stream_session_id, - &stream_command_id, - move |chunk| { - let callback = stdout_callback.clone(); - let stdout_seen = Arc::clone(&stdout_seen_for_stream); - let saw_live_chunk = Arc::clone(&stdout_live); - async move { - let bytes = chunk.into_bytes(); - if !bytes.is_empty() { - saw_live_chunk.store(true, Ordering::Relaxed); - stdout_seen.lock().await.push(&bytes); - if let Some(callback) = callback { - callback(CommandOutputStream::Stdout, bytes) - .await - .map_err(|err| daytona_callback_error(&err))?; - } - } - Ok(()) - } - }, - move |chunk| { - let callback = stderr_callback.clone(); - let stderr_seen = Arc::clone(&stderr_seen_for_stream); - let saw_live_chunk = Arc::clone(&stderr_live); - async move { - let bytes = chunk.into_bytes(); - if !bytes.is_empty() { - saw_live_chunk.store(true, Ordering::Relaxed); - stderr_seen.lock().await.push(&bytes); - if let Some(callback) = callback { - callback(CommandOutputStream::Stderr, bytes) - .await - .map_err(|err| daytona_callback_error(&err))?; - } - } - Ok(()) - } - }, - ) - .await - }); - - let outcome = match wait_for_completion( - &session, - &command_id, - session_exec.exit_code, - timeout_ms, - cancel_token.unwrap_or_default(), - ) - .await - { - Ok(outcome) => outcome, - Err(err) => { - stream_task.abort(); - session.close("status poll failure").await; - return Err(err); - } - }; - let exit_code = outcome.exit_code; - let termination = outcome.termination; - let mut final_logs = outcome.final_logs; - - // On timeout/cancel, delete the session early so the streaming task can - // terminate (Daytona closes the log stream when the session is deleted). - // On natural exit we delete after the stream task drains. - if termination != CommandTermination::Exited { - session.close("terminal command state").await; - } - - let stream_succeeded = match finish_daytona_log_stream(&mut stream_task).await { - Ok(stream_succeeded) => stream_succeeded, - Err(err) => { - session.close("log stream failure").await; - return Err(err); - } - }; - - if final_logs.is_none() { - final_logs = session.fetch_logs(&command_id).await; - } - - session.close("command finished").await; - - let mut streams_separated = stream_succeeded; - if let Some(logs) = final_logs.as_ref() { - streams_separated |= logs.streams_separated; - append_missing_log_suffix( - CommandOutputStream::Stdout, - logs.stdout.as_bytes(), - &stdout_seen, - output_callback.as_ref(), - ) - .await?; - append_missing_log_suffix( - CommandOutputStream::Stderr, - logs.stderr.as_bytes(), - &stderr_seen, - output_callback.as_ref(), - ) - .await?; - } - - let (stdout, stdout_capture) = drain_captured_stream(&stdout_seen).await; - let (stderr, stderr_capture) = drain_captured_stream(&stderr_seen).await; - - let result = ExecStreamingResult { - result: ExecResult { - stdout, - stderr, - exit_code: (termination == CommandTermination::Exited) - .then_some(exit_code) - .flatten(), - termination, - duration_ms: elapsed_ms(start), - }, - streams_separated, - live_streaming: saw_live_chunk.load(Ordering::Relaxed), - stdout_capture, - stderr_capture, - }; - if let Some(stdin_file) = stdin_file.as_mut() { - stdin_file.close().await; - } - Ok(result) - } - - async fn spawn_stdio_process( - &self, - _command: &str, - _working_dir: Option<&str>, - _env_vars: Option<&HashMap>, - _cancel_token: Option, - ) -> crate::Result { - Err(crate::Error::message( - "ACP backend requires bidirectional stdio; the Daytona sandbox provider does not support it yet", - )) - } - - async fn grep( - &self, - pattern: &str, - path: &str, - options: &GrepOptions, - ) -> crate::Result> { - let resolved = self.resolve_path(path); - - // Detect ripgrep availability (cached) - let use_rg = *self - .rg_available - .get_or_init(|| async { - let result = self - .exec_command("rg --version", 10_000, None, None, None) - .await; - matches!(result, Ok(r) if r.is_success()) - }) - .await; - - let cmd = if use_rg { - let mut cmd = "rg --line-number --no-heading".to_string(); - if options.case_insensitive { - cmd.push_str(" -i"); - } - if let Some(ref glob_filter) = options.glob_filter { - let _ = write!(cmd, " --glob {}", shell_quote(glob_filter)); - } - if let Some(max) = options.max_results { - let _ = write!(cmd, " --max-count {max}"); - } - let _ = write!( - cmd, - " -- {} {}", - shell_quote(pattern), - shell_quote(&resolved) - ); - cmd - } else { - let mut cmd = "grep -rn".to_string(); - if options.case_insensitive { - cmd.push_str(" -i"); - } - if let Some(ref glob_filter) = options.glob_filter { - let _ = write!(cmd, " --include {}", shell_quote(glob_filter)); - } - if let Some(max) = options.max_results { - let _ = write!(cmd, " -m {max}"); - } - let _ = write!( - cmd, - " -- {} {}", - shell_quote(pattern), - shell_quote(&resolved) - ); - cmd - }; - - let result = self.exec_command(&cmd, 30_000, None, None, None).await?; - - if result.exit_code == Some(1) { - // Both rg and grep exit 1 for no matches - return Ok(Vec::new()); - } - if !result.is_success() { - return Err(crate::Error::message(format!( - "grep failed (exit {}): {}", - result.display_exit_code(), - result.stderr - ))); - } - - Ok(result.stdout.lines().map(String::from).collect()) - } - - async fn walk_files( - &self, - base: &str, - relative_start: &str, - options: &WalkOptions, - ) -> crate::Result> { - if options.excludes_relative_path(relative_start) { - return Ok(Vec::new()); - } - - let base = self.resolve_path(base); - let command = sandbox::build_remote_walk_command(&base, relative_start, options); - let result = self - .exec_command(&command, REMOTE_WALK_TIMEOUT_MS, None, None, None) - .await?; - if !result.is_success() { - return Err(crate::Error::exec("recursive file traversal", result)); - } - - sandbox::parse_remote_walk_output(&base, relative_start, &result.stdout) - } -} - -fn daytona_callback_error(err: &crate::Error) -> DaytonaError { - DaytonaError::general(format!("output callback failed: {err}")) -} - -/// Wrap a Daytona filesystem error with a richer message that includes the -/// attempted path and a hint when the status code suggests a configuration -/// issue. Preserves the underlying `DaytonaError` in the source chain so -/// callers can still inspect status/headers via downcasting. -fn wrap_fs_error(operation: &str, path: &str, error: DaytonaError) -> crate::Error { - let message = match error.status_code() { - Some(400) => format!( - "{operation} '{path}' failed (HTTP 400). This usually means the sandbox user \ - lacks write permission on the parent directory. If you're using a custom \ - Daytona snapshot, ensure the sandbox user can write to '{path}', or use a \ - path under the user's home directory (e.g. /home/daytona/...)." - ), - Some(status @ (401 | 403)) => format!( - "{operation} '{path}' rejected by Daytona (HTTP {status}) — check that your \ - DAYTONA_API_KEY has the required permissions." - ), - _ => format!("{operation} '{path}' failed"), - }; - crate::Error::context(message, error) -} - -async fn finish_daytona_log_stream( - stream_task: &mut JoinHandle>, -) -> crate::Result { - match time::timeout(Duration::from_secs(2), &mut *stream_task).await { - Ok(Ok(Ok(()))) => Ok(true), - Ok(Ok(Err(err))) => { - let message = err.to_string(); - if message.contains("output callback failed") { - return Err(crate::Error::context( - "Daytona log stream callback failed", - err, - )); - } - tracing::warn!(error = %message, "Daytona log stream ended with an error"); - Ok(false) - } - Ok(Err(err)) => { - tracing::warn!(error = %err, "Daytona log stream task failed"); - Ok(false) - } - Err(_) => { - stream_task.abort(); - tracing::warn!("Daytona log stream did not close after command completion"); - Ok(false) - } - } -} - -/// A temporary Daytona file used to provide exact stdin bytes and EOF. -/// -/// Daytona sessions accept input strings but do not expose a reliable EOF -/// operation. A file redirection preserves arbitrary bytes and gives the -/// command EOF without embedding workflow data in shell source. -struct DaytonaStdinFile { - fs: Option, - path: String, -} - -impl DaytonaStdinFile { - async fn create(sandbox: &daytona_sdk::Sandbox, stdin: &[u8]) -> crate::Result { - let fs = sandbox - .fs() - .await - .map_err(|err| crate::Error::context("Failed to get Daytona file service", err))?; - let path = format!( - "/tmp/fabro-command-stdin-{:016x}", - rand::rng().random::() - ); - fs.upload_file_bytes(&path, stdin) - .await - .map_err(|err| crate::Error::context("Failed to upload Daytona command stdin", err))?; - Ok(Self { fs: Some(fs), path }) - } - - /// Wrap `command` so it reads this file as its standard input. - fn redirect(&self, command: &str) -> String { - redirect_command_stdin(command, &self.path) - } - - /// Idempotent, best-effort deletion bounded by - /// [`DAYTONA_CLEANUP_TIMEOUT`]. Failures are logged rather than surfaced - /// so cleanup can never fail a command that already completed. - async fn close(&mut self) { - let Some(fs) = self.fs.as_ref() else { - return; - }; - let deletion = - time::timeout(DAYTONA_CLEANUP_TIMEOUT, fs.delete_file(&self.path, false)).await; - - // Keep the service owned until the delete future completes. If this - // method is cancelled at the await above, Drop still has everything it - // needs to retry cleanup. - self.fs.take(); - match deletion { - Ok(Ok(())) => {} - Ok(Err(err)) => { - tracing::warn!(error = %err, "Failed to delete Daytona command stdin"); - } - Err(_) => { - tracing::warn!( - timeout_ms = - u64::try_from(DAYTONA_CLEANUP_TIMEOUT.as_millis()).unwrap_or(u64::MAX), - "Timed out deleting Daytona command stdin" - ); - } - } - } -} - -impl Drop for DaytonaStdinFile { - fn drop(&mut self) { - let Some(fs) = self.fs.take() else { - return; - }; - let path = std::mem::take(&mut self.path); - match Handle::try_current() { - Ok(handle) => { - handle.spawn(async move { - match time::timeout(DAYTONA_CLEANUP_TIMEOUT, fs.delete_file(&path, false)).await - { - Ok(Ok(())) => {} - Ok(Err(err)) => { - tracing::warn!( - error = %err, - "Failed to delete Daytona command stdin from Drop" - ); - } - Err(_) => { - tracing::warn!( - timeout_ms = u64::try_from(DAYTONA_CLEANUP_TIMEOUT.as_millis()) - .unwrap_or(u64::MAX), - "Timed out deleting Daytona command stdin from Drop" - ); - } - } - }); - } - Err(err) => { - tracing::warn!( - error = %err, - "Could not schedule Daytona command stdin cleanup" - ); - } - } - } -} - -/// RAII wrapper around a Daytona toolbox session. -/// -/// Holds the per-session [`ProcessService`] handle and the session id. Callers -/// should invoke [`close`] on every path. If that future is cancelled, or a -/// `DaytonaSession` is otherwise dropped while it still owns its process -/// service, [`Drop`] spawns a bounded cleanup task on the current Tokio runtime -/// as a safety net (matching the -/// [`DetachedRunBootstrapGuard`] pattern in `fabro-workflow`). -struct DaytonaSession { - process_svc: Option, - session_id: String, -} - -impl DaytonaSession { - async fn create(sandbox: &daytona_sdk::Sandbox) -> crate::Result { - let process_svc = sandbox - .process() - .await - .map_err(|e| crate::Error::context("Failed to get process service", e))?; - let session_id = format!("fabro-{:016x}", rand::rng().random::()); - process_svc - .create_session(&session_id) - .await - .map_err(|e| crate::Error::context("Failed to create Daytona session", e))?; - Ok(Self { - process_svc: Some(process_svc), - session_id, - }) - } - - fn id(&self) -> &str { - &self.session_id - } - - fn process_svc(&self) -> &daytona_sdk::ProcessService { - self.process_svc - .as_ref() - .expect("DaytonaSession used after close") - } - - async fn execute( - &self, - command: &str, - run_async: bool, - suppress_input_echo: bool, - ) -> Result { - self.process_svc() - .execute_session_command(&self.session_id, command, run_async, suppress_input_echo) - .await - } - - async fn get_command_status( - &self, - command_id: &str, - ) -> Result { - self.process_svc() - .get_session_command(&self.session_id, command_id) - .await - } - - async fn fetch_logs(&self, command_id: &str) -> Option { - let svc = self.process_svc.as_ref()?; - fetch_daytona_session_logs(svc, &self.session_id, command_id).await - } - - /// Idempotent: a second call after the process service is consumed is a - /// no-op. - /// - /// `delete_session` is bounded by [`DAYTONA_CLEANUP_TIMEOUT`] so a - /// stalled Daytona REST call cannot block cancellation paths indefinitely. - async fn close(&mut self, reason: &'static str) { - let Some(svc) = self.process_svc.as_ref() else { - return; - }; - let deletion = time::timeout( - DAYTONA_CLEANUP_TIMEOUT, - svc.delete_session(&self.session_id), - ) - .await; - - // Keep the service owned until the delete future completes. If this - // method is cancelled at the await above, Drop still has everything it - // needs to retry cleanup. - self.process_svc.take(); - match deletion { - Ok(Ok(())) => {} - Ok(Err(err)) => { - tracing::warn!( - error = %err, - session_id = %self.session_id, - reason, - "failed to delete Daytona session" - ); - } - Err(_) => { - tracing::warn!( - session_id = %self.session_id, - reason, - timeout_ms = u64::try_from(DAYTONA_CLEANUP_TIMEOUT.as_millis()) - .unwrap_or(u64::MAX), - "timed out deleting Daytona session" - ); - } - } - } -} - -impl Drop for DaytonaSession { - fn drop(&mut self) { - let Some(svc) = self.process_svc.take() else { - return; - }; - let session_id = std::mem::take(&mut self.session_id); - match Handle::try_current() { - Ok(handle) => { - handle.spawn(async move { - match time::timeout(DAYTONA_CLEANUP_TIMEOUT, svc.delete_session(&session_id)) - .await - { - Ok(Ok(())) => {} - Ok(Err(err)) => { - tracing::warn!( - error = %err, - session_id, - "Daytona session leaked; failed to delete from Drop" - ); - } - Err(_) => { - tracing::warn!( - session_id, - timeout_ms = u64::try_from(DAYTONA_CLEANUP_TIMEOUT.as_millis()) - .unwrap_or(u64::MAX), - "Daytona session leaked; timed out deleting from Drop" - ); - } - } - }); - } - Err(_) => { - tracing::error!(session_id, "Daytona session leaked; no runtime to clean up"); - } - } - } -} - -struct WaitOutcome { - exit_code: Option, - termination: CommandTermination, - final_logs: Option, -} - -/// Wait for the session command to terminate by polling status, the timeout -/// timer, and the cancel token. The caller owns any associated log-stream task -/// and is responsible for aborting it and closing the session on poll failure. -async fn wait_for_completion( - session: &DaytonaSession, - command_id: &str, - initial_exit_code: Option, - timeout_ms: Option, - cancel_token: CancellationToken, -) -> crate::Result { - if let Some(code) = initial_exit_code { - return Ok(WaitOutcome { - exit_code: Some(code), - termination: CommandTermination::Exited, - final_logs: None, - }); - } - - let timeout_future = optional_timeout(timeout_ms); - tokio::pin!(timeout_future); - loop { - tokio::select! { - () = time::sleep(Duration::from_millis(250)) => { - let status = match session.get_command_status(command_id).await { - Ok(status) => status, - Err(err) => { - return Err(crate::Error::context( - "Failed to get Daytona session command status", - err, - )); - } - }; - if let Some(code) = status.exit_code { - return Ok(WaitOutcome { - exit_code: Some(code), - termination: CommandTermination::Exited, - final_logs: None, - }); - } - } - () = &mut timeout_future => { - return Ok(WaitOutcome { - exit_code: None, - termination: CommandTermination::TimedOut, - final_logs: session.fetch_logs(command_id).await, - }); - } - () = cancel_token.cancelled() => { - return Ok(WaitOutcome { - exit_code: None, - termination: CommandTermination::Cancelled, - final_logs: session.fetch_logs(command_id).await, - }); - } - } - } -} - -async fn fetch_daytona_session_logs( - process_svc: &daytona_sdk::ProcessService, - session_id: &str, - command_id: &str, -) -> Option { - match process_svc - .get_session_command_logs(session_id, command_id) - .await - { - Ok(logs) => Some(logs), - Err(err) => { - tracing::warn!( - error = %err, - session_id, - command_id, - "failed to fetch Daytona session command logs" - ); - None - } - } -} - -async fn append_missing_log_suffix( - stream: CommandOutputStream, - final_bytes: &[u8], - seen: &Arc>, - output_callback: Option<&CommandOutputCallback>, -) -> crate::Result<()> { - if final_bytes.is_empty() { - return Ok(()); - } - - let mut seen = seen.lock().await; - let offset = captured_log_suffix_offset(&mut seen, final_bytes); - if offset >= final_bytes.len() { - return Ok(()); - } - - let missing = final_bytes[offset..].to_vec(); - seen.push(&missing); - drop(seen); - match output_callback { - Some(output_callback) => output_callback(stream, missing).await, - None => Ok(()), - } -} - -/// Take the captured stream bytes out of their shared buffer as a lossy -/// string, avoiding a copy when the bytes are valid UTF-8. -async fn drain_captured_stream( - seen: &Arc>, -) -> (String, OutputCaptureStats) { - let buffer = { - let mut seen = seen.lock().await; - std::mem::replace(&mut *seen, OutputCaptureBuffer::new(None)) - }; - let (bytes, stats) = buffer.into_parts(); - let text = match String::from_utf8(bytes) { - Ok(text) => text, - Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(), - }; - (text, stats) -} - -fn captured_log_suffix_offset(seen: &mut OutputCaptureBuffer, final_bytes: &[u8]) -> usize { - let stats = seen.stats(); - if stats.omitted_bytes == 0 { - return missing_log_suffix_offset(&seen.to_bytes(), final_bytes); - } - - let observed_bytes = stats.observed_bytes; - let (head, tail) = seen.retained_slices(); - if final_bytes.len() >= observed_bytes - && final_bytes.starts_with(head) - && tail == &final_bytes[observed_bytes.saturating_sub(tail.len())..observed_bytes] - { - return observed_bytes; - } - if final_bytes.len() <= observed_bytes && final_bytes.starts_with(head) { - return final_bytes.len(); - } - - let max_overlap = tail.len().min(final_bytes.len()); - for overlap in (1..=max_overlap).rev() { - if tail[tail.len() - overlap..] == final_bytes[..overlap] { - return overlap; - } - } - 0 -} - -fn missing_log_suffix_offset(seen: &[u8], final_bytes: &[u8]) -> usize { - if final_bytes.starts_with(seen) { - return seen.len(); - } - if seen.starts_with(final_bytes) { - return final_bytes.len(); - } - - let max_overlap = seen.len().min(final_bytes.len()); - for overlap in (1..=max_overlap).rev() { - if seen[seen.len() - overlap..] == final_bytes[..overlap] { - return overlap; - } - } - 0 -} - -/// Override caller or snapshot startup-file injection for a Bash process. -fn clean_bash_env(env_vars: Option<&HashMap>) -> HashMap { - let mut clean = env_vars.cloned().unwrap_or_default(); - clean.insert(BASH_ENV_VAR.to_string(), String::new()); - clean -} - -fn bash_export_lines(env_vars: &HashMap) -> Vec { - let mut entries: Vec<_> = env_vars.iter().collect(); - entries.sort_by_key(|(key, _)| *key); - entries - .into_iter() - .map(|(key, value)| format!("export {}={}", shell_quote(key), shell_quote(value))) - .collect() -} - -/// Build the inner Bash script a session command evaluates. -/// -/// The result is Bash source, not something Daytona can exec directly — it is -/// passed through [`wrap_bash_session_script`] before reaching the toolbox. -fn build_bash_session_script( - command: &str, - cwd: &str, - env_vars: Option<&HashMap>, -) -> String { - let mut lines = vec![format!("cd {} || exit $?", shell_quote(cwd))]; - lines.extend(bash_export_lines(&clean_bash_env(env_vars))); - - lines.push("(".to_string()); - lines.push(command.to_string()); - lines.push(")".to_string()); - lines.join("\n") -} - -/// Interpret the outcome of the direct-exec Daytona Bash probe. -/// -/// A failure to run the probe at all, a nonzero exit, and a zero exit without -/// the marker are all probe failures, and all carry the snapshot remediation. -fn daytona_bash_probe_outcome(execution: crate::Result) -> crate::Result<()> { - match execution { - Err(err) => Err(crate::Error::context(DAYTONA_BASH_REMEDIATION, err)), - Ok(result) => validate_bash_probe(result, DAYTONA_BASH_REMEDIATION), - } -} - -/// Interpret the outcome of the session Daytona Bash probe. -/// -/// The marker has to be its own line rather than the whole of stdout: session -/// output arrives through Daytona's log labelers rather than as a single -/// captured buffer, and a probe that rejected any surrounding transport bytes -/// would fail every sandbox creation instead of the transport defect it exists -/// to catch. Substring matching would be too weak in the other direction — -/// [`BASH_PROBE_SCRIPT`] contains the marker literal, so a transport that -/// echoed the submitted script back would pass. -fn daytona_bash_session_probe_outcome(execution: crate::Result) -> crate::Result<()> { - let result = match execution { - Ok(result) => result, - Err(err) => { - return Err(crate::Error::context(DAYTONA_BASH_SESSION_REMEDIATION, err)); - } - }; - - if result.is_success() - && result - .stdout - .lines() - .any(|line| line.trim() == BASH_PROBE_MARKER) - { - return Ok(()); - } - - Err(crate::Error::context( - DAYTONA_BASH_SESSION_REMEDIATION, - result.into_exec_error("Sandbox Bash session probe"), - )) -} - -/// Classify a failed Daytona clone for retry. -/// -/// The GitHub 404 does not arrive as an HTTP 404 on the Daytona call. git runs -/// inside the sandbox, so its stderr comes back through the toolbox as the -/// error message — the credential race has to be matched on text. Daytona's own -/// transport failures are visible structurally. -fn classify_clone_failure(err: &DaytonaError, cred: CredentialContext) -> Option { - // A Daytona request timeout does not prove that the remote clone stopped. - // Retrying could overlap the still-running first request. - if matches!(err, DaytonaError::Timeout { .. }) { - return None; - } - - match git_retry::classify_message(err.message(), cred) { - git_retry::GitMessageClass::Retry(reason) => Some(reason), - git_retry::GitMessageClass::Permanent => None, - git_retry::GitMessageClass::Unknown => match err { - DaytonaError::RateLimit { .. } => Some(GitRetryReason::TransientInfra), - DaytonaError::Api { status_code, .. } if (500..600).contains(status_code) => { - Some(GitRetryReason::TransientInfra) - } - DaytonaError::Timeout { .. } - | DaytonaError::Api { .. } - | DaytonaError::NotFound { .. } - | DaytonaError::General(_) => None, - }, - } -} - -/// Wrap Bash source in the canonical non-login Bash transport. -/// -/// The Daytona API uses direct exec (not a shell), so pipes, env vars, -/// semicolons, etc. won't work without this wrapper. Every path that sends a -/// caller-supplied or Fabro-built command to Daytona goes through here, so -/// there is exactly one interpreter on both sides of the transport. -/// -/// Uses base64 encoding (matching the TypeScript/Python/Ruby Daytona SDKs) -/// to avoid shell escaping issues with quotes and special characters. The -/// pipeline's exit status is the inner Bash's, so command exit codes survive -/// the transport. -fn wrap_bash_command(command: &str) -> String { - use base64::Engine; - use base64::engine::general_purpose::STANDARD; - let encoded = STANDARD.encode(command); - format!("{REMOTE_BASH} -c \"echo '{encoded}' | base64 -d | {REMOTE_BASH}\"") -} - -/// Invoke the canonical Bash interpreter from a Daytona streaming session. -/// -/// Session commands already pass through the provider's shell parser, so an -/// audited shell-quoted argument avoids the direct-exec path's base64 process -/// and second Bash while keeping caller source inert until `/bin/bash -c` -/// evaluates it. Bash must remain a child process: Daytona sources this command -/// inside a wrapper that resumes afterward to drain logs and persist the exit -/// code. -fn wrap_bash_session_script(script: &str) -> String { - format!("{REMOTE_BASH} -c {}", shell_quote(script)) -} - -fn redirect_command_stdin(command: &str, stdin_path: &str) -> String { - format!("(\n{command}\n) < {}", shell_quote(stdin_path)) -} - -/// Build a command for Daytona's streaming session transport. -/// -/// Both [`Sandbox::exec_command_streaming`] and the lifecycle probe call this -/// helper, so they cannot drift onto different script construction or Bash -/// wrappers. -fn build_bash_session_command( - command: &str, - cwd: &str, - env_vars: Option<&HashMap>, -) -> String { - wrap_bash_session_script(&build_bash_session_script(command, cwd, env_vars)) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::AtomicU32; - - use daytona_api_client::models::api_key_list::Permissions; - use fabro_util::error::collect_chain; - use httpmock::Method::{DELETE, GET, POST}; - use httpmock::{HttpMockResponse, MockServer}; - - use super::*; - use crate::sandbox::BASH_PROBE_MARKER; - - #[test] - fn daytona_clone_selector_uses_fully_qualified_tag_unless_sha_is_exact() { - let sha = "0123456789abcdef0123456789abcdef01234567"; - let tag = PinnedRevision::from_selectors(Some("v1.2.3"), None); - assert_eq!( - git_clone_selector(Some("release-work"), tag.as_ref()).as_deref(), - Some("refs/tags/v1.2.3") - ); - let commit = PinnedRevision::from_selectors(Some("v1.2.3"), Some(sha)); - assert_eq!( - git_clone_selector(Some("release-work"), commit.as_ref()).as_deref(), - Some("release-work") - ); - assert_eq!( - git_clone_selector(Some("release-work"), None).as_deref(), - Some("release-work") - ); - } - - #[tokio::test] - async fn invalid_exact_sha_fails_before_daytona_client_construction() { - let error = DaytonaSandbox::new( - DaytonaConfig::default(), - None, - None, - Some("https://github.com/acme/widgets".to_string()), - Some("main".to_string()), - None, - Some("not-a-sha".to_string()), - Some("dtn_not_used".to_string()), - ) - .await - .err() - .expect("validation should run before building a Daytona client"); - - assert!(error.to_string().contains("40 ASCII hexadecimal")); - assert!(!error.to_string().contains("Daytona client")); - } - - #[tokio::test] - async fn exact_sha_without_branch_fails_before_daytona_client_construction() { - let error = DaytonaSandbox::new( - DaytonaConfig::default(), - None, - None, - Some("https://github.com/acme/widgets".to_string()), - None, - None, - Some("0123456789abcdef0123456789abcdef01234567".to_string()), - Some("dtn_not_used".to_string()), - ) - .await - .err() - .expect("branch validation should run before building a Daytona client"); - - assert!(error.to_string().contains("requires a repository branch")); - assert!(!error.to_string().contains("Daytona client")); - } - - fn mock_sandbox_body(sandbox_id: &str) -> serde_json::Value { - serde_json::json!({ - "id": sandbox_id, - "organizationId": "org-1", - "name": sandbox_id, - "user": "daytona", - "env": {}, - "labels": {}, - "public": false, - "networkBlockAll": false, - "target": "us", - "cpu": 2.0, - "gpu": 0.0, - "memory": 4.0, - "disk": 20.0, - "toolboxProxyUrl": "https://proxy.example.com/toolbox", - "state": "started" - }) - } - - async fn mock_sandbox_handle(server: &MockServer, sandbox_id: &str) -> daytona_sdk::Sandbox { - let sandbox_response = server - .mock_async(|when, then| { - when.method(GET).path(format!("/sandbox/{sandbox_id}")); - then.status(200) - .header("content-type", "application/json") - .json_body(mock_sandbox_body(sandbox_id)); - }) - .await; - let client = build_daytona_client_with( - Some("dtn_test".to_string()), - Some(server.base_url()), - None, - Some(fabro_test::test_http_client()), - ) - .await - .expect("create Daytona client"); - let sandbox = client.get(sandbox_id).await.expect("get mock sandbox"); - sandbox_response.assert_async().await; - sandbox - } - - async fn mock_process_service( - server: &MockServer, - sandbox_id: &str, - ) -> daytona_sdk::ProcessService { - let server_url = server.base_url(); - let toolbox_response = server - .mock_async(|when, then| { - when.method(GET) - .path(format!("/sandbox/{sandbox_id}/toolbox-proxy-url")); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ "url": server_url })); - }) - .await; - let sandbox = mock_sandbox_handle(server, sandbox_id).await; - let process_svc = sandbox.process().await.expect("get process service"); - toolbox_response.assert_async().await; - process_svc - } - - #[tokio::test] - async fn post_clone_command_sends_server_timeout_and_returns_output() { - let server = MockServer::start_async().await; - let sandbox_id = "sandbox-post-clone-success"; - let process_svc = mock_process_service(&server, sandbox_id).await; - let execute = server - .mock_async(|when, then| { - when.method(POST) - .path(format!("/{sandbox_id}/process/execute")) - .body_includes(r#""cwd":"/work""#) - .body_matches(r#""timeout":[1-9][0-9]*"#); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "exitCode": 0, - "result": "expected output" - })); - }) - .await; - - let output = DaytonaSandbox::run_required_post_clone_command( - &process_svc, - "git status --short", - "/work", - "inspect exact checkout", - time::Instant::now() + Duration::from_secs(30), - ) - .await - .expect("post-clone command should succeed"); - - assert_eq!(output, "expected output"); - execute.assert_async().await; - } - - #[tokio::test] - async fn expired_post_clone_deadline_does_not_dispatch_command() { - let server = MockServer::start_async().await; - let sandbox_id = "sandbox-post-clone-expired"; - let process_svc = mock_process_service(&server, sandbox_id).await; - let execute = server - .mock_async(|when, then| { - when.method(POST) - .path(format!("/{sandbox_id}/process/execute")); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "exitCode": 0, - "result": "unexpected" - })); - }) - .await; - - let error = DaytonaSandbox::run_required_post_clone_command( - &process_svc, - "git status --short", - "/work", - "inspect exact checkout", - time::Instant::now(), - ) - .await - .expect_err("expired deadline should fail before dispatch"); - - assert!(error.to_string().contains("deadline expired")); - execute.assert_calls_async(0).await; - } - - #[tokio::test] - async fn post_clone_command_has_client_side_timeout_backstop() { - let server = MockServer::start_async().await; - let sandbox_id = "sandbox-post-clone-stalled"; - let process_svc = mock_process_service(&server, sandbox_id).await; - let execute = server - .mock_async(|when, then| { - when.method(POST) - .path(format!("/{sandbox_id}/process/execute")) - .body_matches(r#""timeout":[1-9][0-9]*"#); - then.status(200) - .delay(Duration::from_secs(10)) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "exitCode": 0, - "result": "too late" - })); - }) - .await; - - let error = DaytonaSandbox::run_required_post_clone_command( - &process_svc, - "git status --short", - "/work", - "inspect exact checkout", - time::Instant::now() + Duration::from_millis(1_200), - ) - .await - .expect_err("stalled toolbox response should hit the client backstop"); - - assert!(error.to_string().contains("timed out")); - execute.assert_async().await; - } - - #[tokio::test] - async fn failed_initialization_deletes_created_daytona_sandbox() { - let server = MockServer::start_async().await; - let sandbox_id = "sandbox-failed-initialization"; - let delete = server - .mock_async(|when, then| { - when.method(DELETE).path(format!("/sandbox/{sandbox_id}")); - then.status(200) - .header("content-type", "application/json") - .json_body(mock_sandbox_body(sandbox_id)); - }) - .await; - let sandbox = mock_sandbox_handle(&server, sandbox_id).await; - - let (error, retry_sandbox) = DaytonaSandbox::cleanup_failed_initialization_sandbox( - sandbox, - crate::Error::message("exact checkout failed"), - ) - .await; - - assert_eq!(error.to_string(), "exact checkout failed"); - assert!(retry_sandbox.is_none()); - delete.assert_async().await; - } - - #[tokio::test] - async fn failed_initialization_retains_sandbox_when_delete_fails() { - let server = MockServer::start_async().await; - let sandbox_id = "sandbox-failed-cleanup"; - let delete = server - .mock_async(|when, then| { - when.method(DELETE).path(format!("/sandbox/{sandbox_id}")); - then.status(500) - .header("content-type", "application/json") - .json_body(serde_json::json!({ "message": "try again" })); - }) - .await; - let sandbox = mock_sandbox_handle(&server, sandbox_id).await; - - let (error, retry_sandbox) = DaytonaSandbox::cleanup_failed_initialization_sandbox( - sandbox, - crate::Error::message("exact checkout failed"), - ) - .await; - - assert_eq!(error.to_string(), "exact checkout failed"); - assert_eq!( - retry_sandbox.as_ref().map(|sandbox| sandbox.id.as_str()), - Some(sandbox_id) - ); - delete.assert_async().await; - } - - #[tokio::test] - async fn failed_initialization_treats_missing_sandbox_as_deleted() { - let server = MockServer::start_async().await; - let sandbox_id = "sandbox-already-deleted"; - let delete = server - .mock_async(|when, then| { - when.method(DELETE).path(format!("/sandbox/{sandbox_id}")); - then.status(404) - .header("content-type", "application/json") - .json_body(serde_json::json!({ "message": "not found" })); - }) - .await; - let sandbox = mock_sandbox_handle(&server, sandbox_id).await; - - let (error, retry_sandbox) = DaytonaSandbox::cleanup_failed_initialization_sandbox( - sandbox, - crate::Error::message("exact checkout failed"), - ) - .await; - - assert_eq!(error.to_string(), "exact checkout failed"); - assert!(retry_sandbox.is_none()); - delete.assert_async().await; - } - - fn api_key_body(permissions: &[&str]) -> serde_json::Value { - serde_json::json!({ - "name": "delete-only", - "value": "dtn_****", - "createdAt": "2026-05-01T00:00:00Z", - "permissions": permissions, - "lastUsedAt": null, - "expiresAt": null, - "userId": "user_123" - }) - } - - async fn mock_auth_probe(server: &MockServer, status: usize) -> httpmock::Mock<'_> { - server - .mock_async(move |when, then| { - when.method(GET) - .path("/sandbox/paginated") - .query_param("page", "1") - .query_param("limit", "1"); - then.status(status) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "items": [], - "total": 0, - "page": 1, - "totalPages": 0 - })); - }) - .await - } - - async fn mock_current_key<'a>( - server: &'a MockServer, - permissions: Vec<&'static str>, - ) -> httpmock::Mock<'a> { - server - .mock_async(move |when, then| { - when.method(GET) - .path("/api-keys/current") - .header("authorization", "Bearer dtn_test"); - then.status(200) - .header("content-type", "application/json") - .json_body(api_key_body(&permissions)); - }) - .await - } - - async fn mock_daytona_sandbox( - server: &MockServer, - api_key: &str, - config: DaytonaConfig, - ) -> DaytonaSandbox { - let client = build_daytona_client_with( - Some(api_key.to_string()), - Some(server.base_url()), - None, - Some(fabro_test::test_http_client()), - ) - .await - .expect("mock Daytona client should build"); - - DaytonaSandbox { - config, - client, - api_key: Some(api_key.to_string()), - push_credentials: PushCredentialState::new(None), - sandbox: OnceCell::new(), - snapshot_name: OnceCell::new(), - rg_available: OnceCell::const_new(), - event_callback: None, - origin_url: OnceCell::new(), - repo_cloned: OnceCell::new(), - working_directory: OnceCell::new(), - run_id: None, - clone_origin_url: None, - clone_branch: None, - clone_tag: None, - clone_commit_sha: None, - } - } - - fn snapshot_body(name: &str) -> serde_json::Value { - serde_json::json!({ - "id": name, - "name": name, - "state": "active", - "general": false, - "cpu": 2.0, - "gpu": 0.0, - "mem": 4.0, - "disk": 20.0, - "size": null, - "entrypoint": null, - "errorReason": null, - "sourceSandboxId": null, - "lastUsedAt": null, - "createdAt": "2026-05-01T00:00:00Z", - "updatedAt": "2026-05-01T00:00:00Z" - }) - } - - fn sandbox_body(name: &str, state: SandboxState) -> serde_json::Value { - serde_json::json!({ - "id": name, - "organizationId": "org-1", - "name": name, - "user": "daytona", - "env": {}, - "labels": {}, - "public": false, - "networkBlockAll": false, - "target": "us", - "cpu": 2.0, - "gpu": 0.0, - "memory": 4.0, - "disk": 20.0, - "toolboxProxyUrl": "https://proxy.example.com/toolbox", - "state": state.to_string() - }) - } - - #[test] - fn daytona_config_defaults() { - let config = DaytonaConfig::default(); - assert!(config.snapshot.is_none()); - assert!(config.auto_stop_interval.is_none()); - assert!(config.labels.is_none()); - assert!(config.clone_depth.is_none()); - } - - #[test] - fn computed_snapshot_identity_is_deterministic_and_keyed() { - let config = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM ubuntu:24.04\nRUN apt-get update".to_string(), - )), - }; - - let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); - let second = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); - let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &config).unwrap(); - - assert_eq!(first, second); - assert_eq!(first, "fabro-e607185f-c7ab-88c9-bf9d-d70addba9298"); - assert_ne!(first, rotated_key); - let uuid = first - .strip_prefix("fabro-") - .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) - .expect("snapshot name should be fabro-"); - assert_eq!(uuid.get_version_num(), 8); - assert_eq!(uuid.get_variant(), uuid::Variant::RFC4122); - } - - #[test] - fn computed_snapshot_identity_changes_for_generation_inputs() { - let base = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM ubuntu:24.04".to_string(), - )), - }; - let base_name = snapshot_identity::snapshot_name("dtn_secret", &base).unwrap(); - - let cases = [ - DaytonaSnapshotConfig { - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM ubuntu:24.04\n# roll cache".to_string(), - )), - ..base.clone() - }, - DaytonaSnapshotConfig { - cpu: Some(4), - ..base.clone() - }, - DaytonaSnapshotConfig { - memory: Some(8), - ..base.clone() - }, - DaytonaSnapshotConfig { - disk: Some(20), - ..base.clone() - }, - ]; - - for changed in cases { - let changed_name = snapshot_identity::snapshot_name("dtn_secret", &changed).unwrap(); - assert_ne!(base_name, changed_name); - } - } - - #[test] - fn computed_snapshot_identity_excludes_raw_dockerfile_and_key_material() { - let config = DaytonaSnapshotConfig { - cpu: None, - memory: None, - disk: None, - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM private.example.com/secret-image\nRUN echo raw-secret".to_string(), - )), - }; - - let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &config).unwrap(); - - assert!(name.starts_with("fabro-")); - assert!(!name.contains("private.example.com")); - assert!(!name.contains("raw-secret")); - assert!(!name.contains("dtn_super_secret_key")); - } - - #[test] - fn computed_snapshot_identity_changes_for_image_reference() { - let config = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()), - }; - let first = snapshot_identity::snapshot_name("dtn_secret", &config).unwrap(); - let changed = snapshot_identity::snapshot_name("dtn_secret", &DaytonaSnapshotConfig { - source: DaytonaSnapshotSource::Image("ubuntu:24.10".to_string()), - ..config - }) - .unwrap(); - - assert_eq!(first, "fabro-5d23a023-d7ff-8d68-b3ca-e6286f4211d9"); - assert_ne!(first, changed); - } - - #[test] - fn snapshot_creation_uses_named_image_source() { - let config = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Image("ubuntu:24.04".to_string()), - }; - - let params = create_snapshot_params("fabro-test", &config).unwrap(); - - assert_eq!(params.name, "fabro-test"); - assert!(matches!( - params.image, - daytona_sdk::ImageSource::Name(ref image) if image == "ubuntu:24.04" - )); - let resources = params.resources.expect("resources should be configured"); - assert_eq!(resources.cpu, Some(2)); - assert_eq!(resources.memory, Some(4)); - assert_eq!(resources.disk, Some(10)); - } - - #[tokio::test] - async fn ensure_snapshot_uses_computed_snapshot_name_for_daytona_api_calls() { - let api_key = "dtn_secret"; - let snapshot = DaytonaSnapshotConfig { - cpu: Some(2), - memory: Some(4), - disk: Some(10), - source: DaytonaSnapshotSource::Dockerfile(DockerfileSource::Inline( - "FROM ubuntu:24.04".to_string(), - )), - }; - let computed_name = snapshot_identity::snapshot_name(api_key, &snapshot).unwrap(); - let server = MockServer::start_async().await; - let path = format!("/snapshots/{computed_name}"); - let get_snapshot = server - .mock_async(|when, then| { - when.method(GET) - .path(path.as_str()) - .header("authorization", "Bearer dtn_secret"); - then.status(200) - .header("content-type", "application/json") - .json_body(snapshot_body(&computed_name)); - }) - .await; - let config = DaytonaConfig { - snapshot: Some(snapshot.clone()), - ..DaytonaConfig::default() - }; - let sandbox = mock_daytona_sandbox(&server, api_key, config).await; - - sandbox - .ensure_snapshot(&computed_name, &snapshot) - .await - .expect("existing computed snapshot should be accepted"); - - get_snapshot.assert_async().await; - } - - #[tokio::test] - async fn base_params_create_run_owned_non_ephemeral_sandbox() { - let sandbox = DaytonaSandbox::new( - DaytonaConfig::default(), - None, - None, - None, - None, - None, - None, - Some("dtn_test".to_string()), - ) - .await - .expect("sandbox config should be valid"); - - let params = sandbox.base_params(); - - assert_eq!(params.ephemeral, Some(false)); - assert_eq!(params.auto_delete_interval, Some(-1)); - assert_eq!( - params.auto_stop_interval, - Some(DEFAULT_AUTO_STOP_INTERVAL_MINUTES) - ); - assert_eq!( - params.env_vars, - Some(HashMap::from([(BASH_ENV_VAR.to_string(), String::new())])) - ); - assert_eq!( - params.labels, - Some(HashMap::from([( - managed_labels::MANAGED_LABEL.to_string(), - "true".to_string(), - )])) - ); - } - - #[tokio::test] - async fn base_params_passes_explicit_auto_stop_through() { - for interval in [0, 45] { - let sandbox = DaytonaSandbox::new( - DaytonaConfig { - auto_stop_interval: Some(interval), - ..DaytonaConfig::default() - }, - None, - None, - None, - None, - None, - None, - Some("dtn_test".to_string()), - ) - .await - .expect("sandbox config should be valid"); - - assert_eq!(sandbox.base_params().auto_stop_interval, Some(interval)); - } - } - - #[tokio::test] - async fn activate_skips_start_when_daytona_reports_started() { - let server = MockServer::start_async().await; - let get_sandbox = server - .mock_async(|when, then| { - when.method(GET) - .path("/sandbox/test-sandbox") - .header("authorization", "Bearer dtn_test"); - then.status(200) - .header("content-type", "application/json") - .json_body(sandbox_body("test-sandbox", SandboxState::Started)); - }) - .await; - let start_sandbox = server - .mock_async(|when, then| { - when.method(POST) - .path("/sandbox/test-sandbox/start") - .header("authorization", "Bearer dtn_test"); - then.status(200) - .header("content-type", "application/json") - .json_body(sandbox_body("test-sandbox", SandboxState::Started)); - }) - .await; - let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await; - let sdk_sandbox = sandbox - .client - .get("test-sandbox") - .await - .expect("test sandbox should load"); - sandbox - .sandbox - .set(sdk_sandbox) - .expect("test sandbox should initialize once"); - - let get_calls_before = get_sandbox.calls_async().await; - sandbox - .activate() - .await - .expect("an active sandbox should require no restart"); - - assert_eq!(get_sandbox.calls_async().await, get_calls_before + 1); - start_sandbox.assert_calls_async(0).await; - } - - #[tokio::test] - async fn activate_waits_for_a_daytona_start_already_in_progress() { - let server = MockServer::start_async().await; - let response_count = Arc::new(AtomicU32::new(0)); - let get_sandbox = server - .mock_async({ - let response_count = Arc::clone(&response_count); - move |when, then| { - when.method(GET) - .path("/sandbox/test-sandbox") - .header("authorization", "Bearer dtn_test"); - then.respond_with(move |_| { - let state = if response_count.fetch_add(1, Ordering::Relaxed) == 1 { - SandboxState::Starting - } else { - SandboxState::Started - }; - HttpMockResponse::builder() - .status(200) - .header("content-type", "application/json") - .body(sandbox_body("test-sandbox", state).to_string()) - .build() - }); - } - }) - .await; - let start_sandbox = server - .mock_async(|when, then| { - when.method(POST) - .path("/sandbox/test-sandbox/start") - .header("authorization", "Bearer dtn_test"); - then.status(200) - .header("content-type", "application/json") - .json_body(sandbox_body("test-sandbox", SandboxState::Started)); - }) - .await; - let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await; - let sdk_sandbox = sandbox - .client - .get("test-sandbox") - .await - .expect("test sandbox should load"); - sandbox - .sandbox - .set(sdk_sandbox) - .expect("test sandbox should initialize once"); - - let get_calls_before = get_sandbox.calls_async().await; - sandbox - .activate() - .await - .expect("an in-progress start should be awaited"); - - assert_eq!(get_sandbox.calls_async().await, get_calls_before + 2); - start_sandbox.assert_calls_async(0).await; - } - - #[tokio::test] - async fn activate_waits_out_a_stop_in_progress() { - let server = MockServer::start_async().await; - let response_count = Arc::new(AtomicU32::new(0)); - let get_sandbox = server - .mock_async({ - let response_count = Arc::clone(&response_count); - move |when, then| { - when.method(GET) - .path("/sandbox/test-sandbox") - .header("authorization", "Bearer dtn_test"); - then.respond_with(move |_| { - let state = if response_count.fetch_add(1, Ordering::Relaxed) == 1 { - SandboxState::Stopping - } else { - SandboxState::Started - }; - HttpMockResponse::builder() - .status(200) - .header("content-type", "application/json") - .body(sandbox_body("test-sandbox", state).to_string()) - .build() - }); - } - }) - .await; - let start_sandbox = server - .mock_async(|when, then| { - when.method(POST) - .path("/sandbox/test-sandbox/start") - .header("authorization", "Bearer dtn_test"); - then.status(200) - .header("content-type", "application/json") - .json_body(sandbox_body("test-sandbox", SandboxState::Started)); - }) - .await; - let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await; - let sdk_sandbox = sandbox - .client - .get("test-sandbox") - .await - .expect("test sandbox should load"); - sandbox - .sandbox - .set(sdk_sandbox) - .expect("test sandbox should initialize once"); - - let get_calls_before = get_sandbox.calls_async().await; - sandbox - .activate() - .await - .expect("an in-progress stop should be waited out"); - - assert_eq!(get_sandbox.calls_async().await, get_calls_before + 2); - start_sandbox.assert_calls_async(0).await; - } - - #[tokio::test] - async fn stop_succeeds_when_a_pending_auto_stop_finishes_first() { - let server = MockServer::start_async().await; - let response_count = Arc::new(AtomicU32::new(0)); - let get_sandbox = server - .mock_async({ - let response_count = Arc::clone(&response_count); - move |when, then| { - when.method(GET) - .path("/sandbox/test-sandbox") - .header("authorization", "Bearer dtn_test"); - then.respond_with(move |_| { - let state = if response_count.fetch_add(1, Ordering::Relaxed) == 0 { - SandboxState::Started - } else { - SandboxState::Stopped - }; - HttpMockResponse::builder() - .status(200) - .header("content-type", "application/json") - .body(sandbox_body("test-sandbox", state).to_string()) - .build() - }); - } - }) - .await; - let stop_sandbox = server - .mock_async(|when, then| { - when.method(POST) - .path("/sandbox/test-sandbox/stop") - .header("authorization", "Bearer dtn_test"); - then.status(400) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "message": "Sandbox state change in progress", - "statusCode": 400 - })); - }) - .await; - let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await; - let sdk_sandbox = sandbox - .client - .get("test-sandbox") - .await - .expect("test sandbox should load"); - sandbox - .sandbox - .set(sdk_sandbox) - .expect("test sandbox should initialize once"); - - let get_calls_before = get_sandbox.calls_async().await; - sandbox - .stop() - .await - .expect("a stop already in flight should count as stopped"); - - stop_sandbox.assert_calls_async(1).await; - assert_eq!(get_sandbox.calls_async().await, get_calls_before + 1); - } - - #[tokio::test] - async fn start_surfaces_state_change_rejection_after_the_deadline() { - let server = MockServer::start_async().await; - let _get_sandbox = server - .mock_async(|when, then| { - when.method(GET) - .path("/sandbox/test-sandbox") - .header("authorization", "Bearer dtn_test"); - then.status(200) - .header("content-type", "application/json") - .json_body(sandbox_body("test-sandbox", SandboxState::Stopping)); - }) - .await; - let start_sandbox = server - .mock_async(|when, then| { - when.method(POST) - .path("/sandbox/test-sandbox/start") - .header("authorization", "Bearer dtn_test"); - then.status(400) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "message": "Sandbox state change in progress", - "statusCode": 400 - })); - }) - .await; - let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await; - let sdk_sandbox = sandbox - .client - .get("test-sandbox") - .await - .expect("test sandbox should load"); - sandbox - .sandbox - .set(sdk_sandbox) - .expect("test sandbox should initialize once"); - - let err = sandbox - .start_with_deadline(time::Instant::now() + Duration::from_millis(1500)) - .await - .expect_err("a state change that outlives the deadline should fail"); - - assert_eq!( - start_sandbox.calls_async().await, - 1, - "start should not be retried while the current transition is in flight" - ); - assert!( - err.causes().iter().any(|cause| cause - .to_ascii_lowercase() - .contains("state change in progress")), - "error should carry the Daytona rejection: {err}" - ); - } - - #[test] - fn state_change_in_progress_matcher_ignores_case_and_context() { - assert!(is_state_change_in_progress(&DaytonaError::api( - 400, - "Sandbox state change in progress" - ))); - assert!(is_state_change_in_progress(&DaytonaError::api( - 400, - "State Change In Progress" - ))); - assert!(!is_state_change_in_progress(&DaytonaError::api( - 400, - "Sandbox already started" - ))); - assert!(!is_state_change_in_progress(&DaytonaError::api( - 500, - "Sandbox state change in progress" - ))); - assert!(!is_state_change_in_progress(&DaytonaError::general( - "Sandbox state change in progress" - ))); - } - - #[tokio::test] - async fn base_params_merges_managed_daytona_labels() { - let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let sandbox = DaytonaSandbox::new( - DaytonaConfig { - labels: Some(HashMap::from([ - ("team".to_string(), "platform".to_string()), - ( - managed_labels::MANAGED_LABEL.to_string(), - "false".to_string(), - ), - ( - managed_labels::RUN_ID_LABEL.to_string(), - "wrong".to_string(), - ), - ])), - ..Default::default() - }, - None, - Some(run_id), - None, - None, - None, - None, - Some("dtn_test".to_string()), - ) - .await - .expect("sandbox config should be valid"); - - assert_eq!( - sandbox.base_params().labels, - Some(HashMap::from([ - ("team".to_string(), "platform".to_string()), - ( - managed_labels::MANAGED_LABEL.to_string(), - "true".to_string() - ), - ( - managed_labels::RUN_ID_LABEL.to_string(), - "01HY0000000000000000000000".to_string(), - ), - ])) - ); - } - - #[test] - fn command_kind_classifies_known_prefixes() { - assert_eq!(command_kind(" git status"), "git"); - assert_eq!(command_kind("bash -lc 'echo ok'"), "bash"); - assert_eq!(command_kind("/bin/sh -c 'echo ok'"), "sh"); - assert_eq!(command_kind("rg --version"), "rg"); - assert_eq!(command_kind("find . -maxdepth 1"), "find"); - assert_eq!(command_kind(""), "other"); - } - - #[test] - fn command_kind_does_not_echo_auth_url_commands() { - assert_eq!( - command_kind("https://x-access-token:ghs_FAKE@github.com/owner/repo.git"), - "other" - ); - } - - #[test] - fn clone_not_found_after_a_successful_mint_is_retried() { - // The exact error from run 01KYM99DF27JRRW4XSYZBP27K7: git's stderr, - // relayed through the toolbox, five seconds after a token was minted. - let err = DaytonaError::general("repository not found: Repository not found."); - - assert_eq!( - classify_clone_failure(&err, CredentialContext::FreshApp), - Some(GitRetryReason::TokenReplication) - ); - assert_eq!( - classify_clone_failure(&err, CredentialContext::None), - None, - "without credentials there is no token to replicate" - ); - } - - #[test] - fn clone_transient_transport_failures_are_retried() { - for err in [ - DaytonaError::rate_limit("too many requests"), - DaytonaError::api(503, ""), - ] { - assert_eq!( - classify_clone_failure(&err, CredentialContext::None), - Some(GitRetryReason::TransientInfra), - "expected {err:?} to be transient" - ); - } - } - - #[test] - fn clone_timeout_is_not_retried_without_remote_termination() { - let err = DaytonaError::timeout("request timed out"); - - assert_eq!( - classify_clone_failure(&err, CredentialContext::FreshApp), - None - ); - } - - #[test] - fn clone_api_failure_message_takes_precedence_over_status() { - let not_found = DaytonaError::api(500, "repository not found: Repository not found."); - assert_eq!( - classify_clone_failure(¬_found, CredentialContext::FreshApp), - Some(GitRetryReason::TokenReplication) - ); - assert_eq!( - classify_clone_failure(¬_found, CredentialContext::Static), - None - ); - - for message in [ - "fatal: destination path 'fabro' already exists", - "remote: Permission to fabro-sh/fabro.git denied", - ] { - assert_eq!( - classify_clone_failure( - &DaytonaError::api(500, message), - CredentialContext::FreshApp - ), - None, - "expected {message:?} to take precedence over HTTP 500" - ); - } - } - - #[test] - fn clone_client_errors_are_not_retried() { - for err in [ - DaytonaError::api(400, "bad request"), - DaytonaError::api(403, "forbidden"), - DaytonaError::not_found("Sandbox not found"), - DaytonaError::general("fatal: could not read Username for 'https://github.com'"), - ] { - assert_eq!( - classify_clone_failure(&err, CredentialContext::FreshApp), - None, - "expected {err:?} to fail fast" - ); - } - } - - #[test] - fn wrap_fs_error_classifies_http_400_and_403() { - let err_400 = wrap_fs_error( - "Failed to create Daytona repos root", - "/home/daytona/repos", - DaytonaError::api(400, ""), - ); - let top_400 = err_400.to_string(); - assert!( - top_400.contains("/home/daytona/repos"), - "400 top-level message should include the attempted path, got: {top_400}" - ); - assert!( - top_400.contains("HTTP 400") && top_400.contains("write permission"), - "400 top-level message should classify as a permission issue, got: {top_400}" - ); - - let chain_400 = collect_chain(&err_400); - assert!( - chain_400 - .iter() - .skip(1) - .any(|cause| cause.contains("HTTP 400") || cause.is_empty()), - "400 source chain should preserve the underlying DaytonaError, got: {chain_400:?}" - ); - let source_400 = std::error::Error::source(&err_400) - .and_then(|s| s.downcast_ref::()) - .expect("source should be a DaytonaError"); - assert_eq!( - source_400.status_code(), - Some(400), - "downcast source should preserve the original status code" - ); - - let err_403 = wrap_fs_error( - "Failed to create Daytona repos root", - "/home/daytona/repos", - DaytonaError::api(403, ""), - ); - let top_403 = err_403.to_string(); - assert!( - top_403.contains("/home/daytona/repos") && top_403.contains("HTTP 403"), - "403 top-level message should include the path and status, got: {top_403}" - ); - assert!( - top_403.contains("DAYTONA_API_KEY"), - "403 top-level message should hint at API key permissions, got: {top_403}" - ); - let source_403 = std::error::Error::source(&err_403) - .and_then(|s| s.downcast_ref::()) - .expect("source should be a DaytonaError"); - assert_eq!(source_403.status_code(), Some(403)); - } - - #[test] - fn missing_display_uses_daytona_wire_scope_names() { - let check = DaytonaKeyCheck { - key_name: "delete-only".to_string(), - missing: vec![Permissions::WRITE_SNAPSHOTS, Permissions::WRITE_SANDBOXES], - }; - - assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes"); - assert_eq!( - check.missing_message(), - "API key 'delete-only' is missing required Daytona scopes: \ - write:snapshots, write:sandboxes. Regenerate the key with all \ - snapshot and sandbox scopes." - ); - assert_eq!( - required_perms_display(), - "write:snapshots, delete:snapshots, write:sandboxes, delete:sandboxes" - ); - } - - #[tokio::test] - async fn check_daytona_api_key_with_reports_missing_scopes() { - let server = MockServer::start_async().await; - let auth = mock_auth_probe(&server, 200).await; - let current_key = mock_current_key(&server, vec![ - "delete:snapshots", - "delete:sandboxes", - "delete:volumes", - ]) - .await; - - let check = check_daytona_api_key_with( - &server.base_url(), - None, - "dtn_test".to_string(), - fabro_test::test_http_client(), - ) - .await - .expect("probe should succeed"); - - assert!(!check.ok()); - assert_eq!(check.key_name, "delete-only"); - assert_eq!(check.missing_display(), "write:snapshots, write:sandboxes"); - auth.assert_async().await; - current_key.assert_async().await; - } - - #[tokio::test] - async fn check_daytona_api_key_with_accepts_full_scopes_and_new_scopes() { - let server = MockServer::start_async().await; - let auth = mock_auth_probe(&server, 200).await; - let current_key = mock_current_key(&server, vec![ - "write:snapshots", - "delete:snapshots", - "write:sandboxes", - "delete:sandboxes", - "manage:secrets", - "read:limits", - "manage:sso", - ]) - .await; - - let check = check_daytona_api_key_with( - &server.base_url(), - None, - "dtn_test".to_string(), - fabro_test::test_http_client(), - ) - .await - .expect("probe should succeed"); - - assert!(check.ok()); - assert!(check.missing.is_empty()); - auth.assert_async().await; - current_key.assert_async().await; - } - - #[tokio::test] - async fn check_daytona_api_key_with_preserves_auth_failure_context() { - let server = MockServer::start_async().await; - let auth = mock_auth_probe(&server, 401).await; - - let err = check_daytona_api_key_with( - &server.base_url(), - None, - "dtn_test".to_string(), - fabro_test::test_http_client(), - ) - .await - .expect_err("auth probe should fail"); - let chain = err.chain().map(ToString::to_string).collect::>(); - - assert!( - chain - .iter() - .any(|cause| cause == "failed to authenticate with Daytona"), - "expected auth context in chain, got {chain:#?}" - ); - auth.assert_async().await; - } - - #[tokio::test] - async fn daytona_credential_probe_reports_configured_timeout() { - let err = daytona_credential_probe_with_timeout( - std::future::pending::>(), - Duration::from_millis(1), - ) - .await - .expect_err("probe should time out"); - let timeout = err - .downcast_ref::() - .expect("timeout should preserve its type"); - - assert_eq!(timeout.timeout(), Duration::from_millis(1)); - assert_eq!( - err.to_string(), - "Daytona credential probe timed out after 1ms" - ); - } - - #[tokio::test] - async fn daytona_stdin_file_uploads_exact_bytes_and_is_deleted() { - let server = MockServer::start_async().await; - let server_url = server.base_url(); - let sandbox_response = server - .mock_async(|when, then| { - when.method(GET).path("/sandbox/sandbox-stdin"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "id": "sandbox-stdin", - "organizationId": "org-1", - "name": "stdin-test", - "user": "daytona", - "env": {}, - "labels": {}, - "public": false, - "networkBlockAll": false, - "target": "us", - "cpu": 2.0, - "gpu": 0.0, - "memory": 4.0, - "disk": 20.0, - "toolboxProxyUrl": "https://proxy.example.com/toolbox", - "state": "started" - })); - }) - .await; - let toolbox_response = server - .mock_async(|when, then| { - when.method(GET) - .path("/sandbox/sandbox-stdin/toolbox-proxy-url"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({"url": server_url})); - }) - .await; - let upload = server - .mock_async(|when, then| { - when.method(POST) - .path("/sandbox-stdin/files/upload") - .body_includes("opaque\n$(not shell)\nlast"); - then.status(200); - }) - .await; - let delete = server - .mock_async(|when, then| { - when.method(DELETE) - .path("/sandbox-stdin/files") - .query_param("recursive", "false"); - then.status(200); - }) - .await; - let client = build_daytona_client_with( - Some("dtn_test".to_string()), - Some(server.base_url()), - None, - Some(fabro_test::test_http_client()), - ) - .await - .expect("create Daytona client"); - let sandbox = client.get("sandbox-stdin").await.expect("get mock sandbox"); - - let mut file = DaytonaStdinFile::create(&sandbox, b"opaque\n$(not shell)\nlast") - .await - .expect("upload stdin file"); - assert!(file.path.starts_with("/tmp/fabro-command-stdin-")); - file.close().await; - - sandbox_response.assert_async().await; - toolbox_response.assert_async().await; - upload.assert_async().await; - delete.assert_async().await; - } - - #[tokio::test] - async fn write_existing_file_skips_parent_directory_creation() { - let server = MockServer::start_async().await; - let server_url = server.base_url(); - let sandbox_response = server - .mock_async(|when, then| { - when.method(GET).path("/sandbox/sandbox-edit"); - then.status(200) - .header("content-type", "application/json") - .json_body(sandbox_body("sandbox-edit", SandboxState::Started)); - }) - .await; - let toolbox_response = server - .mock_async(|when, then| { - when.method(GET) - .path("/sandbox/sandbox-edit/toolbox-proxy-url"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({"url": server_url})); - }) - .await; - let folder = server - .mock_async(|when, then| { - when.method(POST).path("/sandbox-edit/files/folder"); - then.status(200); - }) - .await; - let upload = server - .mock_async(|when, then| { - when.method(POST) - .path("/sandbox-edit/files/upload") - .query_param("path", "/home/daytona/workspace/src/lib.rs") - .body_includes("updated contents"); - then.status(200); - }) - .await; - - let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await; - let sdk_sandbox = sandbox - .client - .get("sandbox-edit") - .await - .expect("get mock sandbox"); - assert!(sandbox.sandbox.set(sdk_sandbox).is_ok()); - - sandbox - .write_existing_file("src/lib.rs", "updated contents") - .await - .expect("write existing file"); - - sandbox_response.assert_async().await; - toolbox_response.assert_async().await; - upload.assert_async().await; - folder.assert_calls_async(0).await; - } - - /// Recover the inner command a wrapper carries, proving it survives the - /// base64 transport byte-for-byte. - fn decode_wrapped_command(wrapped: &str) -> String { - use base64::Engine; - use base64::engine::general_purpose::STANDARD; - - let prefix = "/bin/bash -c \"echo '"; - let suffix = "' | base64 -d | /bin/bash\""; - let encoded = wrapped - .strip_prefix(prefix) - .and_then(|rest| rest.strip_suffix(suffix)) - .unwrap_or_else(|| panic!("wrapper should have the canonical bash shape: {wrapped}")); - String::from_utf8( - STANDARD - .decode(encoded) - .expect("wrapper payload should be base64"), - ) - .expect("wrapper payload should be UTF-8") - } - - #[test] - fn wrap_bash_uses_bash_on_both_sides_of_the_transport() { - let wrapped = wrap_bash_command("echo hello"); - - assert!( - wrapped.starts_with("/bin/bash -c \"echo '"), - "should start with a non-login /bin/bash wrapper: {wrapped}" - ); - assert!( - wrapped.ends_with("' | base64 -d | /bin/bash\""), - "should pipe the decoded payload into /bin/bash: {wrapped}" - ); - // The base64 of "echo hello" is "ZWNobyBoZWxsbw==" - assert!( - wrapped.contains("ZWNobyBoZWxsbw=="), - "should contain base64 of 'echo hello'" - ); - } - - #[test] - fn wrap_bash_never_names_sh_as_an_interpreter() { - for command in ["echo hello", "ls | grep foo", "sh -c 'echo explicit'"] { - let wrapped = wrap_bash_command(command); - let transport = wrapped - .strip_suffix('"') - .and_then(|rest| rest.split_once("| base64 -d | ")) - .map(|(_, interpreter)| interpreter) - .expect("wrapper should end with its inner interpreter"); - - assert_eq!(transport, "/bin/bash", "inner interpreter for {command:?}"); - assert!( - !wrapped.starts_with("sh ") && !wrapped.starts_with("/bin/sh"), - "outer interpreter for {command:?} should not be sh: {wrapped}" - ); - } - } - - #[test] - fn wrap_bash_passes_arbitrary_text_through_unchanged() { - for command in [ - "echo 'hello world'", - "printf '%s\\n' \"quoted\"", - "ls | grep foo", - "echo line1\necho line2", - "echo \"it's mixed 'quotes'\"", - ] { - assert_eq!( - decode_wrapped_command(&wrap_bash_command(command)), - command, - "inner command should reach bash unchanged" - ); - } - } - - #[test] - fn wrap_bash_handles_single_quotes_safely() { - // Single quotes in the original command are safely encoded in base64 - let wrapped = wrap_bash_command("echo 'hello world'"); - assert!( - wrapped.starts_with("/bin/bash -c \"echo '"), - "should use the /bin/bash wrapper" - ); - // No raw single quotes from the original command should appear in the base64 - assert!( - !wrapped.contains("hello world"), - "original command should be base64 encoded, not literal" - ); - } - - #[test] - fn build_bash_session_script_adds_cwd_and_sorted_exports() { - let env = HashMap::from([ - ("BETA".to_string(), "two words".to_string()), - ("ALPHA".to_string(), "one".to_string()), - ( - BASH_ENV_VAR.to_string(), - "/tmp/untrusted-startup".to_string(), - ), - ]); - - let command = build_bash_session_script("echo $ALPHA $BETA", "/tmp/with space", Some(&env)); - - assert_eq!( - command, - "cd '/tmp/with space' || exit $?\n\ - export ALPHA=one\n\ - export BASH_ENV=''\n\ - export BETA='two words'\n\ - (\n\ - echo $ALPHA $BETA\n\ - )" - ); - } - - #[test] - fn streaming_session_script_reaches_bash_through_the_canonical_wrapper() { - let script = build_bash_session_script("[[ -d / ]] && echo ok", "/tmp", None); - let wrapped = wrap_bash_session_script(&script); - - assert_eq!( - wrapped, - format!("/bin/bash -c {}", shell_quote(&script)), - "the streaming path must enter the canonical Bash exactly once" - ); - assert!(!wrapped.contains("base64")); - } - - #[cfg(unix)] - #[test] - #[expect( - clippy::disallowed_methods, - reason = "test executes the generated stdin redirection to verify exact bytes and EOF" - )] - fn stdin_file_redirection_applies_to_the_whole_command() { - let dir = tempfile::tempdir().expect("create stdin transport temp dir"); - let stdin_path = dir.path().join("stdin data"); - let injection_path = dir.path().join("must-not-run"); - let stdin = format!( - "first line\n$(touch {})\nlast line", - injection_path.display() - ); - std::fs::write(&stdin_path, &stdin).expect("write stdin fixture"); - let command = redirect_command_stdin( - "IFS= read -r first\nprintf '%s\\n' \"$first\"\ncat", - stdin_path.to_str().expect("temp path should be UTF-8"), - ); - - let output = std::process::Command::new(REMOTE_BASH) - .args(["-c", &command]) - .env_remove(BASH_ENV_VAR) - .output() - .expect("execute redirected command"); - - assert!(output.status.success(), "{output:?}"); - assert_eq!(String::from_utf8_lossy(&output.stdout), stdin); - assert!(!injection_path.exists()); - } - - #[cfg(unix)] - #[test] - #[expect( - clippy::disallowed_methods, - reason = "test executes the generated shell transport to verify provider bookkeeping resumes" - )] - fn streaming_session_script_returns_to_provider_bookkeeping() { - let dir = tempfile::tempdir().expect("create session transport temp dir"); - let command_file = dir.path().join("cmd.sh"); - let exit_code_file = dir.path().join("exit_code"); - let script = build_bash_session_script("printf 'command-finished\\n'; exit 7", "/", None); - std::fs::write(&command_file, wrap_bash_session_script(&script)) - .expect("write generated session command"); - - // Daytona sources the command file, then records the exit code. The - // generated command must return control so that bookkeeping can run. - let provider_wrapper = format!( - "{{ . {}; }}\n\ - command_exit_code=$?\n\ - printf '%s\\n' \"$command_exit_code\" > {}", - shell_quote(&command_file.to_string_lossy()), - shell_quote(&exit_code_file.to_string_lossy()), - ); - let output = std::process::Command::new(REMOTE_BASH) - .args(["-c", &provider_wrapper]) - .env_remove(BASH_ENV_VAR) - .output() - .expect("execute generated session command"); - - assert_eq!( - String::from_utf8_lossy(&output.stdout), - "command-finished\n" - ); - assert_eq!( - std::fs::read_to_string(exit_code_file) - .expect("provider bookkeeping should record the exit code"), - "7\n" - ); - assert!(output.status.success(), "{output:?}"); - } - - fn bash_probe_result(exit_code: i32, stdout: impl Into) -> ExecResult { - ExecResult { - stdout: stdout.into(), - stderr: String::new(), - exit_code: Some(exit_code), - termination: CommandTermination::Exited, - duration_ms: 1, - } - } - - #[test] - fn bash_probe_outcome_accepts_a_marked_zero_exit() { - assert!( - daytona_bash_probe_outcome(Ok(bash_probe_result(0, format!("{BASH_PROBE_MARKER}\n")))) - .is_ok() - ); - } - - #[test] - fn bash_probe_outcome_rejects_failures_with_snapshot_remediation() { - let failures = [ - Err(crate::Error::message("connection reset")), - Ok(bash_probe_result(1, "bash: not found")), - Ok(bash_probe_result(0, "ready")), - ]; - - for failure in failures { - let err = daytona_bash_probe_outcome(failure) - .expect_err("probe should fail") - .display_with_causes(); - - assert!( - err.contains("/bin/bash") && err.contains("snapshot"), - "probe failure should carry the Daytona snapshot remediation: {err}" - ); - assert!( - !err.contains("bash: not found"), - "raw process output must not enter lifecycle errors: {err}" - ); - } - } - - /// The session probe exists to catch a transport that runs the command but - /// never returns control to Daytona's bookkeeping. That failure arrives as - /// a timeout with no exit code — with the marker already on stdout — not as - /// a nonzero exit. - #[test] - fn bash_session_probe_outcome_rejects_a_command_that_never_reports_completion() { - let never_completed = ExecResult { - stdout: format!("{BASH_PROBE_MARKER}\n"), - stderr: String::new(), - exit_code: None, - termination: CommandTermination::TimedOut, - duration_ms: BASH_PROBE_TIMEOUT_MS, - }; - - let err = daytona_bash_session_probe_outcome(Ok(never_completed)) - .expect_err("a command with no recorded exit code must fail the probe") - .display_with_causes(); - - assert!( - err.contains("wrapper shell") && err.contains("exit code"), - "session probe failure should explain the completion contract: {err}" - ); - } - - #[test] - fn bash_session_probe_outcome_accepts_a_marker_line_amid_transport_output() { - assert!( - daytona_bash_session_probe_outcome(Ok(bash_probe_result( - 0, - format!("\n{BASH_PROBE_MARKER}\r\n"), - ))) - .is_ok() - ); - } - - /// The probe script contains the marker literal, so a session transport - /// that echoed the submitted script instead of running it must not pass. - #[test] - fn bash_session_probe_outcome_rejects_echoed_script_source() { - let echoed = daytona_bash_session_probe_outcome(Ok(bash_probe_result( - 0, - build_bash_session_command(BASH_PROBE_SCRIPT, "/", None), - ))); - - assert!(echoed.is_err()); - } - - #[test] - fn bash_session_command_enters_bash_once_without_exec() { - let command = build_bash_session_command(BASH_PROBE_SCRIPT, "/", None); - - assert!( - command.starts_with(&format!("{REMOTE_BASH} -c ")), - "{command}" - ); - assert!( - !command.contains("exec "), - "the session probe must leave Daytona's wrapper shell in place: {command}" - ); - assert_eq!(command.matches(REMOTE_BASH).count(), 1, "{command}"); - } - - #[test] - fn missing_log_suffix_offset_handles_prefix_overlap() { - assert_eq!(missing_log_suffix_offset(b"hello", b"hello world"), 5); - assert_eq!(missing_log_suffix_offset(b"hello wor", b"hello world"), 9); - assert_eq!(missing_log_suffix_offset(b"abcxyz", b"xyz123"), 3); - assert_eq!(missing_log_suffix_offset(b"hello world", b"hello"), 5); - assert_eq!(missing_log_suffix_offset(b"abc", b"def"), 0); - } - - #[test] - fn captured_log_suffix_offset_uses_observed_length_after_truncation() { - let mut seen = OutputCaptureBuffer::new(Some(6)); - seen.push(b"abcdefgh"); - - assert_eq!(captured_log_suffix_offset(&mut seen, b"abcdefghij"), 8); - assert_eq!(captured_log_suffix_offset(&mut seen, b"abcdefgh"), 8); - assert_eq!(captured_log_suffix_offset(&mut seen, b"abcd"), 4); - } - - #[test] - fn detect_git_remote_from_repo() { - let dir = tempfile::tempdir().unwrap(); - let repo = git2::Repository::init(dir.path()).unwrap(); - - repo.remote("origin", "https://github.com/org/repo.git") - .unwrap(); - - let (url, _branch) = detect_repo_info(dir.path()).unwrap(); - assert_eq!(url, "https://github.com/org/repo.git"); - } - - #[test] - fn detect_git_branch_from_repo() { - let dir = tempfile::tempdir().unwrap(); - let repo = git2::Repository::init(dir.path()).unwrap(); - - // Create an initial commit so HEAD points to a branch - let sig = git2::Signature::now("Test", "test@test.com").unwrap(); - let tree_id = repo.index().unwrap().write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) - .unwrap(); - - repo.remote("origin", "https://github.com/org/repo.git") - .unwrap(); - - let (_, branch) = detect_repo_info(dir.path()).unwrap(); - // git init creates "master" or "main" depending on git config - assert!(branch.is_some()); - } - - #[test] - fn network_block_from_string() { - let config: DaytonaConfig = toml::from_str(r#"network = "block""#).unwrap(); - assert_eq!(config.network, Some(DaytonaNetwork::Block)); - } - - #[test] - fn network_allow_all_from_string() { - let config: DaytonaConfig = toml::from_str(r#"network = "allow_all""#).unwrap(); - assert_eq!(config.network, Some(DaytonaNetwork::AllowAll)); - } - - #[test] - fn network_allow_list_from_table() { - let config: DaytonaConfig = - toml::from_str(r#"network = { allow_list = ["10.0.0.0/8", "172.16.0.0/12"] }"#) - .unwrap(); - assert_eq!( - config.network, - Some(DaytonaNetwork::AllowList(vec![ - "10.0.0.0/8".into(), - "172.16.0.0/12".into(), - ])) - ); - } - - #[test] - fn network_typo_string_error() { - let err = toml::from_str::(r#"network = "blck""#).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains(r#"unknown network mode "blck""#), - "unexpected error: {msg}" - ); - } - - #[test] - fn network_wrong_type_error() { - let err = toml::from_str::("network = 42").unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("expected") && msg.contains("allow_list"), - "unexpected error: {msg}" - ); - } - - #[test] - fn network_unknown_key_error() { - let err = toml::from_str::(r#"network = { mode = "block" }"#).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains(r#"unknown key "mode""#), - "unexpected error: {msg}" - ); - } - - #[test] - fn network_empty_table_error() { - let err = toml::from_str::("network = {}").unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("empty table"), "unexpected error: {msg}"); - } - - #[test] - fn network_empty_allow_list_error() { - let err = toml::from_str::("network = { allow_list = [] }").unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("allow_list must not be empty"), - "unexpected error: {msg}" - ); - } - - #[test] - fn network_extra_key_error() { - let err = toml::from_str::( - r#"network = { allow_list = ["10.0.0.0/8"], extra = true }"#, - ) - .unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains(r#"unexpected key "extra""#), - "unexpected error: {msg}" - ); - } - - #[test] - fn detect_repo_info_returns_worktree_branch() { - let dir = tempfile::tempdir().unwrap(); - let repo = git2::Repository::init(dir.path()).unwrap(); - - // Create an initial commit so HEAD exists - let sig = git2::Signature::now("Test", "test@test.com").unwrap(); - let tree_id = repo.index().unwrap().write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let commit = repo - .commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) - .unwrap(); - - repo.remote("origin", "https://github.com/org/repo.git") - .unwrap(); - - // Create and check out a fabro/run/... branch (simulating worktree setup) - let commit_obj = repo.find_commit(commit).unwrap(); - repo.branch("fabro/run/ABC", &commit_obj, false).unwrap(); - repo.set_head("refs/heads/fabro/run/ABC").unwrap(); - - let (_, branch) = detect_repo_info(dir.path()).unwrap(); - // Documents the current behavior: detect_repo_info returns whatever HEAD points - // to - assert_eq!(branch, Some("fabro/run/ABC".into())); - } -} diff --git a/lib/components/fabro-sandbox/src/details.rs b/lib/components/fabro-sandbox/src/details.rs index 2bbe8f45b..e14f7260b 100644 --- a/lib/components/fabro-sandbox/src/details.rs +++ b/lib/components/fabro-sandbox/src/details.rs @@ -3,33 +3,28 @@ use std::collections::BTreeMap; use anyhow::Result; use chrono::{DateTime, Utc}; use fabro_types::{ - BundledProvider, RunId, RunSandboxInstance, SandboxDetails, SandboxNetwork, SandboxResources, - SandboxState, SandboxTimestamps, + BundledProvider, RunId, RunSandboxInstance, SandboxDetails, SandboxNetwork, + SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, }; -use crate::docker; +use crate::driver::DaytonaCredentials; +use crate::{daytona, docker}; /// Inspect the sandbox identified by `record` and return provider-neutral /// details for control-plane display. /// /// - `local` always returns a minimal record describing the host. /// - `docker` describes the managed container through the sandbox driver. -/// - `daytona` reconnects to the SDK sandbox (feature-gated). -#[allow( - unused_variables, - reason = "Feature-gated providers consume some parameters only when enabled." -)] +/// - `daytona` describes the sandbox through the sandbox driver. pub async fn sandbox_details( record: &RunSandboxInstance, - daytona_api_key: Option, - daytona_organization_id: Option, + daytona: Option, run_id: Option, ) -> Result { match record.provider.bundled() { Some(BundledProvider::Local) => Ok(local_details(record)), Some(BundledProvider::Docker) => docker_details(record, run_id).await, - #[cfg(feature = "daytona")] - Some(BundledProvider::Daytona) => daytona::daytona_details(record, daytona_api_key).await, + Some(BundledProvider::Daytona) => daytona_details(record, daytona, run_id).await, _ => Err(anyhow::anyhow!( "Sandbox provider '{}' has no details implementation", record.provider @@ -51,13 +46,6 @@ fn local_details(record: &RunSandboxInstance) -> SandboxDetails { } } -#[cfg(feature = "daytona")] -fn parse_rfc3339_utc(value: &str) -> Option> { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|dt| dt.with_timezone(&Utc)) -} - /// Projection of a sandbox-driver [`sandbox_driver::SandboxStatus`] into /// fabro's inventory shape. The driver reports what a provider exposes /// through its public facets; fields no facet carries (network policy) stay @@ -92,7 +80,14 @@ pub(crate) fn details_from_status( let fields = fields_from_status(status); SandboxDetails { sandbox: RunSandboxInstance { - image: status.source.clone().or_else(|| record.image.clone()), + image: (record.provider == SandboxProviderKind::DOCKER) + .then(|| status.source.clone()) + .flatten() + .or_else(|| record.image.clone()), + snapshot: (record.provider == SandboxProviderKind::DAYTONA) + .then(|| status.source.clone()) + .flatten() + .or_else(|| record.snapshot.clone()), ..record.clone() }, state: fields.state, @@ -153,6 +148,30 @@ pub(crate) fn normalize_driver_state(state: sandbox_driver::SandboxState) -> San } } +async fn daytona_details( + record: &RunSandboxInstance, + daytona: Option, + run_id: Option, +) -> Result { + let runtime = &record.runtime; + let credentials = daytona.ok_or_else(|| { + anyhow::anyhow!("Daytona sandbox details require DAYTONA_API_KEY in the vault") + })?; + let sandbox = daytona::attach_daytona( + &runtime.id, + runtime.repo_cloned.unwrap_or(false), + runtime.working_directory.clone(), + runtime.clone_origin_url.clone(), + run_id, + &credentials, + ) + .await?; + let status = sandbox.handle()?.describe().await.map_err(|err| { + anyhow::anyhow!("Failed to describe Daytona sandbox '{}': {err}", runtime.id) + })?; + Ok(details_from_status(record, &status)) +} + async fn docker_details( record: &RunSandboxInstance, run_id: Option, @@ -175,347 +194,8 @@ async fn docker_details( Ok(details_from_status(record, &status)) } -#[cfg(feature = "daytona")] -pub(crate) mod daytona { - use std::collections::BTreeMap; - - use anyhow::{Context, Result, anyhow}; - use daytona_api_client::models::SandboxState as DaytonaState; - use fabro_types::{ - RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, - SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, - }; - - use super::parse_rfc3339_utc; - use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox, WORKING_DIRECTORY}; - - pub(super) async fn daytona_details( - record: &RunSandboxInstance, - daytona_api_key: Option, - ) -> Result { - let runtime = &record.runtime; - let repo_cloned = runtime - .repo_cloned - .context("Daytona run sandbox missing clone metadata")?; - - let sandbox_handle = DaytonaSandbox::reconnect( - &runtime.id, - daytona_api_key, - repo_cloned, - runtime.working_directory.clone(), - runtime.clone_origin_url.clone(), - runtime.clone_branch.clone(), - ) - .await - .map_err(anyhow::Error::new)?; - let sdk_sandbox = sandbox_handle - .sandbox_handle() - .ok_or_else(|| anyhow!("Daytona sandbox is not initialized after reconnect"))?; - - Ok(map_daytona_sandbox(sdk_sandbox, record)) - } - - pub(crate) fn daytona_info_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> SandboxInfo { - let fields = daytona_fields_from_sdk_sandbox(sandbox); - SandboxInfo { - provider: SandboxProviderKind::DAYTONA, - id: sandbox.id.clone(), - display_name: Some(sandbox.name.clone()).filter(|name| !name.is_empty()), - state: fields.state, - native_state: fields.native_state, - image: None, - snapshot: sandbox.snapshot.clone(), - region: fields.region, - web_url: Some(daytona_dashboard_url(&sandbox.id)), - working_directory: Some(WORKING_DIRECTORY.to_string()), - resources: fields.resources, - network: fields.network, - labels: fields.labels, - timestamps: fields.timestamps, - } - } - - pub(super) fn map_daytona_sandbox( - sandbox: &daytona_sdk::Sandbox, - record: &RunSandboxInstance, - ) -> SandboxDetails { - let fields = daytona_fields_from_sdk_sandbox(sandbox); - SandboxDetails { - sandbox: RunSandboxInstance { - snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()), - ..record.clone() - }, - state: fields.state, - native_state: fields.native_state, - region: fields.region, - web_url: Some(daytona_dashboard_url(&sandbox.id)), - resources: fields.resources, - network: fields.network, - labels: fields.labels, - timestamps: fields.timestamps, - } - } - - struct DaytonaFields { - state: SandboxState, - native_state: Option, - region: Option, - resources: SandboxResources, - network: SandboxNetwork, - labels: BTreeMap, - timestamps: SandboxTimestamps, - } - - fn daytona_fields_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> DaytonaFields { - let normalized_state = sandbox - .state - .map_or(SandboxState::Unknown, normalize_daytona_state); - let native_state = sandbox.state.map(|state| state.to_string()); - - let resources = SandboxResources { - cpu_cores: Some(sandbox.cpu), - memory_bytes: gibibytes_to_bytes(sandbox.memory), - disk_bytes: gibibytes_to_bytes(sandbox.disk), - }; - - let labels: BTreeMap = sandbox - .labels - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect(); - - let target = sandbox.target.clone(); - let region = if target.is_empty() { - None - } else { - Some(target) - }; - - DaytonaFields { - state: normalized_state, - native_state, - region, - resources, - network: daytona_network( - sandbox.network_block_all, - sandbox.network_allow_list.as_deref(), - ), - labels, - timestamps: SandboxTimestamps { - created_at: sandbox.created_at.as_deref().and_then(parse_rfc3339_utc), - last_activity_at: sandbox.updated_at.as_deref().and_then(parse_rfc3339_utc), - }, - } - } - - /// The Daytona SDK reports CPU/memory/disk as floats in their respective - /// SI units (cores, GiB, GiB). Convert mem/disk into bytes. - fn gibibytes_to_bytes(value: f64) -> Option { - if value <= 0.0 || !value.is_finite() { - return None; - } - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - reason = "Daytona memory/disk values are well within u64 range and only need approximate byte counts." - )] - let bytes = (value * 1024.0 * 1024.0 * 1024.0) as u64; - Some(bytes) - } - - fn daytona_dashboard_url(sandbox_id: &str) -> String { - format!("{DAYTONA_DASHBOARD_SANDBOXES_URL}?sandboxId={sandbox_id}") - } - - fn daytona_network( - network_block_all: bool, - network_allow_list: Option<&str>, - ) -> SandboxNetwork { - let egress = if network_block_all { - SandboxNetworkPolicy::blocked() - } else { - let cidrs = network_allow_list - .into_iter() - .flat_map(|allow_list| allow_list.split(',')) - .map(str::trim) - .filter(|cidr| !cidr.is_empty()); - let cidrs: Vec<_> = cidrs.collect(); - if cidrs.is_empty() { - SandboxNetworkPolicy::open() - } else { - SandboxNetworkPolicy::allow_cidrs(cidrs) - } - }; - - SandboxNetwork { - egress, - ingress: SandboxNetworkPolicy::blocked(), - } - } - - pub(super) fn normalize_daytona_state(state: DaytonaState) -> SandboxState { - match state { - DaytonaState::Creating - | DaytonaState::PendingBuild - | DaytonaState::BuildingSnapshot - | DaytonaState::PullingSnapshot - | DaytonaState::Forking => SandboxState::Provisioning, - DaytonaState::Starting | DaytonaState::Resuming => SandboxState::Starting, - DaytonaState::Started | DaytonaState::Snapshotting => SandboxState::Running, - DaytonaState::Stopping | DaytonaState::Archiving | DaytonaState::Pausing => { - SandboxState::Stopping - } - DaytonaState::Stopped => SandboxState::Stopped, - DaytonaState::Paused => SandboxState::Paused, - DaytonaState::Restoring => SandboxState::Restoring, - DaytonaState::Resizing => SandboxState::Resizing, - DaytonaState::Archived => SandboxState::Archived, - DaytonaState::Destroying => SandboxState::Deleting, - DaytonaState::Destroyed => SandboxState::Deleted, - DaytonaState::Error | DaytonaState::BuildFailed => SandboxState::Error, - DaytonaState::Unknown | DaytonaState::UnknownDefaultOpenApi => SandboxState::Unknown, - } - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn started_normalizes_to_running() { - assert_eq!( - normalize_daytona_state(DaytonaState::Started), - SandboxState::Running - ); - } - - #[test] - fn creating_normalizes_to_provisioning() { - assert_eq!( - normalize_daytona_state(DaytonaState::Creating), - SandboxState::Provisioning - ); - } - - #[test] - fn building_snapshot_normalizes_to_provisioning() { - assert_eq!( - normalize_daytona_state(DaytonaState::BuildingSnapshot), - SandboxState::Provisioning - ); - } - - #[test] - fn stopped_normalizes_to_stopped() { - assert_eq!( - normalize_daytona_state(DaytonaState::Stopped), - SandboxState::Stopped - ); - } - - #[test] - fn archived_normalizes_to_archived() { - assert_eq!( - normalize_daytona_state(DaytonaState::Archived), - SandboxState::Archived - ); - } - - #[test] - fn destroyed_normalizes_to_deleted() { - assert_eq!( - normalize_daytona_state(DaytonaState::Destroyed), - SandboxState::Deleted - ); - } - - #[test] - fn build_failed_normalizes_to_error() { - assert_eq!( - normalize_daytona_state(DaytonaState::BuildFailed), - SandboxState::Error - ); - } - - #[test] - fn unknown_normalizes_to_unknown() { - assert_eq!( - normalize_daytona_state(DaytonaState::Unknown), - SandboxState::Unknown - ); - } - - #[test] - fn pause_states_normalize_to_fabro_states() { - assert_eq!( - normalize_daytona_state(DaytonaState::Pausing), - SandboxState::Stopping - ); - assert_eq!( - normalize_daytona_state(DaytonaState::Paused), - SandboxState::Paused - ); - assert_eq!( - normalize_daytona_state(DaytonaState::Resuming), - SandboxState::Starting - ); - } - - #[test] - fn gibibytes_to_bytes_converts_positive_values() { - assert_eq!(gibibytes_to_bytes(2.0), Some(2 * 1024 * 1024 * 1024)); - } - - #[test] - fn gibibytes_to_bytes_returns_none_for_zero() { - assert_eq!(gibibytes_to_bytes(0.0), None); - } - - #[test] - fn daytona_dashboard_url_uses_sandbox_id_query_param() { - assert_eq!( - daytona_dashboard_url("ad65029a-2d01-421e-8936-49451653fcd9"), - "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", - ); - } - - #[test] - fn network_block_all_blocks_egress_and_ingress() { - let network = daytona_network(true, Some("10.0.0.0/8")); - assert_eq!(network.egress, SandboxNetworkPolicy::blocked()); - assert_eq!(network.ingress, SandboxNetworkPolicy::blocked()); - } - - #[test] - fn network_allow_list_maps_to_cidr_allow_list_and_blocks_ingress() { - let network = daytona_network(false, Some("10.0.0.0/8, 192.168.0.0/16 ")); - assert_eq!( - network.egress, - SandboxNetworkPolicy::allow_cidrs(["10.0.0.0/8", "192.168.0.0/16"]) - ); - assert_eq!(network.ingress, SandboxNetworkPolicy::blocked()); - } - - #[test] - fn empty_network_allow_list_is_open_egress_and_blocked_ingress() { - let network = daytona_network(false, Some(" , ")); - assert_eq!(network.egress, SandboxNetworkPolicy::open()); - assert_eq!(network.ingress, SandboxNetworkPolicy::blocked()); - } - - #[test] - fn default_daytona_network_is_open_egress_and_blocked_ingress() { - let network = daytona_network(false, None); - assert_eq!(network.egress, SandboxNetworkPolicy::open()); - assert_eq!(network.ingress, SandboxNetworkPolicy::blocked()); - } - } -} - #[cfg(test)] mod tests { - use fabro_types::SandboxProviderKind; use sandbox_driver::SandboxId; use super::*; diff --git a/lib/components/fabro-sandbox/src/docker.rs b/lib/components/fabro-sandbox/src/docker.rs index a177f54bd..e8f442b4d 100644 --- a/lib/components/fabro-sandbox/src/docker.rs +++ b/lib/components/fabro-sandbox/src/docker.rs @@ -7,8 +7,6 @@ //! repository checks out under [`REPOS_ROOT`] and is linked into the //! workspace, so the run works in `/workspace/`. -use std::collections::BTreeMap; - use fabro_github::GitHubCredentials; use fabro_types::settings::run::RunCloneSettings; use fabro_types::settings::server::ServerSandboxProviderSettings; @@ -20,7 +18,7 @@ use sandbox_driver_docker_config::DockerProviderConfig; use crate::driver::{ProviderConnectOptions, connect_provider}; use crate::driver_sandbox::{DriverSandbox, RepoWorkspace, WorkspaceLayout}; -use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE, RUN_ID_LABEL}; +use crate::managed_labels; pub const WORKING_DIRECTORY: &str = "/workspace"; pub const REPOS_ROOT: &str = "/repos"; @@ -187,7 +185,12 @@ pub async fn attach_docker( ) })?; let status = handle.describe().await?; - verify_managed_labels(container_id, &status.labels, run_id.as_ref())?; + managed_labels::verify_managed( + &SandboxProviderKind::DOCKER, + container_id, + &status.labels, + run_id.as_ref(), + )?; let workspace = RepoWorkspace::attached(layout(), repo_cloned, working_directory, clone_origin_url); Ok(DriverSandbox::attached( @@ -217,28 +220,6 @@ pub async fn check_docker_daemon() -> crate::Result<()> { } } -pub(crate) fn verify_managed_labels( - container_id: &str, - labels: &BTreeMap, - run_id: Option<&RunId>, -) -> crate::Result<()> { - if labels.get(MANAGED_LABEL).map(String::as_str) != Some(MANAGED_LABEL_VALUE) { - return Err(crate::Error::message(format!( - "Refusing to operate on Docker container '{container_id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}" - ))); - } - if let Some(run_id) = run_id { - let actual = labels.get(RUN_ID_LABEL).map(String::as_str); - let expected = run_id.to_string(); - if actual != Some(expected.as_str()) { - return Err(crate::Error::message(format!( - "Refusing to operate on Docker container '{container_id}' because label {RUN_ID_LABEL}={actual:?} does not match run {run_id}" - ))); - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -269,11 +250,11 @@ mod tests { ); assert_eq!(spec.working_directory.as_deref(), Some(WORKING_DIRECTORY)); assert_eq!( - spec.labels.get(MANAGED_LABEL).map(String::as_str), + spec.labels.get("sh.fabro.managed").map(String::as_str), Some("true") ); assert_eq!( - spec.labels.get(RUN_ID_LABEL).map(String::as_str), + spec.labels.get("sh.fabro.run_id").map(String::as_str), Some("01HY0000000000000000000000") ); assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar")); @@ -291,18 +272,6 @@ mod tests { assert!(spec.name.is_none()); assert!(matches!(spec.network, NetworkPolicy::AllowAll)); assert_eq!(spec.resources, Resources::default()); - assert!(!spec.labels.contains_key(RUN_ID_LABEL)); - } - - #[test] - fn managed_label_check_requires_fabro_ownership_and_matching_run() { - let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let mut labels = BTreeMap::new(); - assert!(verify_managed_labels("c1", &labels, None).is_err()); - labels.insert(MANAGED_LABEL.to_string(), "true".to_string()); - assert!(verify_managed_labels("c1", &labels, None).is_ok()); - assert!(verify_managed_labels("c1", &labels, Some(&run_id)).is_err()); - labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string()); - assert!(verify_managed_labels("c1", &labels, Some(&run_id)).is_ok()); + assert!(!spec.labels.contains_key("sh.fabro.run_id")); } } diff --git a/lib/components/fabro-sandbox/src/driver.rs b/lib/components/fabro-sandbox/src/driver.rs index 83d268370..702861b1e 100644 --- a/lib/components/fabro-sandbox/src/driver.rs +++ b/lib/components/fabro-sandbox/src/driver.rs @@ -17,6 +17,7 @@ use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; +use fabro_static::EnvVars; use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings}; use fabro_types::{BundledProvider, SandboxProviderKind}; use sandbox_driver::{ @@ -46,6 +47,22 @@ pub struct DaytonaCredentials { pub http_client: Option, } +impl DaytonaCredentials { + /// Credentials for a vault API key, with the control-plane URL and + /// organization taken from `lookup` (server configuration, or the + /// process environment in a CLI worker). Nothing is read implicitly. + pub fn from_api_key(api_key: String, lookup: impl Fn(&str) -> Option) -> Self { + Self { + api_key, + api_url: lookup(EnvVars::DAYTONA_API_URL) + .or_else(|| lookup(EnvVars::DAYTONA_SERVER_URL)), + organization_id: lookup(EnvVars::DAYTONA_ORGANIZATION_ID), + target: None, + http_client: None, + } + } +} + impl std::fmt::Debug for DaytonaCredentials { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DaytonaCredentials") diff --git a/lib/components/fabro-sandbox/src/driver_sandbox.rs b/lib/components/fabro-sandbox/src/driver_sandbox.rs index 27fb460ba..c6693116d 100644 --- a/lib/components/fabro-sandbox/src/driver_sandbox.rs +++ b/lib/components/fabro-sandbox/src/driver_sandbox.rs @@ -202,13 +202,46 @@ impl RepoWorkspace { } } -/// A sandbox that does not exist yet: `initialize` creates it from the -/// spec on the provider. +/// What a create needs once its inputs are settled. +#[derive(Clone)] +pub(crate) struct PreparedCreate { + pub(crate) spec: DriverSpec, + /// The image or snapshot named by the spec, for pull progress events. + pub(crate) source: Option, + /// The provider snapshot the sandbox is created from, when the provider + /// has that concept; recorded on the run. + pub(crate) snapshot: Option, +} + +/// Settles a create's inputs right before the provider call. A plan may +/// build provider resources first (a Daytona snapshot) and report progress +/// through fabro's events. +#[async_trait] +pub(crate) trait CreatePlan: Send + Sync { + async fn prepare( + &self, + emit: &(dyn Fn(SandboxEvent) + Send + Sync), + ) -> crate::Result; +} + +/// A create whose spec is known up front. +struct SpecPlan(PreparedCreate); + +#[async_trait] +impl CreatePlan for SpecPlan { + async fn prepare( + &self, + _emit: &(dyn Fn(SandboxEvent) + Send + Sync), + ) -> crate::Result { + Ok(self.0.clone()) + } +} + +/// A sandbox that does not exist yet: `initialize` creates it on the +/// provider from the plan's spec. struct PendingCreate { provider: Arc, - spec: DriverSpec, - /// The image or snapshot named by the spec, for pull progress events. - source: Option, + plan: Box, } /// A fabro sandbox backed by a sandbox-driver handle. @@ -224,6 +257,8 @@ pub struct DriverSandbox { /// `(platform, os_version)` learned from the sandbox at initialize or /// start; unknown until then. platform: OnceLock<(String, String)>, + /// The provider snapshot the sandbox was created from, when known. + snapshot: OnceLock, } impl DriverSandbox { @@ -246,16 +281,37 @@ impl DriverSandbox { source: Option, workspace: RepoWorkspace, ) -> Self { - let mut sandbox = Self::empty(kind); - sandbox.pending = Some(PendingCreate { + Self::pending_with_plan( + kind, provider, - spec, - source, - }); + Box::new(SpecPlan(PreparedCreate { + spec, + source, + snapshot: None, + })), + workspace, + ) + } + + /// A sandbox `initialize` will create on `provider` once `plan` has + /// settled its spec, then prepare per `workspace`. + pub(crate) fn pending_with_plan( + kind: SandboxProviderKind, + provider: Arc, + plan: Box, + workspace: RepoWorkspace, + ) -> Self { + let mut sandbox = Self::empty(kind); + sandbox.pending = Some(PendingCreate { provider, plan }); sandbox.workspace = Some(workspace); sandbox } + /// Records the provider snapshot an attached sandbox was created from. + pub(crate) fn set_snapshot(&self, snapshot: String) { + let _ = self.snapshot.set(snapshot); + } + /// An existing sandbox reattached by handle, with the workspace an /// earlier process prepared. pub(crate) fn attached( @@ -282,6 +338,7 @@ impl DriverSandbox { env_policy, event_callback: None, platform: OnceLock::new(), + snapshot: OnceLock::new(), } } @@ -357,13 +414,17 @@ impl DriverSandbox { let Some(pending) = &self.pending else { return self.handle().map(|_| ()); }; + let prepared = pending.plan.prepare(&|event| self.emit(event)).await?; + if let Some(snapshot) = prepared.snapshot { + let _ = self.snapshot.set(snapshot); + } let observer = Arc::new(CreateProgress::new( - pending.source.clone(), + prepared.source, self.event_callback.clone(), )); let handle = pending .provider - .create(&pending.spec, Some(EventContext::new(observer))) + .create(&prepared.spec, Some(EventContext::new(observer))) .await .map_err(|error| { crate::Error::context(format!("Failed to create {} sandbox", self.kind), error) @@ -831,14 +892,26 @@ impl Sandbox for DriverSandbox { .await; let duration_ms = elapsed_ms(started); match &result { - Ok(()) => self.emit(SandboxEvent::Ready { - provider: self.provider_name(), - duration_ms, - name: Some(self.sandbox_info()).filter(|name| !name.is_empty()), - cpu: None, - memory: None, - url: None, - }), + Ok(()) => { + // The provider's console page, when it has one. Best effort: + // a failed describe never fails a successful initialize. + let url = match self.handle() { + Ok(handle) if !self.kind.is_local() => handle + .describe() + .await + .ok() + .and_then(|status| status.web_url), + _ => None, + }; + self.emit(SandboxEvent::Ready { + provider: self.provider_name(), + duration_ms, + name: Some(self.sandbox_info()).filter(|name| !name.is_empty()), + cpu: None, + memory: None, + url, + }); + } Err(error) => self.emit(SandboxEvent::InitializeFailed { provider: self.provider_name(), error: error.to_string(), @@ -989,6 +1062,10 @@ impl Sandbox for DriverSandbox { .unwrap_or_default() } + fn snapshot_info(&self) -> Option { + self.snapshot.get().cloned() + } + async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> { let mut timers = LifecycleTimers::default(); timers.auto_stop_after_idle = u64::try_from(minutes) diff --git a/lib/components/fabro-sandbox/src/from_environment.rs b/lib/components/fabro-sandbox/src/from_environment.rs index 820e451ec..66c4f2ec2 100644 --- a/lib/components/fabro-sandbox/src/from_environment.rs +++ b/lib/components/fabro-sandbox/src/from_environment.rs @@ -6,22 +6,18 @@ use std::path::{Path, PathBuf}; use fabro_types::settings::ResolveError; -#[cfg(feature = "daytona")] -use fabro_types::settings::run::DockerfileSource as ResolvedDockerfileSource; use fabro_types::settings::run::{ - EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings, + DockerfileSource as ResolvedDockerfileSource, EnvironmentNetworkMode, RunCloneSettings, + RunEnvironmentSettings, }; -#[cfg(feature = "daytona")] use crate::config::{ DaytonaNetwork, DaytonaSnapshotSettings, DaytonaSnapshotSource, DockerfileSource as SandboxDockerfileSource, }; -#[cfg(feature = "daytona")] use crate::daytona::DaytonaConfig; use crate::docker::DockerSandboxOptions; -#[cfg(feature = "daytona")] #[must_use] pub fn daytona_config_from_environment( settings: &RunEnvironmentSettings, @@ -167,13 +163,11 @@ pub fn local_working_directory_from_environment( ))) } -#[cfg(feature = "daytona")] fn duration_to_minutes_i32(duration: std::time::Duration) -> i32 { let minutes = duration.as_secs() / 60; i32::try_from(minutes).unwrap_or(i32::MAX) } -#[cfg(feature = "daytona")] fn size_to_gb_i32(bytes: u64) -> i32 { let gb = bytes / 1_000_000_000; i32::try_from(gb).unwrap_or(i32::MAX) @@ -247,7 +241,6 @@ mod tests { assert!(!missing.exists()); } - #[cfg(feature = "daytona")] #[test] fn daytona_config_maps_docker_image_to_snapshot() { let mut settings = run_environment(SandboxProviderKind::DAYTONA); diff --git a/lib/components/fabro-sandbox/src/lib.rs b/lib/components/fabro-sandbox/src/lib.rs index c6824d07f..035d30cb4 100644 --- a/lib/components/fabro-sandbox/src/lib.rs +++ b/lib/components/fabro-sandbox/src/lib.rs @@ -29,14 +29,15 @@ pub mod terminal; mod clone; pub mod docker; -#[cfg(feature = "daytona")] pub mod daytona; #[cfg(any(test, feature = "test-support"))] pub mod test_support; +pub use daytona::{DaytonaConfig, attach_daytona, daytona_sandbox}; pub use details::sandbox_details; pub use docker::{DockerSandboxOptions, attach_docker, check_docker_daemon, docker_sandbox}; +pub use driver::DaytonaCredentials; pub use driver_sandbox::{DriverSandbox, local_sandbox}; pub use error::{Error, Result, default_redacted_output_tail, display_for_log}; pub use exec::{ExplicitEnvPolicy, SandboxExec, is_sensitive_env_var}; @@ -47,14 +48,14 @@ pub use fabro_types::{RunSandboxInstance, SandboxProviderKind}; pub use git_retry::{ CredentialContext, GitRetryReason, RetryPlan, classify_failure, retry_git_operation, }; -#[cfg(feature = "daytona")] -pub use provider::daytona::DaytonaSandboxProvider; pub use provider::driver::DriverInventoryProvider; pub use provider::{ LocalSandboxProvider, SandboxLookupError, SandboxProvider, SandboxProviderRegistry, }; pub use push_credentials::RefreshErrorKind; -pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callback}; +pub use reconnect::{ + reconnect, reconnect_driver_for_run, reconnect_for_run, reconnect_for_run_with_callback, +}; pub use sandbox::{ CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions, diff --git a/lib/components/fabro-sandbox/src/managed_labels.rs b/lib/components/fabro-sandbox/src/managed_labels.rs index 250b08c1c..ccc72c8cc 100644 --- a/lib/components/fabro-sandbox/src/managed_labels.rs +++ b/lib/components/fabro-sandbox/src/managed_labels.rs @@ -1,23 +1,50 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; -use fabro_types::RunId; +use fabro_types::{RunId, SandboxProviderKind}; pub(crate) const MANAGED_LABEL: &str = "sh.fabro.managed"; pub(crate) const MANAGED_LABEL_VALUE: &str = "true"; pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id"; /// True when the provided label map carries the Fabro managed sentinel. -pub(crate) fn is_managed(labels: &HashMap) -> bool { +pub(crate) fn is_managed(labels: &BTreeMap) -> bool { labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE) } +/// Refuses a sandbox fabro did not create, or one created for another run. +/// +/// Providers share a daemon or an organization with every other +/// application, so a persisted id is trusted only when the sandbox behind +/// it still carries fabro's labels. +pub(crate) fn verify_managed( + kind: &SandboxProviderKind, + sandbox_id: &str, + labels: &BTreeMap, + run_id: Option<&RunId>, +) -> crate::Result<()> { + if !is_managed(labels) { + return Err(crate::Error::message(format!( + "Refusing to operate on {kind} sandbox '{sandbox_id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}" + ))); + } + if let Some(run_id) = run_id { + let actual = labels.get(RUN_ID_LABEL).map(String::as_str); + let expected = run_id.to_string(); + if actual != Some(expected.as_str()) { + return Err(crate::Error::message(format!( + "Refusing to operate on {kind} sandbox '{sandbox_id}' because label {RUN_ID_LABEL}={actual:?} does not match run {run_id}" + ))); + } + } + Ok(()) +} + pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap { let mut labels = HashMap::new(); insert_for_run(&mut labels, run_id); labels } -#[cfg(any(feature = "daytona", test))] pub(crate) fn merge_for_run( user_labels: Option<&HashMap>, run_id: Option<&RunId>, @@ -65,7 +92,7 @@ mod tests { labels.get(RUN_ID_LABEL).map(String::as_str), Some("01HY0000000000000000000000") ); - assert!(is_managed(&labels)); + assert!(is_managed(&labels.clone().into_iter().collect())); } #[test] @@ -86,4 +113,17 @@ mod tests { Some("01HY0000000000000000000000") ); } + + #[test] + fn verify_managed_requires_fabro_ownership_and_matching_run() { + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); + let kind = SandboxProviderKind::DOCKER; + let mut labels = BTreeMap::new(); + assert!(verify_managed(&kind, "c1", &labels, None).is_err()); + labels.insert(MANAGED_LABEL.to_string(), "true".to_string()); + assert!(verify_managed(&kind, "c1", &labels, None).is_ok()); + assert!(verify_managed(&kind, "c1", &labels, Some(&run_id)).is_err()); + labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string()); + assert!(verify_managed(&kind, "c1", &labels, Some(&run_id)).is_ok()); + } } diff --git a/lib/components/fabro-sandbox/src/provider.rs b/lib/components/fabro-sandbox/src/provider.rs index 13e2c784b..d32e5b4f1 100644 --- a/lib/components/fabro-sandbox/src/provider.rs +++ b/lib/components/fabro-sandbox/src/provider.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "daytona")] -pub mod daytona; pub mod driver; use std::sync::Arc; diff --git a/lib/components/fabro-sandbox/src/provider/daytona.rs b/lib/components/fabro-sandbox/src/provider/daytona.rs deleted file mode 100644 index 14280e89e..000000000 --- a/lib/components/fabro-sandbox/src/provider/daytona.rs +++ /dev/null @@ -1,132 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use fabro_static::EnvVars; -use fabro_types::{SandboxInfo, SandboxProviderKind}; - -use super::SandboxProvider; -use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE}; -use crate::{daytona, details}; - -const DAYTONA_LIST_PAGE_SIZE: i32 = 100; - -#[derive(Clone)] -pub struct DaytonaSandboxProvider { - api_key: Option, - api_url: Option, - organization_id: Option, - http_client: Option, -} - -impl DaytonaSandboxProvider { - pub fn new( - api_key: Option, - api_url: Option, - organization_id: Option, - http_client: Option, - ) -> Self { - Self { - api_key, - api_url, - organization_id, - http_client, - } - } - - async fn client(&self) -> crate::Result { - let api_key = self.api_key.clone().ok_or_else(|| { - crate::Error::message(format!("{} is not configured", EnvVars::DAYTONA_API_KEY)) - })?; - daytona::build_daytona_client_with( - Some(api_key), - self.api_url.clone(), - self.organization_id.clone(), - self.http_client.clone(), - ) - .await - .map_err(|err| crate::Error::context("Failed to create Daytona client", err)) - } -} - -#[async_trait] -impl SandboxProvider for DaytonaSandboxProvider { - fn kind(&self) -> SandboxProviderKind { - SandboxProviderKind::DAYTONA - } - - async fn list(&self) -> crate::Result> { - let client = self.client().await?; - let labels = HashMap::from([(MANAGED_LABEL.to_string(), MANAGED_LABEL_VALUE.to_string())]); - let mut page = 1; - let mut sandboxes = Vec::new(); - - loop { - let result = client - .list(Some(&labels), Some(page), Some(DAYTONA_LIST_PAGE_SIZE)) - .await - .map_err(|err| crate::Error::context("Failed to list Daytona sandboxes", err))?; - // The Daytona API already filters by the managed label above; map every - // returned sandbox without re-checking the label client-side. - sandboxes.extend( - result - .items - .iter() - .map(details::daytona::daytona_info_from_sdk_sandbox), - ); - - if result.total_pages <= i64::from(page) { - break; - } - page += 1; - } - - Ok(sandboxes) - } - - async fn get(&self, id: &str) -> crate::Result> { - let client = self.client().await?; - let sandbox = match client.get(id).await { - Ok(sandbox) => sandbox, - Err(err) if daytona::daytona_not_found(&err) => return Ok(None), - Err(err) => { - return Err(crate::Error::context( - format!("Failed to get Daytona sandbox '{id}'"), - err, - )); - } - }; - - if !managed_from_sdk_sandbox(&sandbox) { - return Ok(None); - } - Ok(Some(details::daytona::daytona_info_from_sdk_sandbox( - &sandbox, - ))) - } - - async fn delete(&self, id: &str) -> crate::Result<()> { - let client = self.client().await?; - let sandbox = match client.get(id).await { - Ok(sandbox) => sandbox, - Err(err) if daytona::daytona_not_found(&err) => return Ok(()), - Err(err) => { - return Err(crate::Error::context( - format!("Failed to get Daytona sandbox '{id}' before delete"), - err, - )); - } - }; - if !managed_from_sdk_sandbox(&sandbox) { - return Err(crate::Error::message(format!( - "Refusing to delete Daytona sandbox '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}" - ))); - } - client.delete(&sandbox.id).await.map_err(|err| { - crate::Error::context(format!("Failed to delete Daytona sandbox '{id}'"), err) - }) - } -} - -fn managed_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> bool { - managed_labels::is_managed(&sandbox.labels) -} diff --git a/lib/components/fabro-sandbox/src/provider/driver.rs b/lib/components/fabro-sandbox/src/provider/driver.rs index e139911ed..d7aba5332 100644 --- a/lib/components/fabro-sandbox/src/provider/driver.rs +++ b/lib/components/fabro-sandbox/src/provider/driver.rs @@ -6,7 +6,6 @@ //! daemon or account; fabro filters on its label and refuses to delete a //! sandbox that does not carry it. -use std::collections::BTreeMap; use std::sync::Arc; use async_trait::async_trait; @@ -18,7 +17,7 @@ use tokio::sync::OnceCell; use super::SandboxProvider; use crate::details; use crate::driver::{ConnectedProvider, ProviderConnectOptions, connect_provider}; -use crate::managed_labels::{MANAGED_LABEL, MANAGED_LABEL_VALUE}; +use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE}; /// How the driver provider behind the inventory is obtained. enum Connection { @@ -96,10 +95,6 @@ impl DriverInventoryProvider { filter } - fn is_managed(labels: &BTreeMap) -> bool { - labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE) - } - async fn describe_managed( &self, id: &str, @@ -125,7 +120,7 @@ impl DriverInventoryProvider { ) })?; if status.state == sandbox_driver::SandboxState::Deleted - || !Self::is_managed(&status.labels) + || !managed_labels::is_managed(&status.labels) { return Ok(None); } @@ -152,7 +147,7 @@ impl SandboxProvider for DriverInventoryProvider { .iter() // The filter is a request; a provider that cannot filter on // labels returns everything, so the label is checked again. - .filter(|status| Self::is_managed(&status.labels)) + .filter(|status| managed_labels::is_managed(&status.labels)) .map(|status| details::info_from_status(&self.kind, status)) .collect()) } diff --git a/lib/components/fabro-sandbox/src/reconnect.rs b/lib/components/fabro-sandbox/src/reconnect.rs index 34941addf..26424e5a3 100644 --- a/lib/components/fabro-sandbox/src/reconnect.rs +++ b/lib/components/fabro-sandbox/src/reconnect.rs @@ -1,55 +1,50 @@ use std::path::PathBuf; -#[allow( - unused_imports, - reason = "Feature-gated branches consume these imports when optional backends are enabled." -)] use anyhow::{Context, Result, bail}; use fabro_types::{BundledProvider, RunId, RunSandboxInstance}; -#[cfg(feature = "daytona")] -use crate::daytona::DaytonaSandbox; -use crate::driver_sandbox::local_sandbox; -use crate::{SandboxEventCallback, docker}; +use crate::driver::DaytonaCredentials; +use crate::driver_sandbox::{DriverSandbox, local_sandbox}; +use crate::{SandboxEventCallback, daytona, docker}; /// Reconnect to a sandbox from a saved record. /// -/// `daytona_api_key` is forwarded to the Daytona SDK when the provider is -/// `"daytona"`. Pass `None` to fall back to the `DAYTONA_API_KEY` env var. -#[allow( - clippy::unused_async, - unused_variables, - reason = "Feature-gated sandbox backends leave some parameters unused on partial builds." -)] +/// `daytona` carries the vault credentials a `"daytona"` record needs; the +/// process environment is never consulted. pub async fn reconnect( record: &RunSandboxInstance, - daytona_api_key: Option, + daytona: Option, ) -> Result> { - reconnect_for_run(record, daytona_api_key, None).await + reconnect_for_run(record, daytona, None).await } -#[allow( - unused_variables, - reason = "Feature-gated sandbox backends leave parameters unused on partial builds." -)] pub async fn reconnect_for_run( record: &RunSandboxInstance, - daytona_api_key: Option, + daytona: Option, run_id: Option, ) -> Result> { - reconnect_for_run_with_callback(record, daytona_api_key, run_id, None).await + reconnect_for_run_with_callback(record, daytona, run_id, None).await } -#[allow( - unused_variables, - reason = "Feature-gated sandbox backends leave parameters unused on partial builds." -)] pub async fn reconnect_for_run_with_callback( record: &RunSandboxInstance, - daytona_api_key: Option, + daytona: Option, run_id: Option, event_callback: Option, ) -> Result> { + let sandbox = reconnect_driver_for_run(record, daytona, run_id, event_callback).await?; + Ok(Box::new(sandbox)) +} + +/// Reconnects as the driver-backed sandbox type, for callers that need a +/// driver facet fabro's [`Sandbox`](crate::Sandbox) trait does not carry +/// (VNC, signed previews, leased SSH). +pub async fn reconnect_driver_for_run( + record: &RunSandboxInstance, + daytona: Option, + run_id: Option, + event_callback: Option, +) -> Result { let runtime = &record.runtime; match record.provider.bundled() { // A local sandbox is its working directory: rebuilding the handle @@ -62,7 +57,7 @@ pub async fn reconnect_for_run_with_callback( if let Some(callback) = event_callback { sandbox.set_event_callback(callback); } - Ok(Box::new(sandbox)) + Ok(sandbox) } Some(BundledProvider::Docker) => { let repo_cloned = runtime @@ -80,31 +75,30 @@ pub async fn reconnect_for_run_with_callback( if let Some(callback) = event_callback { sandbox.set_event_callback(callback); } - Ok(Box::new(sandbox)) + Ok(sandbox) } - #[cfg(feature = "daytona")] Some(BundledProvider::Daytona) => { let repo_cloned = runtime .repo_cloned .context("Daytona run sandbox missing repo_cloned metadata")?; - - let mut sandbox = DaytonaSandbox::reconnect( + let credentials = daytona.context( + "Daytona run sandbox cannot be reconnected without DAYTONA_API_KEY in the vault", + )?; + let mut sandbox = daytona::attach_daytona( &runtime.id, - daytona_api_key, repo_cloned, runtime.working_directory.clone(), runtime.clone_origin_url.clone(), - runtime.clone_branch.clone(), + run_id, + &credentials, ) .await - .map_err(anyhow::Error::new)?; + .context("Failed to reconnect Daytona sandbox")?; if let Some(callback) = event_callback { sandbox.set_event_callback(callback); } - Ok(Box::new(sandbox)) + Ok(sandbox) } - #[cfg(not(feature = "daytona"))] - Some(BundledProvider::Daytona) => bail!("Daytona sandbox support is not enabled"), None => bail!( "sandbox provider `{}` is not bundled; plugin reconnect is not wired yet", record.provider diff --git a/lib/components/fabro-sandbox/src/sandbox.rs b/lib/components/fabro-sandbox/src/sandbox.rs index 352d372bf..b6158f8c4 100644 --- a/lib/components/fabro-sandbox/src/sandbox.rs +++ b/lib/components/fabro-sandbox/src/sandbox.rs @@ -27,91 +27,6 @@ pub(crate) const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0"; pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024; -/// Maximum time a sandbox lifecycle check may spend proving Bash is usable. -pub(crate) const BASH_PROBE_TIMEOUT_MS: u64 = 10_000; - -/// Bash path required by Linux-backed remote sandbox providers. -pub(crate) const REMOTE_BASH: &str = "/bin/bash"; - -/// Timeout for provider-neutral remote file traversal. -pub(crate) const REMOTE_WALK_TIMEOUT_MS: u64 = 30_000; - -/// Environment variable Bash consults for non-interactive startup source. -/// -/// Sandbox providers must remove or blank this before invoking `bash -c`; -/// otherwise ambient worker or image configuration can execute code before the -/// requested command. -pub(crate) const BASH_ENV_VAR: &str = "BASH_ENV"; - -/// Marker a successful [`BASH_PROBE_SCRIPT`] run prints on stdout. -/// -/// Providers validate the marker rather than trusting a zero exit: a non-Bash -/// shell can exit zero for simple scripts without satisfying the contract. -pub(crate) const BASH_PROBE_MARKER: &str = "fabro-bash-ready"; - -/// Deterministic probe proving a sandbox's interpreter is non-login Bash. -/// -/// Run as the argument to `bash -c` during fresh initialization and on -/// resume/start, before the sandbox is reported usable. It fails when the -/// interpreter has an ambient `BASH_ENV` startup source, is not Bash, was -/// started as a login shell, or is in POSIX mode. Bash invoked under the name -/// `sh` still sets `BASH_VERSION` while enabling POSIX behavior, so the full -/// interpreter contract is checked rather than assumed. -pub(crate) const BASH_PROBE_SCRIPT: &str = r#"if [ -n "${BASH_ENV:-}" ]; then - echo 'sandbox interpreter has BASH_ENV startup source configured' >&2 - exit 1 -fi -if [ -z "${BASH_VERSION:-}" ]; then - echo 'sandbox interpreter is not bash' >&2 - exit 1 -fi -if shopt -q login_shell; then - echo 'sandbox interpreter is a login shell' >&2 - exit 1 -fi -if shopt -qo posix; then - echo 'sandbox interpreter is bash in posix mode' >&2 - exit 1 -fi -printf '%s\n' 'fabro-bash-ready'"#; - -/// Whether a [`BASH_PROBE_SCRIPT`] run succeeded. -/// -/// A zero exit without exactly the marker is not a successful probe. -pub(crate) fn bash_probe_passed(exit_code: Option, stdout: &str) -> bool { - exit_code == Some(0) && stdout.trim() == BASH_PROBE_MARKER -} - -/// Validate a completed Bash probe without flattening its raw output into an -/// error message. -/// -/// [`Error::Exec`](crate::Error::Exec) retains stdout/stderr for the existing -/// redacted-tail diagnostics while its display form exposes only bounded, -/// classified metadata safe for lifecycle events and tracing. -pub(crate) fn validate_bash_probe( - result: ExecResult, - remediation: impl Into, -) -> crate::Result<()> { - if result.is_success() && bash_probe_passed(result.exit_code, &result.stdout) { - return Ok(()); - } - - Err(crate::Error::context( - remediation, - result.into_exec_error("Sandbox Bash probe"), - )) -} - -/// Sleep for `timeout_ms` if `Some`, otherwise never resolves. Used by -/// streaming `exec_command` impls to model "no timeout" without scheduling a -/// `Duration::from_millis(u64::MAX)` sleep. -pub(crate) async fn optional_timeout(timeout_ms: Option) { - match timeout_ms { - Some(ms) => time::sleep(Duration::from_millis(ms)).await, - None => std::future::pending::<()>().await, - } -} - /// Information returned when a sandbox sets up git for a workflow run. #[derive(Debug, Clone)] pub struct GitRunInfo { @@ -865,17 +780,6 @@ impl OutputCaptureBuffer { } } - #[cfg(feature = "daytona")] - #[must_use] - pub(crate) fn to_bytes(&self) -> Vec { - let mut bytes = Vec::with_capacity(self.head.len().saturating_add(self.tail.len())); - bytes.extend_from_slice(&self.head); - let (front, back) = self.tail.as_slices(); - bytes.extend_from_slice(front); - bytes.extend_from_slice(back); - bytes - } - #[must_use] pub(crate) fn into_parts(self) -> (Vec, OutputCaptureStats) { let stats = self.stats(); @@ -889,14 +793,6 @@ impl OutputCaptureBuffer { bytes.extend_from_slice(back); (bytes, stats) } - - /// Retained bytes as two contiguous slices: the stable head, then the - /// rolling tail. - #[cfg(feature = "daytona")] - #[must_use] - pub(crate) fn retained_slices(&mut self) -> (&[u8], &[u8]) { - (&self.head, self.tail.make_contiguous()) - } } pub type CommandOutputCallback = Arc< @@ -1565,75 +1461,6 @@ pub(crate) fn join_sandbox_path(base: &str, relative_path: &str) -> String { format!("{}/{relative_path}", base.trim_end_matches('/')) } -pub(crate) fn build_remote_walk_command( - base: &str, - relative_start: &str, - options: &WalkOptions, -) -> String { - let traversal_root = join_sandbox_path(base, relative_start); - let quoted_root = shell_quote(&traversal_root); - let mut command = format!("if [ -e {quoted_root} ]"); - let mut component_path = base.to_string(); - for segment in relative_start - .split('/') - .filter(|segment| !segment.is_empty()) - { - component_path = join_sandbox_path(&component_path, segment); - let _ = write!(command, " && [ ! -L {} ]", shell_quote(&component_path)); - } - let _ = write!(command, "; then find -H {quoted_root}"); - - if !options.excluded_directory_names.is_empty() { - command.push_str(" \\( -type d \\("); - for (index, directory_name) in options.excluded_directory_names.iter().enumerate() { - if index > 0 { - command.push_str(" -o"); - } - let _ = write!(command, " -name {}", shell_quote(directory_name)); - } - command.push_str(" \\) -prune \\) -o"); - } - - command.push_str(" -not -type l -type f -printf '%s\\0%P\\0'; fi"); - command -} - -pub(crate) fn parse_remote_walk_output( - base: &str, - relative_start: &str, - output: &str, -) -> crate::Result> { - let mut fields = output.split('\0'); - let mut files = Vec::new(); - - while let Some(size) = fields.next() { - if size.is_empty() { - break; - } - let relative_to_start = fields.next().ok_or_else(|| { - crate::Error::message("Malformed recursive file traversal output: missing path") - })?; - let size = size.parse::().map_err(|error| { - crate::Error::context( - format!("Malformed recursive file traversal size {size:?}"), - error, - ) - })?; - let relative_path = if relative_to_start.is_empty() { - relative_start.to_string() - } else { - join_sandbox_path(relative_start, relative_to_start) - }; - files.push(SandboxFile { - path: join_sandbox_path(base, &relative_path), - relative_path, - size, - }); - } - - Ok(files) -} - /// Shell-quote a string using `shlex::try_quote`, with a fallback for edge /// cases. Re-exported from [`fabro_util::shell::shell_quote`] so sandbox code /// and the config resolve layer share one audited implementation. @@ -3041,104 +2868,6 @@ mod tests { assert_eq!(shell_quote("hello world"), "'hello world'"); } - #[test] - fn bash_probe_script_prints_the_marker_callers_validate() { - assert!( - BASH_PROBE_SCRIPT.contains(BASH_PROBE_MARKER), - "the probe must print the marker providers check for" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn bash_probe_accepts_only_clean_non_login_bash() { - use tokio::process::Command; - - async fn run(program: &str, args: &[&str]) -> (Option, String) { - let output = Command::new(program) - .args(args) - .arg(BASH_PROBE_SCRIPT) - .env_remove("BASH_ENV") - .output() - .await - .expect("probe should run"); - ( - output.status.code(), - String::from_utf8_lossy(&output.stdout).into_owned(), - ) - } - - let (code, stdout) = run("bash", &["-c"]).await; - assert!(bash_probe_passed(code, &stdout), "non-login bash: {stdout}"); - - let (code, stdout) = run("bash", &["--noprofile", "-lc"]).await; - assert!( - !bash_probe_passed(code, &stdout), - "a login shell must fail the probe: {stdout}" - ); - - let output = Command::new("bash") - .args(["-c", BASH_PROBE_SCRIPT]) - .env(BASH_ENV_VAR, "/dev/null") - .output() - .await - .expect("probe with BASH_ENV should run"); - assert!( - !bash_probe_passed( - output.status.code(), - &String::from_utf8_lossy(&output.stdout) - ), - "a shell with BASH_ENV must fail the probe" - ); - - // Where `/bin/sh` is really Bash (macOS), Bash enters POSIX mode and - // changes behavior; where it is dash (most Linux images), - // `BASH_VERSION` is unset. The probe rejects both. - let (code, stdout) = run("sh", &["-c"]).await; - assert!( - !bash_probe_passed(code, &stdout), - "sh must fail the probe: {stdout}" - ); - } - - #[test] - fn bash_probe_requires_the_exact_marker_output() { - assert!(bash_probe_passed( - Some(0), - &format!(" {BASH_PROBE_MARKER}\n") - )); - assert!(!bash_probe_passed( - Some(0), - &format!("prefix-{BASH_PROBE_MARKER}-suffix") - )); - assert!(!bash_probe_passed( - Some(0), - &format!("{BASH_PROBE_MARKER}\nunexpected output") - )); - } - - #[test] - fn bash_probe_failure_keeps_raw_output_out_of_the_error_chain() { - let err = validate_bash_probe( - ExecResult { - stdout: String::new(), - stderr: "raw-probe-output".to_string(), - exit_code: Some(1), - termination: CommandTermination::Exited, - duration_ms: 1, - }, - "Install Bash", - ) - .expect_err("failed probe should return remediation"); - - assert!(!err.display_with_causes().contains("raw-probe-output")); - assert_eq!( - err.default_redacted_output_tail() - .and_then(|tail| tail.stderr), - Some("raw-probe-output".to_string()) - ); - } - #[expect( clippy::disallowed_methods, reason = "unit test performs a small synchronous source scan of local Rust files" diff --git a/lib/components/fabro-sandbox/src/sandbox_spec.rs b/lib/components/fabro-sandbox/src/sandbox_spec.rs index 4c42de820..cddf27ca7 100644 --- a/lib/components/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/components/fabro-sandbox/src/sandbox_spec.rs @@ -3,15 +3,11 @@ use std::sync::Arc; use anyhow::Context as _; use fabro_github::GitHubCredentials; -#[allow( - unused_imports, - reason = "Daytona-enabled builds persist RunId in the sandbox spec." -)] use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind}; -#[cfg(feature = "daytona")] -use crate::daytona::{self, DaytonaConfig, DaytonaSandbox}; +use crate::daytona::{self, DaytonaConfig}; use crate::docker::{self, DockerSandboxOptions}; +use crate::driver::DaytonaCredentials; use crate::driver_sandbox::local_sandbox; use crate::{Sandbox, SandboxEventCallback, clone_source}; @@ -29,7 +25,6 @@ pub enum SandboxSpec { clone_tag: Option, clone_commit_sha: Option, }, - #[cfg(feature = "daytona")] Daytona { config: Box, github_app: Option, @@ -38,7 +33,10 @@ pub enum SandboxSpec { clone_branch: Option, clone_tag: Option, clone_commit_sha: Option, - api_key: Option, + /// Vault credentials for the Daytona control plane; `None` fails at + /// build time with a clear message rather than reading the process + /// environment. + credentials: Option, }, } @@ -47,7 +45,6 @@ impl SandboxSpec { match self { Self::Local { .. } => SandboxProviderKind::LOCAL, Self::Docker { .. } => SandboxProviderKind::DOCKER, - #[cfg(feature = "daytona")] Self::Daytona { .. } => SandboxProviderKind::DAYTONA, } } @@ -56,7 +53,6 @@ impl SandboxSpec { match self { Self::Local { .. } => "local", Self::Docker { .. } => "docker", - #[cfg(feature = "daytona")] Self::Daytona { .. } => "daytona", } } @@ -117,7 +113,6 @@ impl SandboxSpec { }, } } - #[cfg(feature = "daytona")] Self::Daytona { config, clone_origin_url, @@ -157,7 +152,7 @@ impl SandboxSpec { }, } } - _ => RunSandboxInstance { + Self::Local { .. } => RunSandboxInstance { provider: self.provider(), image: None, snapshot: None, @@ -215,7 +210,6 @@ impl SandboxSpec { } Ok(Arc::new(sandbox)) } - #[cfg(feature = "daytona")] Self::Daytona { config, github_app, @@ -224,20 +218,23 @@ impl SandboxSpec { clone_branch, clone_tag, clone_commit_sha, - api_key, + credentials, } => { - let mut sandbox = DaytonaSandbox::new( + let credentials = credentials.as_ref().context( + "Daytona sandboxes require DAYTONA_API_KEY in the vault; run `fabro secret set DAYTONA_API_KEY`", + )?; + let mut sandbox = daytona::daytona_sandbox( config.as_ref().clone(), - github_app.clone(), + github_app.as_ref(), *run_id, clone_origin_url.clone(), clone_branch.clone(), clone_tag.clone(), clone_commit_sha.clone(), - api_key.clone(), + credentials, ) .await - .map_err(anyhow::Error::new)?; + .context("Failed to create Daytona sandbox")?; if let Some(callback) = event_callback { sandbox.set_event_callback(callback); } diff --git a/lib/components/fabro-sandbox/src/terminal.rs b/lib/components/fabro-sandbox/src/terminal.rs index de7633728..aa7a89b65 100644 --- a/lib/components/fabro-sandbox/src/terminal.rs +++ b/lib/components/fabro-sandbox/src/terminal.rs @@ -1,11 +1,8 @@ use async_trait::async_trait; -#[cfg(feature = "daytona")] -use fabro_static::EnvVars; use fabro_types::{BundledProvider, RunId, RunSandboxInstance}; -#[cfg(feature = "daytona")] -use crate::daytona::{DEFAULT_DAYTONA_API_URL, DaytonaSandbox}; -use crate::{Sandbox, docker}; +use crate::driver::DaytonaCredentials; +use crate::{Sandbox, daytona, docker}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TerminalSize { @@ -78,47 +75,32 @@ impl TerminalSession for DriverTerminalSession { pub async fn open_terminal_for_run( record: &RunSandboxInstance, - daytona_api_key: Option, - daytona_organization_id: Option, + daytona: Option, run_id: Option, size: TerminalSize, ) -> crate::Result> { let runtime = &record.runtime; - #[cfg(not(feature = "daytona"))] - let _ = (&daytona_api_key, &daytona_organization_id); match record.provider.bundled() { - #[cfg(feature = "daytona")] Some(BundledProvider::Daytona) => { let repo_cloned = runtime.repo_cloned.ok_or_else(|| { crate::Error::message("Daytona run sandbox is missing clone metadata") })?; - let sandbox = DaytonaSandbox::reconnect( + let credentials = daytona.ok_or_else(|| { + crate::Error::message("Daytona terminals require DAYTONA_API_KEY in the vault") + })?; + let sandbox = daytona::attach_daytona( &runtime.id, - daytona_api_key.clone(), repo_cloned, runtime.working_directory.clone(), runtime.clone_origin_url.clone(), - runtime.clone_branch.clone(), + run_id, + &credentials, ) .await?; sandbox.activate().await?; - let api_key = resolve_daytona_api_key(daytona_api_key)?; - let organization_id = resolve_daytona_organization_id(daytona_organization_id); - let session = DaytonaTerminalSession::open( - &sandbox, - api_key, - organization_id, - daytona_api_base_url(), - size, - ) - .await?; - Ok(Box::new(session)) + Ok(Box::new(sandbox.open_terminal(size).await?)) } - #[cfg(not(feature = "daytona"))] - Some(BundledProvider::Daytona) => Err(crate::Error::message( - "Daytona sandbox support is not enabled", - )), Some(BundledProvider::Docker) => { let repo_cloned = runtime.repo_cloned.ok_or_else(|| { crate::Error::message("Docker run sandbox is missing clone metadata") @@ -143,571 +125,3 @@ pub async fn open_terminal_for_run( ))), } } - -#[cfg(feature = "daytona")] -#[expect( - clippy::disallowed_methods, - reason = "Terminal reconnect falls back to the process environment when no vault value was supplied." -)] -fn resolve_daytona_api_key(api_key: Option) -> crate::Result { - api_key - .or_else(|| std::env::var(EnvVars::DAYTONA_API_KEY).ok()) - .ok_or_else(|| crate::Error::message("DAYTONA_API_KEY is required for Daytona terminals")) -} - -#[cfg(feature = "daytona")] -#[expect( - clippy::disallowed_methods, - reason = "Daytona SDK configuration convention uses process environment fallbacks for API URLs." -)] -fn daytona_api_base_url() -> String { - std::env::var(EnvVars::DAYTONA_API_URL) - .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) - .unwrap_or_else(|_| DEFAULT_DAYTONA_API_URL.to_string()) -} - -#[cfg(feature = "daytona")] -#[expect( - clippy::disallowed_methods, - reason = "Terminal reconnect falls back to the process environment when no vault value was supplied." -)] -fn resolve_daytona_organization_id(organization_id: Option) -> Option { - organization_id.or_else(|| std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok()) -} - -#[cfg(feature = "daytona")] -mod daytona_terminal { - use std::collections::HashMap; - use std::sync::Once; - - use async_trait::async_trait; - use daytona_api_client::apis::configuration::Configuration; - use daytona_api_client::apis::sandbox_api; - use futures_util::stream::{SplitSink, SplitStream}; - use futures_util::{SinkExt, StreamExt}; - use rand::Rng; - use rustls::crypto::ring; - use serde::{Deserialize, Serialize}; - use tokio::net::TcpStream; - use tokio::runtime::Handle; - use tokio::sync::Mutex; - use tokio_tungstenite::tungstenite::error::ProtocolError; - use tokio_tungstenite::tungstenite::handshake::client; - use tokio_tungstenite::tungstenite::http::Request; - use tokio_tungstenite::tungstenite::protocol::Message as ProviderMessage; - use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite}; - - use super::{TerminalSession, TerminalSize}; - use crate::Sandbox; - use crate::daytona::DaytonaSandbox; - - type ProviderWs = WebSocketStream>; - type ProviderSink = SplitSink; - type ProviderStream = SplitStream; - - static RUSTLS_PROVIDER: Once = Once::new(); - - pub(super) struct DaytonaTerminalSession { - toolbox_base_url: String, - api_key: String, - org_id: Option, - session_id: String, - write: Mutex>, - read: Mutex>, - closed: Mutex, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct DaytonaPtyCreateRequest { - cols: u16, - rows: u16, - cwd: String, - envs: HashMap, - id: String, - lazy_start: bool, - } - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct DaytonaPtyCreateResponse { - session_id: String, - } - - #[derive(Serialize)] - struct DaytonaPtyResizeRequest { - cols: u16, - rows: u16, - } - - impl DaytonaTerminalSession { - pub(super) async fn open( - sandbox: &DaytonaSandbox, - api_key: String, - org_id: Option, - api_base_url: String, - size: TerminalSize, - ) -> crate::Result { - ensure_rustls_provider(); - let sandbox_id = sandbox.daytona_id()?.to_string(); - let toolbox_base_url = - daytona_toolbox_base_url(&api_base_url, &api_key, org_id.as_deref(), &sandbox_id) - .await?; - let session_id = daytona_terminal_session_id(); - let session_id = create_pty_session( - &toolbox_base_url, - &api_key, - org_id.as_deref(), - &session_id, - sandbox.working_directory().to_string(), - size, - ) - .await?; - let ws_url = daytona_pty_ws_url(&toolbox_base_url, &session_id)?; - let request = daytona_ws_request(&ws_url, &api_key, org_id.as_deref())?; - let (stream, _) = connect_async(request).await.map_err(|err| { - crate::Error::context("Failed to connect Daytona terminal WebSocket", err) - })?; - let (write, read) = stream.split(); - Ok(Self { - toolbox_base_url, - api_key, - org_id, - session_id, - write: Mutex::new(Some(write)), - read: Mutex::new(Some(read)), - closed: Mutex::new(false), - }) - } - - async fn kill_session(&self) -> crate::Result<()> { - let url = format!( - "{}/process/pty/{}", - trim_slash(&self.toolbox_base_url), - url_component(&self.session_id) - ); - let mut request = fabro_http::http_client() - .map_err(|err| crate::Error::context("Failed to build HTTP client", err))? - .delete(url) - .bearer_auth(&self.api_key); - if let Some(org_id) = self.org_id.as_deref() { - request = request.header("X-Daytona-Organization-ID", org_id); - } - let response = request.send().await.map_err(|err| { - crate::Error::context("Failed to delete Daytona PTY session", err) - })?; - if !response.status().is_success() - && response.status() != fabro_http::StatusCode::NOT_FOUND - { - return Err(daytona_response_error( - "Failed to delete Daytona PTY session", - response, - ) - .await); - } - Ok(()) - } - } - - #[async_trait] - impl TerminalSession for DaytonaTerminalSession { - async fn write_input(&self, bytes: &[u8]) -> crate::Result<()> { - let mut write = self.write.lock().await; - let Some(write) = write.as_mut() else { - return Ok(()); - }; - write - .send(ProviderMessage::Binary(bytes.to_vec().into())) - .await - .map_err(|err| crate::Error::context("Failed to write Daytona terminal input", err)) - } - - async fn read_output(&self) -> crate::Result>> { - let mut read = self.read.lock().await; - let Some(read) = read.as_mut() else { - return Ok(None); - }; - while let Some(message) = read.next().await { - match message { - Ok(ProviderMessage::Binary(bytes)) => return Ok(Some(bytes.to_vec())), - Ok(ProviderMessage::Text(text)) => { - if is_daytona_terminal_control_text(text.as_str()) { - continue; - } - return Ok(Some(text.as_str().as_bytes().to_vec())); - } - Ok(ProviderMessage::Close(_)) - | Err(tungstenite::Error::Protocol( - ProtocolError::ResetWithoutClosingHandshake, - )) => return Ok(None), - Ok( - ProviderMessage::Ping(_) - | ProviderMessage::Pong(_) - | ProviderMessage::Frame(_), - ) => {} - Err(err) => { - return Err(crate::Error::context( - "Failed to read Daytona terminal output", - err, - )); - } - } - } - Ok(None) - } - - async fn resize(&self, size: TerminalSize) -> crate::Result<()> { - let url = format!( - "{}/process/pty/{}/resize", - trim_slash(&self.toolbox_base_url), - url_component(&self.session_id) - ); - let mut request = fabro_http::http_client() - .map_err(|err| crate::Error::context("Failed to build HTTP client", err))? - .post(url) - .bearer_auth(&self.api_key) - .json(&DaytonaPtyResizeRequest { - cols: size.cols, - rows: size.rows, - }); - if let Some(org_id) = self.org_id.as_deref() { - request = request.header("X-Daytona-Organization-ID", org_id); - } - let response = request - .send() - .await - .map_err(|err| crate::Error::context("Failed to resize Daytona terminal", err))?; - if !response.status().is_success() { - return Err( - daytona_response_error("Failed to resize Daytona terminal", response).await, - ); - } - Ok(()) - } - - async fn close(&self) -> crate::Result<()> { - let mut closed = self.closed.lock().await; - if *closed { - return Ok(()); - } - *closed = true; - drop(closed); - - if let Some(mut write) = self.write.lock().await.take() { - let _ = write.send(ProviderMessage::Close(None)).await; - } - let _ = self.read.lock().await.take(); - self.kill_session().await - } - } - - impl Drop for DaytonaTerminalSession { - fn drop(&mut self) { - let toolbox_base_url = self.toolbox_base_url.clone(); - let api_key = self.api_key.clone(); - let org_id = self.org_id.clone(); - let session_id = self.session_id.clone(); - if let Ok(handle) = Handle::try_current() { - handle.spawn(async move { - let url = format!( - "{}/process/pty/{}", - trim_slash(&toolbox_base_url), - url_component(&session_id) - ); - let Ok(client) = fabro_http::http_client() else { - return; - }; - let mut request = client.delete(url).bearer_auth(api_key); - if let Some(org_id) = org_id.as_deref() { - request = request.header("X-Daytona-Organization-ID", org_id); - } - if let Err(err) = request.send().await { - tracing::warn!(error = %err, "failed to clean up Daytona terminal session"); - } - }); - } - } - } - - async fn create_pty_session( - toolbox_base_url: &str, - api_key: &str, - org_id: Option<&str>, - session_id: &str, - cwd: String, - size: TerminalSize, - ) -> crate::Result { - let mut envs = HashMap::new(); - envs.insert("TERM".to_string(), "xterm-256color".to_string()); - envs.insert("LANG".to_string(), "C.UTF-8".to_string()); - let url = format!("{}/process/pty", trim_slash(toolbox_base_url)); - let mut request = fabro_http::http_client() - .map_err(|err| crate::Error::context("Failed to build HTTP client", err))? - .post(url) - .bearer_auth(api_key) - .json(&DaytonaPtyCreateRequest { - cols: size.cols, - rows: size.rows, - cwd, - envs, - id: session_id.to_string(), - lazy_start: false, - }); - if let Some(org_id) = org_id { - request = request.header("X-Daytona-Organization-ID", org_id); - } - let response = request - .send() - .await - .map_err(|err| crate::Error::context("Failed to create Daytona PTY session", err))?; - if !response.status().is_success() { - return Err( - daytona_response_error("Failed to create Daytona PTY session", response).await, - ); - } - let body = response - .json::() - .await - .map_err(|err| crate::Error::context("Failed to decode Daytona PTY response", err))?; - Ok(body.session_id) - } - - async fn daytona_toolbox_base_url( - api_base_url: &str, - api_key: &str, - org_id: Option<&str>, - sandbox_id: &str, - ) -> crate::Result { - let http_client = fabro_http::http_client() - .map_err(|err| crate::Error::context("Failed to build HTTP client", err))?; - let configuration = Configuration { - base_path: trim_slash(api_base_url).to_string(), - user_agent: Some(concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION")).into()), - client: reqwest_middleware::ClientBuilder::new(http_client).build(), - basic_auth: None, - oauth_access_token: None, - bearer_access_token: Some(api_key.to_string()), - api_key: None, - }; - let proxy_url = sandbox_api::get_toolbox_proxy_url(&configuration, sandbox_id, org_id) - .await - .map_err(|err| { - crate::Error::context("Failed to resolve Daytona toolbox proxy URL", err) - })?; - Ok(format!( - "{}/{}", - trim_slash(&proxy_url.url), - url_component(sandbox_id) - )) - } - - fn daytona_pty_ws_url(toolbox_base_url: &str, session_id: &str) -> crate::Result { - let base = trim_slash(toolbox_base_url); - let ws_base = if let Some(rest) = base.strip_prefix("https://") { - format!("wss://{rest}") - } else if let Some(rest) = base.strip_prefix("http://") { - format!("ws://{rest}") - } else { - return Err(crate::Error::message( - "Daytona API URL must start with http:// or https://", - )); - }; - Ok(format!( - "{}/process/pty/{}/connect", - ws_base, - url_component(session_id) - )) - } - - async fn daytona_response_error(action: &str, response: fabro_http::Response) -> crate::Error { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - let trimmed = body.trim(); - if trimmed.is_empty() { - crate::Error::message(format!("{action}: HTTP {status}")) - } else { - crate::Error::message(format!( - "{action}: HTTP {status}: {}", - truncate_error_body(trimmed) - )) - } - } - - fn truncate_error_body(body: &str) -> String { - const MAX_LEN: usize = 500; - if body.len() <= MAX_LEN { - return body.to_string(); - } - format!("{}...", &body[..MAX_LEN]) - } - - fn daytona_terminal_session_id() -> String { - format!("fabro-terminal-{:016x}", rand::rng().random::()) - } - - fn is_daytona_terminal_control_text(text: &str) -> bool { - let Ok(value) = serde_json::from_str::(text) else { - return false; - }; - value - .as_object() - .and_then(|object| object.get("type")) - .and_then(serde_json::Value::as_str) - == Some("control") - } - - fn daytona_ws_request( - ws_url: &str, - api_key: &str, - org_id: Option<&str>, - ) -> crate::Result> { - let mut request = Request::builder() - .uri(ws_url) - .header("Host", extract_host(ws_url)) - .header("Connection", "Upgrade") - .header("Upgrade", "websocket") - .header("Sec-WebSocket-Version", "13") - .header("Sec-WebSocket-Key", client::generate_key()) - .header("Authorization", format!("Bearer {api_key}")) - .header("X-Daytona-Source", "fabro"); - if let Some(org_id) = org_id { - request = request.header("X-Daytona-Organization-ID", org_id); - } - request.body(()).map_err(|err| { - crate::Error::context("Failed to build Daytona terminal WebSocket request", err) - }) - } - - fn ensure_rustls_provider() { - RUSTLS_PROVIDER.call_once(|| { - let _ = ring::default_provider().install_default(); - }); - } - - pub(super) fn trim_slash(value: &str) -> &str { - value.trim_end_matches('/') - } - - pub(super) fn url_component(value: &str) -> String { - value.replace('/', "%2F") - } - - fn extract_host(ws_url: &str) -> String { - ws_url - .strip_prefix("wss://") - .or_else(|| ws_url.strip_prefix("ws://")) - .and_then(|rest| rest.split('/').next()) - .unwrap_or_default() - .to_string() - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn builds_daytona_pty_websocket_url() { - assert_eq!( - daytona_pty_ws_url("https://proxy.app.daytona.io/toolbox/sandbox%2Fa", "pty-1") - .unwrap(), - "wss://proxy.app.daytona.io/toolbox/sandbox%2Fa/process/pty/pty-1/connect" - ); - } - - #[tokio::test] - async fn create_daytona_pty_session_posts_to_toolbox_proxy_with_id() { - let server = httpmock::MockServer::start_async().await; - let create = server - .mock_async(|when, then| { - when.method(httpmock::Method::POST) - .path("/toolbox/sandbox-1/process/pty") - .header("authorization", "Bearer dtn_test") - .json_body(serde_json::json!({ - "cols": 120, - "rows": 32, - "cwd": "/home/daytona/workspace", - "envs": { - "TERM": "xterm-256color", - "LANG": "C.UTF-8" - }, - "id": "fabro-terminal-test", - "lazyStart": false - })); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "sessionId": "fabro-terminal-test" - })); - }) - .await; - - let session_id = create_pty_session( - &format!("{}/toolbox/sandbox-1", server.base_url()), - "dtn_test", - None, - "fabro-terminal-test", - "/home/daytona/workspace".to_string(), - TerminalSize { - cols: 120, - rows: 32, - }, - ) - .await - .unwrap(); - - assert_eq!(session_id, "fabro-terminal-test"); - create.assert_async().await; - } - - #[tokio::test] - async fn resolves_daytona_toolbox_proxy_base_url() { - let server = httpmock::MockServer::start_async().await; - let proxy = server - .mock_async(|when, then| { - when.method(httpmock::Method::GET) - .path("/sandbox/sandbox-1/toolbox-proxy-url") - .header("authorization", "Bearer dtn_test") - .header("X-Daytona-Organization-ID", "org-1"); - then.status(200) - .header("content-type", "application/json") - .json_body(serde_json::json!({ - "url": format!("{}/toolbox", server.base_url()) - })); - }) - .await; - - let toolbox_base_url = daytona_toolbox_base_url( - &server.base_url(), - "dtn_test", - Some("org-1"), - "sandbox-1", - ) - .await - .unwrap(); - - assert_eq!( - toolbox_base_url, - format!("{}/toolbox/sandbox-1", server.base_url()) - ); - proxy.assert_async().await; - } - - #[test] - fn identifies_daytona_terminal_control_text() { - assert!(is_daytona_terminal_control_text( - r#"{"status":"connected","type":"control"}"# - )); - assert!(is_daytona_terminal_control_text( - r#"{"type":"control","status":"resized"}"# - )); - assert!(!is_daytona_terminal_control_text("hello\n")); - assert!(!is_daytona_terminal_control_text( - r#"{"type":"output","text":"hello"}"# - )); - assert!(!is_daytona_terminal_control_text("{")); - } - } -} - -#[cfg(feature = "daytona")] -use daytona_terminal::DaytonaTerminalSession; diff --git a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs index dc5e2e7bf..99e980ee4 100644 --- a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs +++ b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs @@ -1,11 +1,13 @@ -#[cfg(feature = "daytona")] mod daytona_streaming_live { use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result, ensure}; - use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox}; - use fabro_sandbox::{CommandOutputCallback, ExecStreamingResult, Sandbox}; + use fabro_sandbox::daytona::DaytonaConfig; + use fabro_sandbox::{ + CommandOutputCallback, DaytonaCredentials, DriverSandbox, ExecStreamingResult, Sandbox, + daytona_sandbox, + }; use fabro_static::EnvVars; use fabro_types::{CommandOutputStream, CommandTermination}; use tokio::sync::Mutex; @@ -27,7 +29,7 @@ mod daytona_streaming_live { ); let sandbox = Arc::new( - DaytonaSandbox::new( + daytona_sandbox( DaytonaConfig { skip_clone: true, ..Default::default() @@ -38,7 +40,7 @@ mod daytona_streaming_live { None, None, None, - None, + &live_credentials()?, ) .await?, ); @@ -66,7 +68,7 @@ mod daytona_streaming_live { "DAYTONA_API_KEY must be set to run this live smoke test" ); - let sandbox = DaytonaSandbox::new( + let sandbox = daytona_sandbox( DaytonaConfig { skip_clone: true, ..Default::default() @@ -77,7 +79,7 @@ mod daytona_streaming_live { None, None, None, - None, + &live_credentials()?, ) .await?; sandbox.initialize().await?; @@ -169,7 +171,7 @@ mod daytona_streaming_live { ); let run_id: fabro_types::RunId = "01HY0000000000000000000000".parse().unwrap(); - let sandbox = DaytonaSandbox::new( + let sandbox = daytona_sandbox( DaytonaConfig { skip_clone: true, labels: Some(std::collections::HashMap::from([( @@ -184,16 +186,18 @@ mod daytona_streaming_live { None, None, None, - None, + &live_credentials()?, ) .await?; sandbox.initialize().await?; let labels = sandbox - .sandbox_handle() + .handle() .context("sandbox handle should be initialized")? - .labels - .clone(); + .describe() + .await + .context("describe sandbox")? + .labels; let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox"); ensure_eq( @@ -224,7 +228,7 @@ mod daytona_streaming_live { "DAYTONA_API_KEY must be set to run this live smoke test" ); - let sandbox = DaytonaSandbox::new( + let sandbox = daytona_sandbox( DaytonaConfig { skip_clone: false, ..Default::default() @@ -235,7 +239,7 @@ mod daytona_streaming_live { None, None, None, - None, + &live_credentials()?, ) .await?; @@ -293,7 +297,7 @@ mod daytona_streaming_live { "DAYTONA_API_KEY must be set to run this live glob test" ); - let sandbox = DaytonaSandbox::new( + let sandbox = daytona_sandbox( DaytonaConfig { skip_clone: true, ..Default::default() @@ -304,7 +308,7 @@ mod daytona_streaming_live { None, None, None, - None, + &live_credentials()?, ) .await?; @@ -319,7 +323,7 @@ mod daytona_streaming_live { Ok(()) } - async fn run_glob_checks(sandbox: &DaytonaSandbox) -> Result<()> { + async fn run_glob_checks(sandbox: &DriverSandbox) -> Result<()> { // Build a skills tree with a SKILL.md at the search root, one level // below it, and two levels below it. let seed = sandbox @@ -364,7 +368,7 @@ mod daytona_streaming_live { Ok(()) } - async fn run_smoke(sandbox: Arc) -> Result<()> { + async fn run_smoke(sandbox: Arc) -> Result<()> { let chunks = Arc::new(Mutex::new(Vec::new())); let cancel_token = CancellationToken::new(); let callback = capture_callback(Arc::clone(&chunks)); @@ -490,7 +494,7 @@ mod daytona_streaming_live { } async fn run_captured( - sandbox: &DaytonaSandbox, + sandbox: &DriverSandbox, command: &str, timeout_ms: u64, cancel_token: Option, @@ -499,7 +503,7 @@ mod daytona_streaming_live { } async fn run_captured_with_stdin( - sandbox: &DaytonaSandbox, + sandbox: &DriverSandbox, command: &str, timeout_ms: u64, cancel_token: Option, @@ -542,6 +546,25 @@ mod daytona_streaming_live { std::env::var_os(EnvVars::DAYTONA_API_KEY).is_some() } + /// Live credentials from the process environment, the way the vault + /// would supply them in production. + #[expect( + clippy::disallowed_methods, + reason = "live smoke tests take Daytona credentials from the developer's environment" + )] + fn live_credentials() -> Result { + Ok(DaytonaCredentials { + api_key: std::env::var(EnvVars::DAYTONA_API_KEY) + .context("DAYTONA_API_KEY must be set")?, + api_url: std::env::var(EnvVars::DAYTONA_API_URL) + .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) + .ok(), + organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(), + target: None, + http_client: None, + }) + } + async fn wait_for_chunks( chunks: &Arc>>, timeout_after: Duration, diff --git a/lib/components/fabro-workflow/Cargo.toml b/lib/components/fabro-workflow/Cargo.toml index 498f7d44b..b92b293c9 100644 --- a/lib/components/fabro-workflow/Cargo.toml +++ b/lib/components/fabro-workflow/Cargo.toml @@ -29,7 +29,7 @@ fabro-graphviz = { path = "../fabro-graphviz" } fabro-hooks = { path = "../fabro-hooks" } fabro-validate = { path = "../fabro-validate" } fabro-dump = { path = "../fabro-dump" } -fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona"] } +fabro-sandbox = { path = "../fabro-sandbox" } fabro-mcp = { path = "../fabro-mcp" } fabro-github = { path = "../fabro-github" } fabro-interview = { path = "../fabro-interview" } @@ -83,7 +83,7 @@ fabro-acp = { path = "../fabro-acp", features = ["test-support"] } fabro-workflow = { path = ".", features = ["test-support"] } fabro-api = { path = "../../foundation/fabro-api" } fabro-environment = { path = "../fabro-environment" } -fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "test-support"] } +fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } fabro-mcp = { path = "../fabro-mcp" } tokio = { workspace = true, features = ["test-util", "macros"] } object_store.workspace = true diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index b659f6013..1ad0ba2a2 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -14,7 +14,7 @@ use fabro_sandbox::from_environment::{ daytona_config_from_environment, docker_config_from_environment_with_secrets, local_working_directory_from_environment, }; -use fabro_sandbox::{DockerSandboxOptions, SandboxSpec}; +use fabro_sandbox::{DaytonaCredentials, DockerSandboxOptions, SandboxSpec}; use fabro_static::EnvVars; #[cfg(test)] use fabro_types::GitRunTarget; @@ -543,9 +543,9 @@ impl RunSession { } } Some(BundledProvider::Daytona) => { - let api_key = vault_guard - .get(EnvVars::DAYTONA_API_KEY) - .map(str::to_string); + let credentials = vault_guard.get(EnvVars::DAYTONA_API_KEY).map(|api_key| { + DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var) + }); let mut config = resolve_daytona_config(resolved); config.skip_clone |= clone_source.skip_clone; SandboxSpec::Daytona { @@ -556,7 +556,7 @@ impl RunSession { clone_branch: clone_source.branch, clone_tag: clone_source.tag, clone_commit_sha: clone_source.commit_sha, - api_key, + credentials, } } None => { diff --git a/lib/components/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs index 3db130fd1..8d898c7db 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -12,7 +12,8 @@ use fabro_graphviz::graph; use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner}; use fabro_model::Catalog; use fabro_sandbox::{ - GitSetupIntent, SandboxEventCallback, SandboxSpec, reconnect_for_run_with_callback, shell_quote, + DaytonaCredentials, GitSetupIntent, SandboxEventCallback, SandboxSpec, + reconnect_for_run_with_callback, shell_quote, }; use fabro_static::EnvVars; use fabro_types::RunSandboxKind; @@ -431,15 +432,15 @@ pub async fn initialize( }; let attach_existing = attach_instance.is_some(); let sandbox: Arc = if let Some(instance) = attach_instance { - let daytona_api_key = options + let daytona = options .vault .read() .await .get(EnvVars::DAYTONA_API_KEY) - .map(str::to_string); + .map(|api_key| DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var)); let sandbox = reconnect_for_run_with_callback( &instance, - daytona_api_key, + daytona, Some(options.run_options.run_id), Some(Arc::clone(&sandbox_event_callback)), ) @@ -737,6 +738,14 @@ pub async fn initialize( }) } +#[expect( + clippy::disallowed_methods, + reason = "A CLI worker resolves the Daytona control-plane URL from its own environment; server-spawned workers run with a cleared environment and take the defaults." +)] +fn process_env_var(name: &str) -> Option { + std::env::var(name).ok() +} + #[cfg(test)] mod tests { use std::collections::{BTreeMap, HashMap}; diff --git a/lib/components/fabro-workflow/tests/it/daytona_integration.rs b/lib/components/fabro-workflow/tests/it/daytona_integration.rs index 1372fe1e9..b193a4d8f 100644 --- a/lib/components/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/components/fabro-workflow/tests/it/daytona_integration.rs @@ -1,4 +1,4 @@ -//! Integration tests for `DaytonaSandbox`. +//! Integration tests for the driver-backed Daytona sandbox. //! //! These tests require a `DAYTONA_API_KEY` environment variable and network //! access. Run with: `cargo test --package arc-workflows -- --ignored daytona` @@ -24,7 +24,8 @@ use std::sync::Arc; use fabro_agent::Sandbox; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; -use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox}; +use fabro_sandbox::daytona::DaytonaConfig; +use fabro_sandbox::{DaytonaCredentials, DriverSandbox, daytona_sandbox}; use fabro_static::EnvVars; use fabro_store::{ArtifactKey, ArtifactStore}; use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref}; @@ -181,7 +182,22 @@ async fn resolve_checkpoint_text( Ok(artifact::resolve_text_or_blob_ref_str(current, &run_store).await?) } -async fn create_env() -> DaytonaSandbox { +/// Live credentials from the process environment, the way the vault would +/// supply them in production. +fn live_daytona_credentials() -> DaytonaCredentials { + DaytonaCredentials { + api_key: std::env::var(EnvVars::DAYTONA_API_KEY) + .expect("DAYTONA_API_KEY must be set"), + api_url: std::env::var(EnvVars::DAYTONA_API_URL) + .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) + .ok(), + organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(), + target: None, + http_client: None, + } +} + +async fn create_env() -> DriverSandbox { let creds = load_github_app_credentials(); create_env_with_github_app(Some(creds)).await } @@ -196,16 +212,16 @@ fn test_artifact_store(run_dir: &Path) -> ArtifactStore { async fn create_env_with_github_app( github_app: Option, -) -> DaytonaSandbox { - DaytonaSandbox::new( +) -> DriverSandbox { + daytona_sandbox( DaytonaConfig::default(), - github_app, - None, + github_app.as_ref(), None, None, None, None, None, + &live_daytona_credentials(), ) .await .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?") @@ -414,9 +430,18 @@ async fn daytona_snapshot_sandbox() { }; let creds = load_github_app_credentials(); - let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None, None, None) - .await - .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?"); + let env = daytona_sandbox( + config, + Some(&creds), + None, + None, + None, + None, + None, + &live_daytona_credentials(), + ) + .await + .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?"); env.initialize().await.unwrap(); // Verify rg is available (installed by snapshot) @@ -1093,7 +1118,11 @@ async fn daytona_ssh_access() { let env = create_env().await; env.initialize().await.unwrap(); - let ssh_command = env.create_ssh_access(Some(60.0)).await.unwrap(); + let ssh_command = env + .ssh_access_command() + .await + .unwrap() + .expect("Daytona should offer an SSH command"); assert!(!ssh_command.is_empty(), "ssh_command should not be empty"); assert!( ssh_command.contains("ssh"), @@ -1107,7 +1136,7 @@ async fn daytona_ssh_access() { async fn daytona_ssh_access_before_init_fails() { let env = create_env().await; - let result = env.create_ssh_access(Some(60.0)).await; + let result = env.ssh_access_command().await; assert!(result.is_err(), "should fail before initialize()"); assert!( result.unwrap_err().to_string().contains("not initialized"), @@ -1613,21 +1642,35 @@ async fn daytona_cp_upload_download_round_trip() { #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))] async fn daytona_computer_use_browser_screenshot() { - use base64::Engine; let config = DaytonaConfig { snapshot: None, skip_clone: true, ..DaytonaConfig::default() }; - let env = DaytonaSandbox::new(config, None, None, None, None, None, None, None) - .await - .expect("DAYTONA_API_KEY must be set"); + let env = daytona_sandbox( + config, + None, + None, + None, + None, + None, + None, + &live_daytona_credentials(), + ) + .await + .expect("DAYTONA_API_KEY must be set"); env.initialize().await.unwrap(); - // 1. Start the computer use desktop environment (Xvfb, xfce4, etc.) - let cu = env.computer_use().await.unwrap(); - let start_resp = cu.start().await.expect("computer_use.start() failed"); - eprintln!("Computer use started: {:?}", start_resp.message); + // 1. Start the computer use desktop environment (Xvfb, xfce4, etc.) through the + // driver's VNC facet, which also signs a viewer URL. + let vnc = env + .handle() + .expect("initialized sandbox has a handle") + .vnc() + .expect("Daytona exposes VNC"); + let connection = vnc.vnc_connection().await.expect("VNC connection failed"); + eprintln!("VNC viewer: {}", connection.url); + assert!(connection.url.contains("vnc.html")); // 2. Find or install a browser let check = env @@ -1724,36 +1767,23 @@ async fn daytona_computer_use_browser_screenshot() { .unwrap(); eprintln!("Chrome stderr:\n{}", stderr_check.stdout); - // 5. Take a screenshot via the Computer Use API - let screenshot = cu - .screenshot() - .take_full_screen() + // 5. The desktop is serving: noVNC listens on its port. + let listening = env + .exec_command( + "ss -ltn 2>/dev/null | grep -q ':6080 ' || (command -v curl >/dev/null && curl -sf -o /dev/null http://127.0.0.1:6080/)", + 10_000, + None, + None, + None, + ) .await - .expect("screenshot failed"); - - let b64_data = screenshot - .screenshot - .expect("screenshot response had no data"); - eprintln!( - "Screenshot captured: {} bytes base64 ({} bytes decoded approx)", - b64_data.len(), - b64_data.len() * 3 / 4 - ); - assert!(!b64_data.is_empty(), "screenshot should not be empty"); - - // 6. Decode and save to /tmp for manual inspection - let png_bytes = base64::engine::general_purpose::STANDARD - .decode(&b64_data) - .expect("base64 decode failed"); - let output_path = "/tmp/daytona_browser_screenshot.png"; - std::fs::write(output_path, &png_bytes).expect("failed to write screenshot"); - eprintln!( - "Screenshot saved to {output_path} ({} bytes)", - png_bytes.len() + .unwrap(); + assert!( + listening.is_success(), + "noVNC should be reachable inside the sandbox" ); // 7. Cleanup - cu.stop().await.ok(); env.cleanup().await.unwrap(); } @@ -1767,9 +1797,18 @@ async fn daytona_playwright_mcp_sandbox_transport() { skip_clone: true, ..DaytonaConfig::default() }; - let sandbox = DaytonaSandbox::new(config, None, None, None, None, None, None, None) - .await - .expect("DAYTONA_API_KEY must be set"); + let sandbox = daytona_sandbox( + config, + None, + None, + None, + None, + None, + None, + &live_daytona_credentials(), + ) + .await + .expect("DAYTONA_API_KEY must be set"); sandbox.initialize().await.unwrap(); // 1. Install Playwright MCP server and its browser