Route Docker sandboxes through the sandbox driver

Fabro's Docker provider kind now maps onto the sandbox-driver Docker
provider instead of its own bollard implementation. Fabro keeps what is
its own: the clone decision and repository layout, the GitHub App push
credentials embedded in origin, git retry classification, the managed
labels that gate destructive operations, and the run-facing events.

- `clone.rs` performs fabro's clone over the driver `Git` and `Exec`
  facets. An exact commit goes through the driver's pinned clone; a tag
  pin runs fabro's init/fetch/attach sequence through `Exec` so the
  fully qualified tag ref is the only revision consulted. Network
  failures retry through `git_retry`, which now classifies driver errors
  (exec output, provider retryability) and never replays an operation
  whose outcome is unknown.
- `DriverSandbox` gains a pending-create state, a `RepoWorkspace` with
  push-credential state, path resolution against the cloned working
  directory, git lifecycle methods, image-pull progress mapped from
  driver events, and an embedded terminal over the driver `Pty` facet.
- `docker.rs` builds the driver `SandboxSpec` (image, `/workspace`,
  fabro labels, env, cpu/memory, network policy, run name) and attaches
  by persisted container id, refusing containers without fabro's labels.
- Inventory, details, diagnostics, terminal, and reconnect run over the
  driver: a `DriverInventoryProvider` lists by fabro's managed label and
  projects `SandboxStatus` into fabro's inventory and details shapes.
- The bollard-based `docker.rs`, `provider/docker.rs`, Docker terminal,
  Docker error variants, the `docker` cargo feature, and the bollard and
  tar dependencies are gone. The Docker integration tests, the agent
  shell test, the workflow artifact test, and the driver benchmark run
  against the driver-backed sandbox.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 17:07:51 -06:00
parent ba3adb92c7
commit 3d33935fba
No known key found for this signature in database
37 changed files with 1875 additions and 4213 deletions

2
Cargo.lock generated
View file

@ -2984,7 +2984,6 @@ dependencies = [
"anyhow",
"async-trait",
"base64",
"bollard",
"chrono",
"daytona-api-client",
"daytona-sdk",
@ -3018,7 +3017,6 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"strum 0.28.0",
"tar",
"tempfile",
"thiserror 2.0.18",
"tokio",

View file

@ -62,8 +62,6 @@ clap_complete = "4"
jsonschema = { version = "0.42", default-features = false }
chrono = { version = "0.4", features = ["clock", "serde"] }
dashmap = "6"
bollard = "0.18"
tar = "0.4"
cli-table = { version = "0.5", default-features = false }
console = "0.15"
dialoguer = "0.12"

View file

@ -159,8 +159,8 @@ pub trait Sandbox: Send + Sync {
| Type | Description |
|---|---|
| `LocalSandbox` | Executes directly on the local filesystem. |
| `DockerSandbox` | Runs inside a Docker container (feature-gated: `docker`). |
| `local_sandbox(...)` | Executes directly on the local filesystem through the sandbox driver Host provider. |
| `docker_sandbox(...)` | Runs inside a Docker container through the sandbox driver. |
The `DaytonaSandbox` implementation (feature-gated: `daytona`) runs inside a Daytona cloud sandbox.

View file

@ -34,7 +34,7 @@ fabro-slack = { path = "../../components/fabro-slack" }
fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
fabro-validate = { path = "../../components/fabro-validate" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona", "docker"] }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona"] }
fabro-github = { path = "../../components/fabro-github" }
fabro-agent = { path = "../../components/fabro-agent" }
fabro-llm = { path = "../../components/fabro-llm" }

View file

@ -10,7 +10,7 @@ use fabro_llm::client::Client as LlmClient;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe_with_timeout};
use fabro_model::{Catalog, ProviderId};
use fabro_redact::redact_string;
use fabro_sandbox::{DockerSandboxProvider, daytona};
use fabro_sandbox::daytona;
use fabro_static::EnvVars;
use fabro_types::SandboxProviderKind;
use fabro_types::settings::ServerAuthMethod;
@ -584,7 +584,7 @@ async fn check_docker_sandbox(state: &AppState) -> CheckResult {
.providers
.is_enabled(&SandboxProviderKind::DOCKER),
|| async {
DockerSandboxProvider::check_daemon()
fabro_sandbox::check_docker_daemon()
.await
.map_err(|err| err.display_with_causes())
},

View file

@ -69,10 +69,11 @@ use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderI
use fabro_redact::redact_jsonl_line;
use fabro_sandbox::daytona::{self, DaytonaSandbox};
use fabro_sandbox::details::sandbox_details;
use fabro_sandbox::driver::ProviderConnectOptions;
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_sandbox::{
DaytonaSandboxProvider, DockerSandboxProvider, LocalSandboxProvider, Sandbox, SandboxProvider,
SandboxProviderRegistry,
DaytonaSandboxProvider, DriverInventoryProvider, LocalSandboxProvider, Sandbox,
SandboxProvider, SandboxProviderRegistry,
};
use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient};
use fabro_slack::config::{
@ -2340,8 +2341,14 @@ fn build_sandbox_provider_registry(
providers.push(Arc::new(LocalSandboxProvider));
}
if provider_settings.is_enabled(&SandboxProviderKind::DOCKER) {
providers.push(Arc::new(DockerSandboxProvider::new()));
if let Some(docker) = provider_settings.get(&SandboxProviderKind::DOCKER) {
if docker.enabled {
providers.push(Arc::new(DriverInventoryProvider::lazy(
SandboxProviderKind::DOCKER,
docker.clone(),
ProviderConnectOptions::default(),
)));
}
}
if provider_settings.is_enabled(&SandboxProviderKind::DAYTONA) && daytona_api_key.is_some() {

View file

@ -11,8 +11,6 @@ keywords = ["llm", "ai", "agent", "coding"]
categories = ["api-bindings"]
[features]
default = ["docker"]
docker = ["fabro-sandbox/docker"]
quarantine = []
[lib]

View file

@ -1,2 +0,0 @@
// Re-export from fabro-sandbox
pub use fabro_sandbox::docker::{DockerSandbox, DockerSandboxOptions};

View file

@ -1,6 +1,3 @@
#[cfg(feature = "docker")]
pub mod docker_sandbox;
pub mod agent_profile;
pub mod apply_patch;
pub mod cli;
@ -38,11 +35,10 @@ pub use config::{
NativeToolOptions, SessionOptions, ToolAccess, ToolAccessPolicy, ToolApprovalAdapter,
ToolExposureMode, ToolHookCallback, ToolHookDecision, ToolSecrets,
};
#[cfg(feature = "docker")]
pub use docker_sandbox::{DockerSandbox, DockerSandboxOptions};
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_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::{DockerSandbox, DockerSandboxOptions, Emitter};
use fabro_agent::{DockerSandboxOptions, Emitter, docker_sandbox};
use fabro_types::CommandTermination;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
@ -17,7 +17,7 @@ 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) = DockerSandbox::new(
let Ok(sandbox) = docker_sandbox(
DockerSandboxOptions {
image: "buildpack-deps:noble".to_string(),
auto_pull: false,
@ -30,7 +30,9 @@ async fn shell_reports_real_docker_process_outcome() {
None,
None,
None,
) else {
)
.await
else {
return;
};
// No Docker daemon or no local image: the integration precondition is not met.

View file

@ -1,5 +1,4 @@
mod compaction;
#[cfg(feature = "docker")]
mod docker_shell;
mod guardrails;
mod parity_matrix;

View file

@ -9,7 +9,6 @@ description = "Sandbox trait and implementations for Fabro agent execution envir
[features]
default = ["local"]
local = []
docker = ["dep:bollard", "dep:tar"]
daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-config", "dep:fabro-http", "dep:reqwest-middleware", "dep:rand", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls"]
test-support = []
@ -58,10 +57,6 @@ fabro-redact.workspace = true
futures = { workspace = true }
# docker
bollard = { workspace = true, optional = true }
tar = { workspace = true, optional = true }
# daytona
fabro-config = { path = "../../foundation/fabro-config", optional = true }
fabro-github = { path = "../fabro-github" }

View file

@ -0,0 +1,430 @@
//! Fabro's clone orchestration over the sandbox-driver [`Git`] and [`Exec`]
//! facets.
//!
//! The driver clones; fabro decides what to clone, where it lands, which
//! credentials it carries, and how failures retry. The layout is fabro's:
//! the repository checks out under `<repos_root>/<owner>/<repo>` and the
//! run works in `<workspace_root>/<repo>`, a symlink to the checkout. An
//! exact commit goes through the driver's pinned clone; a tag pin runs
//! fabro's own init, fetch, and attach sequence through `Exec`, because a
//! tag must be fetched by its fully qualified ref so a same-named branch is
//! never consulted. Neither path ever falls back to the branch head.
use std::time::Duration;
use fabro_github::token_source::ResolvedToken;
use fabro_redact::DisplaySafeUrl;
use fabro_types::SandboxProviderKind;
use sandbox_driver::{Git as _, GitCloneOptions, GitCredentials, Sandbox as DriverHandle};
use tokio::time;
use crate::clone_source::{self, GitHubRepoLayout, PinnedRevision};
use crate::exec::SandboxExec;
use crate::git_retry::{self, CredentialContext, GitRetryReason, RetryPlan};
use crate::push_credentials::PushCredentialState;
use crate::redact::redact_auth_url;
use crate::sandbox::shell_quote;
use crate::{ExecResult, ExecStreamingRequest};
/// Whole-clone budget, shared by every network and local step.
pub(crate) const GIT_CLONE_TIMEOUT: Duration = Duration::from_mins(5);
const STEP_TIMEOUT: Duration = Duration::from_secs(10);
/// A GitHub clone fabro decided to perform.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GitHubClone {
pub(crate) origin_url: String,
pub(crate) branch: Option<String>,
pub(crate) tag: Option<String>,
pub(crate) commit_sha: Option<String>,
pub(crate) depth: Option<u32>,
}
/// What the clone left behind: the layout and the token now embedded in
/// `origin`, if any.
pub(crate) struct CloneOutcome {
pub(crate) layout: GitHubRepoLayout,
}
/// Whether a failing git step talked to the remote. Local steps cannot fail
/// on credentials, so they must not suggest reconfiguring the GitHub App.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CloneStep {
Network,
Local,
}
struct CloneFailure {
error: crate::Error,
retry_reason: Option<GitRetryReason>,
}
/// Clone `plan` into `handle`, laid out under `workspace_root` and
/// `repos_root`, embedding a GitHub App token from `credentials` when one
/// is available.
pub(crate) async fn clone_github_repo(
kind: &SandboxProviderKind,
handle: &dyn DriverHandle,
exec: &SandboxExec<'_>,
plan: &GitHubClone,
workspace_root: &str,
repos_root: &str,
credentials: &PushCredentialState,
) -> crate::Result<CloneOutcome> {
verify_git_available(exec).await?;
let layout = clone_source::github_repo_layout(&plan.origin_url, workspace_root, repos_root)?;
// The clone mints its own token (never a warm-cache reuse) and seeds the
// shared source, so the first refresh compares against the clone token
// instead of believing nothing was ever embedded.
let resolved_token = match credentials.source() {
Some(source) => Some(source.mint_for_clone().await.map_err(|err| {
crate::Error::context_anyhow("Failed to get GitHub App credentials for clone", err)
})?),
None => None,
};
let credential_context =
CredentialContext::from_snapshot(resolved_token.as_ref().map(|token| &token.snapshot));
let auth_url = match &resolved_token {
Some(token) => Some(
fabro_github::embed_token_in_url(&plan.origin_url, token.token.expose()).map_err(
|err| {
crate::Error::context_anyhow(
"Failed to build authenticated GitHub clone URL",
err,
)
},
)?,
),
None => None,
};
let fs = handle.fs();
for dir in [workspace_root, layout.repos_owner_path.as_str()] {
fs.create_dir(dir)
.await
.map_err(|error| crate::Error::context(format!("Failed to create {dir}"), error))?;
}
let deadline = time::Instant::now() + GIT_CLONE_TIMEOUT;
let has_app = credentials.source().is_some();
match PinnedRevision::from_selectors(plan.tag.as_deref(), plan.commit_sha.as_deref()) {
Some(PinnedRevision::Tag(tag)) => {
// `decide_clone` already requires a branch for a pin; the branch
// names the checkout the run works on.
let branch = plan
.branch
.as_deref()
.filter(|branch| !branch.trim().is_empty())
.ok_or_else(|| {
crate::Error::message("Tag checkout requires a repository branch")
})?;
clone_pinned_tag(
kind,
exec,
&layout,
plan,
&tag,
branch,
auth_url.as_ref(),
credential_context,
deadline,
has_app,
)
.await?;
}
pin => {
let git = handle.git().ok_or_else(|| {
crate::Error::message(format!(
"sandbox provider `{kind}` does not support git operations"
))
})?;
let mut options = GitCloneOptions::default();
options.branch = plan
.branch
.clone()
.filter(|branch| !branch.trim().is_empty());
options.commit = plan.commit_sha.clone();
options.depth = plan.depth;
options.credentials = resolved_token
.as_ref()
.map(|token| GitCredentials::new("x-access-token", token.token.expose()));
let retry_plan = RetryPlan::clone_default(Some(deadline));
let target = layout.primary_repo_path.clone();
git_retry::retry_git_operation(
kind.clone(),
"clone",
&retry_plan,
|_attempt| {
let options = options.clone();
let target = target.clone();
let origin_url = plan.origin_url.clone();
let git = &git;
async move {
git.clone_repo(&origin_url, &target, &options)
.await
.map_err(|error| CloneFailure {
retry_reason: git_retry::classify_driver_failure(
&error,
credential_context,
),
error: clone_failure_error(
crate::Error::driver_error(error),
CloneStep::Network,
has_app,
),
})
}
},
|failure: &CloneFailure| failure.retry_reason,
)
.await
.map_err(|failure| failure.error)?;
if let Some(pin) = pin {
let head = run_local_step(
exec,
&clone_source::exact_head_revision_command(&layout.primary_repo_path),
"git rev-parse HEAD (pinned checkout)",
deadline,
auth_url.as_ref(),
has_app,
)
.await?;
pin.verify_head(&head.stdout)?;
}
}
}
run_local_step(
exec,
&clone_source::repo_symlink_command(&layout),
"create workspace repo symlink",
deadline,
auth_url.as_ref(),
has_app,
)
.await?;
if let Some(token) = resolved_token {
embed_origin_credentials(exec, &layout, auth_url.as_ref(), token, credentials).await;
}
Ok(CloneOutcome { layout })
}
/// Fabro's exact tag checkout: init, fetch the fully qualified tag ref at
/// the same depth a branch clone gets, attach the admitted branch to the
/// fetched commit, and verify HEAD. Every step runs through `Exec`.
#[expect(
clippy::too_many_arguments,
reason = "the pinned path threads clone inputs, credentials, and the shared deadline"
)]
async fn clone_pinned_tag(
kind: &SandboxProviderKind,
exec: &SandboxExec<'_>,
layout: &GitHubRepoLayout,
plan: &GitHubClone,
tag: &str,
branch: &str,
auth_url: Option<&DisplaySafeUrl>,
credential_context: CredentialContext,
deadline: time::Instant,
has_app: bool,
) -> crate::Result<()> {
let clone_url = auth_url.map_or(plan.origin_url.as_str(), |url| url.as_raw_url().as_str());
let init = clone_source::exact_repository_init_command(clone_url, &layout.primary_repo_path);
run_local_step(
exec,
&init,
"initialize pinned repository checkout",
deadline,
auth_url,
has_app,
)
.await?;
let pin = PinnedRevision::Tag(tag.to_string());
let fetch = clone_source::pinned_fetch_command(
&layout.primary_repo_path,
"origin",
&pin.fetch_refspec(),
plan.depth.map(|depth| depth as usize),
);
let retry_plan = RetryPlan::clone_default(Some(deadline));
git_retry::retry_git_operation(
kind.clone(),
"fetch",
&retry_plan,
|_attempt| async {
let remaining = deadline.saturating_duration_since(time::Instant::now());
if remaining.is_zero() {
return Err(CloneFailure {
error: crate::Error::message(
"git fetch pinned revision deadline expired before retry",
),
retry_reason: None,
});
}
let result = exec
.run_streaming(ExecStreamingRequest {
timeout_ms: Some(millis(remaining)),
working_dir: Some("/"),
..ExecStreamingRequest::new(&fetch)
})
.await
.map_err(|error| CloneFailure {
error: crate::Error::context(
"git fetch pinned revision transport failed",
error,
),
retry_reason: None,
})?
.result;
if result.is_success() {
return Ok(());
}
let retry_reason =
git_retry::classify_output(&result.stderr, &result.stdout, credential_context)
.retry_reason();
Err(CloneFailure {
error: clone_failure_error(
result.into_exec_error_with_redactor("git fetch pinned revision", |output| {
redact_auth_url(output, auth_url)
}),
CloneStep::Network,
has_app,
),
retry_reason,
})
},
|failure: &CloneFailure| failure.retry_reason,
)
.await
.map_err(|failure| failure.error)?;
let checkout = clone_source::exact_checkout_verify_command(
&layout.primary_repo_path,
branch,
clone_source::FETCH_HEAD_COMMIT,
);
let head = run_local_step(
exec,
&checkout,
"git checkout pinned revision",
deadline,
auth_url,
has_app,
)
.await?;
pin.verify_head(&head.stdout)?;
Ok(())
}
async fn verify_git_available(exec: &SandboxExec<'_>) -> crate::Result<()> {
let result = exec
.run("git --version", Some(STEP_TIMEOUT), Some("/"), None, None)
.await?;
if !result.is_success() {
return Err(crate::Error::message(
"The sandbox image must include git for repository clone and git lifecycle \
operations. Use an image with bash and git, such as buildpack-deps:noble.",
));
}
Ok(())
}
/// Run a local (non-network) step under the shared clone deadline.
///
/// Materializing a large working tree takes far longer than the short fixed
/// timeout used for trivial commands, so these steps get the same budget the
/// network steps have.
async fn run_local_step(
exec: &SandboxExec<'_>,
command: &str,
label: &'static str,
deadline: time::Instant,
auth_url: Option<&DisplaySafeUrl>,
has_app: bool,
) -> crate::Result<ExecResult> {
let remaining = deadline.saturating_duration_since(time::Instant::now());
if remaining.is_zero() {
return Err(crate::Error::message(format!(
"{label} deadline expired before the step could run"
)));
}
let result = exec
.run(command, Some(remaining), Some("/"), None, None)
.await
.map_err(|error| crate::Error::context(format!("{label} transport failed"), error))?;
if result.is_success() {
return Ok(result);
}
Err(clone_failure_error(
result.into_exec_error_with_redactor(label, |output| redact_auth_url(output, auth_url)),
CloneStep::Local,
has_app,
))
}
fn clone_failure_error(error: crate::Error, step: CloneStep, has_app: bool) -> crate::Error {
let message = match step {
CloneStep::Network if !has_app => {
"Git clone failed. If this is a private repository, configure a GitHub App with \
`fabro install` and install it for your organization."
}
CloneStep::Network => "Failed to clone repository into the sandbox",
CloneStep::Local => "Failed to prepare the cloned repository in the sandbox",
};
crate::Error::context(message, error)
}
/// Point `origin` at the authenticated URL so pushes from the checkout
/// carry the clone token, and record that generation for refreshes. A
/// failure here is logged, not fatal: the checkout is complete, and the
/// first push will re-embed.
async fn embed_origin_credentials(
exec: &SandboxExec<'_>,
layout: &GitHubRepoLayout,
auth_url: Option<&DisplaySafeUrl>,
token: ResolvedToken,
credentials: &PushCredentialState,
) {
credentials.record_embedded(token).await;
let Some(auth_url) = auth_url else {
return;
};
let command = format!(
"git -c maintenance.auto=0 remote set-url origin {}",
shell_quote(auth_url.as_raw_url().as_str())
);
match exec
.run(
&command,
Some(STEP_TIMEOUT),
Some(&layout.execution_directory),
None,
None,
)
.await
{
Ok(result) if result.is_success() => {}
Ok(result) => {
let err = result
.into_exec_error_with_redactor("git remote set-url origin (post-clone)", |s| {
redact_auth_url(s, Some(auth_url))
});
tracing::warn!(
error = %err,
"Failed to set sandbox push credentials on origin; git push from this sandbox will fail"
);
}
Err(err) => {
tracing::warn!(
error = %redact_auth_url(&crate::display_for_log(&err), Some(auth_url)),
"Failed to set sandbox push credentials on origin; git push from this sandbox will fail"
);
}
}
}
fn millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}

View file

@ -73,7 +73,6 @@ pub(crate) fn repo_symlink_command(layout: &GitHubRepoLayout) -> String {
)
}
#[cfg(any(feature = "docker", test))]
pub(crate) fn exact_repository_init_command(clone_url: &str, checkout_path: &str) -> String {
format!(
"{git} init -- {path} && git -C {path} remote add origin {origin}",
@ -159,7 +158,6 @@ pub(crate) fn tag_ref(tag: &str) -> String {
///
/// The fetch names the revision directly rather than the branch, and
/// `--no-tags` keeps unrelated tags from being pulled alongside it.
#[cfg(any(feature = "docker", test))]
pub(crate) fn pinned_fetch_command(
checkout_path: &str,
fetch_source: &str,
@ -178,7 +176,6 @@ pub(crate) fn pinned_fetch_command(
/// Leading-space ` --depth N` fragment for a Git command, or empty when
/// `depth` is `None` to fetch full history.
#[cfg(any(feature = "docker", test))]
pub(crate) fn depth_argument(depth: Option<usize>) -> String {
depth.map_or_else(String::new, |depth| format!(" --depth {depth}"))
}
@ -215,7 +212,6 @@ pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String {
/// Check out the admitted branch and print the resulting HEAD in one shell
/// command; stdout is the `rev-parse HEAD` output for
/// [`PinnedRevision::verify_head`].
#[cfg(any(feature = "docker", test))]
pub(crate) fn exact_checkout_verify_command(
checkout_path: &str,
branch: &str,
@ -230,7 +226,6 @@ pub(crate) fn exact_checkout_verify_command(
/// The peeled commit behind whatever `git fetch` just wrote to `FETCH_HEAD`;
/// a commit peels to itself, an annotated tag to the commit it points at.
#[cfg(any(feature = "docker", test))]
pub(crate) const FETCH_HEAD_COMMIT: &str = "FETCH_HEAD^{commit}";
/// Validate that a `rev-parse HEAD` output is a single commit ID and return it

View file

@ -1,20 +1,20 @@
use std::collections::BTreeMap;
use anyhow::Result;
#[cfg(any(feature = "docker", feature = "daytona"))]
use chrono::{DateTime, Utc};
use fabro_types::{
BundledProvider, RunId, RunSandboxInstance, SandboxDetails, SandboxNetwork, SandboxResources,
SandboxState, SandboxTimestamps,
};
use crate::docker;
/// Inspect the sandbox identified by `record` and return provider-neutral
/// details for control-plane display.
///
/// Provider feature flags determine which branches resolve real data:
/// - `local` always returns a minimal record describing the host.
/// - `docker` inspects the managed container through Bollard.
/// - `daytona` reconnects to the SDK sandbox.
/// - `docker` describes the managed container through the sandbox driver.
/// - `daytona` reconnects to the SDK sandbox (feature-gated).
#[allow(
unused_variables,
reason = "Feature-gated providers consume some parameters only when enabled."
@ -27,8 +27,7 @@ pub async fn sandbox_details(
) -> Result<SandboxDetails> {
match record.provider.bundled() {
Some(BundledProvider::Local) => Ok(local_details(record)),
#[cfg(feature = "docker")]
Some(BundledProvider::Docker) => docker::docker_details(record, run_id).await,
Some(BundledProvider::Docker) => docker_details(record, run_id).await,
#[cfg(feature = "daytona")]
Some(BundledProvider::Daytona) => daytona::daytona_details(record, daytona_api_key).await,
_ => Err(anyhow::anyhow!(
@ -52,434 +51,128 @@ fn local_details(record: &RunSandboxInstance) -> SandboxDetails {
}
}
#[cfg(any(feature = "docker", feature = "daytona"))]
#[cfg(feature = "daytona")]
fn parse_rfc3339_utc(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
#[cfg(feature = "docker")]
pub(crate) mod docker {
use std::collections::BTreeMap;
use anyhow::{Context, Result, anyhow};
use bollard::Docker;
use bollard::container::InspectContainerOptions;
use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig};
use fabro_types::{
RunId, RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork,
SandboxNetworkPolicy, SandboxProviderKind, SandboxResources, SandboxState,
SandboxTimestamps,
};
use super::parse_rfc3339_utc;
use crate::docker::WORKING_DIRECTORY;
pub(super) async fn docker_details(
record: &RunSandboxInstance,
_run_id: Option<RunId>,
) -> Result<SandboxDetails> {
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
let runtime = &record.runtime;
let inspect = docker
.inspect_container(&runtime.id, None::<InspectContainerOptions>)
.await
.map_err(|err| anyhow!("Failed to inspect Docker container '{}': {err}", runtime.id))?;
Ok(map_docker_inspect(&inspect, record))
/// Projection of a sandbox-driver [`sandbox_driver::SandboxStatus`] into
/// fabro's inventory shape. The driver reports what a provider exposes
/// through its public facets; fields no facet carries (network policy) stay
/// unknown rather than being read from provider SDK types.
pub(crate) fn info_from_status(
kind: &fabro_types::SandboxProviderKind,
status: &sandbox_driver::SandboxStatus,
) -> fabro_types::SandboxInfo {
let fields = fields_from_status(status);
fabro_types::SandboxInfo {
provider: kind.clone(),
id: status.id.to_string(),
display_name: status.name.clone().filter(|name| !name.is_empty()),
state: fields.state,
native_state: fields.native_state,
image: status.source.clone(),
snapshot: None,
region: status.region.clone(),
web_url: status.web_url.clone(),
working_directory: None,
resources: fields.resources,
network: SandboxNetwork::unknown(),
labels: status.labels.clone(),
timestamps: fields.timestamps,
}
}
pub(crate) fn docker_info_from_inspect(inspect: &ContainerInspectResponse) -> SandboxInfo {
let fields = docker_fields_from_inspect(inspect);
SandboxInfo {
provider: SandboxProviderKind::DOCKER,
id: fields.id,
display_name: fields.display_name,
state: fields.state,
native_state: fields.native_state,
image: fields.image,
snapshot: None,
region: None,
web_url: None,
working_directory: fields.working_directory,
resources: fields.resources,
network: fields.network,
labels: fields.labels,
timestamps: fields.timestamps,
}
pub(crate) fn details_from_status(
record: &RunSandboxInstance,
status: &sandbox_driver::SandboxStatus,
) -> SandboxDetails {
let fields = fields_from_status(status);
SandboxDetails {
sandbox: RunSandboxInstance {
image: status.source.clone().or_else(|| record.image.clone()),
..record.clone()
},
state: fields.state,
native_state: fields.native_state,
region: status.region.clone(),
web_url: status.web_url.clone(),
resources: fields.resources,
network: SandboxNetwork::unknown(),
labels: status.labels.clone(),
timestamps: fields.timestamps,
}
}
pub(super) fn map_docker_inspect(
inspect: &ContainerInspectResponse,
record: &RunSandboxInstance,
) -> SandboxDetails {
let fields = docker_fields_from_inspect(inspect);
let image = fields.image.clone().or_else(|| record.image.clone());
struct StatusFields {
state: SandboxState,
native_state: Option<String>,
resources: SandboxResources,
timestamps: SandboxTimestamps,
}
SandboxDetails {
sandbox: RunSandboxInstance {
image,
..record.clone()
},
state: fields.state,
native_state: fields.native_state,
region: None,
web_url: None,
resources: fields.resources,
network: fields.network,
labels: fields.labels,
timestamps: fields.timestamps,
}
}
struct DockerFields {
id: String,
display_name: Option<String>,
state: SandboxState,
native_state: Option<String>,
image: Option<String>,
working_directory: Option<String>,
resources: SandboxResources,
network: SandboxNetwork,
labels: BTreeMap<String, String>,
timestamps: SandboxTimestamps,
}
fn docker_fields_from_inspect(inspect: &ContainerInspectResponse) -> DockerFields {
let status_enum = inspect
.state
fn fields_from_status(status: &sandbox_driver::SandboxStatus) -> StatusFields {
StatusFields {
state: normalize_driver_state(status.state),
native_state: Some(status.provider_state.clone()).filter(|value| !value.is_empty()),
resources: status
.resources
.as_ref()
.and_then(|state| state.status.as_ref())
.copied();
let normalized_state = status_enum.map_or(SandboxState::Unknown, normalize_docker_state);
let native_state = status_enum
.map(|status| status.to_string())
.filter(|value| !value.is_empty());
let host_config = inspect.host_config.as_ref();
let resources = SandboxResources {
cpu_cores: host_config.and_then(docker_cpu_cores),
memory_bytes: host_config
.and_then(|host| host.memory)
.filter(|bytes| *bytes > 0)
.and_then(|bytes| u64::try_from(bytes).ok()),
disk_bytes: None,
};
let network = docker_network(host_config);
let labels: BTreeMap<String, String> = inspect
.config
.as_ref()
.and_then(|config| config.labels.clone())
.map(|map| map.into_iter().collect())
.unwrap_or_default();
let image = inspect
.config
.as_ref()
.and_then(|config| config.image.clone())
.or_else(|| inspect.image.clone())
.filter(|value| !value.is_empty());
let working_directory = inspect
.config
.as_ref()
.and_then(|config| config.working_dir.clone())
.filter(|value| !value.is_empty())
.or_else(|| Some(WORKING_DIRECTORY.to_string()));
let id = inspect
.id
.clone()
.or_else(|| inspect.name.as_ref().map(|name| trim_container_name(name)))
.unwrap_or_default();
let display_name = inspect
.name
.as_ref()
.map(|name| trim_container_name(name))
.filter(|name| !name.is_empty());
let created_at = inspect.created.as_deref().and_then(parse_rfc3339_utc);
DockerFields {
id,
display_name,
state: normalized_state,
native_state,
image,
working_directory,
resources,
network,
labels,
timestamps: SandboxTimestamps {
created_at,
last_activity_at: None,
},
}
.map(|resources| SandboxResources {
cpu_cores: resources.cpu_cores.map(f64::from),
memory_bytes: resources.memory_mb.map(|mb| mb * 1024 * 1024),
disk_bytes: resources.disk_mb.map(|mb| mb * 1024 * 1024),
})
.unwrap_or_default(),
timestamps: SandboxTimestamps {
created_at: status.created_at.map(DateTime::<Utc>::from),
last_activity_at: status.updated_at.map(DateTime::<Utc>::from),
},
}
}
fn trim_container_name(name: &str) -> String {
name.strip_prefix('/').unwrap_or(name).to_string()
pub(crate) fn normalize_driver_state(state: sandbox_driver::SandboxState) -> SandboxState {
use sandbox_driver::SandboxState as Driver;
match state {
Driver::Creating | Driver::Forking => SandboxState::Provisioning,
Driver::Starting | Driver::Resuming => SandboxState::Starting,
// A sandbox mid-snapshot keeps serving commands.
Driver::Running | Driver::Snapshotting => SandboxState::Running,
Driver::Stopping | Driver::Archiving => SandboxState::Stopping,
Driver::Stopped => SandboxState::Stopped,
Driver::Pausing | Driver::Paused => SandboxState::Paused,
Driver::Archived => SandboxState::Archived,
Driver::Restoring => SandboxState::Restoring,
Driver::Resizing => SandboxState::Resizing,
Driver::Deleting => SandboxState::Deleting,
Driver::Deleted => SandboxState::Deleted,
Driver::Error => SandboxState::Error,
_ => SandboxState::Unknown,
}
}
fn docker_network(host_config: Option<&HostConfig>) -> SandboxNetwork {
match host_config.and_then(|host| host.network_mode.as_deref()) {
Some("none") => {
let blocked = SandboxNetworkPolicy::blocked();
SandboxNetwork {
egress: blocked.clone(),
ingress: blocked,
}
}
_ => SandboxNetwork::unknown(),
}
}
pub(super) fn docker_cpu_cores(host_config: &HostConfig) -> Option<f64> {
let quota = host_config.cpu_quota?;
let period = host_config.cpu_period?;
if quota <= 0 || period <= 0 {
return None;
}
#[allow(
clippy::cast_precision_loss,
reason = "CPU quota/period are bounded well within f64 mantissa precision."
)]
let cores = (quota as f64) / (period as f64);
Some(cores)
}
pub(super) fn normalize_docker_state(status: ContainerStateStatusEnum) -> SandboxState {
match status {
ContainerStateStatusEnum::EMPTY => SandboxState::Unknown,
ContainerStateStatusEnum::CREATED => SandboxState::Provisioning,
ContainerStateStatusEnum::RUNNING => SandboxState::Running,
ContainerStateStatusEnum::PAUSED => SandboxState::Paused,
ContainerStateStatusEnum::RESTARTING => SandboxState::Starting,
ContainerStateStatusEnum::REMOVING => SandboxState::Deleting,
ContainerStateStatusEnum::EXITED => SandboxState::Stopped,
ContainerStateStatusEnum::DEAD => SandboxState::Error,
}
}
#[cfg(test)]
mod tests {
use bollard::models::HostConfig;
use fabro_types::{
RunSandboxInstance, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy,
SandboxProviderKind,
};
use super::*;
fn record() -> RunSandboxInstance {
RunSandboxInstance {
provider: SandboxProviderKind::DOCKER,
image: None,
snapshot: None,
runtime: RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(true),
clone_origin_url: None,
clone_branch: None,
workspace_root: None,
repos_root: None,
primary_repo_path: None,
primary_repo_link: None,
},
}
}
#[test]
fn cpu_cores_divides_quota_by_period() {
let host = HostConfig {
cpu_quota: Some(200_000),
cpu_period: Some(100_000),
..Default::default()
};
assert_eq!(docker_cpu_cores(&host), Some(2.0));
}
#[test]
fn cpu_cores_returns_none_when_quota_missing() {
let host = HostConfig {
cpu_quota: None,
cpu_period: Some(100_000),
..Default::default()
};
assert_eq!(docker_cpu_cores(&host), None);
}
#[test]
fn cpu_cores_returns_none_when_period_zero() {
let host = HostConfig {
cpu_quota: Some(200_000),
cpu_period: Some(0),
..Default::default()
};
assert_eq!(docker_cpu_cores(&host), None);
}
#[test]
fn memory_bytes_zero_is_unset() {
let inspect = ContainerInspectResponse {
host_config: Some(HostConfig {
memory: Some(0),
..Default::default()
}),
..Default::default()
};
let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.resources.memory_bytes, None);
}
#[test]
fn memory_bytes_present_is_carried_through() {
let inspect = ContainerInspectResponse {
host_config: Some(HostConfig {
memory: Some(2 * 1024 * 1024 * 1024),
..Default::default()
}),
..Default::default()
};
let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.resources.memory_bytes, Some(2_147_483_648));
}
#[test]
fn network_mode_none_blocks_ingress_and_egress() {
let inspect = ContainerInspectResponse {
host_config: Some(HostConfig {
network_mode: Some("none".to_string()),
..Default::default()
}),
..Default::default()
};
let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.network.egress, SandboxNetworkPolicy::blocked());
assert_eq!(details.network.ingress, SandboxNetworkPolicy::blocked());
}
#[test]
fn non_none_network_mode_is_unknown() {
let inspect = ContainerInspectResponse {
host_config: Some(HostConfig {
network_mode: Some("bridge".to_string()),
..Default::default()
}),
..Default::default()
};
let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.network, SandboxNetwork::unknown());
}
#[test]
fn record_identity_is_carried_through() {
let inspect = ContainerInspectResponse {
name: Some("/fabro-run-abc".to_string()),
..Default::default()
};
let details = map_docker_inspect(&inspect, &record());
let runtime = details.sandbox.runtime;
assert_eq!(runtime.id, "container-abc123");
assert_eq!(runtime.working_directory, "/workspace");
}
#[test]
fn inventory_identity_uses_native_id_and_display_name() {
let inspect = ContainerInspectResponse {
id: Some("container-abc123".to_string()),
name: Some("/fabro-run-abc".to_string()),
..Default::default()
};
let info = docker_info_from_inspect(&inspect);
assert_eq!(info.id, "container-abc123");
assert_eq!(info.display_name.as_deref(), Some("fabro-run-abc"));
}
#[test]
fn inventory_working_directory_defaults_to_fabro_workspace() {
let inspect = ContainerInspectResponse::default();
let info = docker_info_from_inspect(&inspect);
assert_eq!(info.working_directory.as_deref(), Some("/workspace"));
}
#[test]
fn empty_status_is_unknown() {
assert_eq!(
normalize_docker_state(ContainerStateStatusEnum::EMPTY),
SandboxState::Unknown
);
}
#[test]
fn created_status_is_provisioning() {
assert_eq!(
normalize_docker_state(ContainerStateStatusEnum::CREATED),
SandboxState::Provisioning
);
}
#[test]
fn running_status_is_running() {
assert_eq!(
normalize_docker_state(ContainerStateStatusEnum::RUNNING),
SandboxState::Running
);
}
#[test]
fn paused_status_is_paused() {
assert_eq!(
normalize_docker_state(ContainerStateStatusEnum::PAUSED),
SandboxState::Paused
);
}
#[test]
fn restarting_status_is_starting() {
assert_eq!(
normalize_docker_state(ContainerStateStatusEnum::RESTARTING),
SandboxState::Starting
);
}
#[test]
fn removing_status_is_deleting() {
assert_eq!(
normalize_docker_state(ContainerStateStatusEnum::REMOVING),
SandboxState::Deleting
);
}
#[test]
fn exited_status_is_stopped() {
assert_eq!(
normalize_docker_state(ContainerStateStatusEnum::EXITED),
SandboxState::Stopped
);
}
#[test]
fn dead_status_is_error() {
assert_eq!(
normalize_docker_state(ContainerStateStatusEnum::DEAD),
SandboxState::Error
);
}
#[test]
fn parse_timestamp_accepts_rfc3339() {
let parsed = parse_rfc3339_utc("2026-05-09T12:00:00Z");
assert!(parsed.is_some());
}
#[test]
fn parse_timestamp_rejects_garbage() {
assert!(parse_rfc3339_utc("not a date").is_none());
}
}
async fn docker_details(
record: &RunSandboxInstance,
run_id: Option<RunId>,
) -> Result<SandboxDetails> {
let runtime = &record.runtime;
let sandbox = docker::attach_docker(
&runtime.id,
runtime.repo_cloned.unwrap_or(false),
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
)
.await?;
let status = sandbox.handle()?.describe().await.map_err(|err| {
anyhow::anyhow!(
"Failed to describe Docker container '{}': {err}",
runtime.id
)
})?;
Ok(details_from_status(record, &status))
}
#[cfg(feature = "daytona")]
@ -823,9 +516,86 @@ pub(crate) mod daytona {
#[cfg(test)]
mod tests {
use fabro_types::SandboxProviderKind;
use sandbox_driver::SandboxId;
use super::*;
#[test]
fn driver_states_map_onto_fabro_states() {
use sandbox_driver::SandboxState as Driver;
for (driver, fabro) in [
(Driver::Creating, SandboxState::Provisioning),
(Driver::Starting, SandboxState::Starting),
(Driver::Running, SandboxState::Running),
(Driver::Snapshotting, SandboxState::Running),
(Driver::Stopping, SandboxState::Stopping),
(Driver::Stopped, SandboxState::Stopped),
(Driver::Paused, SandboxState::Paused),
(Driver::Archived, SandboxState::Archived),
(Driver::Deleting, SandboxState::Deleting),
(Driver::Deleted, SandboxState::Deleted),
(Driver::Error, SandboxState::Error),
(Driver::Unknown, SandboxState::Unknown),
] {
assert_eq!(normalize_driver_state(driver), fabro, "{driver:?}");
}
}
#[test]
fn status_projection_carries_identity_source_and_labels() {
let mut status = sandbox_driver::SandboxStatus::new(
SandboxId::try_new("container-abc123").unwrap(),
sandbox_driver::SandboxState::Running,
);
status.name = Some("fabro-run-abc".to_string());
status.provider_state = "running".to_string();
status.source = Some("buildpack-deps:noble".to_string());
status
.labels
.insert("sh.fabro.managed".to_string(), "true".to_string());
let mut resources = sandbox_driver::Resources::default();
resources.cpu_cores = Some(2);
resources.memory_mb = Some(2048);
status.resources = Some(resources);
let info = info_from_status(&SandboxProviderKind::DOCKER, &status);
assert_eq!(info.id, "container-abc123");
assert_eq!(info.display_name.as_deref(), Some("fabro-run-abc"));
assert_eq!(info.state, SandboxState::Running);
assert_eq!(info.native_state.as_deref(), Some("running"));
assert_eq!(info.image.as_deref(), Some("buildpack-deps:noble"));
assert_eq!(info.resources.cpu_cores, Some(2.0));
assert_eq!(info.resources.memory_bytes, Some(2_147_483_648));
assert_eq!(
info.labels.get("sh.fabro.managed").map(String::as_str),
Some("true")
);
let record = RunSandboxInstance {
provider: SandboxProviderKind::DOCKER,
image: None,
snapshot: None,
runtime: fabro_types::RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(true),
clone_origin_url: None,
clone_branch: None,
workspace_root: None,
repos_root: None,
primary_repo_path: None,
primary_repo_link: None,
},
};
let details = details_from_status(&record, &status);
assert_eq!(
details.sandbox.image.as_deref(),
Some("buildpack-deps:noble")
);
assert_eq!(details.sandbox.runtime.id, "container-abc123");
assert_eq!(details.network, SandboxNetwork::unknown());
}
#[test]
fn local_details_returns_running_with_no_metadata() {
let record = RunSandboxInstance {

File diff suppressed because it is too large Load diff

View file

@ -13,20 +13,28 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use fabro_github::GitHubCredentials;
use fabro_github::token_source::InstallationTokenSource;
use fabro_types::SandboxProviderKind;
use sandbox_driver::{
FileKind, LifecycleTimers, Sandbox as DriverHandle, SandboxProvider as _, SandboxSource,
Action, Event, EventBody, EventContext, EventObserver, FileKind, LifecycleTimers, ProgressCode,
PtyOptions, PtySize, Sandbox as DriverHandle, SandboxProvider as DriverProvider, SandboxSource,
SandboxSpec as DriverSpec, SandboxState, Search as _, WaitOptions,
};
use sandbox_driver_host::HostProvider;
use tokio::fs;
use tokio::sync::OnceCell;
use tokio_util::sync::CancellationToken;
use crate::RetryPlan;
use crate::clone::{self, GitHubClone};
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason};
use crate::push_credentials::{self, PushCredentialState};
use crate::terminal::{DriverTerminalSession, TerminalSize};
use crate::{GitRunInfo, GitSetupIntent, RefreshOutcome, RetryPlan};
/// A sandbox on the worker host at `working_directory`, the fabro `local`
/// kind, served by the driver's in-process Host provider.
@ -59,10 +67,158 @@ use crate::sandbox::{
WalkOptions,
};
/// Where a clone-based provider puts its files: the run works under
/// `workspace_root`, and repositories check out under `repos_root`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct WorkspaceLayout {
pub(crate) workspace_root: String,
pub(crate) repos_root: String,
}
/// What `initialize` does to the workspace once the sandbox runs.
enum WorkspacePlan {
/// Clone this GitHub repository into the layout.
Clone(GitHubClone),
/// Create the empty workspace root and nothing else.
Empty(EmptyWorkspaceReason),
/// The workspace was prepared by an earlier process; leave it alone.
Attached,
}
/// Fabro's clone-based workspace on an isolated sandbox: the layout, the
/// clone it performs, and the GitHub credentials its checkout carries.
pub(crate) struct RepoWorkspace {
layout: WorkspaceLayout,
plan: WorkspacePlan,
credentials: PushCredentialState,
repo_cloned: OnceLock<bool>,
origin_url: OnceLock<String>,
/// The directory the run works in once known: the repository link for a
/// clone, the workspace root otherwise.
execution_directory: OnceLock<String>,
/// The real checkout behind the workspace link, for traversals that
/// must not start at a symlink.
checkout_path: OnceLock<String>,
}
impl RepoWorkspace {
/// Decide the clone for a new sandbox. Fails before any provider call
/// when the selectors are inconsistent (a pin without a branch, a
/// non-GitHub origin without `skip_clone`).
#[expect(
clippy::too_many_arguments,
reason = "the clone selectors are validated together by decide_clone"
)]
pub(crate) fn plan(
layout: WorkspaceLayout,
skip_clone: bool,
clone_origin_url: Option<&str>,
clone_branch: Option<&str>,
clone_tag: Option<&str>,
clone_commit_sha: Option<&str>,
clone_depth: Option<u32>,
github_app: Option<&GitHubCredentials>,
) -> crate::Result<Self> {
let decision = clone_source::decide_clone(
skip_clone,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
)?;
let credentials = PushCredentialState::new(push_credentials::build_token_source(
github_app,
clone_origin_url,
)?);
let plan = match decision {
CloneDecision::EmptyWorkspace { reason } => WorkspacePlan::Empty(reason),
CloneDecision::GitHub {
origin_url,
branch,
tag,
commit_sha,
} => WorkspacePlan::Clone(GitHubClone {
origin_url,
branch,
tag,
commit_sha,
depth: clone_depth,
}),
};
Ok(Self {
layout,
plan,
credentials,
repo_cloned: OnceLock::new(),
origin_url: OnceLock::new(),
execution_directory: OnceLock::new(),
checkout_path: OnceLock::new(),
})
}
/// A workspace prepared by an earlier process, described by the run
/// record. Pushes from a reattached sandbox use whatever credentials the
/// checkout's `origin` already carries.
pub(crate) fn attached(
layout: WorkspaceLayout,
repo_cloned: bool,
working_directory: String,
clone_origin_url: Option<String>,
) -> Self {
let workspace = Self {
layout,
plan: WorkspacePlan::Attached,
credentials: PushCredentialState::new(None),
repo_cloned: OnceLock::new(),
origin_url: OnceLock::new(),
execution_directory: OnceLock::new(),
checkout_path: OnceLock::new(),
};
let _ = workspace.repo_cloned.set(repo_cloned);
let _ = workspace.execution_directory.set(working_directory);
if repo_cloned {
if let Some(origin) = clone_origin_url {
if let Ok(repo_layout) = clone_source::github_repo_layout(
&origin,
&workspace.layout.workspace_root,
&workspace.layout.repos_root,
) {
let _ = workspace.checkout_path.set(repo_layout.primary_repo_path);
}
let _ = workspace.origin_url.set(origin);
}
}
workspace
}
fn repo_cloned(&self) -> bool {
self.repo_cloned.get().copied().unwrap_or(false)
}
fn working_directory(&self) -> &str {
self.execution_directory
.get()
.map_or(self.layout.workspace_root.as_str(), String::as_str)
}
}
/// A sandbox that does not exist yet: `initialize` creates it from the
/// spec on the provider.
struct PendingCreate {
provider: Arc<dyn DriverProvider>,
spec: DriverSpec,
/// The image or snapshot named by the spec, for pull progress events.
source: Option<String>,
}
/// A fabro sandbox backed by a sandbox-driver handle.
pub struct DriverSandbox {
kind: SandboxProviderKind,
handle: Arc<dyn DriverHandle>,
/// Set at construction for an existing sandbox, at `initialize` for a
/// pending one.
handle: OnceCell<Arc<dyn DriverHandle>>,
pending: Option<PendingCreate>,
workspace: Option<RepoWorkspace>,
env_policy: ExplicitEnvPolicy,
event_callback: Option<SandboxEventCallback>,
/// `(platform, os_version)` learned from the sandbox at initialize or
@ -76,6 +232,43 @@ impl DriverSandbox {
/// is isolated and takes the caller's environment as composed.
#[must_use]
pub fn new(kind: SandboxProviderKind, handle: Arc<dyn DriverHandle>) -> Self {
let sandbox = Self::empty(kind);
let _ = sandbox.handle.set(handle);
sandbox
}
/// A sandbox `initialize` will create from `spec` on `provider`, then
/// prepare per `workspace`.
pub(crate) fn pending(
kind: SandboxProviderKind,
provider: Arc<dyn DriverProvider>,
spec: DriverSpec,
source: Option<String>,
workspace: RepoWorkspace,
) -> Self {
let mut sandbox = Self::empty(kind);
sandbox.pending = Some(PendingCreate {
provider,
spec,
source,
});
sandbox.workspace = Some(workspace);
sandbox
}
/// An existing sandbox reattached by handle, with the workspace an
/// earlier process prepared.
pub(crate) fn attached(
kind: SandboxProviderKind,
handle: Arc<dyn DriverHandle>,
workspace: RepoWorkspace,
) -> Self {
let mut sandbox = Self::new(kind, handle);
sandbox.workspace = Some(workspace);
sandbox
}
fn empty(kind: SandboxProviderKind) -> Self {
let env_policy = if kind.is_local() {
ExplicitEnvPolicy::FilterSensitive
} else {
@ -83,7 +276,9 @@ impl DriverSandbox {
};
Self {
kind,
handle,
handle: OnceCell::new(),
pending: None,
workspace: None,
env_policy,
event_callback: None,
platform: OnceLock::new(),
@ -101,14 +296,37 @@ impl DriverSandbox {
}
/// The driver handle, for callers that need a facet fabro's trait does
/// not carry (git, services, access).
#[must_use]
pub fn handle(&self) -> &Arc<dyn DriverHandle> {
&self.handle
/// not carry (git, services, access). Absent until a pending sandbox is
/// initialized.
pub fn handle(&self) -> crate::Result<&Arc<dyn DriverHandle>> {
self.handle.get().ok_or_else(|| {
crate::Error::message(format!(
"{} sandbox is not initialized; call initialize() first",
self.kind
))
})
}
fn exec(&self) -> SandboxExec<'_> {
SandboxExec::new(self.handle.exec(), self.env_policy)
fn exec(&self) -> crate::Result<SandboxExec<'_>> {
let mut exec = SandboxExec::new(self.handle()?.exec(), self.env_policy);
if let Some(workspace) = &self.workspace {
if let Some(dir) = workspace.execution_directory.get() {
exec = exec.with_working_dir(dir.clone());
}
}
Ok(exec)
}
/// Resolve a caller path against fabro's working directory. The driver
/// resolves relative paths against the sandbox's own working directory,
/// which sits above a cloned repository's link.
fn resolve(&self, path: &str) -> String {
match &self.workspace {
Some(workspace) if workspace.execution_directory.get().is_some() => {
sandbox::resolve_path(path, workspace.working_directory())
}
_ => path.to_string(),
}
}
fn provider_name(&self) -> String {
@ -123,7 +341,7 @@ impl DriverSandbox {
}
fn search(&self) -> crate::Result<sandbox_driver::SearchFacet<'_>> {
self.handle.search().ok_or_else(|| {
self.handle()?.search().ok_or_else(|| {
crate::Error::message(format!(
"sandbox provider `{}` does not support search",
self.kind
@ -131,18 +349,142 @@ impl DriverSandbox {
})
}
/// Create the sandbox on the provider when it does not exist yet.
async fn ensure_created(&self) -> crate::Result<()> {
if self.handle.get().is_some() {
return Ok(());
}
let Some(pending) = &self.pending else {
return self.handle().map(|_| ());
};
let observer = Arc::new(CreateProgress::new(
pending.source.clone(),
self.event_callback.clone(),
));
let handle = pending
.provider
.create(&pending.spec, Some(EventContext::new(observer)))
.await
.map_err(|error| {
crate::Error::context(format!("Failed to create {} sandbox", self.kind), error)
})?;
let _ = self.handle.set(handle);
Ok(())
}
/// Bring the sandbox to `Running` with a verified Bash, and learn its
/// platform. Shared by initialize and start.
async fn make_ready(&self) -> crate::Result<()> {
sandbox_driver::activate(self.handle.as_ref(), &WaitOptions::default()).await?;
sandbox_driver::activate(self.handle()?.as_ref(), &WaitOptions::default()).await?;
self.learn_platform().await
}
/// Prepare the workspace after the sandbox runs for the first time:
/// an empty root, or fabro's clone.
async fn prepare_workspace(&self) -> crate::Result<()> {
let Some(workspace) = &self.workspace else {
return Ok(());
};
match &workspace.plan {
WorkspacePlan::Attached => Ok(()),
WorkspacePlan::Empty(reason) => {
if matches!(reason, EmptyWorkspaceReason::MissingOrigin) {
tracing::warn!(
provider = %self.kind,
reason = reason.message(),
"Clone source missing for clone-based sandbox"
);
}
self.handle()?
.fs()
.create_dir(&workspace.layout.workspace_root)
.await
.map_err(|error| {
crate::Error::context(
format!("Failed to create {}", workspace.layout.workspace_root),
error,
)
})?;
let _ = workspace.repo_cloned.set(false);
Ok(())
}
WorkspacePlan::Clone(plan) => {
self.emit(SandboxEvent::GitCloneStarted {
url: plan.origin_url.clone(),
branch: plan.branch.clone(),
});
let started = Instant::now();
let handle = self.handle()?;
// The clone names every directory it touches, so it runs
// without fabro's working-directory override.
let exec = SandboxExec::new(handle.exec(), self.env_policy);
let outcome = clone::clone_github_repo(
&self.kind,
handle.as_ref(),
&exec,
plan,
&workspace.layout.workspace_root,
&workspace.layout.repos_root,
&workspace.credentials,
)
.await;
match outcome {
Ok(outcome) => {
let _ = workspace.repo_cloned.set(true);
let _ = workspace.origin_url.set(plan.origin_url.clone());
let _ = workspace
.checkout_path
.set(outcome.layout.primary_repo_path.clone());
let _ = workspace
.execution_directory
.set(outcome.layout.execution_directory.clone());
self.emit(SandboxEvent::GitCloneCompleted {
url: plan.origin_url.clone(),
duration_ms: elapsed_ms(started),
});
Ok(())
}
Err(error) => {
self.emit(SandboxEvent::GitCloneFailed {
url: plan.origin_url.clone(),
error: error.to_string(),
causes: error.causes(),
});
Err(error)
}
}
}
}
}
/// Open an interactive shell in the sandbox's working directory over the
/// driver's Pty facet.
pub async fn open_terminal(&self, size: TerminalSize) -> crate::Result<DriverTerminalSession> {
let handle = self.handle()?;
let pty = handle.pty().ok_or_else(|| {
crate::Error::message(format!(
"sandbox provider `{}` does not support terminals",
self.kind
))
})?;
let mut options = PtyOptions::default();
options.size = PtySize {
rows: size.rows,
cols: size.cols,
};
options.working_dir = Some(self.working_directory().to_string());
let session = pty
.open(&options)
.await
.map_err(|error| crate::Error::context("Failed to open sandbox terminal", error))?;
Ok(DriverTerminalSession::new(session))
}
/// Ask the sandbox for its platform once; `platform` and `os_version`
/// report `unknown` until this has run.
async fn learn_platform(&self) -> crate::Result<()> {
if self.platform.get().is_none() {
let info = self.handle.platform_info().await?;
let info = self.handle()?.platform_info().await?;
let platform = fabro_platform_name(&info.os).to_string();
let os_version = if info.version.is_empty() {
platform.clone()
@ -160,13 +502,109 @@ impl DriverSandbox {
/// walked as given.
fn walk_base(&self, base: &str, relative_start: &str) -> String {
if base == self.working_directory() || base.is_empty() || base == "." {
// A cloned repository is reached through a workspace link. The
// driver refuses a symlinked traversal root, so walk the real
// checkout; results are reported under the link.
if let Some(checkout) = self
.workspace
.as_ref()
.and_then(|workspace| workspace.checkout_path.get())
{
return sandbox::join_sandbox_path(checkout, relative_start);
}
if relative_start.is_empty() {
".".to_string()
} else {
relative_start.to_string()
}
} else {
sandbox::join_sandbox_path(base, relative_start)
sandbox::join_sandbox_path(&self.resolve(base), relative_start)
}
}
}
/// Turns the driver's create-time progress into fabro's snapshot events:
/// an image pull starts `SnapshotPulling` and the create's completion ends
/// it. A create without a pull emits nothing.
struct CreateProgress {
source: Option<String>,
callback: Option<SandboxEventCallback>,
pull_started: Mutex<Option<Instant>>,
}
impl CreateProgress {
fn new(source: Option<String>, callback: Option<SandboxEventCallback>) -> Self {
Self {
source,
callback,
pull_started: Mutex::new(None),
}
}
fn emit(&self, event: SandboxEvent) {
event.trace();
if let Some(cb) = &self.callback {
cb(event);
}
}
fn name(&self) -> String {
self.source.clone().unwrap_or_default()
}
}
#[async_trait]
impl EventObserver for CreateProgress {
async fn observe(&self, event: Event) {
match &event.body {
EventBody::OperationProgress { progress, .. }
if progress.code.as_str() == ProgressCode::IMAGE_PULL =>
{
let mut started = self
.pull_started
.lock()
.unwrap_or_else(PoisonError::into_inner);
if started.is_none() {
*started = Some(Instant::now());
drop(started);
self.emit(SandboxEvent::SnapshotPulling { name: self.name() });
}
}
EventBody::OperationCompleted {
action: Action::Create,
..
} => {
let started = self
.pull_started
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if let Some(started) = started {
self.emit(SandboxEvent::SnapshotReady {
name: self.name(),
duration_ms: elapsed_ms(started),
});
}
}
EventBody::OperationFailed {
action: Action::Create,
error,
..
} => {
let started = self
.pull_started
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if started.is_some() {
self.emit(SandboxEvent::SnapshotFailed {
name: self.name(),
error: error.message.clone(),
causes: error.causes.clone(),
});
}
}
_ => {}
}
}
}
@ -186,17 +624,17 @@ fn file_context(action: &str, path: &str) -> String {
#[async_trait]
impl Sandbox for DriverSandbox {
async fn read_file_bytes(&self, path: &str) -> crate::Result<Vec<u8>> {
self.handle
self.handle()?
.fs()
.read(path)
.read(&self.resolve(path))
.await
.map_err(|error| crate::Error::context(file_context("read", path), error))
}
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
self.handle
self.handle()?
.fs()
.write(path, content.as_bytes())
.write(&self.resolve(path), content.as_bytes())
.await
.map_err(|error| crate::Error::context(file_context("write", path), error))
}
@ -210,17 +648,17 @@ impl Sandbox for DriverSandbox {
file_context("delete", path)
)));
}
self.handle
self.handle()?
.fs()
.delete(path, false)
.delete(&self.resolve(path), false)
.await
.map_err(|error| crate::Error::context(file_context("delete", path), error))
}
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
self.handle
self.handle()?
.fs()
.exists(path)
.exists(&self.resolve(path))
.await
.map_err(|error| crate::Error::context(file_context("stat", path), error))
}
@ -231,9 +669,9 @@ impl Sandbox for DriverSandbox {
depth: Option<usize>,
) -> crate::Result<Vec<DirEntry>> {
let entries = self
.handle
.handle()?
.fs()
.list_dir(path, depth.unwrap_or(1))
.list_dir(&self.resolve(path), depth.unwrap_or(1))
.await
.map_err(|error| crate::Error::context(file_context("list", path), error))?;
let mut entries: Vec<DirEntry> = entries
@ -258,7 +696,7 @@ impl Sandbox for DriverSandbox {
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> crate::Result<ExecResult> {
self.exec()
self.exec()?
.run(
command,
Some(Duration::from_millis(timeout_ms)),
@ -273,7 +711,7 @@ impl Sandbox for DriverSandbox {
&self,
request: ExecStreamingRequest<'_>,
) -> crate::Result<ExecStreamingResult> {
self.exec().run_streaming(request).await
self.exec()?.run_streaming(request).await
}
async fn spawn_stdio_process(
@ -283,7 +721,7 @@ impl Sandbox for DriverSandbox {
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
self.exec()
self.exec()?
.spawn_stdio(command, working_dir, env_vars, cancel_token)
.await
}
@ -300,7 +738,7 @@ impl Sandbox for DriverSandbox {
driver_options.include.clone_from(&options.glob_filter);
let matches = self
.search()?
.grep(pattern, path, &driver_options)
.grep(pattern, &self.resolve(path), &driver_options)
.await
.map_err(|error| crate::Error::context("Failed to search file contents", error))?;
Ok(matches
@ -337,7 +775,7 @@ impl Sandbox for DriverSandbox {
let size = match file.size {
Some(size) => size,
None => {
self.handle
self.handle()?
.fs()
.metadata(&path)
.await
@ -359,9 +797,9 @@ impl Sandbox for DriverSandbox {
remote_path: &str,
local_path: &Path,
) -> crate::Result<()> {
self.handle
self.handle()?
.fs()
.download(remote_path, local_path)
.download(&self.resolve(remote_path), local_path)
.await
.map_err(|error| crate::Error::context(file_context("download", remote_path), error))
}
@ -371,19 +809,26 @@ impl Sandbox for DriverSandbox {
local_path: &Path,
remote_path: &str,
) -> crate::Result<()> {
self.handle
self.handle()?
.fs()
.upload(local_path, remote_path)
.upload(local_path, &self.resolve(remote_path))
.await
.map_err(|error| crate::Error::context(file_context("upload", remote_path), error))
}
/// Create the sandbox when it is pending, bring it to `Running`, and
/// prepare fabro's workspace (empty root or clone) on first use.
async fn initialize(&self) -> crate::Result<()> {
self.emit(SandboxEvent::Initializing {
provider: self.provider_name(),
});
let started = Instant::now();
let result = self.make_ready().await;
let result = async {
self.ensure_created().await?;
self.make_ready().await?;
self.prepare_workspace().await
}
.await;
let duration_ms = elapsed_ms(started);
match &result {
Ok(()) => self.emit(SandboxEvent::Ready {
@ -407,7 +852,7 @@ impl Sandbox for DriverSandbox {
/// Idempotent access-time check: a running sandbox is left alone; a
/// stopped or paused one is brought back and its Bash verified.
async fn activate(&self) -> crate::Result<()> {
let status = self.handle.describe().await?;
let status = self.handle()?.describe().await?;
if status.state == SandboxState::Running {
return Ok(());
}
@ -439,7 +884,10 @@ impl Sandbox for DriverSandbox {
provider: self.provider_name(),
});
let started = Instant::now();
let result = self.handle.stop().await.map_err(crate::Error::from);
let result = match self.handle() {
Ok(handle) => handle.stop().await.map_err(crate::Error::from),
Err(error) => Err(error),
};
match &result {
Ok(()) => self.emit(SandboxEvent::StopCompleted {
provider: self.provider_name(),
@ -459,7 +907,7 @@ impl Sandbox for DriverSandbox {
provider: self.provider_name(),
});
let started = Instant::now();
let result = self.handle.delete().await.map_err(crate::Error::from);
let result = self.release().await;
match &result {
Ok(()) => self.emit(SandboxEvent::DeleteCompleted {
provider: self.provider_name(),
@ -482,7 +930,7 @@ impl Sandbox for DriverSandbox {
provider: self.provider_name(),
});
let started = Instant::now();
let result = self.handle.delete().await.map_err(crate::Error::from);
let result = self.release().await;
match &result {
Ok(()) => self.emit(SandboxEvent::CleanupCompleted {
provider: self.provider_name(),
@ -497,12 +945,21 @@ impl Sandbox for DriverSandbox {
result
}
/// The directory the run works in: the cloned repository's link for a
/// clone-based workspace, the provider's working directory otherwise.
fn working_directory(&self) -> &str {
self.handle.working_directory()
if let Some(workspace) = &self.workspace {
return workspace.working_directory();
}
self.handle
.get()
.map_or("", |handle| handle.working_directory())
}
fn runtime_directory(&self) -> Option<&str> {
self.handle.runtime_directory()
self.handle
.get()
.and_then(|handle| handle.runtime_directory())
}
fn platform(&self) -> &str {
@ -521,12 +978,15 @@ impl Sandbox for DriverSandbox {
/// The provider's id for this sandbox, or empty for `local`: a local
/// sandbox is its working directory, which the run record already
/// carries, and its Host registry id does not outlive the process.
/// Empty for a pending sandbox that has not been created.
fn sandbox_info(&self) -> String {
if self.kind.is_local() {
String::new()
} else {
self.handle.id().to_string()
return String::new();
}
self.handle
.get()
.map(|handle| handle.id().to_string())
.unwrap_or_default()
}
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
@ -535,7 +995,7 @@ impl Sandbox for DriverSandbox {
.ok()
.filter(|minutes| *minutes > 0)
.map(Duration::from_mins);
match self.handle.set_timers(&timers).await {
match self.handle()?.set_timers(&timers).await {
// A provider without timers has nothing to stop automatically.
Ok(()) | Err(sandbox_driver::Error::Unsupported { .. }) => Ok(()),
Err(error) => Err(crate::Error::context(
@ -545,33 +1005,108 @@ impl Sandbox for DriverSandbox {
}
}
async fn setup_git(&self, intent: &GitSetupIntent) -> crate::Result<Option<GitRunInfo>> {
if !self.repo_cloned() {
return Ok(None);
}
sandbox::setup_git_via_exec(self, intent).await.map(Some)
}
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
if !self.repo_cloned() {
return Vec::new();
}
vec![format!(
"git fetch origin {} && git checkout {}",
sandbox::shell_quote(run_branch),
sandbox::shell_quote(run_branch)
)]
}
async fn git_push_ref(&self, refspec: &str, plan: &RetryPlan) -> Result<PushReport, PushError> {
let has_origin = match self
.exec_command("git remote get-url origin", 10_000, None, None, None)
.await
{
Ok(result) if result.is_success() => true,
Ok(_) => false,
Err(err) => {
return Err(PushError {
report: PushReport::default(),
error: crate::Error::context("git remote get-url origin", err),
});
let Some(workspace) = &self.workspace else {
// A designated directory: push only when the checkout has an
// origin, with whatever credentials its URL already carries.
let has_origin = match self
.exec_command("git remote get-url origin", 10_000, None, None, None)
.await
{
Ok(result) if result.is_success() => true,
Ok(_) => false,
Err(err) => {
return Err(PushError {
report: PushReport::default(),
error: crate::Error::context("git remote get-url origin", err),
});
}
};
if !has_origin {
return Ok(PushReport::default());
}
return sandbox::git_push_via_exec(self, None, refspec, plan).await;
};
if !has_origin {
if !workspace.repo_cloned() {
return Ok(PushReport::default());
}
// No managed credentials yet: the checkout pushes with whatever the
// remote URL already carries.
sandbox::git_push_via_exec(self, None, refspec, plan).await
let credentials = workspace
.origin_url
.get()
.map(|origin_url| (&workspace.credentials, origin_url.as_str()));
sandbox::git_push_via_exec(self, credentials, refspec, plan).await
}
fn origin_url(&self) -> Option<&str> {
let workspace = self.workspace.as_ref()?;
if !workspace.repo_cloned() {
return None;
}
workspace.origin_url.get().map(String::as_str)
}
#[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))]
async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
let Some(workspace) = &self.workspace else {
return Ok(RefreshOutcome::none());
};
if !workspace.repo_cloned() {
return Ok(RefreshOutcome::none());
}
let Some(origin_url) = workspace.origin_url.get() else {
return Ok(RefreshOutcome::none());
};
workspace
.credentials
.refresh(origin_url, |auth_url| {
push_credentials::set_auth_url_via_exec(self, auth_url)
})
.await
}
fn push_token_source(&self) -> Option<Arc<InstallationTokenSource>> {
self.workspace
.as_ref()
.and_then(|workspace| workspace.credentials.source().cloned())
}
/// The local command that opens a shell in the sandbox, from the
/// provider's access facet. `None` when the provider has no such
/// command (the local sandbox is the host).
async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
let Some(shell) = self.handle()?.shell_command() else {
return Ok(None);
};
shell
.shell_command()
.await
.map(Some)
.map_err(|error| crate::Error::context("Failed to build sandbox shell command", error))
}
async fn get_preview_url(
&self,
port: u16,
) -> crate::Result<Option<(String, HashMap<String, String>)>> {
let Some(previews) = self.handle.preview_urls() else {
let Some(previews) = self.handle()?.preview_urls() else {
return Ok(None);
};
let preview = previews
@ -585,14 +1120,30 @@ impl Sandbox for DriverSandbox {
}
}
impl DriverSandbox {
fn repo_cloned(&self) -> bool {
self.workspace
.as_ref()
.is_some_and(RepoWorkspace::repo_cloned)
}
/// Delete the sandbox on the provider. A pending sandbox that was never
/// created has nothing to release.
async fn release(&self) -> crate::Result<()> {
match self.handle.get() {
Some(handle) => handle.delete().await.map_err(crate::Error::from),
None if self.pending.is_some() => Ok(()),
None => self.handle().map(|_| ()),
}
}
}
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use fabro_types::CommandTermination;
use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec};
use sandbox_driver_host::HostProvider;
@ -832,9 +1383,9 @@ mod tests {
"",
"local sandboxes are identified by directory"
);
let isolated =
DriverSandbox::new(SandboxProviderKind::DOCKER, Arc::clone(f.sandbox.handle()));
assert_eq!(isolated.sandbox_info(), f.sandbox.handle().id().to_string());
let handle = Arc::clone(f.sandbox.handle().unwrap());
let isolated = DriverSandbox::new(SandboxProviderKind::DOCKER, Arc::clone(&handle));
assert_eq!(isolated.sandbox_info(), handle.id().to_string());
f.sandbox.stop().await.unwrap();
f.sandbox.activate().await.unwrap();

View file

@ -1,7 +1,5 @@
use std::fmt::Write as _;
#[cfg(feature = "docker")]
use bollard::errors::Error as BollardError;
use fabro_util::error::{collect_causes, render_with_causes};
use crate::ExecResult;
@ -26,29 +24,6 @@ pub enum Error {
source: anyhow::Error,
},
#[cfg(feature = "docker")]
#[error("Failed to connect to Docker daemon")]
DockerConnect {
#[source]
source: BollardError,
},
#[cfg(feature = "docker")]
#[error("Failed to inspect Docker image {image}")]
DockerImageInspect {
image: String,
#[source]
source: BollardError,
},
#[cfg(feature = "docker")]
#[error("Failed to pull Docker image {image}")]
DockerImagePull {
image: String,
#[source]
source: BollardError,
},
/// A sandbox-driver failure: provider, transport, or an operation whose
/// outcome is unknown. The driver's own variants stay reachable through
/// [`Error::driver`] so callers can act on `NotFound`, `Unsupported`,
@ -101,27 +76,6 @@ impl Error {
default_redacted_output_tail(self)
}
#[cfg(feature = "docker")]
pub fn docker_connect(source: BollardError) -> Self {
Self::DockerConnect { source }
}
#[cfg(feature = "docker")]
pub fn docker_image_inspect(image: impl Into<String>, source: BollardError) -> Self {
Self::DockerImageInspect {
image: image.into(),
source,
}
}
#[cfg(feature = "docker")]
pub fn docker_image_pull(image: impl Into<String>, source: BollardError) -> Self {
Self::DockerImagePull {
image: image.into(),
source,
}
}
pub fn causes(&self) -> Vec<String> {
collect_causes(self)
}

View file

@ -89,9 +89,12 @@ pub fn is_sensitive_env_var(key: &str) -> bool {
/// Fabro's exec policy bound to one driver [`Exec`] facet.
pub struct SandboxExec<'a> {
exec: &'a dyn Exec,
env_policy: ExplicitEnvPolicy,
stop_grace: Duration,
exec: &'a dyn Exec,
env_policy: ExplicitEnvPolicy,
stop_grace: Duration,
/// Where a command runs when the caller names no directory. `None`
/// leaves the choice to the provider's own working directory.
working_dir: Option<String>,
}
impl<'a> SandboxExec<'a> {
@ -101,9 +104,19 @@ impl<'a> SandboxExec<'a> {
exec,
env_policy,
stop_grace: DEFAULT_STOP_GRACE,
working_dir: None,
}
}
/// The directory commands run in when the caller names none. Fabro's
/// working directory can sit below the provider's (a cloned repository
/// inside the container workspace), so it is passed explicitly.
#[must_use]
pub fn with_working_dir(mut self, working_dir: impl Into<String>) -> Self {
self.working_dir = Some(working_dir.into());
self
}
/// Time between `TERM` and `KILL` when a command is stopped.
#[must_use]
pub fn with_stop_grace(mut self, stop_grace: Duration) -> Self {
@ -161,7 +174,7 @@ impl<'a> SandboxExec<'a> {
let started = Instant::now();
let mut spec = ExecSpec::bash(command).no_timeout();
if let Some(dir) = working_dir {
if let Some(dir) = working_dir.or(self.working_dir.as_deref()) {
spec = spec.working_dir(dir);
}
for (key, value) in self.explicit_env(env_vars) {
@ -222,7 +235,7 @@ impl<'a> SandboxExec<'a> {
cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
let mut spec = SpawnSpec::bash(format!("exec {command}"));
if let Some(dir) = working_dir {
if let Some(dir) = working_dir.or(self.working_dir.as_deref()) {
spec = spec.working_dir(dir);
}
for (key, value) in self.explicit_env(env_vars) {

View file

@ -5,7 +5,6 @@
use std::path::{Path, PathBuf};
#[cfg(feature = "docker")]
use fabro_types::settings::ResolveError;
#[cfg(feature = "daytona")]
use fabro_types::settings::run::DockerfileSource as ResolvedDockerfileSource;
@ -20,7 +19,6 @@ use crate::config::{
};
#[cfg(feature = "daytona")]
use crate::daytona::DaytonaConfig;
#[cfg(feature = "docker")]
use crate::docker::DockerSandboxOptions;
#[cfg(feature = "daytona")]
@ -74,7 +72,6 @@ pub fn daytona_config_from_environment(
}
}
#[cfg(feature = "docker")]
#[must_use]
pub fn docker_config_from_environment(
settings: &RunEnvironmentSettings,
@ -96,7 +93,6 @@ pub fn docker_config_from_environment(
docker_config_from_environment_env(settings, clone, env)
}
#[cfg(feature = "docker")]
pub fn docker_config_from_environment_with_secrets(
settings: &RunEnvironmentSettings,
clone: &RunCloneSettings,
@ -106,7 +102,6 @@ pub fn docker_config_from_environment_with_secrets(
Ok(docker_config_from_environment_env(settings, clone, env))
}
#[cfg(feature = "docker")]
fn docker_config_from_environment_env(
settings: &RunEnvironmentSettings,
clone: &RunCloneSettings,

View file

@ -194,6 +194,42 @@ pub fn classify_failure(message: &str, cred: CredentialContext) -> Option<GitRet
classify_message(message, cred).retry_reason()
}
/// Classify a sandbox-driver git failure.
///
/// A command the driver ran surfaces as [`sandbox_driver::Error::Exec`] with
/// the git output attached, and is classified like fabro's own exec output.
/// A provider-side failure carries a message and a retryability hint. An
/// operation whose outcome is unknown (a transport break, a timeout, an
/// incomplete operation) is never retried: replaying it could overlap a
/// clone that is still running.
#[must_use]
pub(crate) fn classify_driver_failure(
error: &sandbox_driver::Error,
cred: CredentialContext,
) -> Option<GitRetryReason> {
match error {
sandbox_driver::Error::Exec(failure) => classify_output(
&String::from_utf8_lossy(failure.stderr()),
&String::from_utf8_lossy(failure.stdout()),
cred,
)
.retry_reason(),
sandbox_driver::Error::Provider(provider) => {
match classify_message(&provider.message, cred) {
GitMessageClass::Retry(reason) => Some(reason),
GitMessageClass::Permanent => None,
GitMessageClass::Unknown => {
provider.retryable.then_some(GitRetryReason::TransientInfra)
}
}
}
sandbox_driver::Error::RateLimited { .. } | sandbox_driver::Error::Overloaded { .. } => {
Some(GitRetryReason::TransientInfra)
}
_ => None,
}
}
/// Backoff between attempts: 3s, then 9s.
///
/// GitHub's guidance for token replication is to wait a few seconds and retry

View file

@ -1,17 +1,14 @@
pub mod config;
pub mod error;
#[cfg(any(feature = "docker", feature = "daytona"))]
pub mod from_environment;
pub mod provider;
pub mod sandbox;
pub mod sandbox_spec;
#[cfg(any(feature = "docker", feature = "daytona"))]
mod clone_source;
mod git_retry;
#[cfg(any(feature = "docker", feature = "daytona", test))]
mod managed_labels;
mod push_credentials;
@ -29,7 +26,7 @@ pub mod reconnect;
pub mod terminal;
#[cfg(feature = "docker")]
mod clone;
pub mod docker;
#[cfg(feature = "daytona")]
@ -39,8 +36,7 @@ pub mod daytona;
pub mod test_support;
pub use details::sandbox_details;
#[cfg(feature = "docker")]
pub use docker::{DockerSandbox, DockerSandboxOptions};
pub use docker::{DockerSandboxOptions, attach_docker, check_docker_daemon, docker_sandbox};
pub use driver_sandbox::{DriverSandbox, local_sandbox};
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
pub use exec::{ExplicitEnvPolicy, SandboxExec, is_sensitive_env_var};
@ -53,11 +49,9 @@ pub use git_retry::{
};
#[cfg(feature = "daytona")]
pub use provider::daytona::DaytonaSandboxProvider;
#[cfg(feature = "docker")]
pub use provider::docker::DockerSandboxProvider;
pub use provider::driver::DriverInventoryProvider;
pub use provider::{
LocalSandboxProvider, SandboxCreateSpec, SandboxLookupError, SandboxProvider,
SandboxProviderRegistry,
LocalSandboxProvider, SandboxLookupError, SandboxProvider, SandboxProviderRegistry,
};
pub use push_credentials::RefreshErrorKind;
pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callback};
@ -70,4 +64,4 @@ pub use sandbox::{
redacted_output_tail, setup_git_via_exec, shell_quote,
};
pub use sandbox_spec::SandboxSpec;
pub use terminal::{TerminalSession, TerminalSize, open_terminal_for_run};
pub use terminal::{DriverTerminalSession, TerminalSession, TerminalSize, open_terminal_for_run};

View file

@ -7,12 +7,10 @@ pub(crate) const MANAGED_LABEL_VALUE: &str = "true";
pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id";
/// True when the provided label map carries the Fabro managed sentinel.
#[cfg(any(feature = "docker", feature = "daytona", test))]
pub(crate) fn is_managed(labels: &HashMap<String, String>) -> bool {
labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE)
}
#[cfg(any(feature = "docker", test))]
pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap<String, String> {
let mut labels = HashMap::new();
insert_for_run(&mut labels, run_id);

View file

@ -1,15 +1,10 @@
#[cfg(feature = "daytona")]
pub mod daytona;
#[cfg(feature = "docker")]
pub mod docker;
pub mod driver;
use std::sync::Arc;
use async_trait::async_trait;
#[cfg(any(feature = "docker", feature = "daytona"))]
use fabro_github::GitHubCredentials;
#[cfg(any(feature = "docker", feature = "daytona"))]
use fabro_types::RunId;
use fabro_types::{
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind,
SandboxProviderLookupError,
@ -17,39 +12,12 @@ use fabro_types::{
use fabro_util::error::collect_chain;
use futures::future::join_all;
#[cfg(feature = "daytona")]
use crate::daytona::DaytonaConfig;
#[cfg(feature = "docker")]
use crate::docker::DockerSandboxOptions;
pub enum SandboxCreateSpec {
Local,
#[cfg(feature = "docker")]
Docker {
config: DockerSandboxOptions,
github_app: Option<GitHubCredentials>,
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
},
#[cfg(feature = "daytona")]
Daytona {
config: Box<DaytonaConfig>,
github_app: Option<GitHubCredentials>,
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
api_key: Option<String>,
},
}
#[async_trait]
pub trait SandboxProvider: Send + Sync {
fn kind(&self) -> SandboxProviderKind;
async fn list(&self) -> crate::Result<Vec<SandboxInfo>>;
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>>;
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo>;
async fn delete(&self, id: &str) -> crate::Result<()>;
}
@ -168,12 +136,6 @@ impl SandboxProvider for LocalSandboxProvider {
Ok(None)
}
async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
Err(crate::Error::message(
"local sandbox provider has no provider-managed inventory",
))
}
async fn delete(&self, _id: &str) -> crate::Result<()> {
Ok(())
}

View file

@ -4,10 +4,9 @@ use async_trait::async_trait;
use fabro_static::EnvVars;
use fabro_types::{SandboxInfo, SandboxProviderKind};
use super::{SandboxCreateSpec, SandboxProvider};
use crate::daytona::{self, DaytonaSandbox};
use super::SandboxProvider;
use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE};
use crate::{Sandbox, details};
use crate::{daytona, details};
const DAYTONA_LIST_PAGE_SIZE: i32 = 100;
@ -105,42 +104,6 @@ impl SandboxProvider for DaytonaSandboxProvider {
)))
}
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
let SandboxCreateSpec::Daytona {
config,
github_app,
run_id,
clone_origin_url,
clone_branch,
api_key,
} = spec
else {
return Err(crate::Error::message(
"Daytona sandbox provider can only create Daytona sandboxes",
));
};
let api_key = api_key.or_else(|| self.api_key.clone()).ok_or_else(|| {
crate::Error::message(format!("{} is not configured", EnvVars::DAYTONA_API_KEY))
})?;
let sandbox = DaytonaSandbox::new(
config.as_ref().clone(),
github_app,
run_id,
clone_origin_url,
clone_branch,
None,
None,
Some(api_key),
)
.await?;
sandbox.initialize().await?;
let sdk_sandbox = sandbox.sandbox_handle().ok_or_else(|| {
crate::Error::message("Daytona sandbox was created but no SDK handle is available")
})?;
Ok(details::daytona::daytona_info_from_sdk_sandbox(sdk_sandbox))
}
async fn delete(&self, id: &str) -> crate::Result<()> {
let client = self.client().await?;
let sandbox = match client.get(id).await {

View file

@ -1,179 +0,0 @@
use std::collections::HashMap;
use async_trait::async_trait;
use bollard::Docker;
use bollard::container::{InspectContainerOptions, ListContainersOptions, RemoveContainerOptions};
use bollard::errors::Error as DockerError;
use bollard::models::ContainerInspectResponse;
use fabro_types::{SandboxInfo, SandboxProviderKind};
use futures::future::try_join_all;
use super::{SandboxCreateSpec, SandboxProvider};
use crate::docker::DockerSandbox;
use crate::managed_labels::{self, MANAGED_LABEL, MANAGED_LABEL_VALUE};
use crate::{Sandbox, details};
#[derive(Debug, Clone, Default)]
pub struct DockerSandboxProvider;
impl DockerSandboxProvider {
pub fn new() -> Self {
Self
}
pub async fn check_daemon() -> crate::Result<()> {
let docker = Self::docker_client()?;
docker
.ping()
.await
.map_err(|err| crate::Error::context("Failed to reach Docker daemon", err))?;
Ok(())
}
fn docker_client() -> crate::Result<Docker> {
Docker::connect_with_local_defaults().map_err(crate::Error::docker_connect)
}
}
#[async_trait]
impl SandboxProvider for DockerSandboxProvider {
fn kind(&self) -> SandboxProviderKind {
SandboxProviderKind::DOCKER
}
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
let docker = Self::docker_client()?;
let mut filters = HashMap::new();
filters.insert("label".to_string(), vec![format!(
"{MANAGED_LABEL}={MANAGED_LABEL_VALUE}"
)]);
let options = ListContainersOptions::<String> {
all: true,
filters,
..Default::default()
};
let containers = docker
.list_containers(Some(options))
.await
.map_err(|err| crate::Error::context("Failed to list Docker containers", err))?;
let ids: Vec<String> = containers.into_iter().filter_map(|c| c.id).collect();
// Daemon-side label filter already restricts to managed containers, so we
// can skip the per-inspect managed re-check. Run inspects concurrently on
// the shared Docker client to avoid a serial N+1 round-trip.
let inspects = try_join_all(
ids.iter()
.map(|id| docker.inspect_container(id, None::<InspectContainerOptions>)),
)
.await
.map_err(|err| crate::Error::context("Failed to inspect Docker container", err))?;
Ok(inspects
.iter()
.map(details::docker::docker_info_from_inspect)
.collect())
}
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>> {
let docker = Self::docker_client()?;
let Some(inspect) = inspect_container(&docker, id).await? else {
return Ok(None);
};
if !managed_from_inspect(&inspect) {
return Ok(None);
}
Ok(Some(details::docker::docker_info_from_inspect(&inspect)))
}
async fn create(&self, spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
let SandboxCreateSpec::Docker {
config,
github_app,
run_id,
clone_origin_url,
clone_branch,
} = spec
else {
return Err(crate::Error::message(
"Docker sandbox provider can only create Docker sandboxes",
));
};
let sandbox = DockerSandbox::new(
config,
github_app.as_ref(),
run_id,
clone_origin_url,
clone_branch,
None,
None,
)?;
sandbox.initialize().await?;
let container_id = sandbox.container_identifier()?.to_string();
self.get(&container_id).await?.ok_or_else(|| {
crate::Error::message(format!(
"Docker sandbox '{container_id}' was created but is not visible in provider inventory"
))
})
}
async fn delete(&self, id: &str) -> crate::Result<()> {
let docker = Self::docker_client()?;
let Some(inspect) = inspect_container(&docker, id).await? else {
return Ok(());
};
if !managed_from_inspect(&inspect) {
return Err(crate::Error::message(format!(
"Refusing to delete Docker container '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}"
)));
}
let container_id = inspect.id.as_deref().unwrap_or(id);
docker
.remove_container(
container_id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.map_err(|err| {
crate::Error::context(
format!("Failed to remove Docker container '{container_id}'"),
err,
)
})
}
}
async fn inspect_container(
docker: &Docker,
id: &str,
) -> crate::Result<Option<ContainerInspectResponse>> {
match docker
.inspect_container(id, None::<InspectContainerOptions>)
.await
{
Ok(inspect) => Ok(Some(inspect)),
Err(err) if docker_not_found(&err) => Ok(None),
Err(err) => Err(crate::Error::context(
format!("Failed to inspect Docker container '{id}'"),
err,
)),
}
}
fn managed_from_inspect(inspect: &ContainerInspectResponse) -> bool {
inspect
.config
.as_ref()
.and_then(|config| config.labels.as_ref())
.is_some_and(managed_labels::is_managed)
}
fn docker_not_found(error: &DockerError) -> bool {
matches!(error, DockerError::DockerResponseServerError {
status_code: 404,
..
})
}

View file

@ -0,0 +1,246 @@
//! Fabro-managed inventory over a sandbox-driver provider.
//!
//! Lists and looks up the sandboxes fabro created, identified by fabro's
//! own `sh.fabro.managed` label. The driver marks every sandbox it creates
//! with its own label too, but that covers every application on the same
//! daemon or account; fabro filters on its label and refuses to delete a
//! sandbox that does not carry it.
use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_types::settings::server::ServerSandboxProviderSettings;
use fabro_types::{SandboxInfo, SandboxProviderKind};
use sandbox_driver::{SandboxFilter, SandboxId, SandboxProvider as DriverProvider};
use tokio::sync::OnceCell;
use super::SandboxProvider;
use crate::details;
use crate::driver::{ConnectedProvider, ProviderConnectOptions, connect_provider};
use crate::managed_labels::{MANAGED_LABEL, MANAGED_LABEL_VALUE};
/// How the driver provider behind the inventory is obtained.
enum Connection {
Connected(Arc<dyn DriverProvider>),
/// Connected on first use, so a registry can be assembled synchronously
/// and a provider that is down surfaces as a lookup error rather than a
/// startup failure.
Lazy(Box<LazyConnection>),
}
struct LazyConnection {
settings: ServerSandboxProviderSettings,
options: ProviderConnectOptions,
provider: OnceCell<Arc<dyn DriverProvider>>,
}
pub struct DriverInventoryProvider {
kind: SandboxProviderKind,
connection: Connection,
}
impl DriverInventoryProvider {
#[must_use]
pub fn new(connected: ConnectedProvider) -> Self {
Self {
kind: connected.kind,
connection: Connection::Connected(connected.provider),
}
}
/// An inventory over a provider connected through
/// [`connect_provider`] on first use.
#[must_use]
pub fn lazy(
kind: SandboxProviderKind,
settings: ServerSandboxProviderSettings,
options: ProviderConnectOptions,
) -> Self {
Self {
kind,
connection: Connection::Lazy(Box::new(LazyConnection {
settings,
options,
provider: OnceCell::new(),
})),
}
}
async fn provider(&self) -> crate::Result<&Arc<dyn DriverProvider>> {
match &self.connection {
Connection::Connected(provider) => Ok(provider),
Connection::Lazy(lazy) => {
lazy.provider
.get_or_try_init(|| async {
connect_provider(&self.kind, &lazy.settings, &lazy.options)
.await
.map(|connected| connected.provider)
.map_err(|error| {
crate::Error::context(
format!("Failed to connect to the {} provider", self.kind),
error,
)
})
})
.await
}
}
}
fn managed_filter() -> SandboxFilter {
let mut filter = SandboxFilter::default();
filter
.labels
.insert(MANAGED_LABEL.to_string(), MANAGED_LABEL_VALUE.to_string());
filter
}
fn is_managed(labels: &BTreeMap<String, String>) -> bool {
labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE)
}
async fn describe_managed(
&self,
id: &str,
) -> crate::Result<Option<sandbox_driver::SandboxStatus>> {
// An id the driver cannot even name is not one of ours.
let Ok(sandbox_id) = SandboxId::try_new(id) else {
return Ok(None);
};
let handle = match self.provider().await?.attach(&sandbox_id, None).await {
Ok(handle) => handle,
Err(sandbox_driver::Error::NotFound { .. }) => return Ok(None),
Err(error) => {
return Err(crate::Error::context(
format!("Failed to look up {} sandbox '{id}'", self.kind),
error,
));
}
};
let status = handle.describe().await.map_err(|error| {
crate::Error::context(
format!("Failed to describe {} sandbox '{id}'", self.kind),
error,
)
})?;
if status.state == sandbox_driver::SandboxState::Deleted
|| !Self::is_managed(&status.labels)
{
return Ok(None);
}
Ok(Some(status))
}
}
#[async_trait]
impl SandboxProvider for DriverInventoryProvider {
fn kind(&self) -> SandboxProviderKind {
self.kind.clone()
}
async fn list(&self) -> crate::Result<Vec<SandboxInfo>> {
let statuses = self
.provider()
.await?
.list(&Self::managed_filter())
.await
.map_err(|error| {
crate::Error::context(format!("Failed to list {} sandboxes", self.kind), error)
})?;
Ok(statuses
.iter()
// The filter is a request; a provider that cannot filter on
// labels returns everything, so the label is checked again.
.filter(|status| Self::is_managed(&status.labels))
.map(|status| details::info_from_status(&self.kind, status))
.collect())
}
async fn get(&self, id: &str) -> crate::Result<Option<SandboxInfo>> {
Ok(self
.describe_managed(id)
.await?
.map(|status| details::info_from_status(&self.kind, &status)))
}
async fn delete(&self, id: &str) -> crate::Result<()> {
let Some(status) = self.describe_managed(id).await? else {
// Missing, already deleted, or not fabro's: the first two are
// idempotent successes and the third must never be deleted here,
// so distinguish them for the caller.
if let Ok(sandbox_id) = SandboxId::try_new(id) {
if let Ok(handle) = self.provider().await?.attach(&sandbox_id, None).await {
let status = handle.describe().await?;
if status.state != sandbox_driver::SandboxState::Deleted {
return Err(crate::Error::message(format!(
"Refusing to delete {} sandbox '{id}' because it is missing label {MANAGED_LABEL}={MANAGED_LABEL_VALUE}",
self.kind
)));
}
}
}
return Ok(());
};
self.provider()
.await?
.delete(&status.id, None)
.await
.map_err(|error| {
crate::Error::context(
format!("Failed to delete {} sandbox '{id}'", self.kind),
error,
)
})
}
}
#[cfg(test)]
mod tests {
use sandbox_driver::{SandboxSource, SandboxSpec};
use sandbox_driver_host::HostProvider;
use super::*;
fn inventory() -> (DriverInventoryProvider, Arc<HostProvider>) {
let host = Arc::new(HostProvider::new());
let provider = DriverInventoryProvider::new(ConnectedProvider {
kind: SandboxProviderKind::try_new("host").unwrap(),
provider: host.clone(),
});
(provider, host)
}
#[tokio::test]
async fn lists_and_deletes_only_fabro_managed_sandboxes() {
let (inventory, host) = inventory();
let ours = host
.create(
&SandboxSpec::new(SandboxSource::HostDirectory).label(MANAGED_LABEL, "true"),
None,
)
.await
.unwrap();
let theirs = host
.create(&SandboxSpec::new(SandboxSource::HostDirectory), None)
.await
.unwrap();
let listed = inventory.list().await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, ours.id().as_str());
assert_eq!(listed[0].provider.as_str(), "host");
assert!(inventory.get(ours.id().as_str()).await.unwrap().is_some());
assert!(inventory.get(theirs.id().as_str()).await.unwrap().is_none());
let refused = inventory.delete(theirs.id().as_str()).await.unwrap_err();
assert!(
refused.to_string().contains("Refusing to delete"),
"{refused}"
);
inventory.delete(ours.id().as_str()).await.unwrap();
assert!(inventory.get(ours.id().as_str()).await.unwrap().is_none());
inventory.delete(ours.id().as_str()).await.unwrap();
inventory.delete("never-existed").await.unwrap();
}
}

View file

@ -7,12 +7,10 @@ use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
use crate::SandboxEventCallback;
#[cfg(feature = "daytona")]
use crate::daytona::DaytonaSandbox;
#[cfg(feature = "docker")]
use crate::docker::DockerSandbox;
use crate::driver_sandbox::local_sandbox;
use crate::{SandboxEventCallback, docker};
/// Reconnect to a sandbox from a saved record.
///
@ -66,17 +64,15 @@ pub async fn reconnect_for_run_with_callback(
}
Ok(Box::new(sandbox))
}
#[cfg(feature = "docker")]
Some(BundledProvider::Docker) => {
let repo_cloned = runtime
.repo_cloned
.context("Docker run sandbox missing repo_cloned metadata")?;
let mut sandbox = DockerSandbox::reconnect(
let mut sandbox = docker::attach_docker(
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
runtime.clone_branch.clone(),
run_id,
)
.await
@ -86,8 +82,6 @@ pub async fn reconnect_for_run_with_callback(
}
Ok(Box::new(sandbox))
}
#[cfg(not(feature = "docker"))]
Some(BundledProvider::Docker) => bail!("Docker sandbox support is not enabled"),
#[cfg(feature = "daytona")]
Some(BundledProvider::Daytona) => {
let repo_cloned = runtime

View file

@ -13,7 +13,7 @@ use fabro_types::{CommandOutputStream, CommandTermination};
use fabro_util::shell;
use fabro_util::workspace_glob::WorkspaceGlob;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
use tokio::time;
@ -31,11 +31,9 @@ pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024;
pub(crate) const BASH_PROBE_TIMEOUT_MS: u64 = 10_000;
/// Bash path required by Linux-backed remote sandbox providers.
#[cfg(any(feature = "docker", feature = "daytona"))]
pub(crate) const REMOTE_BASH: &str = "/bin/bash";
/// Timeout for provider-neutral remote file traversal.
#[cfg(any(feature = "docker", feature = "daytona"))]
pub(crate) const REMOTE_WALK_TIMEOUT_MS: u64 = 30_000;
/// Environment variable Bash consults for non-interactive startup source.
@ -946,42 +944,6 @@ impl<'a> ExecStreamingRequest<'a> {
}
}
pub(crate) async fn write_process_stdin<W>(mut writer: W, stdin: &[u8]) -> crate::Result<()>
where
W: AsyncWrite + Unpin,
{
// A command that stops reading its input (`head -1`, an early exit) is
// not an error; its exit code is the authoritative result. Local pipes
// surface that as `BrokenPipe`, remote transports (a TCP Docker daemon)
// as `ConnectionReset`/`ConnectionAborted`.
fn command_stopped_reading(err: &std::io::Error) -> bool {
matches!(
err.kind(),
std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
)
}
if let Err(err) = writer.write_all(stdin).await {
if !command_stopped_reading(&err) {
return Err(crate::Error::context(
"Failed to write command standard input",
err,
));
}
}
if let Err(err) = writer.shutdown().await {
if !command_stopped_reading(&err) {
return Err(crate::Error::context(
"Failed to close command standard input",
err,
));
}
}
Ok(())
}
pub(crate) async fn replay_exec_result(
mut result: ExecResult,
streams_separated: bool,
@ -1603,7 +1565,6 @@ pub(crate) fn join_sandbox_path(base: &str, relative_path: &str) -> String {
format!("{}/{relative_path}", base.trim_end_matches('/'))
}
#[cfg(any(feature = "docker", feature = "daytona"))]
pub(crate) fn build_remote_walk_command(
base: &str,
relative_start: &str,
@ -1637,7 +1598,6 @@ pub(crate) fn build_remote_walk_command(
command
}
#[cfg(any(feature = "docker", feature = "daytona"))]
pub(crate) fn parse_remote_walk_output(
base: &str,
relative_start: &str,

View file

@ -2,7 +2,6 @@ use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context as _;
#[cfg(any(feature = "docker", feature = "daytona"))]
use fabro_github::GitHubCredentials;
#[allow(
unused_imports,
@ -10,21 +9,17 @@ use fabro_github::GitHubCredentials;
)]
use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
#[cfg(any(feature = "docker", feature = "daytona"))]
use crate::clone_source;
#[cfg(feature = "daytona")]
use crate::daytona::{self, DaytonaConfig, DaytonaSandbox};
#[cfg(feature = "docker")]
use crate::docker::{self, DockerSandbox, DockerSandboxOptions};
use crate::docker::{self, DockerSandboxOptions};
use crate::driver_sandbox::local_sandbox;
use crate::{Sandbox, SandboxEventCallback};
use crate::{Sandbox, SandboxEventCallback, clone_source};
/// Options for sandbox initialization and construction.
pub enum SandboxSpec {
Local {
working_directory: PathBuf,
},
#[cfg(feature = "docker")]
Docker {
config: DockerSandboxOptions,
github_app: Option<GitHubCredentials>,
@ -51,7 +46,6 @@ impl SandboxSpec {
pub fn provider(&self) -> SandboxProviderKind {
match self {
Self::Local { .. } => SandboxProviderKind::LOCAL,
#[cfg(feature = "docker")]
Self::Docker { .. } => SandboxProviderKind::DOCKER,
#[cfg(feature = "daytona")]
Self::Daytona { .. } => SandboxProviderKind::DAYTONA,
@ -61,7 +55,6 @@ impl SandboxSpec {
pub fn provider_name(&self) -> &'static str {
match self {
Self::Local { .. } => "local",
#[cfg(feature = "docker")]
Self::Docker { .. } => "docker",
#[cfg(feature = "daytona")]
Self::Daytona { .. } => "daytona",
@ -85,7 +78,6 @@ impl SandboxSpec {
};
match self {
#[cfg(feature = "docker")]
Self::Docker {
config,
clone_origin_url,
@ -198,7 +190,6 @@ impl SandboxSpec {
}
Ok(Arc::new(sandbox))
}
#[cfg(feature = "docker")]
Self::Docker {
config,
github_app,
@ -208,7 +199,7 @@ impl SandboxSpec {
clone_tag,
clone_commit_sha,
} => {
let mut sandbox = DockerSandbox::new(
let mut sandbox = docker::docker_sandbox(
config.clone(),
github_app.as_ref(),
*run_id,
@ -217,6 +208,7 @@ impl SandboxSpec {
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);
@ -255,7 +247,6 @@ impl SandboxSpec {
}
}
#[cfg(any(feature = "docker", feature = "daytona"))]
fn runtime_layout_metadata(
repo_cloned: Option<bool>,
clone_origin_url: Option<&str>,
@ -270,15 +261,11 @@ fn runtime_layout_metadata(
#[cfg(test)]
mod tests {
#[cfg(feature = "docker")]
use fabro_types::RunId;
#[cfg(feature = "docker")]
use super::*;
#[cfg(feature = "docker")]
use crate::test_support::MockSandbox;
#[cfg(feature = "docker")]
#[test]
fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() {
let spec = SandboxSpec::Docker {
@ -317,7 +304,6 @@ mod tests {
assert!(runtime_json.get("clone_commit_sha").is_none());
}
#[cfg(feature = "docker")]
#[tokio::test]
async fn invalid_exact_checkout_spec_fails_before_provider_connection() {
let spec = SandboxSpec::Docker {
@ -344,7 +330,6 @@ mod tests {
assert!(!format!("{error:#}").contains("Docker daemon"));
}
#[cfg(feature = "docker")]
#[test]
fn docker_run_sandbox_omits_primary_repo_metadata_for_empty_workspace() {
let spec = SandboxSpec::Docker {

View file

@ -3,12 +3,9 @@ use async_trait::async_trait;
use fabro_static::EnvVars;
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
#[cfg(any(feature = "daytona", feature = "docker"))]
use crate::Sandbox;
#[cfg(feature = "daytona")]
use crate::daytona::{DEFAULT_DAYTONA_API_URL, DaytonaSandbox};
#[cfg(feature = "docker")]
use crate::docker::DockerSandbox;
use crate::{Sandbox, docker};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalSize {
@ -33,6 +30,52 @@ pub trait TerminalSession: Send + Sync {
async fn close(&self) -> crate::Result<()>;
}
/// A terminal over the driver's Pty facet.
pub struct DriverTerminalSession {
session: Box<dyn sandbox_driver::PtySession>,
}
impl DriverTerminalSession {
#[must_use]
pub fn new(session: Box<dyn sandbox_driver::PtySession>) -> Self {
Self { session }
}
}
#[async_trait]
impl TerminalSession for DriverTerminalSession {
async fn write_input(&self, bytes: &[u8]) -> crate::Result<()> {
self.session
.write_input(bytes)
.await
.map_err(|err| crate::Error::context("Failed to write terminal input", err))
}
async fn read_output(&self) -> crate::Result<Option<Vec<u8>>> {
self.session
.read_output()
.await
.map_err(|err| crate::Error::context("Failed to read terminal output", err))
}
async fn resize(&self, size: TerminalSize) -> crate::Result<()> {
self.session
.resize(sandbox_driver::PtySize {
rows: size.rows,
cols: size.cols,
})
.await
.map_err(|err| crate::Error::context("Failed to resize terminal", err))
}
async fn close(&self) -> crate::Result<()> {
self.session
.close()
.await
.map_err(|err| crate::Error::context("Failed to close terminal", err))
}
}
pub async fn open_terminal_for_run(
record: &RunSandboxInstance,
daytona_api_key: Option<String>,
@ -40,14 +83,9 @@ pub async fn open_terminal_for_run(
run_id: Option<RunId>,
size: TerminalSize,
) -> crate::Result<Box<dyn TerminalSession>> {
#[cfg(any(feature = "daytona", feature = "docker"))]
let runtime = &record.runtime;
#[cfg(not(feature = "daytona"))]
let _ = (&daytona_api_key, &daytona_organization_id);
#[cfg(not(feature = "docker"))]
let _ = &run_id;
#[cfg(not(any(feature = "daytona", feature = "docker")))]
let _ = size;
match record.provider.bundled() {
#[cfg(feature = "daytona")]
@ -81,28 +119,21 @@ pub async fn open_terminal_for_run(
Some(BundledProvider::Daytona) => Err(crate::Error::message(
"Daytona sandbox support is not enabled",
)),
#[cfg(feature = "docker")]
Some(BundledProvider::Docker) => {
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
crate::Error::message("Docker run sandbox is missing clone metadata")
})?;
let sandbox = DockerSandbox::reconnect(
let sandbox = docker::attach_docker(
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
runtime.clone_branch.clone(),
run_id,
)
.await?;
sandbox.activate().await?;
let session = DockerTerminalSession::open(&sandbox, size).await?;
Ok(Box::new(session))
Ok(Box::new(sandbox.open_terminal(size).await?))
}
#[cfg(not(feature = "docker"))]
Some(BundledProvider::Docker) => Err(crate::Error::message(
"Docker sandbox support is not enabled",
)),
Some(BundledProvider::Local) => Err(crate::Error::message(
"Local sandboxes do not support embedded terminals",
)),
@ -680,213 +711,3 @@ mod daytona_terminal {
#[cfg(feature = "daytona")]
use daytona_terminal::DaytonaTerminalSession;
#[cfg(feature = "docker")]
mod docker_terminal {
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use async_trait::async_trait;
use bollard::Docker;
use bollard::container::LogOutput;
use bollard::errors::Error as DockerError;
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures::{Stream, StreamExt};
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::Mutex;
use super::{TerminalSession, TerminalSize};
use crate::Sandbox;
use crate::docker::DockerSandbox;
type DockerInput = Pin<Box<dyn AsyncWrite + Send>>;
type DockerOutput = Pin<Box<dyn Stream<Item = Result<LogOutput, DockerError>> + Send>>;
pub(super) struct DockerTerminalSession {
docker: Docker,
container_id: String,
exec_id: String,
pid_file: String,
input: Mutex<Option<DockerInput>>,
output: Mutex<Option<DockerOutput>>,
closed: Mutex<bool>,
}
impl DockerTerminalSession {
pub(super) async fn open(
sandbox: &DockerSandbox,
size: TerminalSize,
) -> crate::Result<Self> {
let docker = sandbox.docker_client();
let container_id = sandbox.container_identifier()?.to_string();
let pid_file = format!("/tmp/fabro-terminal-{}.pid", uuid_fragment());
let exec_opts = docker_terminal_exec_options(sandbox.working_directory(), &pid_file);
let exec = docker
.create_exec(&container_id, exec_opts)
.await
.map_err(|err| {
crate::Error::context("Failed to create Docker terminal exec", err)
})?;
let exec_id = exec.id;
let start = docker.start_exec(&exec_id, None).await.map_err(|err| {
crate::Error::context("Failed to start Docker terminal exec", err)
})?;
let StartExecResults::Attached { output, input } = start else {
return Err(crate::Error::message("Docker terminal exec did not attach"));
};
docker
.resize_exec(&exec_id, ResizeExecOptions {
height: size.rows,
width: size.cols,
})
.await
.map_err(|err| {
crate::Error::context("Failed to resize Docker terminal exec", err)
})?;
Ok(Self {
docker,
container_id,
exec_id,
pid_file,
input: Mutex::new(Some(input)),
output: Mutex::new(Some(output)),
closed: Mutex::new(false),
})
}
async fn kill_shell(&self) -> crate::Result<()> {
let command = format!(
"if [ -f {pid_file} ]; then kill -TERM \"$(cat {pid_file})\" 2>/dev/null || true; rm -f {pid_file}; fi",
pid_file = crate::shell_quote(&self.pid_file),
);
let exec = self
.docker
.create_exec(&self.container_id, CreateExecOptions {
cmd: Some(vec!["sh".to_string(), "-lc".to_string(), command]),
attach_stdout: Some(false),
attach_stderr: Some(false),
..Default::default()
})
.await
.map_err(|err| {
crate::Error::context("Failed to create Docker terminal cleanup exec", err)
})?;
self.docker
.start_exec(&exec.id, None)
.await
.map_err(|err| {
crate::Error::context("Failed to run Docker terminal cleanup exec", err)
})?;
Ok(())
}
}
#[async_trait]
impl TerminalSession for DockerTerminalSession {
async fn write_input(&self, bytes: &[u8]) -> crate::Result<()> {
let mut input = self.input.lock().await;
let Some(input) = input.as_mut() else {
return Ok(());
};
input
.write_all(bytes)
.await
.map_err(|err| crate::Error::context("Failed to write Docker terminal input", err))
}
async fn read_output(&self) -> crate::Result<Option<Vec<u8>>> {
let mut output = self.output.lock().await;
let Some(output) = output.as_mut() else {
return Ok(None);
};
match output.next().await {
Some(Ok(chunk)) => Ok(Some(chunk.into_bytes().to_vec())),
Some(Err(err)) => Err(crate::Error::context(
"Failed to read Docker terminal output",
err,
)),
None => Ok(None),
}
}
async fn resize(&self, size: TerminalSize) -> crate::Result<()> {
self.docker
.resize_exec(&self.exec_id, ResizeExecOptions {
height: size.rows,
width: size.cols,
})
.await
.map_err(|err| crate::Error::context("Failed to resize Docker terminal exec", err))
}
async fn close(&self) -> crate::Result<()> {
let mut closed = self.closed.lock().await;
if *closed {
return Ok(());
}
*closed = true;
drop(closed);
let _ = self.input.lock().await.take();
let _ = self.output.lock().await.take();
self.kill_shell().await
}
}
fn docker_terminal_exec_options(
working_directory: &str,
pid_file: &str,
) -> CreateExecOptions<String> {
let command = format!(
"printf '%s\\n' $$ > {pid_file}; exec sh -l",
pid_file = crate::shell_quote(pid_file),
);
CreateExecOptions {
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(true),
cmd: Some(vec!["sh".to_string(), "-lc".to_string(), command]),
working_dir: Some(working_directory.to_string()),
env: Some(vec![
"TERM=xterm-256color".to_string(),
"LANG=C.UTF-8".to_string(),
]),
..Default::default()
}
}
static DOCKER_TERMINAL_COUNTER: AtomicU64 = AtomicU64::new(1);
fn uuid_fragment() -> String {
format!(
"{:016x}",
DOCKER_TERMINAL_COUNTER.fetch_add(1, Ordering::Relaxed)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn docker_terminal_exec_options_attach_tty_and_workspace_env() {
let options = docker_terminal_exec_options("/workspace", "/tmp/fabro-terminal.pid");
assert_eq!(options.attach_stdin, Some(true));
assert_eq!(options.attach_stdout, Some(true));
assert_eq!(options.attach_stderr, Some(true));
assert_eq!(options.tty, Some(true));
assert_eq!(options.working_dir.as_deref(), Some("/workspace"));
assert_eq!(
options.env,
Some(vec![
"TERM=xterm-256color".to_string(),
"LANG=C.UTF-8".to_string()
])
);
assert!(options.cmd.unwrap().join(" ").contains("exec sh -l"));
}
}
}
#[cfg(feature = "docker")]
use docker_terminal::DockerTerminalSession;

View file

@ -741,7 +741,7 @@ mod fake_provider {
SandboxTimestamps,
};
use crate::provider::{SandboxCreateSpec, SandboxProvider, SandboxProviderRegistry};
use crate::provider::{SandboxProvider, SandboxProviderRegistry};
#[derive(Clone)]
pub enum FakeList {
@ -789,10 +789,6 @@ mod fake_provider {
}
}
async fn create(&self, _spec: SandboxCreateSpec) -> crate::Result<SandboxInfo> {
Err(crate::Error::message("not implemented"))
}
async fn delete(&self, _id: &str) -> crate::Result<()> {
Ok(())
}

View file

@ -1,13 +1,25 @@
#![cfg(feature = "docker")]
//! Docker sandbox behaviour through the sandbox-driver Docker provider.
use std::sync::Arc;
use bollard::Docker;
use fabro_sandbox::{
CommandOutputCallback, DockerSandbox, DockerSandboxOptions, ExecStreamingRequest, Sandbox,
CommandOutputCallback, DockerSandboxOptions, ExecStreamingRequest, Sandbox, docker_sandbox,
};
use tokio::process::Command;
use tokio::sync::Mutex;
/// Whether a Docker daemon answers and has `image` locally. The tests are
/// skipped (not failed) otherwise, matching the ignore reason.
async fn docker_image_available(image: &str) -> bool {
Command::new("docker")
.args(["image", "inspect", image])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.is_ok_and(|status| status.success())
}
fn capture_bytes(chunks: Arc<Mutex<Vec<u8>>>) -> CommandOutputCallback {
Arc::new(move |_stream, bytes| {
let chunks = Arc::clone(&chunks);
@ -22,14 +34,11 @@ fn capture_bytes(chunks: Arc<Mutex<Vec<u8>>>) -> CommandOutputCallback {
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
async fn streaming_timeout_terminates_docker_exec_before_returning() {
let image = "buildpack-deps:noble";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
if !docker_image_available(image).await {
return;
}
let sandbox = DockerSandbox::new(
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
@ -43,6 +52,7 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
@ -96,14 +106,11 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
async fn streaming_command_receives_exact_stdin_and_eof() {
let image = "buildpack-deps:noble";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
if !docker_image_available(image).await {
return;
}
let sandbox = DockerSandbox::new(
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
@ -117,6 +124,7 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
@ -159,14 +167,11 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
#[ignore = "requires real Docker container lifecycle, image, network, and a public GitHub clone"]
async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
let image = "buildpack-deps:noble";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
if !docker_image_available(image).await {
return;
}
let sandbox = DockerSandbox::new(
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
@ -180,6 +185,7 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
@ -226,14 +232,11 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker exec integration"]
async fn docker_runs_clean_bash_through_both_command_paths() {
let image = "buildpack-deps:noble";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
if !docker_image_available(image).await {
return;
}
let sandbox = DockerSandbox::new(
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
@ -248,6 +251,7 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
@ -322,14 +326,11 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Sandbox::glob"]
async fn docker_glob_matches_patterns_containing_a_path_separator() {
let image = "buildpack-deps:noble";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
if !docker_image_available(image).await {
return;
}
let sandbox = DockerSandbox::new(
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
@ -343,6 +344,7 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()
@ -408,14 +410,11 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
#[ignore = "requires real Docker container lifecycle; run explicitly when changing Docker runtime directory setup"]
async fn docker_runtime_directory_is_private_and_outside_workspace() {
let image = "buildpack-deps:noble";
let Ok(docker) = Docker::connect_with_local_defaults() else {
return;
};
if docker.inspect_image(image).await.is_err() {
if !docker_image_available(image).await {
return;
}
let sandbox = DockerSandbox::new(
let sandbox = docker_sandbox(
DockerSandboxOptions {
image: image.to_string(),
auto_pull: false,
@ -429,6 +428,7 @@ async fn docker_runtime_directory_is_private_and_outside_workspace() {
None,
None,
)
.await
.expect("docker sandbox should construct");
sandbox
.initialize()

View file

@ -4,9 +4,9 @@
//! Three comparisons, each over the same medium repository (fabro's own
//! `lib/` tree, about 1,100 Rust files):
//!
//! - Docker file reads and content search: fabro's `DockerSandbox` (archive API
//! reads, `docker exec` grep) against the driver `DockerProvider` (archive
//! API reads, exec-derived search) in-process.
//! - Docker file reads and content search: fabro's driver-backed Docker sandbox
//! (its path resolution and result shaping) against the bare driver
//! `DockerProvider` in-process.
//! - Host tool calls: fabro's local sandbox against the driver `HostProvider`
//! in-process, to confirm no regression on the path every local run takes.
//! - The wire: the driver Host and Docker providers served over the JSON-RPC
@ -15,10 +15,9 @@
//!
//! Ignored: it needs a Docker daemon with `buildpack-deps:noble` present and
//! takes a minute. Run with
//! `cargo nextest run -p fabro-sandbox --features docker --test driver_bench
//! --run-ignored only --no-capture`.
//! `cargo nextest run -p fabro-sandbox --test driver_bench --run-ignored only
//! --no-capture`.
#![cfg(feature = "docker")]
#![allow(
clippy::print_stderr,
clippy::cast_precision_loss,
@ -36,8 +35,7 @@ use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant};
use bollard::Docker;
use fabro_sandbox::{DockerSandbox, DockerSandboxOptions, Sandbox as FabroSandbox, local_sandbox};
use fabro_sandbox::{DockerSandboxOptions, Sandbox as FabroSandbox, docker_sandbox, local_sandbox};
use sandbox_driver::{
ExecSpec, GrepOptions, Sandbox as DriverSandbox, SandboxProvider, SandboxSource, SandboxSpec,
Search,
@ -324,12 +322,13 @@ async fn serve_over_duplex(provider: Arc<dyn SandboxProvider>) -> PluginProvider
#[tokio::test(flavor = "multi_thread")]
#[ignore = "benchmark: needs a Docker daemon with buildpack-deps:noble and takes about a minute"]
async fn agent_tool_call_latency_through_the_driver() {
let Ok(docker) = Docker::connect_with_local_defaults() else {
eprintln!("no Docker daemon; skipping");
return;
};
if docker.inspect_image(IMAGE).await.is_err() {
eprintln!("{IMAGE} is not present locally; skipping");
let image_check = Command::new("docker")
.args(["image", "inspect", IMAGE])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if !image_check.is_ok_and(|status| status.success()) {
eprintln!("no Docker daemon or {IMAGE} is not present locally; skipping");
return;
}
let repo = Repository::pack();
@ -363,8 +362,8 @@ async fn agent_tool_call_latency_through_the_driver() {
remote_host.shutdown().await.expect("shutdown");
host.delete().await.expect("host delete");
// -- Docker, in-process: fabro DockerSandbox vs driver DockerProvider.
let fabro_docker = DockerSandbox::new(
// -- 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,
@ -378,10 +377,11 @@ async fn agent_tool_call_latency_through_the_driver() {
None,
None,
)
.await
.expect("fabro docker sandbox");
fabro_docker.initialize().await.expect("fabro docker init");
unpack_fabro(&fabro_docker, &repo).await;
rows.extend(bench_fabro("fabro DockerSandbox", &fabro_docker, &repo).await);
rows.extend(bench_fabro("fabro Docker (driver-backed)", &fabro_docker, &repo).await);
fabro_docker.cleanup().await.expect("fabro docker cleanup");
let docker_provider = Arc::new(DockerProvider::connect().await.expect("docker connect"));

View file

@ -11,24 +11,3 @@ fn context_error_preserves_source_cause() {
"Failed to read file\n caused by: permission denied"
);
}
#[cfg(feature = "docker")]
#[test]
fn docker_image_inspect_error_preserves_source_cause() {
use bollard::errors::Error as BollardError;
let source = BollardError::DockerResponseServerError {
status_code: 500,
message: "daemon unavailable".to_string(),
};
let error = fabro_sandbox::Error::docker_image_inspect("buildpack-deps:noble", source);
assert_eq!(
error.to_string(),
"Failed to inspect Docker image buildpack-deps:noble"
);
assert_eq!(error.causes(), vec![
"Docker responded with status code 500: daemon unavailable"
]);
}

View file

@ -83,7 +83,7 @@ fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
fabro-workflow = { path = ".", features = ["test-support"] }
fabro-api = { path = "../../foundation/fabro-api" }
fabro-environment = { path = "../fabro-environment" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker", "test-support"] }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "test-support"] }
fabro-mcp = { path = "../fabro-mcp" }
tokio = { workspace = true, features = ["test-util", "macros"] }
object_store.workspace = true

View file

@ -13760,7 +13760,8 @@ async fn asset_collection_docker_sandbox() {
..Default::default()
};
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
fabro_agent::DockerSandbox::new(config, None, None, None, None, None, None)
fabro_agent::docker_sandbox(config, None, None, None, None, None, None)
.await
.expect("Docker not available"),
);
sandbox.initialize().await.expect("Docker init failed");