Add the single sandbox provider construction function

fabro_sandbox::driver::connect_provider turns one
[server.sandbox.providers.<kind>] entry into an Arc<dyn SandboxProvider>
from the sandbox-driver crates. Bundled kinds link the driver's Host,
Docker, and Daytona providers in-process; Daytona receives its API key
and endpoint explicitly through connect_explicit with a
fabro-sandbox/<version> user agent, never from the process environment.
Any other kind launches the configured plugin executable through a
PluginSupervisor and returns a wrapper that replaces a crashed plugin
for new work only, never replaying a failed call. Disabled entries are
refused at the construction point.

The Host and Docker providers also ship as fabro-sandbox-host and
fabro-sandbox-docker plugin executables so CI can drive the bundled
providers over stdio and a deployment can move one out of process by
configuration alone. An integration test registers the Host executable
under the non-bundled kind `host`, creates a sandbox and runs a command
over the wire, then attaches to it by persisted id from a fresh plugin
process, and checks that a plugin declaring a different kind is refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 15:23:56 -06:00
parent 80bc51c40e
commit 4dcbcec400
No known key found for this signature in database
7 changed files with 611 additions and 0 deletions

2
Cargo.lock generated
View file

@ -3004,6 +3004,7 @@ dependencies = [
"hmac 0.12.1",
"httpmock",
"rand 0.9.4",
"reqwest 0.13.2",
"reqwest-middleware",
"rustls",
"sandbox-driver",
@ -3025,6 +3026,7 @@ dependencies = [
"tokio-util",
"toml 0.8.23",
"tracing",
"tracing-subscriber",
"uuid",
]

View file

@ -16,6 +16,14 @@ test-support = []
[lib]
doctest = false
[[bin]]
name = "fabro-sandbox-host"
path = "src/bin/fabro-sandbox-host.rs"
[[bin]]
name = "fabro-sandbox-docker"
path = "src/bin/fabro-sandbox-docker.rs"
[lints]
workspace = true
@ -36,6 +44,8 @@ serde.workspace = true
serde_json.workspace = true
strum.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
reqwest.workspace = true
base64.workspace = true
hmac.workspace = true
sha2.workspace = true

View file

@ -0,0 +1,43 @@
//! The bundled Docker provider served as a sandbox-driver plugin over stdio.
//!
//! Fabro links Docker in-process for normal runs. This executable exists so
//! CI can run the same provider through the plugin protocol and so a
//! deployment can move it out of process. The daemon named by `DOCKER_HOST`
//! (or the local default) is not required to answer at launch: an
//! unreachable daemon is reported through `provider/health`. Stdout belongs
//! to the protocol; logs go to stderr.
use std::io::stderr;
use std::sync::Arc;
use anyhow::Context as _;
use sandbox_driver_docker::DockerProvider;
use sandbox_driver_protocol::serve_stdio;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{EnvFilter, fmt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
// Stdout carries the protocol, so diagnostics must go to the process
// stderr handle; tracing's writer contract is synchronous.
#[expect(
clippy::disallowed_methods,
reason = "tracing-subscriber requires a synchronous writer and stdout is reserved for \
the plugin protocol"
)]
let diagnostics = fmt::layer().with_writer(stderr);
tracing_subscriber::registry()
.with(filter)
.with(diagnostics)
.try_init()
.context("configuring docker plugin diagnostics")?;
let provider = DockerProvider::connect_unverified().context("configuring the docker client")?;
serve_stdio(Arc::new(provider))
.await
.context("serving the docker provider plugin")
}

View file

@ -0,0 +1,55 @@
//! The bundled Host provider served as a sandbox-driver plugin over stdio.
//!
//! Fabro links Host in-process for normal runs. This executable exists so
//! CI can run the same provider through the plugin protocol and so a
//! deployment can move it out of process by configuring
//! `[server.sandbox.providers.host]` instead of `local`. Stdout belongs to
//! the protocol; logs go to stderr. `SANDBOX_DRIVER_HOST_REGISTRY` names a
//! persistent registry directory; without it sandboxes live in a fresh
//! temporary registry.
use std::io::stderr;
use std::sync::Arc;
use anyhow::Context as _;
use sandbox_driver_host::HostProvider;
use sandbox_driver_protocol::serve_stdio;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{EnvFilter, fmt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
// Stdout carries the protocol, so diagnostics must go to the process
// stderr handle; tracing's writer contract is synchronous.
#[expect(
clippy::disallowed_methods,
reason = "tracing-subscriber requires a synchronous writer and stdout is reserved for \
the plugin protocol"
)]
let diagnostics = fmt::layer().with_writer(stderr);
tracing_subscriber::registry()
.with(filter)
.with(diagnostics)
.try_init()
.context("configuring host plugin diagnostics")?;
#[expect(
clippy::disallowed_methods,
reason = "a plugin executable starts from the scrubbed environment its host declared; \
reading it here is the configured channel"
)]
let registry = std::env::var_os("SANDBOX_DRIVER_HOST_REGISTRY");
let provider = match registry {
Some(root) => HostProvider::with_registry(root)
.await
.context("opening the host registry")?,
None => HostProvider::new(),
};
serve_stdio(Arc::new(provider))
.await
.context("serving the host provider plugin")
}

View file

@ -0,0 +1,389 @@
//! The one place fabro turns provider configuration into a sandbox-driver
//! [`SandboxProvider`].
//!
//! Bundled kinds (`local`, `docker`, `daytona`) link the driver's provider
//! crates in-process. Any other kind launches the configured plugin
//! executable over stdio and supervises it. Callers never learn which they
//! got: both come back as `Arc<dyn SandboxProvider>` tagged with fabro's own
//! [`SandboxProviderKind`], which is what run records and inventory persist.
//!
//! Credentials arrive explicitly. Nothing here reads the process environment:
//! the Daytona key comes from the vault through [`DaytonaCredentials`], and a
//! plugin starts from a scrubbed environment containing only what its
//! settings declare.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings};
use fabro_types::{BundledProvider, SandboxProviderKind};
use sandbox_driver::{
Capabilities, EventContext, ProviderHealth, ProviderKind, Sandbox, SandboxFilter, SandboxId,
SandboxProvider, SandboxSpec, SandboxStatus, SnapshotProvider, VolumeProvider,
};
use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider};
use sandbox_driver_docker::DockerProvider;
use sandbox_driver_host::HostProvider;
use sandbox_driver_protocol::{PluginConfig, PluginSupervisor};
/// Binary naming prefix for plugin discovery: a plugin for kind `e2b` is
/// `fabro-sandbox-e2b` on `PATH` unless the settings name a path.
pub const PLUGIN_BINARY_PREFIX: &str = "fabro-sandbox";
/// `User-Agent` fabro presents to remote sandbox control planes.
pub const USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION"));
/// Explicit Daytona credentials. The process environment is never consulted.
#[derive(Clone)]
pub struct DaytonaCredentials {
pub api_key: String,
pub api_url: Option<String>,
pub organization_id: Option<String>,
pub target: Option<String>,
/// Shared HTTP client; tests pass a no-proxy client here.
pub http_client: Option<reqwest::Client>,
}
impl std::fmt::Debug for DaytonaCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DaytonaCredentials")
.field("api_url", &self.api_url)
.field("organization_id", &self.organization_id)
.field("target", &self.target)
.finish_non_exhaustive()
}
}
/// Everything besides the settings entry that a provider connection needs.
#[derive(Clone, Debug, Default)]
pub struct ProviderConnectOptions {
/// Directory where the in-process Host provider records its sandboxes so
/// they survive a server restart. `None` uses a fresh temporary registry
/// that is removed when the provider drops.
pub host_registry_root: Option<PathBuf>,
/// Required to connect the bundled Daytona provider.
pub daytona: Option<DaytonaCredentials>,
}
/// A provider fabro connected, tagged with the kind fabro persists for it.
///
/// The driver's own `provider.kind()` may differ from fabro's kind: fabro's
/// `local` is the driver's `host`. Persist and dispatch on `kind`, never on
/// the driver's name.
#[derive(Clone)]
pub struct ConnectedProvider {
pub kind: SandboxProviderKind,
pub provider: Arc<dyn SandboxProvider>,
}
#[derive(Debug, thiserror::Error)]
pub enum ConnectError {
#[error("sandbox provider `{kind}` is disabled by server.sandbox.providers.{kind}.enabled")]
Disabled { kind: SandboxProviderKind },
#[error(
"sandbox provider `{kind}` has no plugin settings; add server.sandbox.providers.{kind}"
)]
MissingPluginSettings { kind: SandboxProviderKind },
#[error("sandbox provider `daytona` requires DAYTONA_API_KEY in the vault")]
MissingDaytonaCredentials,
#[error("sandbox provider `{kind}` is not a valid sandbox-driver kind")]
InvalidKind {
kind: SandboxProviderKind,
#[source]
source: sandbox_driver::InvalidIdError,
},
#[error("failed to connect sandbox provider `{kind}`")]
Driver {
kind: SandboxProviderKind,
#[source]
source: sandbox_driver::Error,
},
}
/// Connects the provider behind `kind`.
///
/// Bundled kinds return the in-process driver provider. Any other kind
/// launches the plugin named by `settings.plugin` and returns a supervised
/// handle that relaunches it after a crash for new work only. Disabled
/// entries are refused here so no caller has to remember the policy check.
pub async fn connect_provider(
kind: &SandboxProviderKind,
settings: &ServerSandboxProviderSettings,
options: &ProviderConnectOptions,
) -> Result<ConnectedProvider, ConnectError> {
if !settings.enabled {
return Err(ConnectError::Disabled { kind: kind.clone() });
}
let driver = |source| ConnectError::Driver {
kind: kind.clone(),
source,
};
let provider: Arc<dyn SandboxProvider> = match kind.bundled() {
Some(BundledProvider::Local) => match &options.host_registry_root {
Some(root) => Arc::new(HostProvider::with_registry(root).await.map_err(driver)?),
None => Arc::new(HostProvider::new()),
},
Some(BundledProvider::Docker) => {
// The daemon is not required to answer at connect time; `health`
// reports an unreachable daemon so preflight sees the cause.
Arc::new(DockerProvider::connect_unverified().map_err(driver)?)
}
Some(BundledProvider::Daytona) => {
let credentials = options
.daytona
.as_ref()
.ok_or(ConnectError::MissingDaytonaCredentials)?;
let config = DaytonaConfig {
api_key: Some(credentials.api_key.clone()),
jwt_token: None,
organization_id: credentials.organization_id.clone(),
api_url: credentials.api_url.clone(),
target: credentials.target.clone(),
http_client: credentials.http_client.clone(),
user_agent: Some(USER_AGENT.to_string()),
};
Arc::new(
DaytonaProvider::connect_explicit(config)
.await
.map_err(driver)?,
)
}
None => {
let plugin = settings
.plugin
.as_ref()
.ok_or_else(|| ConnectError::MissingPluginSettings { kind: kind.clone() })?;
Arc::new(PluginBackedProvider::launch(kind, plugin).await?)
}
};
Ok(ConnectedProvider {
kind: kind.clone(),
provider,
})
}
/// A plugin provider that survives its executable crashing.
///
/// Wraps a [`PluginSupervisor`]: every call obtains the current plugin
/// generation, and a closed transport is replaced with a fresh launch before
/// the call. A failed call is never replayed, and handles obtained from an
/// earlier generation stay bound to it; callers rebuild them through
/// [`SandboxProvider::attach`] with the persisted sandbox id.
pub struct PluginBackedProvider {
kind: ProviderKind,
capabilities: Capabilities,
supervisor: PluginSupervisor,
}
impl PluginBackedProvider {
async fn launch(
kind: &SandboxProviderKind,
settings: &SandboxPluginSettings,
) -> Result<Self, ConnectError> {
let driver_kind =
ProviderKind::try_new(kind.as_str()).map_err(|source| ConnectError::InvalidKind {
kind: kind.clone(),
source,
})?;
let supervisor = PluginSupervisor::new(
PLUGIN_BINARY_PREFIX,
plugin_config(driver_kind.clone(), settings),
);
// Launch once now so a misconfigured plugin fails at connect time and
// the declared capabilities are known for preflight.
let capabilities = supervisor
.current()
.await
.map_err(|source| ConnectError::Driver {
kind: kind.clone(),
source,
})?
.capabilities()
.clone();
Ok(Self {
kind: driver_kind,
capabilities,
supervisor,
})
}
/// Asks the current plugin generation to exit and reaps it.
pub async fn shutdown(&self) -> sandbox_driver::Result<()> {
self.supervisor.shutdown().await
}
}
fn plugin_config(kind: ProviderKind, settings: &SandboxPluginSettings) -> PluginConfig {
PluginConfig {
kind,
path: settings.path.as_deref().map(PathBuf::from),
sha256: settings.sha256.clone(),
dev: settings.dev,
args: settings.args.clone(),
env: settings
.env
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<BTreeMap<_, _>>(),
inherit_env: settings.inherit_env.clone(),
}
}
#[async_trait]
impl SandboxProvider for PluginBackedProvider {
fn kind(&self) -> &ProviderKind {
&self.kind
}
fn capabilities(&self) -> &Capabilities {
&self.capabilities
}
async fn create(
&self,
spec: &SandboxSpec,
events: Option<EventContext>,
) -> sandbox_driver::Result<Arc<dyn Sandbox>> {
self.supervisor.current().await?.create(spec, events).await
}
async fn attach(
&self,
id: &SandboxId,
events: Option<EventContext>,
) -> sandbox_driver::Result<Arc<dyn Sandbox>> {
self.supervisor.current().await?.attach(id, events).await
}
async fn undelete(
&self,
id: &SandboxId,
events: Option<EventContext>,
) -> sandbox_driver::Result<Arc<dyn Sandbox>> {
self.supervisor.current().await?.undelete(id, events).await
}
async fn delete(
&self,
id: &SandboxId,
events: Option<EventContext>,
) -> sandbox_driver::Result<()> {
self.supervisor.current().await?.delete(id, events).await
}
async fn list(&self, filter: &SandboxFilter) -> sandbox_driver::Result<Vec<SandboxStatus>> {
self.supervisor.current().await?.list(filter).await
}
async fn health(&self) -> sandbox_driver::Result<ProviderHealth> {
self.supervisor.current().await?.health().await
}
/// Snapshot and volume management cross the wire per plugin generation,
/// which these borrowing accessors cannot express. Fabro drives
/// snapshots on the bundled Daytona provider only, so a plugin reports
/// none until a generation-aware accessor exists.
fn snapshots(&self) -> Option<&dyn SnapshotProvider> {
None
}
fn volumes(&self) -> Option<&dyn VolumeProvider> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(plugin: Option<SandboxPluginSettings>) -> ServerSandboxProviderSettings {
ServerSandboxProviderSettings {
enabled: true,
plugin,
}
}
#[tokio::test]
async fn disabled_entries_are_refused_before_any_connection() {
let error = connect_provider(
&SandboxProviderKind::DOCKER,
&ServerSandboxProviderSettings {
enabled: false,
plugin: None,
},
&ProviderConnectOptions::default(),
)
.await
.err()
.expect("disabled provider must not connect");
assert!(
matches!(error, ConnectError::Disabled { kind } if kind == SandboxProviderKind::DOCKER)
);
}
#[tokio::test]
async fn daytona_requires_explicit_credentials() {
let error = connect_provider(
&SandboxProviderKind::DAYTONA,
&settings(None),
&ProviderConnectOptions::default(),
)
.await
.err()
.expect("daytona must not fall back to the environment");
assert!(matches!(error, ConnectError::MissingDaytonaCredentials));
}
#[tokio::test]
async fn plugin_kinds_require_plugin_settings() {
let kind = SandboxProviderKind::try_new("e2b").unwrap();
let error = connect_provider(&kind, &settings(None), &ProviderConnectOptions::default())
.await
.err()
.expect("a plugin kind without settings cannot launch");
assert!(matches!(error, ConnectError::MissingPluginSettings { kind: k } if k == kind));
}
#[tokio::test]
async fn local_connects_the_host_provider_in_process() {
let registry = tempfile::tempdir().unwrap();
let connected = connect_provider(
&SandboxProviderKind::LOCAL,
&settings(None),
&ProviderConnectOptions {
host_registry_root: Some(registry.path().to_path_buf()),
daytona: None,
},
)
.await
.expect("host provider connects without external services");
assert_eq!(connected.kind, SandboxProviderKind::LOCAL);
assert_eq!(connected.provider.kind().as_str(), "host");
}
#[test]
fn plugin_config_carries_every_launch_setting() {
let config = plugin_config(
ProviderKind::try_new("e2b").unwrap(),
&SandboxPluginSettings {
path: Some("/opt/e2b".to_string()),
sha256: Some("abc".to_string()),
dev: true,
args: vec!["--flag".to_string()],
env: BTreeMap::from([("A".to_string(), "1".to_string())]),
inherit_env: vec!["PATH".to_string()],
},
);
assert_eq!(
config.path.as_deref(),
Some(std::path::Path::new("/opt/e2b"))
);
assert_eq!(config.sha256.as_deref(), Some("abc"));
assert!(config.dev);
assert_eq!(config.args, vec!["--flag"]);
assert_eq!(config.env.get("A").map(String::as_str), Some("1"));
assert_eq!(config.inherit_env, vec!["PATH"]);
}
}

View file

@ -20,6 +20,8 @@ pub mod redact;
pub mod details;
pub mod driver;
pub mod reconnect;
pub mod terminal;

View file

@ -0,0 +1,110 @@
//! The construction function serves a non-bundled kind through a plugin
//! executable, and a sandbox created through one plugin generation is
//! reachable by persisted id from a fresh connection.
use std::collections::BTreeMap;
use fabro_sandbox::driver::{ProviderConnectOptions, connect_provider};
use fabro_types::SandboxProviderKind;
use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings};
use sandbox_driver::{ExecSpec, SandboxId, SandboxSource, SandboxSpec};
const HOST_PLUGIN: &str = env!("CARGO_BIN_EXE_fabro-sandbox-host");
fn host_plugin_settings(registry: &std::path::Path) -> ServerSandboxProviderSettings {
ServerSandboxProviderSettings {
enabled: true,
plugin: Some(SandboxPluginSettings {
path: Some(HOST_PLUGIN.to_string()),
sha256: None,
dev: true,
args: Vec::new(),
env: BTreeMap::from([(
"SANDBOX_DRIVER_HOST_REGISTRY".to_string(),
registry.display().to_string(),
)]),
inherit_env: Vec::new(),
}),
}
}
#[tokio::test]
async fn host_plugin_under_a_non_bundled_kind_creates_and_reattaches_by_persisted_id() {
let registry = tempfile::tempdir().expect("registry tempdir");
let workspace = tempfile::tempdir().expect("workspace tempdir");
let kind = SandboxProviderKind::try_new("host").expect("host is a valid kind");
assert_eq!(
kind.bundled(),
None,
"host is not one of fabro's bundled kinds"
);
let settings = host_plugin_settings(registry.path());
let persisted_id: SandboxId = {
let connected = connect_provider(&kind, &settings, &ProviderConnectOptions::default())
.await
.expect("plugin launches");
assert_eq!(connected.kind, kind);
assert_eq!(connected.provider.kind().as_str(), "host");
let spec = SandboxSpec::new(SandboxSource::HostDirectory)
.working_directory(workspace.path().display().to_string())
.label("sh.fabro.managed", "true");
let sandbox = connected
.provider
.create(&spec, None)
.await
.expect("create over the wire");
let result = sandbox
.exec()
.run(&ExecSpec::bash(
"printf hello > marker.txt && cat marker.txt",
))
.await
.expect("exec over the wire");
assert!(result.success(), "{result:?}");
assert_eq!(result.stdout_lossy(), "hello");
sandbox.id().clone()
};
// A fresh connection is a new plugin process; the id alone must be
// enough to find the sandbox again, exactly as run reconnect will do.
let connected = connect_provider(&kind, &settings, &ProviderConnectOptions::default())
.await
.expect("plugin relaunches");
let sandbox = connected
.provider
.attach(&persisted_id, None)
.await
.expect("attach by persisted id");
let content = sandbox
.fs()
.read("marker.txt")
.await
.expect("file survives across plugin generations");
assert_eq!(content, b"hello");
assert!(workspace.path().join("marker.txt").is_file());
sandbox.delete().await.expect("delete releases the handle");
assert!(
workspace.path().is_dir(),
"designated directories are never removed by delete"
);
}
#[tokio::test]
async fn a_plugin_that_declares_another_kind_is_rejected() {
let registry = tempfile::tempdir().expect("registry tempdir");
let kind = SandboxProviderKind::try_new("e2b").expect("valid kind");
let error = connect_provider(
&kind,
&host_plugin_settings(registry.path()),
&ProviderConnectOptions::default(),
)
.await
.err()
.expect("the host executable declares `host`, not `e2b`");
let rendered = format!(
"{error}: {:?}",
std::error::Error::source(&error).map(ToString::to_string)
);
assert!(rendered.contains("e2b"), "{rendered}");
}