Map an environment onto one sandbox spec for every provider

Three copies of the same mapping turned an environment into a sandbox:
Docker had its own options type built from the environment, Daytona a
config type with its own network enum and minute-and-gigabyte units,
and plugin kinds a third options type, each with its own spec builder,
constructor, and attach function, and the run spec had a variant per
provider carrying them. The mapping now runs once. SandboxOptions is
what any environment asks of any provider; options_from_environment
builds it, and one base spec carries the source, name, labels,
variables, resources, and network policy. Docker and Daytona are
overlays on that spec: Docker fixes its working directory, supplies the
default image, and pulls; Daytona replaces the source with the snapshot
it ensures from the same image or Dockerfile, fixes its working
directory and name, and sets the timers. The snapshot's content-addressed
name is unchanged, since the identity still hashes the image or
Dockerfile and the resources in whole gigabytes.

SandboxSpec has two variants, Local and Provider, and the worker and
the server preflight build the Provider one without knowing which kind
it is; provider_sandbox and attach_provider_sandbox connect any kind
through the single construction function and lay the repository out
where that kind keeps it. The per-provider constructors, the config
module, and the environment mapping module are gone, and the SDK
reference and the integration tests use the one constructor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 22:41:09 -06:00
parent ff231e890b
commit 0c569638fe
No known key found for this signature in database
21 changed files with 1254 additions and 1888 deletions

View file

@ -160,8 +160,7 @@ pub trait Sandbox: Send + Sync {
| Type | Description |
|---|---|
| `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. |
| `daytona_sandbox(...)` | Runs inside a Daytona cloud sandbox through the sandbox driver. |
| `provider_sandbox(kind, ...)` | Runs on any sandbox driver provider by kind: the bundled `docker` and `daytona` providers in process, or a configured plugin. |
### Provider profiles

View file

@ -17,14 +17,11 @@ use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment,
local_working_directory_from_environment,
};
use fabro_sandbox::plugin::plugin_options_from_environment;
use fabro_sandbox::redact::redact_auth_url;
use fabro_sandbox::{DockerSandboxOptions, ProviderAccess, Sandbox, SandboxSpec};
use fabro_sandbox::{
ProviderAccess, ProviderSandboxSpec, Sandbox, SandboxSpec,
local_working_directory_from_environment, options_from_environment, unresolved_env,
};
use fabro_static::EnvVars;
use fabro_types::settings::ModelRef;
use fabro_types::settings::cli::OutputVerbosity;
@ -674,14 +671,6 @@ pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProv
configured_sandbox_provider(settings).effective_for(settings.execution.mode)
}
fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig {
daytona_config_from_environment(&settings.environment, &settings.clone)
}
fn resolve_docker_config(settings: &RunNamespace) -> DockerSandboxOptions {
docker_config_from_environment(&settings.environment, &settings.clone)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct GitRemoteRefCheck {
origin_url: String,
@ -928,78 +917,32 @@ fn preflight_sandbox_spec(
.map(|git| fabro_github::normalize_repo_origin_url(&git.origin_url));
let clone_branch = prepared.git.as_ref().map(|git| git.branch.clone());
Ok(match sandbox_provider.bundled() {
Some(BundledProvider::Local) => {
let working_directory = local_working_directory_from_environment(
&resolved_run.environment,
Some(&prepared.source_directory),
)?;
SandboxSpec::Local { working_directory }
}
Some(BundledProvider::Docker) => {
let mut config = resolve_docker_config(resolved_run);
config.skip_clone = true;
SandboxSpec::Docker {
config,
github_app,
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
}
}
Some(BundledProvider::Daytona) => {
let mut config = resolve_daytona_config(resolved_run);
config.skip_clone = true;
SandboxSpec::Daytona {
config: Box::new(config),
github_app,
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
credentials: access.daytona.clone(),
}
}
None => {
let settings = access.settings_for(sandbox_provider).ok_or_else(|| {
fabro_sandbox::Error::message(format!(
"sandbox provider `{sandbox_provider}` is not configured; add [server.sandbox.providers.{sandbox_provider}] to settings.toml"
))
})?;
// No vault is available on this path, so a `{{ secrets.* }}` value
// keeps its source form, as the Docker preflight does.
#[expect(
clippy::disallowed_methods,
reason = "preflight has no vault; an unresolved secret token is carried in source form"
)]
let env = resolved_run
.environment
.env
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect();
let mut options = plugin_options_from_environment(
&resolved_run.environment,
&resolved_run.clone,
env,
);
options.skip_clone = true;
SandboxSpec::Plugin {
kind: sandbox_provider.clone(),
settings: Box::new(settings),
options: Box::new(options),
github_app,
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
}
}
})
if sandbox_provider.bundled() == Some(BundledProvider::Local) {
let working_directory = local_working_directory_from_environment(
&resolved_run.environment,
Some(&prepared.source_directory),
)?;
return Ok(SandboxSpec::Local { working_directory });
}
// No vault is available on this path, so a `{{ secrets.* }}` value keeps
// its source form.
let mut options = options_from_environment(
&resolved_run.environment,
&resolved_run.clone,
unresolved_env(&resolved_run.environment),
)?;
options.skip_clone = true;
Ok(SandboxSpec::Provider(Box::new(ProviderSandboxSpec {
kind: sandbox_provider.clone(),
access: access.clone(),
options,
github_app,
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
})))
}
async fn run_sandbox_check(
@ -2278,18 +2221,14 @@ provider = "local"
);
match spec {
Ok(SandboxSpec::Docker {
config,
clone_origin_url,
clone_branch,
..
}) => {
assert!(config.skip_clone);
Ok(SandboxSpec::Provider(spec)) => {
assert_eq!(spec.kind, SandboxProviderKind::DOCKER);
assert!(spec.options.skip_clone);
assert_eq!(
clone_origin_url.as_deref(),
spec.clone_origin_url.as_deref(),
Some("https://github.com/acme/widgets")
);
assert_eq!(clone_branch.as_deref(), Some("main"));
assert_eq!(spec.clone_branch.as_deref(), Some("main"));
}
_ => panic!("expected Docker preflight sandbox spec"),
}

View file

@ -38,7 +38,7 @@ pub use config::{
pub use error::{CompactionError, Error, InterruptReason, Result};
pub use event::Emitter;
pub use fabro_mcp::config::McpServerSettings;
pub use fabro_sandbox::{DockerSandboxOptions, docker_sandbox};
pub use fabro_sandbox::{ProviderAccess, SandboxOptions, SandboxProviderKind, provider_sandbox};
pub use fabro_types::SteeringMessage;
pub use history::History;
pub use local_sandbox::{DriverSandbox, local_sandbox};

View file

@ -9,7 +9,7 @@ use fabro_agent::sandbox::Sandbox;
use fabro_agent::tool_registry::ToolContext;
use fabro_agent::tools::make_shell_tool;
use fabro_agent::types::AgentEvent;
use fabro_agent::{DockerSandboxOptions, Emitter, docker_sandbox};
use fabro_agent::{Emitter, ProviderAccess, SandboxOptions, SandboxProviderKind, provider_sandbox};
use fabro_types::CommandTermination;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
@ -17,12 +17,13 @@ use tokio_util::sync::CancellationToken;
#[tokio::test]
#[ignore = "requires real Docker container lifecycle; run explicitly when changing shell tool exec integration"]
async fn shell_reports_real_docker_process_outcome() {
let Ok(sandbox) = docker_sandbox(
DockerSandboxOptions {
image: "buildpack-deps:noble".to_string(),
auto_pull: false,
let Ok(sandbox) = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxOptions {
image: Some("buildpack-deps:noble".to_string()),
skip_clone: true,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
None,
None,

View file

@ -1,135 +0,0 @@
//! Sandbox configuration runtime types.
//!
//! These types are the runtime shape that the sandbox providers consume.
//!
//! The `DaytonaSettings`/`DaytonaSnapshotSettings` names are kept for
//! backward compatibility with the old import path; [`crate::daytona`]
//! continues to re-export them under `DaytonaConfig`/`DaytonaSnapshotConfig`
//! aliases.
use std::collections::HashMap;
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct DaytonaSettings {
pub auto_stop_interval: Option<i32>,
pub labels: Option<HashMap<String, String>>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetwork>,
/// Git history depth for the repository clone; `None` clones full
/// history.
pub clone_depth: Option<i32>,
#[serde(default)]
pub skip_clone: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DaytonaNetwork {
Block,
AllowAll,
AllowList(Vec<String>),
}
impl Serialize for DaytonaNetwork {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Block => serializer.serialize_str("block"),
Self::AllowAll => serializer.serialize_str("allow_all"),
Self::AllowList(cidrs) => {
use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("allow_list", cidrs)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for DaytonaNetwork {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct DaytonaNetworkVisitor;
impl<'de> Visitor<'de> for DaytonaNetworkVisitor {
type Value = DaytonaNetwork;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
r#""block", "allow_all", or {{ allow_list = [...] }}"#
)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<DaytonaNetwork, E> {
match value {
"block" => Ok(DaytonaNetwork::Block),
"allow_all" => Ok(DaytonaNetwork::AllowAll),
other => Err(de::Error::custom(format!(
"unknown network mode \"{other}\": expected \"block\" or \"allow_all\""
))),
}
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<DaytonaNetwork, M::Error> {
let Some(key) = map.next_key::<String>()? else {
return Err(de::Error::custom(
"empty table: expected { allow_list = [...] }",
));
};
if key != "allow_list" {
return Err(de::Error::custom(format!(
"unknown key \"{key}\": expected \"allow_list\""
)));
}
let cidrs: Vec<String> = map.next_value()?;
if cidrs.is_empty() {
return Err(de::Error::custom("allow_list must not be empty"));
}
if let Some(extra) = map.next_key::<String>()? {
return Err(de::Error::custom(format!(
"unexpected key \"{extra}\": allow_list table must have exactly one key"
)));
}
Ok(DaytonaNetwork::AllowList(cidrs))
}
}
deserializer.deserialize_any(DaytonaNetworkVisitor)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum DockerfileSource {
Inline(String),
Path { path: String },
}
/// Where a custom Daytona snapshot is built from.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum DaytonaSnapshotSource {
/// A pullable image reference such as `ubuntu:24.04`.
Image(String),
/// A Dockerfile that Daytona builds into the snapshot.
Dockerfile(DockerfileSource),
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct DaytonaSnapshotSettings {
pub cpu: Option<i32>,
pub memory: Option<i32>,
pub disk: Option<i32>,
pub source: DaytonaSnapshotSource,
}

View file

@ -1,37 +1,31 @@
//! The `daytona` provider kind: fabro's environment mapping onto the
//! The `daytona` provider kind: what fabro adds to a run's spec for 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.
//! The environment's options build the spec once; Daytona's overlay creates
//! sandboxes from a snapshot (built from the environment's image or
//! Dockerfile and named by an HMAC of its inputs, or Daytona's default when
//! the environment names neither), fixes the working directory, and sets
//! the lifecycle timers. 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,
HealthStatus, LifecycleTimers, Resources, 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, LayoutSource, PreparedCreate, RepoWorkspace, WorkspaceLayout,
};
use crate::managed_labels;
use crate::driver_sandbox::{CreatePlan, PreparedCreate, WorkspaceLayout};
use crate::options::SandboxOptions;
use crate::sandbox::SandboxEvent;
pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
@ -46,8 +40,8 @@ const DAYTONA_SNAPSHOT_ACTIVE_TIMEOUT: Duration = Duration::from_mins(30);
/// 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;
/// leaked by a dead worker. An explicit zero disables auto-stop entirely.
const DEFAULT_AUTO_STOP: Duration = Duration::from_hours(2);
/// Scopes a Daytona API key needs for fabro's snapshot and sandbox flow, in
/// the order the remediation text lists them.
@ -58,13 +52,53 @@ pub const REQUIRED_DAYTONA_SCOPES: &[&str] = &[
"delete:sandboxes",
];
/// What a custom snapshot is built from: the environment's image or
/// Dockerfile and its resources in whole gigabytes, the units Daytona
/// sizes snapshots in and the values the snapshot's name is derived from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SnapshotInputs<'a> {
pub source: SnapshotInput<'a>,
pub cpu: Option<i32>,
pub memory_gb: Option<i32>,
pub disk_gb: Option<i32>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SnapshotInput<'a> {
/// A pullable image reference such as `ubuntu:24.04`.
Image(&'a str),
/// A Dockerfile Daytona builds into the snapshot.
Dockerfile(&'a str),
}
/// The snapshot `options` ask for, or `None` when the environment names no
/// image or Dockerfile and the sandbox comes from Daytona's default.
pub fn snapshot_inputs(options: &SandboxOptions) -> Option<SnapshotInputs<'_>> {
let source = match (&options.image, &options.dockerfile) {
(Some(image), _) => SnapshotInput::Image(image),
(None, Some(dockerfile)) => SnapshotInput::Dockerfile(dockerfile),
(None, None) => return None,
};
Some(SnapshotInputs {
source,
cpu: options.cpu.and_then(|cpu| i32::try_from(cpu).ok()),
memory_gb: options.memory_bytes.map(bytes_to_gb),
disk_gb: options.disk_bytes.map(bytes_to_gb),
})
}
/// Whole decimal gigabytes, the unit Daytona sizes snapshots in.
fn bytes_to_gb(bytes: u64) -> i32 {
i32::try_from(bytes / 1_000_000_000).unwrap_or(i32::MAX)
}
pub mod snapshot_identity {
use hmac::{Hmac, Mac};
use serde::Serialize;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use super::{DaytonaSnapshotConfig, DaytonaSnapshotSource, DockerfileSource};
use super::{SnapshotInput, SnapshotInputs};
const IDENTITY_VERSION: u8 = 1;
const PROVIDER: &str = "daytona";
@ -96,12 +130,12 @@ pub mod snapshot_identity {
entrypoint: Option<&'static str>,
}
/// The name of the snapshot built from `config`: a UUIDv8 derived from an
/// The name of the snapshot built from `inputs`: 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)?;
pub fn snapshot_name(api_key: &str, inputs: &SnapshotInputs<'_>) -> crate::Result<String> {
let manifest = canonical_manifest(inputs)?;
let mut mac = HmacSha256::new_from_slice(api_key.as_bytes())
.expect("HMAC-SHA256 accepts keys of any length");
mac.update(&manifest);
@ -111,26 +145,21 @@ pub mod snapshot_identity {
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)) => {
fn canonical_manifest(inputs: &SnapshotInputs<'_>) -> crate::Result<Vec<u8>> {
let source = match inputs.source {
SnapshotInput::Image(image) => SourceManifest::Image(image),
SnapshotInput::Dockerfile(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,
cpu: inputs.cpu,
memory_gb: inputs.memory_gb,
disk_gb: inputs.disk_gb,
entrypoint: None,
};
serde_json::to_vec(&manifest).map_err(|err| {
@ -273,63 +302,40 @@ pub(crate) fn layout() -> WorkspaceLayout {
}
}
/// The driver spec for a fabro Daytona sandbox created from `snapshot`.
pub(crate) fn driver_spec(
config: &DaytonaConfig,
/// Daytona's additions to the base spec: the snapshot the sandbox is created
/// from, the fixed working directory, the run's Daytona name, and the
/// lifecycle timers.
pub(crate) fn overlay(
spec: DriverSpec,
options: &SandboxOptions,
run_id: Option<&RunId>,
snapshot: &SnapshotId,
) -> DriverSpec {
let mut spec = DriverSpec::new(SandboxSource::Snapshot {
let mut spec = spec.working_directory(WORKING_DIRECTORY);
spec.source = 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);
}
};
spec.name = run_id.map(|run_id| format!("fabro-{run_id}"));
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),
));
timers.auto_stop_after_idle = Some(options.auto_stop.unwrap_or(DEFAULT_AUTO_STOP));
// 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
/// Ensures the snapshot `inputs` describe 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,
inputs: &SnapshotInputs<'_>,
emit: &(dyn Fn(SandboxEvent) + Send + Sync),
) -> crate::Result<(SnapshotId, String)> {
let name = snapshot_identity::snapshot_name(api_key, config)?;
let name = snapshot_identity::snapshot_name(api_key, inputs)?;
let snapshots = provider.snapshots().ok_or_else(|| {
crate::Error::message("The Daytona provider does not expose snapshot management")
})?;
@ -372,7 +378,7 @@ async fn ensure_snapshot(
}
} else {
emit(SandboxEvent::SnapshotCreating { name: name.clone() });
let spec = snapshot_spec(&name, config)?;
let spec = snapshot_spec(&name, inputs);
snapshots.create(&spec, None).await.map_err(|error| {
crate::Error::context(format!("Failed to create snapshot '{name}'"), error)
})?
@ -381,33 +387,26 @@ async fn ensure_snapshot(
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(),
fn snapshot_spec(name: &str, inputs: &SnapshotInputs<'_>) -> SnapshotSpec {
let source = match inputs.source {
SnapshotInput::Image(image) => SnapshotSource::Image {
reference: image.to_string(),
},
SnapshotInput::Dockerfile(content) => SnapshotSource::Dockerfile {
content: content.to_string(),
},
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
resources.cpu_cores = inputs.cpu.and_then(|cpu| u32::try_from(cpu).ok());
resources.memory_mb = inputs
.memory_gb
.and_then(|gb| u64::try_from(gb).ok())
.map(|gb| gb * 1024);
resources.disk_mb = config
.disk
resources.disk_mb = inputs
.disk_gb
.and_then(|gb| u64::try_from(gb).ok())
.map(|gb| gb * 1024);
Ok(SnapshotSpec::new(source).name(name).resources(resources))
SnapshotSpec::new(source).name(name).resources(resources)
}
/// Polls a snapshot until it is active, with exponential back-off, or fails
@ -442,24 +441,44 @@ async fn wait_for_active_snapshot(
}
/// Prepares a Daytona create: the snapshot first, then the spec naming it.
struct DaytonaCreatePlan {
pub(crate) struct DaytonaCreatePlan {
provider: Arc<dyn SandboxProvider>,
api_key: String,
config: DaytonaConfig,
base: DriverSpec,
options: SandboxOptions,
run_id: Option<RunId>,
}
/// The create plan for a run on Daytona: `base` is the spec the
/// environment's options built, which the plan completes with the snapshot
/// once it exists.
pub(crate) fn create_plan(
provider: Arc<dyn SandboxProvider>,
api_key: String,
base: DriverSpec,
options: SandboxOptions,
run_id: Option<RunId>,
) -> DaytonaCreatePlan {
DaytonaCreatePlan {
provider,
api_key,
base,
options,
run_id,
}
}
#[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 (snapshot_id, snapshot_name) = match snapshot_inputs(&self.options) {
Some(inputs) => {
let started = time::Instant::now();
let result =
ensure_snapshot(self.provider.as_ref(), &self.api_key, snapshot, emit).await;
ensure_snapshot(self.provider.as_ref(), &self.api_key, &inputs, emit).await;
match result {
Ok((id, name)) => {
emit(SandboxEvent::SnapshotReady {
@ -470,7 +489,7 @@ impl CreatePlan for DaytonaCreatePlan {
(id, name)
}
Err(error) => {
let name = snapshot_identity::snapshot_name(&self.api_key, snapshot)
let name = snapshot_identity::snapshot_name(&self.api_key, &inputs)
.unwrap_or_default();
emit(SandboxEvent::SnapshotFailed {
name,
@ -487,134 +506,87 @@ impl CreatePlan for DaytonaCreatePlan {
),
};
Ok(PreparedCreate {
spec: driver_spec(&self.config, self.run_id.as_ref(), &snapshot_id),
spec: overlay(
self.base.clone(),
&self.options,
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(
LayoutSource::Fixed(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(
LayoutSource::Fixed(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 std::collections::BTreeMap;
use sandbox_driver::NetworkPolicy;
use super::*;
use crate::options::base_spec;
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());
fn dockerfile_inputs(dockerfile: &str) -> SnapshotInputs<'_> {
SnapshotInputs {
source: SnapshotInput::Dockerfile(dockerfile),
cpu: Some(2),
memory_gb: Some(4),
disk_gb: Some(10),
}
}
#[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()
fn snapshot_inputs_come_from_the_image_or_dockerfile_in_whole_gigabytes() {
assert!(snapshot_inputs(&SandboxOptions::default()).is_none());
let options = SandboxOptions {
image: Some("ubuntu:24.04".to_string()),
cpu: Some(2),
memory_bytes: Some(4_000_000_000),
disk_bytes: Some(10_500_000_000),
..SandboxOptions::default()
};
assert_eq!(
snapshot_inputs(&options),
Some(SnapshotInputs {
source: SnapshotInput::Image("ubuntu:24.04"),
cpu: Some(2),
memory_gb: Some(4),
disk_gb: Some(10),
})
);
let options = SandboxOptions {
dockerfile: Some("FROM ubuntu".to_string()),
..SandboxOptions::default()
};
assert_eq!(
snapshot_inputs(&options).map(|inputs| inputs.source),
Some(SnapshotInput::Dockerfile("FROM ubuntu"))
);
}
#[test]
fn overlay_names_the_run_and_carries_fabro_labels_and_timers() {
let options = SandboxOptions {
labels: BTreeMap::from([("team".to_string(), "platform".to_string())]),
network: NetworkPolicy::CidrAllowList {
cidrs: vec!["10.0.0.0/8".to_string()],
},
..SandboxOptions::default()
};
let snapshot = SnapshotId::try_new("snap-1").unwrap();
let spec = driver_spec(&config, Some(&run_id()), &snapshot);
let spec = overlay(
base_spec(&options, Some(&run_id())),
&options,
Some(&run_id()),
&snapshot,
);
assert!(matches!(&spec.source, SandboxSource::Snapshot { id } if id == &snapshot));
assert_eq!(
@ -648,17 +620,14 @@ mod tests {
}
#[test]
fn driver_spec_passes_explicit_auto_stop_through_and_zero_disables() {
fn overlay_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,
);
let options = SandboxOptions {
auto_stop: Some(Duration::from_mins(45)),
network: NetworkPolicy::Block,
..SandboxOptions::default()
};
let explicit = overlay(base_spec(&options, None), &options, None, &snapshot);
assert_eq!(
explicit.timers.auto_stop_after_idle,
Some(Duration::from_mins(45))
@ -667,26 +636,23 @@ mod tests {
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,
);
let options = SandboxOptions {
auto_stop: Some(Duration::ZERO),
..SandboxOptions::default()
};
let disabled = overlay(base_spec(&options, None), &options, 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 inputs = SnapshotInputs {
source: SnapshotInput::Image("ubuntu:24.04"),
cpu: Some(2),
memory_gb: Some(4),
disk_gb: Some(10),
};
let spec = snapshot_spec("fabro-x", &config).unwrap();
let spec = snapshot_spec("fabro-x", &inputs);
assert_eq!(spec.name.as_deref(), Some("fabro-x"));
assert!(matches!(
&spec.source,
@ -696,46 +662,23 @@ mod tests {
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();
let dockerfile = snapshot_spec("fabro-y", &SnapshotInputs {
source: SnapshotInput::Dockerfile("FROM ubuntu"),
..inputs
});
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 inputs = dockerfile_inputs("FROM ubuntu:24.04\nRUN apt-get update");
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();
let first = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap();
let second = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap();
let rotated_key = snapshot_identity::snapshot_name("dtn_rotated", &inputs).unwrap();
assert_eq!(first, second);
assert_eq!(first, "fabro-e607185f-c7ab-88c9-bf9d-d70addba9298");
@ -750,33 +693,24 @@ mod tests {
#[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 = dockerfile_inputs("FROM ubuntu:24.04");
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(),
)),
SnapshotInputs {
source: SnapshotInput::Dockerfile("FROM ubuntu:24.04\n# roll cache"),
..base.clone()
},
DaytonaSnapshotConfig {
SnapshotInputs {
cpu: Some(4),
..base.clone()
},
DaytonaSnapshotConfig {
memory: Some(8),
SnapshotInputs {
memory_gb: Some(8),
..base.clone()
},
DaytonaSnapshotConfig {
disk: Some(20),
SnapshotInputs {
disk_gb: Some(20),
..base.clone()
},
];
@ -789,16 +723,16 @@ mod tests {
#[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 inputs = SnapshotInputs {
source: SnapshotInput::Dockerfile(
"FROM private.example.com/secret-image\nRUN echo raw-secret",
),
cpu: None,
memory_gb: None,
disk_gb: None,
};
let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &config).unwrap();
let name = snapshot_identity::snapshot_name("dtn_super_secret_key", &inputs).unwrap();
assert!(name.starts_with("fabro-"));
assert!(!name.contains("private.example.com"));
@ -808,16 +742,16 @@ mod tests {
#[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 inputs = SnapshotInputs {
source: SnapshotInput::Image("ubuntu:24.04"),
cpu: Some(2),
memory_gb: Some(4),
disk_gb: Some(10),
};
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
let first = snapshot_identity::snapshot_name("dtn_secret", &inputs).unwrap();
let changed = snapshot_identity::snapshot_name("dtn_secret", &SnapshotInputs {
source: SnapshotInput::Image("ubuntu:24.10"),
..inputs
})
.unwrap();
@ -886,13 +820,14 @@ mod wire_gate {
use fabro_static::EnvVars;
use fabro_types::SandboxProviderKind;
use sandbox_driver::{SandboxProvider, SandboxSource, SandboxSpec as DriverSpec};
use sandbox_driver::SandboxProvider;
use sandbox_driver_protocol::{PluginProvider, serve};
use tokio::io::{duplex, split};
use super::*;
use crate::Sandbox as _;
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace};
use crate::options::base_spec;
#[expect(
clippy::disallowed_methods,
@ -939,8 +874,8 @@ mod wire_gate {
)
.expect("clone plan");
let snapshot = SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("snapshot id");
let spec = DriverSpec::new(SandboxSource::Snapshot { id: snapshot });
let spec = driver_spec(&DaytonaConfig::default(), None, &snapshot_id_of(&spec));
let options = SandboxOptions::default();
let spec = overlay(base_spec(&options, None), &options, None, &snapshot);
let sandbox = DriverSandbox::pending(
SandboxProviderKind::DAYTONA,
remote,
@ -981,11 +916,4 @@ mod wire_gate {
checks.await;
sandbox.cleanup().await.expect("cleanup");
}
fn snapshot_id_of(spec: &DriverSpec) -> SnapshotId {
match &spec.source {
SandboxSource::Snapshot { id } => id.clone(),
_ => unreachable!("the gate builds a snapshot source"),
}
}
}

View file

@ -1,67 +1,25 @@
//! The `docker` provider kind: fabro's environment mapping onto the
//! The `docker` provider kind: what fabro adds to a run's spec for the
//! sandbox-driver Docker provider.
//!
//! Fabro decides the image, resources, network policy, environment, labels,
//! and workspace layout; the driver creates and drives the container. The
//! container's working directory is [`WORKING_DIRECTORY`]; a cloned
//! repository checks out under [`REPOS_ROOT`] and is linked into the
//! workspace, so the run works in `/workspace/<repo>`.
//! The environment's options build the spec once; Docker's overlay fixes the
//! container's working directory at [`WORKING_DIRECTORY`], supplies the
//! default image when the environment names none, and asks the provider to
//! pull a missing image. A cloned repository checks out under
//! [`REPOS_ROOT`] and is linked into the workspace, so the run works in
//! `/workspace/<repo>`.
use fabro_github::GitHubCredentials;
use fabro_types::settings::run::RunCloneSettings;
use fabro_types::settings::server::ServerSandboxProviderSettings;
use fabro_types::{RunId, SandboxProviderKind};
use sandbox_driver::{
HealthStatus, NetworkPolicy, Resources, SandboxId, SandboxSource, SandboxSpec as DriverSpec,
};
use sandbox_driver::{HealthStatus, SandboxSource, SandboxSpec as DriverSpec};
use sandbox_driver_docker_config::DockerProviderConfig;
use crate::driver::{ProviderConnectOptions, connect_provider};
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace, WorkspaceLayout};
use crate::managed_labels;
use crate::driver::ProviderAccess;
use crate::driver_sandbox::WorkspaceLayout;
use crate::options::SandboxOptions;
use crate::provider_sandbox;
pub const WORKING_DIRECTORY: &str = "/workspace";
pub const REPOS_ROOT: &str = "/repos";
const DEFAULT_GIT_CLONE_DEPTH: usize = RunCloneSettings::DEFAULT_DEPTH.unsigned_abs() as usize;
/// Docker's default CFS period; a whole core is one period's worth of quota.
const CPU_PERIOD_MICROS: i64 = 100_000;
/// Options fabro derives from an environment for a Docker sandbox.
#[derive(Clone, Debug, PartialEq)]
pub struct DockerSandboxOptions {
/// Docker image to use.
pub image: String,
/// Docker network mode. Default: `Some("bridge")`; `Some("none")` blocks.
pub network_mode: Option<String>,
/// Memory limit in bytes. `None` = unlimited.
pub memory_limit: Option<i64>,
/// CPU quota (microseconds per 100ms period). `None` = unlimited.
pub cpu_quota: Option<i64>,
/// Whether to pull the image if not found locally. Default: `true`.
pub auto_pull: bool,
/// Additional `KEY=VALUE` environment variables for the container.
pub env_vars: Vec<String>,
/// Maximum Git history depth fetched during clone; `None` fetches full
/// history.
pub clone_depth: Option<usize>,
/// Create an empty workspace instead of cloning even when an origin exists.
pub skip_clone: bool,
}
impl Default for DockerSandboxOptions {
fn default() -> Self {
Self {
image: "buildpack-deps:noble".to_string(),
network_mode: Some("bridge".to_string()),
memory_limit: None,
cpu_quota: None,
auto_pull: true,
env_vars: Vec::new(),
clone_depth: Some(DEFAULT_GIT_CLONE_DEPTH),
skip_clone: false,
}
}
}
/// The image a Docker environment gets when it names none.
pub const DEFAULT_IMAGE: &str = "buildpack-deps:noble";
/// The workspace layout every Docker sandbox uses.
pub(crate) fn layout() -> WorkspaceLayout {
@ -71,142 +29,34 @@ pub(crate) fn layout() -> WorkspaceLayout {
}
}
pub(crate) fn container_name(run_id: &RunId) -> String {
format!("fabro-run-{run_id}")
/// The image a Docker sandbox runs: the environment's, or the default.
pub(crate) fn effective_image(options: &SandboxOptions) -> String {
options
.image
.clone()
.unwrap_or_else(|| DEFAULT_IMAGE.to_string())
}
/// The driver spec for a fabro Docker sandbox.
pub(crate) fn driver_spec(options: &DockerSandboxOptions, run_id: Option<&RunId>) -> DriverSpec {
let mut spec = DriverSpec::new(SandboxSource::Image {
reference: options.image.clone(),
})
.working_directory(WORKING_DIRECTORY)
.network(match options.network_mode.as_deref() {
Some("none") => NetworkPolicy::Block,
_ => NetworkPolicy::AllowAll,
})
.provider_config(
/// Docker's additions to the base spec, and the image it will run.
pub(crate) fn overlay(spec: DriverSpec, options: &SandboxOptions) -> (DriverSpec, String) {
let image = effective_image(options);
let mut spec = spec;
spec.source = SandboxSource::Image {
reference: image.clone(),
};
let spec = spec.working_directory(WORKING_DIRECTORY).provider_config(
DockerProviderConfig {
auto_pull: options.auto_pull,
auto_pull: true,
..DockerProviderConfig::default()
}
.into_value(),
);
if let Some(run_id) = run_id {
spec = spec.name(container_name(run_id));
}
for (key, value) in managed_labels::for_run(run_id) {
spec = spec.label(key, value);
}
for entry in &options.env_vars {
let (key, value) = entry.split_once('=').unwrap_or((entry.as_str(), ""));
spec = spec.env_var(key, value);
}
let mut resources = Resources::default();
resources.cpu_cores = options
.cpu_quota
.filter(|quota| *quota > 0)
.map(|quota| (quota + CPU_PERIOD_MICROS - 1) / CPU_PERIOD_MICROS)
.and_then(|cores| u32::try_from(cores).ok());
resources.memory_mb = options
.memory_limit
.filter(|bytes| *bytes > 0)
.and_then(|bytes| u64::try_from(bytes).ok())
.map(|bytes| bytes.div_ceil(1024 * 1024));
spec.resources(resources)
}
async fn connect_docker() -> crate::Result<std::sync::Arc<dyn sandbox_driver::SandboxProvider>> {
connect_provider(
&SandboxProviderKind::DOCKER,
&ServerSandboxProviderSettings::default(),
&ProviderConnectOptions::default(),
)
.await
.map(|connected| connected.provider)
.map_err(|error| crate::Error::context("Failed to connect to the Docker provider", error))
}
/// A Docker sandbox for a run. The container is created by `initialize`;
/// construction validates the clone request and connects the provider, so a
/// bad spec fails before any daemon call.
pub async fn docker_sandbox(
options: DockerSandboxOptions,
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>,
) -> crate::Result<DriverSandbox> {
let workspace = RepoWorkspace::plan(
LayoutSource::Fixed(layout()),
options.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
clone_tag.as_deref(),
clone_commit_sha.as_deref(),
options
.clone_depth
.and_then(|depth| u32::try_from(depth).ok()),
github_app,
)?;
let provider = connect_docker().await?;
let spec = driver_spec(&options, run_id.as_ref());
Ok(DriverSandbox::pending(
SandboxProviderKind::DOCKER,
provider,
spec,
Some(options.image),
workspace,
))
}
/// Reattach to a run's Docker container by its persisted id.
///
/// The container must carry fabro's managed label and, when a run id is
/// known, the matching run label: the driver shares a daemon with every
/// other application, and fabro never operates on a container it did not
/// create.
pub async fn attach_docker(
container_id: &str,
repo_cloned: bool,
working_directory: String,
clone_origin_url: Option<String>,
run_id: Option<RunId>,
) -> crate::Result<DriverSandbox> {
let provider = connect_docker().await?;
let id = SandboxId::try_new(container_id)
.map_err(|error| crate::Error::context("Invalid Docker container id", error))?;
let handle = provider.attach(&id, None).await.map_err(|error| {
crate::Error::context(
format!("Failed to reconnect Docker container '{container_id}'"),
error,
)
})?;
let status = handle.describe().await?;
managed_labels::verify_managed(
&SandboxProviderKind::DOCKER,
container_id,
&status.labels,
run_id.as_ref(),
)?;
let workspace = RepoWorkspace::attached(
LayoutSource::Fixed(layout()),
repo_cloned,
working_directory,
clone_origin_url,
);
Ok(DriverSandbox::attached(
SandboxProviderKind::DOCKER,
handle,
workspace,
))
(spec, image)
}
/// Whether the Docker daemon answers. Used by `fabro doctor`.
pub async fn check_docker_daemon() -> crate::Result<()> {
let provider = connect_docker().await?;
let provider = provider_sandbox::connect_bundled_docker(&ProviderAccess::default()).await?;
let health = provider
.health()
.await
@ -226,27 +76,31 @@ pub async fn check_docker_daemon() -> crate::Result<()> {
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use fabro_types::RunId;
use sandbox_driver::NetworkPolicy;
use super::*;
use crate::options::base_spec;
#[test]
fn driver_spec_maps_image_workspace_labels_env_and_limits() {
fn overlay_fixes_the_workspace_and_pulls_the_named_image() {
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
let options = DockerSandboxOptions {
env_vars: vec![
"FOO=bar".to_string(),
"BASH_ENV=/tmp/untrusted-startup".to_string(),
],
memory_limit: Some(4_000_000_000),
cpu_quota: Some(200_000),
network_mode: Some("none".to_string()),
auto_pull: false,
..DockerSandboxOptions::default()
let options = SandboxOptions {
image: Some("ghcr.io/acme/dev:1".to_string()),
env: BTreeMap::from([("FOO".to_string(), "bar".to_string())]),
memory_bytes: Some(4_000_000_000),
cpu: Some(2),
network: NetworkPolicy::Block,
..SandboxOptions::default()
};
let spec = driver_spec(&options, Some(&run_id));
let (spec, image) = overlay(base_spec(&options, Some(&run_id)), &options);
assert_eq!(image, "ghcr.io/acme/dev:1");
assert!(matches!(
&spec.source,
SandboxSource::Image { reference } if reference == "buildpack-deps:noble"
SandboxSource::Image { reference } if reference == "ghcr.io/acme/dev:1"
));
assert_eq!(
spec.name.as_deref(),
@ -262,20 +116,22 @@ mod tests {
Some("01HY0000000000000000000000")
);
assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar"));
// The driver blanks BASH_ENV on every container; a caller value is
// passed through here and overridden there.
assert_eq!(spec.resources.cpu_cores, Some(2));
assert_eq!(spec.resources.memory_mb, Some(3815));
assert!(matches!(spec.network, NetworkPolicy::Block));
assert_eq!(spec.provider_config["auto_pull"], false);
assert_eq!(spec.provider_config["auto_pull"], true);
}
#[test]
fn driver_spec_defaults_to_bridge_networking_without_a_name() {
let spec = driver_spec(&DockerSandboxOptions::default(), None);
fn overlay_supplies_the_default_image_when_the_environment_names_none() {
let options = SandboxOptions::default();
let (spec, image) = overlay(base_spec(&options, None), &options);
assert_eq!(image, DEFAULT_IMAGE);
assert!(matches!(
&spec.source,
SandboxSource::Image { reference } if reference == DEFAULT_IMAGE
));
assert!(spec.name.is_none());
assert!(matches!(spec.network, NetworkPolicy::AllowAll));
assert_eq!(spec.resources, Resources::default());
assert!(!spec.labels.contains_key("sh.fabro.run_id"));
}
}

View file

@ -1,259 +0,0 @@
//! Convert resolved [`RunEnvironmentSettings`] into runtime sandbox configs.
//!
//! These mappings are consumed by both the workflow run-start path and the
//! server preflight path, so they live here next to their destination types.
use std::path::{Path, PathBuf};
use fabro_types::settings::ResolveError;
use fabro_types::settings::run::{
DockerfileSource as ResolvedDockerfileSource, EnvironmentNetworkMode, RunCloneSettings,
RunEnvironmentSettings,
};
use crate::config::{
DaytonaNetwork, DaytonaSnapshotSettings, DaytonaSnapshotSource,
DockerfileSource as SandboxDockerfileSource,
};
use crate::daytona::DaytonaConfig;
use crate::docker::DockerSandboxOptions;
#[must_use]
pub fn daytona_config_from_environment(
settings: &RunEnvironmentSettings,
clone: &RunCloneSettings,
) -> DaytonaConfig {
// fabro-config rejects Daytona environments that set both image.docker
// and image.dockerfile. If both still arrive here, the image wins, which
// matches how the Docker provider treats the pair.
let source = match (&settings.image.docker, &settings.image.dockerfile) {
(Some(image), _) => Some(DaytonaSnapshotSource::Image(image.clone())),
(None, Some(ResolvedDockerfileSource::Inline(text))) => Some(
DaytonaSnapshotSource::Dockerfile(SandboxDockerfileSource::Inline(text.clone())),
),
(None, Some(ResolvedDockerfileSource::Path { path })) => Some(
DaytonaSnapshotSource::Dockerfile(SandboxDockerfileSource::Path { path: path.clone() }),
),
(None, None) => None,
};
let snapshot = source.map(|source| DaytonaSnapshotSettings {
cpu: settings.resources.cpu,
memory: settings
.resources
.memory
.map(|size| size_to_gb_i32(size.as_bytes())),
disk: settings
.resources
.disk
.map(|size| size_to_gb_i32(size.as_bytes())),
source,
});
DaytonaConfig {
auto_stop_interval: settings
.lifecycle
.auto_stop
.map(|duration| duration_to_minutes_i32(duration.as_std())),
labels: (!settings.labels.is_empty()).then(|| settings.labels.clone()),
snapshot,
network: Some(match settings.network.mode {
EnvironmentNetworkMode::Block => DaytonaNetwork::Block,
EnvironmentNetworkMode::AllowAll => DaytonaNetwork::AllowAll,
EnvironmentNetworkMode::CidrAllowList => {
DaytonaNetwork::AllowList(settings.network.allow.clone())
}
}),
clone_depth: clone.depth_limit(),
skip_clone: !clone.enabled,
}
}
#[must_use]
pub fn docker_config_from_environment(
settings: &RunEnvironmentSettings,
clone: &RunCloneSettings,
) -> DockerSandboxOptions {
// No vault is available on this path (server preflight / manifest), so a
// `{{ secrets.* }}` value keeps its source form. Nothing else is left to
// resolve: `{{ vars.* }}` is substituted at run creation.
#[expect(
clippy::disallowed_methods,
reason = "preflight has no vault, so an unresolved secret token is carried in source \
form; the real value is resolved by docker_config_from_environment_with_secrets"
)]
let env = settings
.env
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect();
docker_config_from_environment_env(settings, clone, env)
}
pub fn docker_config_from_environment_with_secrets(
settings: &RunEnvironmentSettings,
clone: &RunCloneSettings,
secrets_lookup: impl FnMut(&str) -> Option<String>,
) -> Result<DockerSandboxOptions, ResolveError> {
let env = settings.resolve_env(secrets_lookup)?;
Ok(docker_config_from_environment_env(settings, clone, env))
}
fn docker_config_from_environment_env(
settings: &RunEnvironmentSettings,
clone: &RunCloneSettings,
env: std::collections::HashMap<String, String>,
) -> DockerSandboxOptions {
let mut env_vars = env
.into_iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>();
env_vars.sort();
let default_options = DockerSandboxOptions::default();
DockerSandboxOptions {
image: settings
.image
.docker
.clone()
.unwrap_or(default_options.image),
network_mode: match settings.network.mode {
EnvironmentNetworkMode::Block => Some("none".to_string()),
EnvironmentNetworkMode::AllowAll | EnvironmentNetworkMode::CidrAllowList => {
default_options.network_mode
}
},
memory_limit: settings
.resources
.memory
.and_then(|size| i64::try_from(size.as_bytes()).ok()),
cpu_quota: settings
.resources
.cpu
.map(|cpu| i64::from(cpu).saturating_mul(100_000)),
env_vars,
clone_depth: clone
.depth_limit()
.and_then(|depth| usize::try_from(depth).ok()),
skip_clone: !clone.enabled,
..DockerSandboxOptions::default()
}
}
pub fn local_working_directory_from_environment(
settings: &RunEnvironmentSettings,
source_directory: Option<&Path>,
) -> crate::Result<PathBuf> {
if let Some(cwd) = settings.cwd.as_deref() {
return Ok(PathBuf::from(cwd));
}
let Some(source_directory) = source_directory else {
return Err(crate::Error::message(
"local environment requires a server-side working directory; configure `environment.cwd = \"/absolute/path\"` on the selected local environment",
));
};
if source_directory.is_dir() {
return Ok(source_directory.to_path_buf());
}
Err(crate::Error::message(format!(
"local environment source_directory does not exist or is not a directory on this server: {}. Configure `environment.cwd = \"/absolute/path\"` on the selected local environment for remote client/server deployments.",
source_directory.display()
)))
}
fn duration_to_minutes_i32(duration: std::time::Duration) -> i32 {
let minutes = duration.as_secs() / 60;
i32::try_from(minutes).unwrap_or(i32::MAX)
}
fn size_to_gb_i32(bytes: u64) -> i32 {
let gb = bytes / 1_000_000_000;
i32::try_from(gb).unwrap_or(i32::MAX)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use fabro_types::SandboxProviderKind;
use fabro_types::settings::run::{
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings,
EnvironmentResourcesSettings,
};
use super::*;
fn run_environment(provider: SandboxProviderKind) -> RunEnvironmentSettings {
RunEnvironmentSettings {
id: "host".to_string(),
provider,
cwd: None,
image: EnvironmentImageSettings::default(),
resources: EnvironmentResourcesSettings::default(),
network: EnvironmentNetworkSettings::default(),
lifecycle: EnvironmentLifecycleSettings::default(),
labels: HashMap::new(),
env: HashMap::new(),
}
}
#[test]
fn local_working_directory_prefers_environment_cwd() {
let mut settings = run_environment(SandboxProviderKind::LOCAL);
settings.cwd = Some("/srv/fabro/workspaces/team-a".to_string());
let missing_source = Path::new("/path/that/should/not/exist");
let resolved = local_working_directory_from_environment(&settings, Some(missing_source))
.expect("configured cwd should be accepted");
assert_eq!(resolved, PathBuf::from("/srv/fabro/workspaces/team-a"));
assert!(!missing_source.exists());
}
#[test]
fn local_working_directory_uses_existing_source_directory_without_cwd() {
let settings = run_environment(SandboxProviderKind::LOCAL);
let dir = tempfile::tempdir().unwrap();
let resolved = local_working_directory_from_environment(&settings, Some(dir.path()))
.expect("existing source directory should be accepted");
assert_eq!(resolved, dir.path());
}
#[test]
fn local_working_directory_rejects_missing_source_directory_without_cwd() {
let settings = run_environment(SandboxProviderKind::LOCAL);
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("client-only");
let err = local_working_directory_from_environment(&settings, Some(&missing))
.expect_err("missing source directory without cwd should fail");
let message = err.to_string();
assert!(
message.contains("environment.cwd") && message.contains("does not exist"),
"unexpected error: {message}"
);
assert!(!missing.exists());
}
#[test]
fn daytona_config_maps_docker_image_to_snapshot() {
let mut settings = run_environment(SandboxProviderKind::DAYTONA);
settings.image.docker = Some("ubuntu:24.04".to_string());
settings.resources.cpu = Some(2);
let config = daytona_config_from_environment(&settings, &RunCloneSettings::default());
let snapshot = config.snapshot.expect("image should configure a snapshot");
assert_eq!(
snapshot.source,
DaytonaSnapshotSource::Image("ubuntu:24.04".to_string())
);
assert_eq!(snapshot.cpu, Some(2));
}
}

View file

@ -1,6 +1,5 @@
pub mod config;
pub mod error;
pub mod from_environment;
pub mod options;
pub mod provider;
pub mod sandbox;
pub mod sandbox_spec;
@ -28,16 +27,15 @@ pub mod terminal;
mod clone;
pub mod docker;
pub mod plugin;
pub mod provider_sandbox;
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 docker::check_docker_daemon;
pub use driver::{DaytonaCredentials, ProviderAccess};
pub use driver_sandbox::{DriverSandbox, local_sandbox};
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
@ -49,11 +47,15 @@ pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};
pub use git_retry::{
CredentialContext, GitRetryReason, RetryPlan, classify_failure, retry_git_operation,
};
pub use plugin::{PluginSandboxOptions, attach_plugin, plugin_sandbox};
pub use options::{
SandboxOptions, local_working_directory_from_environment, options_from_environment,
unresolved_env,
};
pub use provider::driver::DriverInventoryProvider;
pub use provider::{
LocalSandboxProvider, SandboxLookupError, SandboxProvider, SandboxProviderRegistry,
};
pub use provider_sandbox::{attach_provider_sandbox, provider_sandbox};
pub use push_credentials::RefreshErrorKind;
pub use reconnect::{
reconnect, reconnect_driver_for_run, reconnect_for_run, reconnect_for_run_with_callback,
@ -66,5 +68,8 @@ pub use sandbox::{
StderrCollector, StdioProcess, StdioProcessHandle, StdioProcessTermination, WalkOptions,
format_lines_numbered, redacted_output_tail, setup_git_via_exec, shell_quote,
};
pub use sandbox_spec::SandboxSpec;
/// The network policy a [`SandboxOptions`] asks for, re-exported so consumers
/// building options need no direct driver dependency.
pub use sandbox_driver::NetworkPolicy;
pub use sandbox_spec::{ProviderSandboxSpec, SandboxSpec};
pub use terminal::{DriverTerminalSession, TerminalSession, TerminalSize, open_terminal_for_run};

View file

@ -39,12 +39,6 @@ pub(crate) fn verify_managed(
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
}
pub(crate) fn merge_for_run(
user_labels: Option<&HashMap<String, String>>,
run_id: Option<&RunId>,
@ -85,7 +79,7 @@ mod tests {
#[test]
fn managed_labels_include_run_id_when_present() {
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
let labels = for_run(Some(&run_id));
let labels = merge_for_run(None, Some(&run_id));
assert_eq!(labels.get(MANAGED_LABEL).map(String::as_str), Some("true"));
assert_eq!(

View file

@ -0,0 +1,406 @@
//! What an environment asks of a sandbox, mapped once for every provider.
//!
//! The environment names an image or Dockerfile, resources, a network
//! policy, labels, variables, a lifecycle, and a clone policy. Every
//! provider consumes the same [`SandboxOptions`]: the driver spec is built
//! from them in one place, and a bundled provider adds only what its
//! backend needs on top (the Docker working directory, the Daytona
//! snapshot and timers) in its own overlay.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use fabro_types::RunId;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings,
};
use sandbox_driver::{
Capabilities, NetworkPolicy, Resources, SandboxSource, SandboxSpec as DriverSpec,
};
use crate::managed_labels;
/// What an environment asks of a sandbox, provider-neutral.
#[derive(Clone, Debug, Default)]
pub struct SandboxOptions {
/// Image reference, when the environment names one.
pub image: Option<String>,
/// Inline Dockerfile, when the environment names one instead of an
/// image.
pub dockerfile: Option<String>,
/// Environment variables for the sandbox, resolved.
pub env: BTreeMap<String, String>,
pub network: NetworkPolicy,
pub cpu: Option<u32>,
pub memory_bytes: Option<u64>,
pub disk_bytes: Option<u64>,
/// Labels from the environment; fabro's managed labels are added.
pub labels: BTreeMap<String, String>,
/// Idle time before the provider stops the sandbox, when the
/// environment sets one.
pub auto_stop: Option<Duration>,
/// Maximum Git history depth fetched during clone; `None` fetches full
/// history.
pub clone_depth: Option<u32>,
/// Create an empty workspace instead of cloning even when an origin
/// exists.
pub skip_clone: bool,
}
impl SandboxOptions {
/// Memory in whole mebibytes, rounded up.
pub fn memory_mb(&self) -> Option<u64> {
self.memory_bytes.map(|bytes| bytes.div_ceil(1024 * 1024))
}
/// Disk in whole mebibytes, rounded up.
pub fn disk_mb(&self) -> Option<u64> {
self.disk_bytes.map(|bytes| bytes.div_ceil(1024 * 1024))
}
}
/// Maps resolved environment settings onto sandbox options. `env` is the
/// environment's variables, resolved by the caller: the worker resolves
/// secrets through the vault, while preflight carries them in source form.
///
/// A Dockerfile given as a path must have been resolved to inline content
/// earlier; none of the providers can read a path.
pub fn options_from_environment(
settings: &RunEnvironmentSettings,
clone: &RunCloneSettings,
env: BTreeMap<String, String>,
) -> crate::Result<SandboxOptions> {
// fabro-config rejects environments that set both image.docker and
// image.dockerfile. If both still arrive here, the image wins.
let dockerfile = match (&settings.image.docker, &settings.image.dockerfile) {
(Some(_), _) | (None, None) => None,
(None, Some(DockerfileSource::Inline(content))) => Some(content.clone()),
(None, Some(DockerfileSource::Path { path })) => {
return Err(crate::Error::message(format!(
"environment `{}` names a Dockerfile path ({path}) that should have been \
resolved to inline content before sandbox creation",
settings.id
)));
}
};
Ok(SandboxOptions {
image: settings.image.docker.clone(),
dockerfile,
env,
network: match settings.network.mode {
EnvironmentNetworkMode::Block => NetworkPolicy::Block,
EnvironmentNetworkMode::AllowAll => NetworkPolicy::AllowAll,
EnvironmentNetworkMode::CidrAllowList => NetworkPolicy::CidrAllowList {
cidrs: settings.network.allow.clone(),
},
},
cpu: settings
.resources
.cpu
.and_then(|cpu| u32::try_from(cpu).ok()),
memory_bytes: settings.resources.memory.map(|size| size.as_bytes()),
disk_bytes: settings.resources.disk.map(|size| size.as_bytes()),
labels: settings
.labels
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
auto_stop: settings
.lifecycle
.auto_stop
.map(|duration| duration.as_std()),
clone_depth: clone
.depth_limit()
.and_then(|depth| u32::try_from(depth).ok()),
skip_clone: !clone.enabled,
})
}
/// The environment's variables in source form, for a path with no vault
/// (server preflight): a `{{ secrets.* }}` value keeps its token, and
/// nothing else is left to resolve because `{{ vars.* }}` is substituted at
/// run creation.
pub fn unresolved_env(settings: &RunEnvironmentSettings) -> BTreeMap<String, String> {
#[expect(
clippy::disallowed_methods,
reason = "preflight has no vault, so an unresolved secret token is carried in source form"
)]
settings
.env
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect()
}
pub fn local_working_directory_from_environment(
settings: &RunEnvironmentSettings,
source_directory: Option<&Path>,
) -> crate::Result<PathBuf> {
if let Some(cwd) = settings.cwd.as_deref() {
return Ok(PathBuf::from(cwd));
}
let Some(source_directory) = source_directory else {
return Err(crate::Error::message(
"local environment requires a server-side working directory; configure `environment.cwd = \"/absolute/path\"` on the selected local environment",
));
};
if source_directory.is_dir() {
return Ok(source_directory.to_path_buf());
}
Err(crate::Error::message(format!(
"local environment source_directory does not exist or is not a directory on this server: {}. Configure `environment.cwd = \"/absolute/path\"` on the selected local environment for remote client/server deployments.",
source_directory.display()
)))
}
/// The driver spec every provider starts from: the environment's source
/// (an image, a Dockerfile, or a managed directory when it names
/// neither), the run's name and labels, the variables, resources, and
/// network policy. A bundled provider's overlay adjusts what its backend
/// needs.
pub(crate) fn base_spec(options: &SandboxOptions, run_id: Option<&RunId>) -> DriverSpec {
let source = match (&options.image, &options.dockerfile) {
(Some(reference), _) => SandboxSource::Image {
reference: reference.clone(),
},
(None, Some(content)) => SandboxSource::Dockerfile {
content: content.clone(),
},
// A provider without images (a host-style plugin) manages a
// workspace directory of its own.
(None, None) => SandboxSource::HostDirectory,
};
let mut spec = DriverSpec::new(source).network(options.network.clone());
if let Some(run_id) = run_id {
spec = spec.name(run_name(run_id));
}
let user_labels: std::collections::HashMap<String, String> = options
.labels
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
let mut labels: Vec<(String, String)> =
managed_labels::merge_for_run(Some(&user_labels), run_id)
.into_iter()
.collect();
labels.sort();
for (key, value) in labels {
spec = spec.label(key, value);
}
for (key, value) in &options.env {
spec = spec.env_var(key, value);
}
let mut resources = Resources::default();
resources.cpu_cores = options.cpu;
resources.memory_mb = options.memory_mb();
resources.disk_mb = options.disk_mb();
spec.resources(resources)
}
/// The provider-side name of a run's sandbox.
pub(crate) fn run_name(run_id: &RunId) -> String {
format!("fabro-run-{run_id}")
}
/// The environment's default `allow_all` means "unrestricted", which a
/// provider without network controls already is; asking such a provider
/// for it explicitly would be rejected. An explicit restriction is still
/// requested, and refused by the provider when it cannot honor it.
pub(crate) fn supported_network(
requested: NetworkPolicy,
capabilities: &Capabilities,
) -> NetworkPolicy {
match requested {
NetworkPolicy::AllowAll if !capabilities.network.allow_all => {
NetworkPolicy::ProviderDefault
}
other => other,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_types::SandboxProviderKind;
use fabro_types::settings::run::{
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings,
EnvironmentResourcesSettings,
};
use fabro_types::settings::{Duration as SettingsDuration, Size};
use super::*;
fn environment(kind: &str) -> RunEnvironmentSettings {
RunEnvironmentSettings {
id: kind.to_string(),
provider: SandboxProviderKind::try_new(kind).unwrap(),
cwd: None,
image: EnvironmentImageSettings::default(),
resources: EnvironmentResourcesSettings::default(),
network: EnvironmentNetworkSettings::default(),
lifecycle: EnvironmentLifecycleSettings::default(),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
env: HashMap::new(),
}
}
fn run_id() -> RunId {
"01HY0000000000000000000000".parse().unwrap()
}
#[test]
fn options_without_an_image_ask_for_a_managed_directory() {
let options = options_from_environment(
&environment("host"),
&RunCloneSettings::default(),
BTreeMap::from([("FOO".to_string(), "bar".to_string())]),
)
.unwrap();
assert!(options.image.is_none());
assert!(options.dockerfile.is_none());
assert_eq!(options.clone_depth, Some(100));
assert!(!options.skip_clone);
assert!(options.auto_stop.is_none());
let spec = base_spec(&options, Some(&run_id()));
assert!(matches!(spec.source, SandboxSource::HostDirectory));
assert!(spec.working_directory.is_none());
assert_eq!(
spec.name.as_deref(),
Some("fabro-run-01HY0000000000000000000000")
);
assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(
spec.labels.get("team").map(String::as_str),
Some("platform")
);
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!(matches!(spec.network, NetworkPolicy::AllowAll));
}
#[test]
fn options_with_an_image_map_resources_network_and_lifecycle() {
let mut settings = environment("e2b");
settings.image.docker = Some("ubuntu:24.04".to_string());
settings.resources.cpu = Some(2);
settings.resources.memory = Some(Size::from_bytes(4_000_000_000));
settings.network.mode = EnvironmentNetworkMode::Block;
settings.lifecycle.auto_stop = Some(SettingsDuration::from_std(Duration::from_mins(45)));
let clone = RunCloneSettings {
enabled: false,
depth: 0,
};
let options = options_from_environment(&settings, &clone, BTreeMap::new()).unwrap();
assert_eq!(options.image.as_deref(), Some("ubuntu:24.04"));
assert!(options.skip_clone);
assert_eq!(options.clone_depth, None);
assert_eq!(options.memory_bytes, Some(4_000_000_000));
assert_eq!(options.memory_mb(), Some(3815));
assert_eq!(options.auto_stop, Some(Duration::from_mins(45)));
let spec = base_spec(&options, None);
assert!(matches!(
&spec.source,
SandboxSource::Image { reference } if reference == "ubuntu:24.04"
));
assert_eq!(spec.resources.cpu_cores, Some(2));
assert_eq!(spec.resources.memory_mb, Some(3815));
assert!(matches!(spec.network, NetworkPolicy::Block));
assert!(spec.name.is_none());
assert!(!spec.labels.contains_key("sh.fabro.run_id"));
}
#[test]
fn an_inline_dockerfile_becomes_the_source_and_a_path_is_rejected() {
let mut settings = environment("daytona");
settings.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu".to_string()));
let options =
options_from_environment(&settings, &RunCloneSettings::default(), BTreeMap::new())
.unwrap();
assert_eq!(options.dockerfile.as_deref(), Some("FROM ubuntu"));
assert!(matches!(
base_spec(&options, None).source,
SandboxSource::Dockerfile { content } if content == "FROM ubuntu"
));
settings.image.dockerfile = Some(DockerfileSource::Path {
path: "Dockerfile".to_string(),
});
let error =
options_from_environment(&settings, &RunCloneSettings::default(), BTreeMap::new())
.unwrap_err();
assert!(error.to_string().contains("Dockerfile path"), "{error}");
}
#[test]
fn allow_all_falls_back_to_the_provider_default_without_network_control() {
let none = Capabilities::minimal(sandbox_driver::Isolation::None);
assert!(matches!(
supported_network(NetworkPolicy::AllowAll, &none),
NetworkPolicy::ProviderDefault
));
assert!(matches!(
supported_network(NetworkPolicy::Block, &none),
NetworkPolicy::Block
));
let mut full = Capabilities::minimal(sandbox_driver::Isolation::Container);
full.network.allow_all = true;
assert!(matches!(
supported_network(NetworkPolicy::AllowAll, &full),
NetworkPolicy::AllowAll
));
}
#[test]
fn local_working_directory_prefers_environment_cwd() {
let mut settings = environment("local");
settings.cwd = Some("/srv/fabro/workspaces/team-a".to_string());
let missing_source = Path::new("/path/that/should/not/exist");
let resolved = local_working_directory_from_environment(&settings, Some(missing_source))
.expect("configured cwd should be accepted");
assert_eq!(resolved, PathBuf::from("/srv/fabro/workspaces/team-a"));
assert!(!missing_source.exists());
}
#[test]
fn local_working_directory_uses_existing_source_directory_without_cwd() {
let settings = environment("local");
let dir = tempfile::tempdir().unwrap();
let resolved = local_working_directory_from_environment(&settings, Some(dir.path()))
.expect("existing source directory should be accepted");
assert_eq!(resolved, dir.path());
}
#[test]
fn local_working_directory_rejects_missing_source_directory_without_cwd() {
let settings = environment("local");
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("client-only");
let err = local_working_directory_from_environment(&settings, Some(&missing))
.expect_err("missing source directory without cwd should fail");
let message = err.to_string();
assert!(
message.contains("environment.cwd") && message.contains("does not exist"),
"unexpected error: {message}"
);
assert!(!missing.exists());
}
}

View file

@ -1,358 +0,0 @@
//! Sandboxes on a provider fabro does not bundle: any kind served by a
//! sandbox-driver plugin executable, and a bundled kind an operator chose to
//! run out of process.
//!
//! Fabro knows nothing about the provider beyond its declared capabilities,
//! so the environment maps onto the normalized [`SandboxSpec`] only: an
//! image or Dockerfile source when the environment names one (a host-style
//! provider gets a managed directory), resources, network policy, labels,
//! and environment variables. The provider chooses the working directory;
//! fabro lays its repository checkout out inside it.
use std::collections::BTreeMap;
use std::sync::Arc;
use fabro_github::GitHubCredentials;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings,
};
use fabro_types::settings::server::ServerSandboxProviderSettings;
use fabro_types::{RunId, SandboxProviderKind};
use sandbox_driver::{
Capabilities, NetworkPolicy, Resources, SandboxId, SandboxProvider, SandboxSource,
SandboxSpec as DriverSpec,
};
use crate::driver::{ProviderConnectOptions, connect_provider};
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace};
use crate::managed_labels;
/// What an environment asks of a plugin provider.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PluginSandboxOptions {
/// Image reference, when the environment names one.
pub image: Option<String>,
/// Inline Dockerfile, when the environment names one instead of an
/// image.
pub dockerfile: Option<String>,
/// Environment variables for the sandbox, resolved.
pub env: BTreeMap<String, String>,
pub network: PluginNetwork,
pub cpu_cores: Option<u32>,
pub memory_mb: Option<u64>,
pub disk_mb: Option<u64>,
/// Labels from the environment; fabro's managed labels are added.
pub labels: BTreeMap<String, String>,
/// Maximum Git history depth fetched during clone; `None` fetches full
/// history.
pub clone_depth: Option<u32>,
/// Create an empty workspace instead of cloning even when an origin
/// exists.
pub skip_clone: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum PluginNetwork {
#[default]
ProviderDefault,
AllowAll,
Block,
CidrAllowList(Vec<String>),
}
/// Map a resolved environment onto plugin options. `env` is the resolved
/// environment map (secrets substituted by the caller).
#[must_use]
pub fn plugin_options_from_environment(
settings: &RunEnvironmentSettings,
clone: &RunCloneSettings,
env: BTreeMap<String, String>,
) -> PluginSandboxOptions {
PluginSandboxOptions {
image: settings.image.docker.clone(),
dockerfile: match &settings.image.dockerfile {
Some(DockerfileSource::Inline(content)) if settings.image.docker.is_none() => {
Some(content.clone())
}
_ => None,
},
env,
network: match settings.network.mode {
EnvironmentNetworkMode::Block => PluginNetwork::Block,
EnvironmentNetworkMode::AllowAll => PluginNetwork::AllowAll,
EnvironmentNetworkMode::CidrAllowList => {
PluginNetwork::CidrAllowList(settings.network.allow.clone())
}
},
cpu_cores: settings
.resources
.cpu
.and_then(|cpu| u32::try_from(cpu).ok()),
memory_mb: settings
.resources
.memory
.map(|size| size.as_bytes().div_ceil(1024 * 1024)),
disk_mb: settings
.resources
.disk
.map(|size| size.as_bytes().div_ceil(1024 * 1024)),
labels: settings
.labels
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
clone_depth: clone
.depth_limit()
.and_then(|depth| u32::try_from(depth).ok()),
skip_clone: !clone.enabled,
}
}
/// The driver spec for a fabro sandbox on a plugin provider.
pub(crate) fn driver_spec(options: &PluginSandboxOptions, run_id: Option<&RunId>) -> DriverSpec {
let source = match (&options.image, &options.dockerfile) {
(Some(reference), _) => SandboxSource::Image {
reference: reference.clone(),
},
(None, Some(content)) => SandboxSource::Dockerfile {
content: content.clone(),
},
// A provider without images (a host-style plugin) manages a
// workspace directory of its own.
(None, None) => SandboxSource::HostDirectory,
};
let mut spec = DriverSpec::new(source).network(match &options.network {
PluginNetwork::ProviderDefault => NetworkPolicy::ProviderDefault,
PluginNetwork::AllowAll => NetworkPolicy::AllowAll,
PluginNetwork::Block => NetworkPolicy::Block,
PluginNetwork::CidrAllowList(cidrs) => NetworkPolicy::CidrAllowList {
cidrs: cidrs.clone(),
},
});
if let Some(run_id) = run_id {
spec = spec.name(format!("fabro-run-{run_id}"));
}
let user_labels: std::collections::HashMap<String, String> = options
.labels
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
let mut labels: Vec<(String, String)> =
managed_labels::merge_for_run(Some(&user_labels), run_id)
.into_iter()
.collect();
labels.sort();
for (key, value) in labels {
spec = spec.label(key, value);
}
for (key, value) in &options.env {
spec = spec.env_var(key, value);
}
let mut resources = Resources::default();
resources.cpu_cores = options.cpu_cores;
resources.memory_mb = options.memory_mb;
resources.disk_mb = options.disk_mb;
spec.resources(resources)
}
/// The environment's default `allow_all` means "unrestricted", which a
/// provider without network controls already is; asking such a provider
/// for it explicitly would be rejected. An explicit restriction is still
/// requested, and refused by the provider when it cannot honor it.
fn supported_network(requested: NetworkPolicy, capabilities: &Capabilities) -> NetworkPolicy {
match requested {
NetworkPolicy::AllowAll if !capabilities.network.allow_all => {
NetworkPolicy::ProviderDefault
}
other => other,
}
}
async fn connect(
kind: &SandboxProviderKind,
settings: &ServerSandboxProviderSettings,
) -> crate::Result<Arc<dyn SandboxProvider>> {
connect_provider(kind, settings, &ProviderConnectOptions::default())
.await
.map(|connected| connected.provider)
.map_err(|error| {
crate::Error::context(format!("Failed to connect to the {kind} provider"), error)
})
}
/// A sandbox for a run on the plugin provider `kind`. The sandbox is
/// created by `initialize`; construction validates the clone request and
/// launches the plugin, so a bad spec or a missing executable fails first.
#[expect(
clippy::too_many_arguments,
reason = "mirrors SandboxSpec::Plugin; clone inputs are validated together"
)]
pub async fn plugin_sandbox(
kind: SandboxProviderKind,
settings: &ServerSandboxProviderSettings,
options: PluginSandboxOptions,
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>,
) -> crate::Result<DriverSandbox> {
let workspace = RepoWorkspace::plan(
LayoutSource::ProviderWorkingDirectory,
options.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
clone_tag.as_deref(),
clone_commit_sha.as_deref(),
options.clone_depth,
github_app,
)?;
let provider = connect(&kind, settings).await?;
let mut spec = driver_spec(&options, run_id.as_ref());
spec.network = supported_network(spec.network, provider.capabilities());
Ok(DriverSandbox::pending(
kind,
provider,
spec,
options.image.clone(),
workspace,
))
}
/// Reattach to a run's sandbox on the plugin provider `kind` by its
/// persisted id. The sandbox must carry fabro's labels.
pub async fn attach_plugin(
kind: SandboxProviderKind,
settings: &ServerSandboxProviderSettings,
sandbox_id: &str,
repo_cloned: bool,
working_directory: String,
clone_origin_url: Option<String>,
run_id: Option<RunId>,
) -> crate::Result<DriverSandbox> {
let provider = connect(&kind, settings).await?;
let id = SandboxId::try_new(sandbox_id)
.map_err(|error| crate::Error::context(format!("Invalid {kind} sandbox id"), error))?;
let handle = provider.attach(&id, None).await.map_err(|error| {
crate::Error::context(
format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"),
error,
)
})?;
let status = handle.describe().await?;
managed_labels::verify_managed(&kind, sandbox_id, &status.labels, run_id.as_ref())?;
let workspace = RepoWorkspace::attached(
LayoutSource::ProviderWorkingDirectory,
repo_cloned,
working_directory,
clone_origin_url,
);
Ok(DriverSandbox::attached(kind, handle, workspace))
}
#[cfg(test)]
mod tests {
use fabro_types::settings::run::{
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentNetworkSettings,
EnvironmentResourcesSettings,
};
use super::*;
fn environment(kind: &str) -> RunEnvironmentSettings {
RunEnvironmentSettings {
id: kind.to_string(),
provider: SandboxProviderKind::try_new(kind).unwrap(),
cwd: None,
image: EnvironmentImageSettings::default(),
resources: EnvironmentResourcesSettings::default(),
network: EnvironmentNetworkSettings::default(),
lifecycle: EnvironmentLifecycleSettings::default(),
labels: std::collections::HashMap::from([(
"team".to_string(),
"platform".to_string(),
)]),
env: std::collections::HashMap::new(),
}
}
#[test]
fn options_without_an_image_ask_for_a_managed_directory() {
let options = plugin_options_from_environment(
&environment("host"),
&RunCloneSettings::default(),
BTreeMap::from([("FOO".to_string(), "bar".to_string())]),
);
assert!(options.image.is_none());
assert_eq!(options.clone_depth, Some(100));
assert!(!options.skip_clone);
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
let spec = driver_spec(&options, Some(&run_id));
assert!(matches!(spec.source, SandboxSource::HostDirectory));
assert!(spec.working_directory.is_none());
assert_eq!(
spec.name.as_deref(),
Some("fabro-run-01HY0000000000000000000000")
);
assert_eq!(spec.env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(
spec.labels.get("team").map(String::as_str),
Some("platform")
);
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!(matches!(spec.network, NetworkPolicy::AllowAll));
}
#[test]
fn allow_all_falls_back_to_the_provider_default_without_network_control() {
let none = Capabilities::minimal(sandbox_driver::Isolation::None);
assert!(matches!(
supported_network(NetworkPolicy::AllowAll, &none),
NetworkPolicy::ProviderDefault
));
assert!(matches!(
supported_network(NetworkPolicy::Block, &none),
NetworkPolicy::Block
));
let mut full = Capabilities::minimal(sandbox_driver::Isolation::Container);
full.network.allow_all = true;
assert!(matches!(
supported_network(NetworkPolicy::AllowAll, &full),
NetworkPolicy::AllowAll
));
}
#[test]
fn options_with_an_image_map_resources_and_network() {
let mut settings = environment("e2b");
settings.image.docker = Some("ubuntu:24.04".to_string());
settings.resources.cpu = Some(2);
settings.network.mode = EnvironmentNetworkMode::Block;
let clone = RunCloneSettings {
enabled: false,
depth: 0,
};
let options = plugin_options_from_environment(&settings, &clone, BTreeMap::new());
assert_eq!(options.image.as_deref(), Some("ubuntu:24.04"));
assert!(options.skip_clone);
assert_eq!(options.clone_depth, None);
let spec = driver_spec(&options, None);
assert!(matches!(
&spec.source,
SandboxSource::Image { reference } if reference == "ubuntu:24.04"
));
assert_eq!(spec.resources.cpu_cores, Some(2));
assert!(matches!(spec.network, NetworkPolicy::Block));
assert!(spec.name.is_none());
}
}

View file

@ -0,0 +1,177 @@
//! Run sandboxes on any provider fabro can name: a bundled kind in process
//! or a sandbox-driver plugin executable.
//!
//! One path builds them all. The environment's [`SandboxOptions`] become
//! the driver spec once, the provider is connected through the single
//! construction function, and a bundled provider adds only what its
//! backend needs on top: Docker its fixed working directory and default
//! image, Daytona the snapshot it creates sandboxes from and its lifecycle
//! timers. A plugin gets the spec as is, laid out inside the working
//! directory the provider chooses.
use std::sync::Arc;
use fabro_github::GitHubCredentials;
use fabro_types::{BundledProvider, RunId, SandboxProviderKind};
use sandbox_driver::{SandboxId, SandboxProvider};
use crate::driver::{ProviderAccess, connect_provider};
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace};
use crate::options::{self, SandboxOptions};
use crate::{daytona, docker, managed_labels};
/// A sandbox for a run on `kind`. The sandbox is created by `initialize`;
/// construction validates the clone request and connects the provider, so
/// a bad spec, a missing credential, or a missing plugin executable fails
/// before any backend call.
#[expect(
clippy::too_many_arguments,
reason = "mirrors SandboxSpec::Provider; clone inputs are validated together"
)]
pub async fn provider_sandbox(
kind: SandboxProviderKind,
access: &ProviderAccess,
options: SandboxOptions,
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>,
) -> crate::Result<DriverSandbox> {
let workspace = RepoWorkspace::plan(
layout_source(&kind),
options.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
clone_tag.as_deref(),
clone_commit_sha.as_deref(),
options.clone_depth,
github_app,
)?;
let provider = connect(&kind, access).await?;
let base = options::base_spec(&options, run_id.as_ref());
Ok(match kind.bundled() {
Some(BundledProvider::Docker) => {
let (spec, image) = docker::overlay(base, &options);
DriverSandbox::pending(kind, provider, spec, Some(image), workspace)
}
Some(BundledProvider::Daytona) => {
let credentials = access
.daytona
.as_ref()
.ok_or_else(|| crate::Error::message(MISSING_DAYTONA_CREDENTIALS))?;
let plan = daytona::create_plan(
Arc::clone(&provider),
credentials.api_key.clone(),
base,
options,
run_id,
);
DriverSandbox::pending_with_plan(kind, provider, Box::new(plan), workspace)
}
Some(BundledProvider::Local) => {
return Err(crate::Error::message(
"local sandboxes are built from a working directory, not a provider spec",
));
}
None => {
let mut spec = base;
spec.network = options::supported_network(spec.network, provider.capabilities());
DriverSandbox::pending(kind, provider, spec, options.image.clone(), workspace)
}
})
}
/// Reattach to a run's sandbox on `kind` by its persisted id.
///
/// The sandbox must carry fabro's managed label and, when a run id is
/// known, the matching run label: the provider shares its backend with
/// every other application, and fabro never operates on a sandbox it did
/// not create.
pub async fn attach_provider_sandbox(
kind: SandboxProviderKind,
access: &ProviderAccess,
sandbox_id: &str,
repo_cloned: bool,
working_directory: String,
clone_origin_url: Option<String>,
run_id: Option<RunId>,
) -> crate::Result<DriverSandbox> {
let provider = connect(&kind, access).await?;
let id = SandboxId::try_new(sandbox_id)
.map_err(|error| crate::Error::context(format!("Invalid {kind} sandbox id"), error))?;
let handle = provider.attach(&id, None).await.map_err(|error| {
crate::Error::context(
format!("Failed to reconnect {kind} sandbox '{sandbox_id}'"),
error,
)
})?;
let status = handle.describe().await?;
managed_labels::verify_managed(&kind, sandbox_id, &status.labels, run_id.as_ref())?;
let workspace = RepoWorkspace::attached(
layout_source(&kind),
repo_cloned,
working_directory,
clone_origin_url,
);
let sandbox = DriverSandbox::attached(kind.clone(), handle, workspace);
if kind.bundled() == Some(BundledProvider::Daytona) {
if let Some(snapshot) = status.source {
sandbox.set_snapshot(snapshot);
}
}
Ok(sandbox)
}
/// The image the run record names for a sandbox on `kind`: the
/// environment's, or Docker's default when the environment names none.
pub(crate) fn recorded_image(
kind: &SandboxProviderKind,
options: &SandboxOptions,
) -> Option<String> {
match kind.bundled() {
Some(BundledProvider::Docker) => Some(docker::effective_image(options)),
_ => options.image.clone(),
}
}
/// Where a run's repository checks out on `kind`: fabro fixes the roots
/// inside the containers and VMs it shapes itself, and follows the working
/// directory a plugin provider chooses.
pub(crate) fn layout_source(kind: &SandboxProviderKind) -> LayoutSource {
match kind.bundled() {
Some(BundledProvider::Docker) => LayoutSource::Fixed(docker::layout()),
Some(BundledProvider::Daytona) => LayoutSource::Fixed(daytona::layout()),
Some(BundledProvider::Local) | None => LayoutSource::ProviderWorkingDirectory,
}
}
/// The in-process Docker provider with default settings, for `fabro doctor`.
pub(crate) async fn connect_bundled_docker(
access: &ProviderAccess,
) -> crate::Result<Arc<dyn SandboxProvider>> {
connect(&SandboxProviderKind::DOCKER, access).await
}
const MISSING_DAYTONA_CREDENTIALS: &str = "Daytona sandboxes require DAYTONA_API_KEY in the vault; run `fabro secret set DAYTONA_API_KEY`";
async fn connect(
kind: &SandboxProviderKind,
access: &ProviderAccess,
) -> crate::Result<Arc<dyn SandboxProvider>> {
if kind.bundled() == Some(BundledProvider::Daytona) && access.daytona.is_none() {
return Err(crate::Error::message(MISSING_DAYTONA_CREDENTIALS));
}
let settings = access.settings_for(kind).ok_or_else(|| {
crate::Error::message(format!(
"sandbox provider `{kind}` is not configured; add [server.sandbox.providers.{kind}] to settings.toml"
))
})?;
connect_provider(kind, &settings, &access.connect_options())
.await
.map(|connected| connected.provider)
.map_err(|error| {
crate::Error::context(format!("Failed to connect to the {kind} provider"), error)
})
}

View file

@ -5,7 +5,7 @@ use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
use crate::driver::ProviderAccess;
use crate::driver_sandbox::{DriverSandbox, local_sandbox};
use crate::{SandboxEventCallback, daytona, docker, plugin};
use crate::{SandboxEventCallback, provider_sandbox};
/// Reconnect to a sandbox from a saved record.
///
@ -46,84 +46,34 @@ pub async fn reconnect_driver_for_run(
event_callback: Option<SandboxEventCallback>,
) -> Result<DriverSandbox> {
let runtime = &record.runtime;
match record.provider.bundled() {
// A local sandbox is its working directory: rebuilding the handle
// over that directory is the reconnect. The per-process Host
// registry holds no state worth attaching to.
Some(BundledProvider::Local) => {
let mut sandbox = local_sandbox(PathBuf::from(&runtime.working_directory))
.await
.context("Failed to reconnect local sandbox")?;
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(sandbox)
}
Some(BundledProvider::Docker) => {
let repo_cloned = runtime
.repo_cloned
.context("Docker run sandbox missing repo_cloned metadata")?;
let mut sandbox = docker::attach_docker(
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
)
// A local sandbox is its working directory: rebuilding the handle over
// that directory is the reconnect. The per-process Host registry holds
// no state worth attaching to.
let mut sandbox = if record.provider.bundled() == Some(BundledProvider::Local) {
local_sandbox(PathBuf::from(&runtime.working_directory))
.await
.context("Failed to reconnect Docker sandbox")?;
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(sandbox)
}
Some(BundledProvider::Daytona) => {
let repo_cloned = runtime
.repo_cloned
.context("Daytona run sandbox missing repo_cloned metadata")?;
let credentials = access.daytona.clone().context(
"Daytona run sandbox cannot be reconnected without DAYTONA_API_KEY in the vault",
)?;
let mut sandbox = daytona::attach_daytona(
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
&credentials,
.context("Failed to reconnect local sandbox")?
} else {
let repo_cloned = runtime.repo_cloned.with_context(|| {
format!(
"{} run sandbox missing repo_cloned metadata",
record.provider
)
.await
.context("Failed to reconnect Daytona sandbox")?;
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(sandbox)
}
None => {
let settings = access.settings_for(&record.provider).with_context(|| {
format!(
"sandbox provider `{}` is not configured; add [server.sandbox.providers.{}] to settings.toml",
record.provider, record.provider
)
})?;
let repo_cloned = runtime
.repo_cloned
.context("run sandbox missing repo_cloned metadata")?;
let mut sandbox = plugin::attach_plugin(
record.provider.clone(),
&settings,
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
)
.await
.with_context(|| format!("Failed to reconnect {} sandbox", record.provider))?;
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(sandbox)
}
})?;
provider_sandbox::attach_provider_sandbox(
record.provider.clone(),
access,
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
)
.await
.with_context(|| format!("Failed to reconnect {} sandbox", record.provider))?
};
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(sandbox)
}

View file

@ -3,64 +3,45 @@ use std::sync::Arc;
use anyhow::Context as _;
use fabro_github::GitHubCredentials;
use fabro_types::settings::server::ServerSandboxProviderSettings;
use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
use crate::daytona::{self, DaytonaConfig};
use crate::docker::{self, DockerSandboxOptions};
use crate::driver::DaytonaCredentials;
use crate::driver_sandbox::local_sandbox;
use crate::plugin::{self, PluginSandboxOptions};
use crate::{Sandbox, SandboxEventCallback, clone_source};
use crate::driver::ProviderAccess;
use crate::driver_sandbox::{LayoutSource, local_sandbox};
use crate::options::SandboxOptions;
use crate::{Sandbox, SandboxEventCallback, clone_source, provider_sandbox};
/// Options for sandbox initialization and construction.
#[derive(Clone, Debug)]
pub enum SandboxSpec {
Local {
working_directory: PathBuf,
},
Docker {
config: DockerSandboxOptions,
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>,
},
Daytona {
config: Box<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>,
/// 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>,
},
/// A provider served by a sandbox-driver plugin executable.
Plugin {
kind: SandboxProviderKind,
settings: Box<ServerSandboxProviderSettings>,
options: Box<PluginSandboxOptions>,
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>,
},
/// A sandbox on any provider fabro can name: a bundled kind in process
/// or a sandbox-driver plugin.
Provider(Box<ProviderSandboxSpec>),
}
/// A run's sandbox on a provider: what the environment asked for and how
/// the repository is cloned into it.
#[derive(Clone, Debug)]
pub struct ProviderSandboxSpec {
pub kind: SandboxProviderKind,
/// The provider settings and vault credentials the kind needs.
pub access: ProviderAccess,
pub options: SandboxOptions,
pub github_app: Option<GitHubCredentials>,
pub run_id: Option<RunId>,
pub clone_origin_url: Option<String>,
pub clone_branch: Option<String>,
pub clone_tag: Option<String>,
pub clone_commit_sha: Option<String>,
}
impl SandboxSpec {
pub fn provider(&self) -> SandboxProviderKind {
match self {
Self::Local { .. } => SandboxProviderKind::LOCAL,
Self::Docker { .. } => SandboxProviderKind::DOCKER,
Self::Daytona { .. } => SandboxProviderKind::DAYTONA,
Self::Plugin { kind, .. } => kind.clone(),
Self::Provider(spec) => spec.kind.clone(),
}
}
@ -85,98 +66,44 @@ impl SandboxSpec {
};
match self {
Self::Docker {
config,
clone_origin_url,
clone_branch,
..
} => {
let repo_cloned = clone_source::repo_cloned_for_record(
config.skip_clone,
clone_origin_url.as_deref(),
);
let layout = runtime_layout_metadata(
repo_cloned,
clone_origin_url.as_deref(),
docker::WORKING_DIRECTORY,
docker::REPOS_ROOT,
);
RunSandboxInstance {
provider: self.provider(),
image: (!config.image.is_empty()).then(|| config.image.clone()),
snapshot: None,
runtime: RunSandboxRuntime {
id,
working_directory: working_directory.clone(),
repo_cloned,
clone_origin_url: clone_source::clean_clone_origin_for_record(
clone_origin_url.as_deref(),
),
clone_branch: clone_branch.clone(),
workspace_root: Some(docker::WORKING_DIRECTORY.to_string()),
repos_root: Some(docker::REPOS_ROOT.to_string()),
primary_repo_path: layout
.as_ref()
.map(|layout| layout.primary_repo_path.clone()),
primary_repo_link: layout
.as_ref()
.map(|layout| layout.primary_repo_link.clone()),
},
}
}
Self::Daytona {
config,
clone_origin_url,
clone_branch,
..
} => {
let repo_cloned = clone_source::repo_cloned_for_record(
config.skip_clone,
clone_origin_url.as_deref(),
);
let layout = runtime_layout_metadata(
repo_cloned,
clone_origin_url.as_deref(),
daytona::WORKING_DIRECTORY,
daytona::REPOS_ROOT,
);
RunSandboxInstance {
provider: self.provider(),
image: None,
snapshot: sandbox.snapshot_info(),
runtime: RunSandboxRuntime {
id,
working_directory: working_directory.clone(),
repo_cloned,
clone_origin_url: clone_source::clean_clone_origin_for_record(
clone_origin_url.as_deref(),
),
clone_branch: clone_branch.clone(),
workspace_root: Some(daytona::WORKING_DIRECTORY.to_string()),
repos_root: Some(daytona::REPOS_ROOT.to_string()),
primary_repo_path: layout
.as_ref()
.map(|layout| layout.primary_repo_path.clone()),
primary_repo_link: layout
.as_ref()
.map(|layout| layout.primary_repo_link.clone()),
},
}
}
Self::Plugin {
options,
clone_origin_url,
clone_branch,
..
} => {
Self::Provider(spec) => {
let ProviderSandboxSpec {
kind,
options,
clone_origin_url,
clone_branch,
..
} = spec.as_ref();
let repo_cloned = clone_source::repo_cloned_for_record(
options.skip_clone,
clone_origin_url.as_deref(),
);
let layout = sandbox.workspace_layout();
// A fixed layout is known before the sandbox exists; a
// provider-chosen one only from the sandbox.
let layout = match provider_sandbox::layout_source(kind) {
LayoutSource::Fixed(fixed) => {
let repo = runtime_layout_metadata(
repo_cloned,
clone_origin_url.as_deref(),
&fixed.workspace_root,
&fixed.repos_root,
);
Some(crate::SandboxWorkspaceLayout {
workspace_root: fixed.workspace_root,
repos_root: fixed.repos_root,
primary_repo_path: repo
.as_ref()
.map(|layout| layout.primary_repo_path.clone()),
primary_repo_link: repo
.as_ref()
.map(|layout| layout.primary_repo_link.clone()),
})
}
LayoutSource::ProviderWorkingDirectory => sandbox.workspace_layout(),
};
RunSandboxInstance {
provider: self.provider(),
image: options.image.clone(),
provider: kind.clone(),
image: provider_sandbox::recorded_image(kind, options),
snapshot: sandbox.snapshot_info(),
runtime: RunSandboxRuntime {
id,
@ -230,76 +157,22 @@ impl SandboxSpec {
}
Ok(Arc::new(sandbox))
}
Self::Docker {
config,
github_app,
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
} => {
let mut sandbox = docker::docker_sandbox(
config.clone(),
github_app.as_ref(),
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_tag.clone(),
clone_commit_sha.clone(),
)
.await
.context("Failed to create Docker sandbox")?;
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(Arc::new(sandbox))
}
Self::Daytona {
config,
github_app,
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
credentials,
} => {
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.as_ref(),
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_tag.clone(),
clone_commit_sha.clone(),
credentials,
)
.await
.context("Failed to create Daytona sandbox")?;
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(Arc::new(sandbox))
}
Self::Plugin {
kind,
settings,
options,
github_app,
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
} => {
let mut sandbox = plugin::plugin_sandbox(
Self::Provider(spec) => {
let ProviderSandboxSpec {
kind,
access,
options,
github_app,
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
} = spec.as_ref();
let mut sandbox = provider_sandbox::provider_sandbox(
kind.clone(),
settings,
options.as_ref().clone(),
access,
options.clone(),
github_app.as_ref(),
*run_id,
clone_origin_url.clone(),
@ -339,15 +212,17 @@ mod tests {
#[test]
fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() {
let spec = SandboxSpec::Docker {
config: DockerSandboxOptions::default(),
let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec {
kind: SandboxProviderKind::DOCKER,
access: ProviderAccess::default(),
options: SandboxOptions::default(),
github_app: None,
run_id: None,
clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()),
clone_branch: Some("main".to_string()),
clone_tag: None,
clone_commit_sha: None,
};
}));
let mut sandbox = MockSandbox::linux();
sandbox.working_dir = "/workspace/rack-test";
@ -377,15 +252,17 @@ mod tests {
#[tokio::test]
async fn invalid_exact_checkout_spec_fails_before_provider_connection() {
let spec = SandboxSpec::Docker {
config: DockerSandboxOptions::default(),
let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec {
kind: SandboxProviderKind::DOCKER,
access: ProviderAccess::default(),
options: SandboxOptions::default(),
github_app: None,
run_id: None,
clone_origin_url: Some("https://github.com/acme/widgets".to_string()),
clone_branch: Some("main".to_string()),
clone_tag: None,
clone_commit_sha: Some("not-a-sha".to_string()),
};
}));
let error = spec
.build(None)
@ -395,7 +272,7 @@ mod tests {
assert!(
error
.to_string()
.contains("Failed to create Docker sandbox")
.contains("Failed to create docker sandbox")
);
assert!(format!("{error:#}").contains("40 ASCII hexadecimal"));
assert!(!format!("{error:#}").contains("Docker daemon"));
@ -403,10 +280,12 @@ mod tests {
#[test]
fn docker_run_sandbox_omits_primary_repo_metadata_for_empty_workspace() {
let spec = SandboxSpec::Docker {
config: DockerSandboxOptions {
let spec = SandboxSpec::Provider(Box::new(ProviderSandboxSpec {
kind: SandboxProviderKind::DOCKER,
access: ProviderAccess::default(),
options: SandboxOptions {
skip_clone: true,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
github_app: None,
run_id: None,
@ -414,7 +293,7 @@ mod tests {
clone_branch: None,
clone_tag: None,
clone_commit_sha: None,
};
}));
let mut sandbox = MockSandbox::linux();
sandbox.working_dir = "/workspace";

View file

@ -3,10 +3,9 @@ mod daytona_streaming_live {
use std::time::Duration;
use anyhow::{Context, Result, ensure};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{
CommandOutputCallback, DaytonaCredentials, DriverSandbox, ExecStreamingResult, Sandbox,
daytona_sandbox,
CommandOutputCallback, DaytonaCredentials, DriverSandbox, ExecStreamingResult,
ProviderAccess, Sandbox, SandboxOptions, SandboxProviderKind, provider_sandbox,
};
use fabro_static::EnvVars;
use fabro_types::{CommandOutputStream, CommandTermination};
@ -29,8 +28,10 @@ mod daytona_streaming_live {
);
let sandbox = Arc::new(
daytona_sandbox(
DaytonaConfig {
provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_credentials()?),
SandboxOptions {
skip_clone: true,
..Default::default()
},
@ -40,7 +41,6 @@ mod daytona_streaming_live {
None,
None,
None,
&live_credentials()?,
)
.await?,
);
@ -68,8 +68,10 @@ mod daytona_streaming_live {
"DAYTONA_API_KEY must be set to run this live smoke test"
);
let sandbox = daytona_sandbox(
DaytonaConfig {
let sandbox = provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_credentials()?),
SandboxOptions {
skip_clone: true,
..Default::default()
},
@ -79,7 +81,6 @@ mod daytona_streaming_live {
None,
None,
None,
&live_credentials()?,
)
.await?;
sandbox.initialize().await?;
@ -171,13 +172,15 @@ mod daytona_streaming_live {
);
let run_id: fabro_types::RunId = "01HY0000000000000000000000".parse().unwrap();
let sandbox = daytona_sandbox(
DaytonaConfig {
let sandbox = provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_credentials()?),
SandboxOptions {
skip_clone: true,
labels: Some(std::collections::HashMap::from([(
labels: std::collections::BTreeMap::from([(
"team".to_string(),
"platform".to_string(),
)])),
)]),
..Default::default()
},
None,
@ -186,7 +189,6 @@ mod daytona_streaming_live {
None,
None,
None,
&live_credentials()?,
)
.await?;
@ -228,8 +230,10 @@ mod daytona_streaming_live {
"DAYTONA_API_KEY must be set to run this live smoke test"
);
let sandbox = daytona_sandbox(
DaytonaConfig {
let sandbox = provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_credentials()?),
SandboxOptions {
skip_clone: false,
..Default::default()
},
@ -239,7 +243,6 @@ mod daytona_streaming_live {
None,
None,
None,
&live_credentials()?,
)
.await?;
@ -297,8 +300,10 @@ mod daytona_streaming_live {
"DAYTONA_API_KEY must be set to run this live glob test"
);
let sandbox = daytona_sandbox(
DaytonaConfig {
let sandbox = provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_credentials()?),
SandboxOptions {
skip_clone: true,
..Default::default()
},
@ -308,7 +313,6 @@ mod daytona_streaming_live {
None,
None,
None,
&live_credentials()?,
)
.await?;
@ -565,6 +569,13 @@ mod daytona_streaming_live {
})
}
fn daytona_access(credentials: DaytonaCredentials) -> ProviderAccess {
ProviderAccess {
daytona: Some(credentials),
..ProviderAccess::default()
}
}
async fn wait_for_chunks(
chunks: &Arc<Mutex<Vec<CapturedChunk>>>,
timeout_after: Duration,

View file

@ -1,9 +1,11 @@
//! Docker sandbox behaviour through the sandbox-driver Docker provider.
use std::collections::BTreeMap;
use std::sync::Arc;
use fabro_sandbox::{
CommandOutputCallback, DockerSandboxOptions, ExecStreamingRequest, Sandbox, docker_sandbox,
CommandOutputCallback, ExecStreamingRequest, ProviderAccess, Sandbox, SandboxOptions,
SandboxProviderKind, provider_sandbox,
};
use tokio::process::Command;
use tokio::sync::Mutex;
@ -38,12 +40,13 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
return;
}
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxOptions {
image: Some(image.to_string()),
skip_clone: true,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
None,
None,
@ -110,12 +113,13 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
return;
}
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxOptions {
image: Some(image.to_string()),
skip_clone: true,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
None,
None,
@ -171,12 +175,13 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
return;
}
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxOptions {
image: Some(image.to_string()),
skip_clone: false,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
None,
None,
@ -236,13 +241,14 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
return;
}
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
env_vars: vec!["BASH_ENV=/tmp/fabro-bash-env".to_string()],
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxOptions {
image: Some(image.to_string()),
env: BTreeMap::from([("BASH_ENV".to_string(), "/tmp/fabro-bash-env".to_string())]),
skip_clone: true,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
None,
None,
@ -330,12 +336,13 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
return;
}
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxOptions {
image: Some(image.to_string()),
skip_clone: true,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
None,
None,
@ -414,12 +421,13 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() {
return;
}
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
let sandbox = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxOptions {
image: Some(image.to_string()),
skip_clone: true,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
None,
None,

View file

@ -35,7 +35,10 @@ use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant};
use fabro_sandbox::{DockerSandboxOptions, Sandbox as FabroSandbox, docker_sandbox, local_sandbox};
use fabro_sandbox::{
ProviderAccess, Sandbox as FabroSandbox, SandboxOptions, SandboxProviderKind, local_sandbox,
provider_sandbox,
};
use sandbox_driver::{
ExecSpec, GrepOptions, Sandbox as DriverSandbox, SandboxProvider, SandboxSource, SandboxSpec,
Search,
@ -363,12 +366,13 @@ async fn agent_tool_call_latency_through_the_driver() {
host.delete().await.expect("host delete");
// -- Docker, in-process: fabro's driver-backed sandbox vs the bare driver.
let fabro_docker = docker_sandbox(
DockerSandboxOptions {
image: IMAGE.to_owned(),
auto_pull: false,
let fabro_docker = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
SandboxOptions {
image: Some(IMAGE.to_owned()),
skip_clone: true,
..DockerSandboxOptions::default()
..SandboxOptions::default()
},
None,
None,

View file

@ -9,13 +9,10 @@ use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_llm::client::Client as LlmClient;
use fabro_mcp::config::McpServerSettings;
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment_with_secrets,
local_working_directory_from_environment,
use fabro_sandbox::{
DaytonaCredentials, ProviderAccess, ProviderSandboxSpec, SandboxOptions, SandboxSpec,
local_working_directory_from_environment, options_from_environment,
};
use fabro_sandbox::plugin::plugin_options_from_environment;
use fabro_sandbox::{DaytonaCredentials, DockerSandboxOptions, SandboxSpec};
use fabro_static::EnvVars;
#[cfg(test)]
use fabro_types::GitRunTarget;
@ -536,68 +533,27 @@ impl RunSession {
SandboxSpec::Local { working_directory }
}
},
Some(BundledProvider::Docker) => {
let mut config = resolve_docker_config(resolved, secret_lookup)?;
config.skip_clone |= clone_source.skip_clone;
SandboxSpec::Docker {
config,
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
clone_branch: clone_source.branch,
clone_tag: clone_source.tag,
clone_commit_sha: clone_source.commit_sha,
}
}
Some(BundledProvider::Daytona) => {
let credentials = vault_guard.get(EnvVars::DAYTONA_API_KEY).map(|api_key| {
_ => {
let daytona = 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 {
config: Box::new(config),
let access = ProviderAccess {
providers: services.sandbox_providers.clone(),
daytona,
};
let mut options = resolve_sandbox_options(resolved, secret_lookup)?;
options.skip_clone |= clone_source.skip_clone;
SandboxSpec::Provider(Box::new(ProviderSandboxSpec {
kind: sandbox_provider.clone(),
access,
options,
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
clone_branch: clone_source.branch,
clone_tag: clone_source.tag,
clone_commit_sha: clone_source.commit_sha,
credentials,
}
}
None => {
let settings = services
.sandbox_providers
.get(&sandbox_provider)
.cloned()
.ok_or_else(|| {
Error::engine(format!(
"sandbox provider `{sandbox_provider}` is not configured; add [server.sandbox.providers.{sandbox_provider}] to settings.toml"
))
})?;
let env = resolved
.environment
.resolve_env(secret_lookup)
.map_err(|err| {
Error::engine_with_source("failed to resolve environment variables", err)
})?
.into_iter()
.collect();
let mut options =
plugin_options_from_environment(&resolved.environment, &resolved.clone, env);
options.skip_clone |= clone_source.skip_clone;
SandboxSpec::Plugin {
kind: sandbox_provider.clone(),
settings: Box::new(settings),
options: Box::new(options),
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
clone_branch: clone_source.branch,
clone_tag: clone_source.tag,
clone_commit_sha: clone_source.commit_sha,
}
}))
}
};
@ -856,20 +812,20 @@ fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProviderKi
settings.environment.provider.clone()
}
fn resolve_daytona_config(settings: &ResolvedRunSettings) -> DaytonaConfig {
daytona_config_from_environment(&settings.environment, &settings.clone)
}
fn resolve_docker_config(
/// The environment's sandbox options with its variables resolved through
/// the vault.
fn resolve_sandbox_options(
settings: &ResolvedRunSettings,
secrets_lookup: impl FnMut(&str) -> Option<String>,
) -> Result<DockerSandboxOptions, Error> {
docker_config_from_environment_with_secrets(
&settings.environment,
&settings.clone,
secrets_lookup,
)
.map_err(|err| Error::engine_with_source("failed to resolve Docker environment config", err))
) -> Result<SandboxOptions, Error> {
let env = settings
.environment
.resolve_env(secrets_lookup)
.map_err(|err| Error::engine_with_source("failed to resolve environment variables", err))?
.into_iter()
.collect();
options_from_environment(&settings.environment, &settings.clone, env)
.map_err(|err| Error::engine_with_source("failed to resolve sandbox options", err))
}
fn resolve_start_llm(
@ -1620,19 +1576,9 @@ reasoning = false
..RunLayer::default()
});
assert!(
resolve_docker_config(&settings.run, |_| None)
.unwrap()
.skip_clone
);
assert!(resolve_daytona_config(&settings.run).skip_clone);
assert_eq!(resolve_daytona_config(&settings.run).clone_depth, Some(1));
assert_eq!(
resolve_docker_config(&settings.run, |_| None)
.unwrap()
.clone_depth,
Some(1)
);
let options = resolve_sandbox_options(&settings.run, |_| None).unwrap();
assert!(options.skip_clone);
assert_eq!(options.clone_depth, Some(1));
}
#[test]
@ -1645,26 +1591,16 @@ reasoning = false
..RunLayer::default()
});
assert_eq!(resolve_daytona_config(&settings.run).clone_depth, None);
assert_eq!(
resolve_docker_config(&settings.run, |_| None)
.unwrap()
.clone_depth,
None
);
let options = resolve_sandbox_options(&settings.run, |_| None).unwrap();
assert_eq!(options.clone_depth, None);
}
#[test]
fn clone_providers_default_to_depth_100() {
let settings = settings_from_run_layer(RunLayer::default());
assert_eq!(resolve_daytona_config(&settings.run).clone_depth, Some(100));
assert_eq!(
resolve_docker_config(&settings.run, |_| None)
.unwrap()
.clone_depth,
Some(100)
);
let options = resolve_sandbox_options(&settings.run, |_| None).unwrap();
assert_eq!(options.clone_depth, Some(100));
}
#[test]
@ -1989,17 +1925,19 @@ reasoning = false
assert_eq!(runtime.clone_branch, None);
assert_eq!(runtime.primary_repo_path, None);
assert_eq!(runtime.primary_repo_link, None);
let SandboxSpec::Docker {
config,
let SandboxSpec::Provider(spec) = sandbox else {
panic!("none target should retain the selected Docker provider");
};
let ProviderSandboxSpec {
kind,
options,
clone_origin_url,
clone_branch,
clone_commit_sha,
..
} = sandbox
else {
panic!("none target should retain the selected Docker provider");
};
assert!(config.skip_clone);
} = *spec;
assert_eq!(kind, SandboxProviderKind::DOCKER);
assert!(options.skip_clone);
assert_eq!(clone_origin_url, None);
assert_eq!(clone_branch, None);
assert_eq!(clone_commit_sha, None);
@ -2056,17 +1994,21 @@ reasoning = false
assert_eq!(runtime.clone_branch, None);
assert_eq!(runtime.primary_repo_path, None);
assert_eq!(runtime.primary_repo_link, None);
let SandboxSpec::Daytona {
config,
let SandboxSpec::Provider(spec) = sandbox else {
panic!("none target should retain the selected Daytona provider");
};
let ProviderSandboxSpec {
kind,
access,
options,
clone_origin_url,
clone_branch,
clone_commit_sha,
..
} = sandbox
else {
panic!("none target should retain the selected Daytona provider");
};
assert!(config.skip_clone);
} = *spec;
assert_eq!(kind, SandboxProviderKind::DAYTONA);
assert!(access.daytona.is_some(), "the vault key reaches the spec");
assert!(options.skip_clone);
assert_eq!(clone_origin_url, None);
assert_eq!(clone_branch, None);
assert_eq!(clone_commit_sha, None);
@ -2447,13 +2389,19 @@ reasoning = false
..RunLayer::default()
});
let config = resolve_docker_config(&settings.run, |_| None).unwrap();
let options = resolve_sandbox_options(&settings.run, |_| None).unwrap();
assert_eq!(config.image, "ubuntu:24.04");
assert_eq!(config.cpu_quota, Some(400_000));
assert_eq!(config.memory_limit, Some(2_000_000_000));
assert_eq!(config.network_mode.as_deref(), Some("none"));
assert_eq!(config.env_vars, vec!["NODE_ENV=test"]);
assert_eq!(options.image.as_deref(), Some("ubuntu:24.04"));
assert_eq!(options.cpu, Some(4));
assert_eq!(options.memory_bytes, Some(2_000_000_000));
assert!(matches!(
options.network,
fabro_sandbox::NetworkPolicy::Block
));
assert_eq!(
options.env,
std::collections::BTreeMap::from([("NODE_ENV".to_string(), "test".to_string())])
);
}
#[test]

View file

@ -24,8 +24,10 @@ use std::sync::Arc;
use fabro_agent::Sandbox;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{DaytonaCredentials, DriverSandbox, ProviderAccess, daytona_sandbox};
use fabro_sandbox::{
DaytonaCredentials, DriverSandbox, ProviderAccess, SandboxOptions, SandboxProviderKind,
provider_sandbox,
};
use fabro_static::EnvVars;
use fabro_store::{ArtifactKey, ArtifactStore};
use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref};
@ -184,6 +186,13 @@ async fn resolve_checkpoint_text(
/// Live credentials from the process environment, the way the vault would
/// supply them in production.
fn daytona_access(credentials: DaytonaCredentials) -> ProviderAccess {
ProviderAccess {
daytona: Some(credentials),
..ProviderAccess::default()
}
}
fn live_daytona_credentials() -> DaytonaCredentials {
DaytonaCredentials {
api_key: std::env::var(EnvVars::DAYTONA_API_KEY)
@ -213,15 +222,16 @@ fn test_artifact_store(run_dir: &Path) -> ArtifactStore {
async fn create_env_with_github_app(
github_app: Option<fabro_github::GitHubCredentials>,
) -> DriverSandbox {
daytona_sandbox(
DaytonaConfig::default(),
provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_daytona_credentials()),
SandboxOptions::default(),
github_app.as_ref(),
None,
None,
None,
None,
None,
&live_daytona_credentials(),
)
.await
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?")
@ -411,34 +421,28 @@ async fn daytona_full_lifecycle() {
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
async fn daytona_snapshot_sandbox() {
use fabro_sandbox::daytona::DaytonaSnapshotConfig;
let config = DaytonaConfig {
auto_stop_interval: Some(60),
snapshot: Some(DaytonaSnapshotConfig {
cpu: Some(2),
memory: Some(4),
disk: Some(10),
source: fabro_sandbox::daytona::DaytonaSnapshotSource::Dockerfile(
fabro_sandbox::daytona::DockerfileSource::Inline(
"FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep"
.to_string(),
),
),
}),
..DaytonaConfig::default()
let options = SandboxOptions {
auto_stop: Some(std::time::Duration::from_hours(1)),
dockerfile: Some(
"FROM ubuntu:22.04\nRUN apt-get update && apt-get install -y ripgrep".to_string(),
),
cpu: Some(2),
memory_bytes: Some(4_000_000_000),
disk_bytes: Some(10_000_000_000),
..SandboxOptions::default()
};
let creds = load_github_app_credentials();
let env = daytona_sandbox(
config,
let env = provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_daytona_credentials()),
options,
Some(&creds),
None,
None,
None,
None,
None,
&live_daytona_credentials(),
)
.await
.expect("Failed to create Daytona client — is DAYTONA_API_KEY set?");
@ -1544,7 +1548,7 @@ async fn daytona_toolbox_idle_diagnostic() {
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
async fn daytona_cp_upload_download_round_trip() {
use fabro_sandbox::reconnect::reconnect;
use fabro_types::{RunSandboxInstance, SandboxProviderKind};
use fabro_types::RunSandboxInstance;
// 1. Create and initialize a real Daytona sandbox
let env = create_env().await;
@ -1646,20 +1650,20 @@ async fn daytona_cp_upload_download_round_trip() {
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
async fn daytona_computer_use_browser_screenshot() {
let config = DaytonaConfig {
snapshot: None,
let options = SandboxOptions {
skip_clone: true,
..DaytonaConfig::default()
..SandboxOptions::default()
};
let env = daytona_sandbox(
config,
let env = provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_daytona_credentials()),
options,
None,
None,
None,
None,
None,
None,
&live_daytona_credentials(),
)
.await
.expect("DAYTONA_API_KEY must be set");
@ -1796,20 +1800,20 @@ async fn daytona_playwright_mcp_sandbox_transport() {
use fabro_agent::Sandbox;
// Create sandbox from daytona-medium (has Node.js + Chromium)
let config = DaytonaConfig {
snapshot: None,
let options = SandboxOptions {
skip_clone: true,
..DaytonaConfig::default()
..SandboxOptions::default()
};
let sandbox = daytona_sandbox(
config,
let sandbox = provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_daytona_credentials()),
options,
None,
None,
None,
None,
None,
None,
&live_daytona_credentials(),
)
.await
.expect("DAYTONA_API_KEY must be set");

View file

@ -13754,15 +13754,24 @@ async fn asset_collection_local_sandbox_on_failure() {
async fn asset_collection_docker_sandbox() {
let run_dir = tempfile::tempdir().unwrap();
let config = fabro_agent::DockerSandboxOptions {
auto_pull: false,
let options = fabro_agent::SandboxOptions {
skip_clone: true,
..Default::default()
};
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
fabro_agent::docker_sandbox(config, None, None, None, None, None, None)
.await
.expect("Docker not available"),
fabro_agent::provider_sandbox(
fabro_agent::SandboxProviderKind::DOCKER,
&fabro_agent::ProviderAccess::default(),
options,
None,
None,
None,
None,
None,
None,
)
.await
.expect("Docker not available"),
);
sandbox.initialize().await.expect("Docker init failed");