Route Daytona sandboxes through the sandbox driver

The `daytona` provider kind now maps onto the sandbox-driver Daytona
provider instead of fabro's own SDK client. Fabro keeps what is its own:
the HMAC-named snapshot built from the environment's image or Dockerfile,
the explicit 120 minute auto-stop default, the managed labels that gate
destructive operations, the clone decision and layout, and push
credentials. The driver creates the sandbox, clones natively, and serves
exec, files, search, terminal, SSH, preview, and VNC through its facets.

- `daytona.rs` builds the driver `SandboxSpec` (snapshot source,
  `/home/daytona/workspace`, labels, timers, network policy, run name),
  ensures the snapshot through the driver `SnapshotProvider`, attaches by
  persisted id with fabro's label guard, and probes credentials through
  the provider health check under fabro's 20 second budget.
- `DriverSandbox` gains a create plan that settles the spec right before
  the provider call, records the snapshot a sandbox came from, and
  reports the provider console URL on `Ready`.
- Terminals use the driver `Pty` facet; the server's SSH, preview, and
  VNC endpoints use the `SshAccess`, `PreviewUrls`, and `Vnc` facets
  through the driver-typed reconnect. The preview endpoint now answers
  for every provider with a preview facet, so the local sandbox returns
  its loopback URL.
- Daytona credentials travel as `DaytonaCredentials` built from the
  vault key plus configured URL and organization; nothing reads the
  process environment implicitly. The inventory registry uses the shared
  `DriverInventoryProvider`.
- The SDK-based `daytona/mod.rs`, `provider/daytona.rs`, the Daytona
  terminal, the `daytona` cargo feature, and the direct daytona-sdk,
  git2 (in fabro-sandbox), tungstenite, and rustls dependencies are
  gone. The live Daytona tests run against the driver-backed sandbox.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 19:14:13 -06:00
parent 3d33935fba
commit 28d4242df0
No known key found for this signature in database
38 changed files with 1617 additions and 7003 deletions

12
Cargo.lock generated
View file

@ -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",

View file

@ -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.

View file

@ -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

View file

@ -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]

View file

@ -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<String>)> {
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()));
}
}

View file

@ -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.
");
}

View file

@ -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" }

View file

@ -674,7 +674,7 @@ fn cloud_sandbox_probe_check(probe: anyhow::Result<daytona::DaytonaKeyCheck>) ->
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,
},

View file

@ -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<daytona::DaytonaKeyCheck> {
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(

View file

@ -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

View file

@ -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<fabro_github::GitHubCredentials>,
daytona_api_key: Option<String>,
daytona: Option<DaytonaCredentials>,
) -> std::result::Result<SandboxSpec, fabro_sandbox::Error> {
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<fabro_github::GitHubCredentials>,
daytona_api_key: Option<String>,
daytona: Option<DaytonaCredentials>,
) -> bool {
let spec = match preflight_sandbox_spec(
sandbox_provider,
prepared,
resolved_run,
github_app.clone(),
daytona_api_key,
daytona,
) {
Ok(spec) => spec,
Err(err) => {

View file

@ -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<Option<DaytonaCredentials>, 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<daytona::DaytonaKeyCheck> {
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!(

View file

@ -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<String>>;
/// Starts the desktop and returns the signed viewer URL the provider
/// hands out for it.
fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result<String>>;
}
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<String>> {
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<String>> {
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::<SandboxDetails>(details).into_response(),
Err(err) => {
let detail = format!("{err:#}");
@ -232,7 +219,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, 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<AppState>, 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<VncPreviewResponse, Response> {
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<String> {
// 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<String> {
)]
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<Box<dyn Sandbox>, 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<AppState>,
run_id: &RunId,
record: &RunSandboxInstance,
) -> Result<DriverSandbox, Response> {
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<AppState>,
run_id: &RunId,
) -> Result<DaytonaSandbox, Response> {
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<AppState>,
record: &RunSandboxInstance,
) -> Result<DaytonaSandbox, Response> {
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<Option<DaytonaCredentials>, 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<Option<String>, 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<String>> {
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"
);
}
}

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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<Sha256>;
/// The snapshot source as it appears in the identity manifest. Each
/// variant flattens into a single `"<key>": "<value>"` 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<i32>,
memory_gb: Option<i32>,
disk_gb: Option<i32>,
/// 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<String> {
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<Vec<u8>> {
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<String>,
}
#[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<DaytonaKeyCheck> {
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<String> {
let mut ordered: Vec<String> = 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<Arc<dyn SandboxProvider>> {
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<SnapshotSpec> {
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<dyn SandboxProvider>,
api_key: String,
config: DaytonaConfig,
run_id: Option<RunId>,
}
#[async_trait]
impl CreatePlan for DaytonaCreatePlan {
async fn prepare(
&self,
emit: &(dyn Fn(SandboxEvent) + Send + Sync),
) -> crate::Result<PreparedCreate> {
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<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
credentials: &DaytonaCredentials,
) -> crate::Result<DriverSandbox> {
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<String>,
run_id: Option<RunId>,
credentials: &DaytonaCredentials,
) -> crate::Result<DriverSandbox> {
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-<uuid>");
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::<DaytonaCredentialProbeTimeout>()
.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"
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<String>,
daytona_organization_id: Option<String>,
daytona: Option<DaytonaCredentials>,
run_id: Option<RunId>,
) -> Result<SandboxDetails> {
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<Utc>> {
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<DaytonaCredentials>,
run_id: Option<RunId>,
) -> Result<SandboxDetails> {
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<RunId>,
@ -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<String>,
) -> Result<SandboxDetails> {
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<String>,
region: Option<String>,
resources: SandboxResources,
network: SandboxNetwork,
labels: BTreeMap<String, String>,
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<String, String> = 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<u64> {
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::*;

View file

@ -7,8 +7,6 @@
//! repository checks out under [`REPOS_ROOT`] and is linked into the
//! workspace, so the run works in `/workspace/<repo>`.
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<String, String>,
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"));
}
}

View file

@ -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<reqwest::Client>,
}
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<String>) -> 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")

View file

@ -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<String>,
/// The provider snapshot the sandbox is created from, when the provider
/// has that concept; recorded on the run.
pub(crate) snapshot: Option<String>,
}
/// 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<PreparedCreate>;
}
/// 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<PreparedCreate> {
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<dyn DriverProvider>,
spec: DriverSpec,
/// The image or snapshot named by the spec, for pull progress events.
source: Option<String>,
plan: Box<dyn CreatePlan>,
}
/// 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<String>,
}
impl DriverSandbox {
@ -246,16 +281,37 @@ impl DriverSandbox {
source: Option<String>,
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<dyn DriverProvider>,
plan: Box<dyn CreatePlan>,
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<String> {
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)

View file

@ -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);

View file

@ -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,

View file

@ -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<String, String>) -> bool {
pub(crate) fn is_managed(labels: &BTreeMap<String, String>) -> 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<String, String>,
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<String, String> {
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<String, String>>,
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());
}
}

View file

@ -1,5 +1,3 @@
#[cfg(feature = "daytona")]
pub mod daytona;
pub mod driver;
use std::sync::Arc;

View file

@ -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<String>,
api_url: Option<String>,
organization_id: Option<String>,
http_client: Option<fabro_http::HttpClient>,
}
impl DaytonaSandboxProvider {
pub fn new(
api_key: Option<String>,
api_url: Option<String>,
organization_id: Option<String>,
http_client: Option<fabro_http::HttpClient>,
) -> Self {
Self {
api_key,
api_url,
organization_id,
http_client,
}
}
async fn client(&self) -> crate::Result<daytona_sdk::Client> {
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<Vec<SandboxInfo>> {
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<Option<SandboxInfo>> {
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)
}

View file

@ -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<String, String>) -> 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())
}

View file

@ -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<String>,
daytona: Option<DaytonaCredentials>,
) -> Result<Box<dyn crate::Sandbox>> {
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<String>,
daytona: Option<DaytonaCredentials>,
run_id: Option<RunId>,
) -> Result<Box<dyn crate::Sandbox>> {
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<String>,
daytona: Option<DaytonaCredentials>,
run_id: Option<RunId>,
event_callback: Option<SandboxEventCallback>,
) -> Result<Box<dyn crate::Sandbox>> {
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<DaytonaCredentials>,
run_id: Option<RunId>,
event_callback: Option<SandboxEventCallback>,
) -> Result<DriverSandbox> {
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

View file

@ -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<i32>, 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<String>,
) -> 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<u64>) {
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<u8> {
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<u8>, 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<Vec<SandboxFile>> {
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::<u64>().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<i32>, 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"

View file

@ -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<String>,
clone_commit_sha: Option<String>,
},
#[cfg(feature = "daytona")]
Daytona {
config: Box<DaytonaConfig>,
github_app: Option<GitHubCredentials>,
@ -38,7 +33,10 @@ pub enum SandboxSpec {
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
api_key: Option<String>,
/// Vault credentials for the Daytona control plane; `None` fails at
/// build time with a clear message rather than reading the process
/// environment.
credentials: Option<DaytonaCredentials>,
},
}
@ -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);
}

View file

@ -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<String>,
daytona_organization_id: Option<String>,
daytona: Option<DaytonaCredentials>,
run_id: Option<RunId>,
size: TerminalSize,
) -> crate::Result<Box<dyn TerminalSession>> {
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<String>) -> crate::Result<String> {
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<String>) -> Option<String> {
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<MaybeTlsStream<TcpStream>>;
type ProviderSink = SplitSink<ProviderWs, ProviderMessage>;
type ProviderStream = SplitStream<ProviderWs>;
static RUSTLS_PROVIDER: Once = Once::new();
pub(super) struct DaytonaTerminalSession {
toolbox_base_url: String,
api_key: String,
org_id: Option<String>,
session_id: String,
write: Mutex<Option<ProviderSink>>,
read: Mutex<Option<ProviderStream>>,
closed: Mutex<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DaytonaPtyCreateRequest {
cols: u16,
rows: u16,
cwd: String,
envs: HashMap<String, String>,
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<String>,
api_base_url: String,
size: TerminalSize,
) -> crate::Result<Self> {
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<Option<Vec<u8>>> {
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<String> {
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::<DaytonaPtyCreateResponse>()
.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<String> {
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<String> {
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::<u64>())
}
fn is_daytona_terminal_control_text(text: &str) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(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<Request<()>> {
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;

View file

@ -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<DaytonaSandbox>) -> Result<()> {
async fn run_smoke(sandbox: Arc<DriverSandbox>) -> 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<CancellationToken>,
@ -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<CancellationToken>,
@ -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<DaytonaCredentials> {
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<Mutex<Vec<CapturedChunk>>>,
timeout_after: Duration,

View file

@ -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

View file

@ -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 => {

View file

@ -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<dyn Sandbox> = 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<String> {
std::env::var(name).ok()
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap};

View file

@ -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<fabro_github::GitHubCredentials>,
) -> 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