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