From 4dcbcec400053864d3fd78cc79041838eebe0c05 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 15:23:56 -0600 Subject: [PATCH] Add the single sandbox provider construction function fabro_sandbox::driver::connect_provider turns one [server.sandbox.providers.] entry into an Arc 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/ 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 --- Cargo.lock | 2 + lib/components/fabro-sandbox/Cargo.toml | 10 + .../src/bin/fabro-sandbox-docker.rs | 43 ++ .../src/bin/fabro-sandbox-host.rs | 55 +++ lib/components/fabro-sandbox/src/driver.rs | 389 ++++++++++++++++++ lib/components/fabro-sandbox/src/lib.rs | 2 + .../fabro-sandbox/tests/plugin_provider.rs | 110 +++++ 7 files changed, 611 insertions(+) create mode 100644 lib/components/fabro-sandbox/src/bin/fabro-sandbox-docker.rs create mode 100644 lib/components/fabro-sandbox/src/bin/fabro-sandbox-host.rs create mode 100644 lib/components/fabro-sandbox/src/driver.rs create mode 100644 lib/components/fabro-sandbox/tests/plugin_provider.rs diff --git a/Cargo.lock b/Cargo.lock index 101226b80..229c303d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/lib/components/fabro-sandbox/Cargo.toml b/lib/components/fabro-sandbox/Cargo.toml index d5672f3f4..8b8fb303d 100644 --- a/lib/components/fabro-sandbox/Cargo.toml +++ b/lib/components/fabro-sandbox/Cargo.toml @@ -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 diff --git a/lib/components/fabro-sandbox/src/bin/fabro-sandbox-docker.rs b/lib/components/fabro-sandbox/src/bin/fabro-sandbox-docker.rs new file mode 100644 index 000000000..dc9577d71 --- /dev/null +++ b/lib/components/fabro-sandbox/src/bin/fabro-sandbox-docker.rs @@ -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") +} diff --git a/lib/components/fabro-sandbox/src/bin/fabro-sandbox-host.rs b/lib/components/fabro-sandbox/src/bin/fabro-sandbox-host.rs new file mode 100644 index 000000000..b444eb9b1 --- /dev/null +++ b/lib/components/fabro-sandbox/src/bin/fabro-sandbox-host.rs @@ -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") +} diff --git a/lib/components/fabro-sandbox/src/driver.rs b/lib/components/fabro-sandbox/src/driver.rs new file mode 100644 index 000000000..83d268370 --- /dev/null +++ b/lib/components/fabro-sandbox/src/driver.rs @@ -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` 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, + pub organization_id: Option, + pub target: Option, + /// Shared HTTP client; tests pass a no-proxy client here. + pub http_client: Option, +} + +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, + /// Required to connect the bundled Daytona provider. + pub daytona: Option, +} + +/// 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, +} + +#[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 { + if !settings.enabled { + return Err(ConnectError::Disabled { kind: kind.clone() }); + } + let driver = |source| ConnectError::Driver { + kind: kind.clone(), + source, + }; + let provider: Arc = 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 { + 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::>(), + 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, + ) -> sandbox_driver::Result> { + self.supervisor.current().await?.create(spec, events).await + } + + async fn attach( + &self, + id: &SandboxId, + events: Option, + ) -> sandbox_driver::Result> { + self.supervisor.current().await?.attach(id, events).await + } + + async fn undelete( + &self, + id: &SandboxId, + events: Option, + ) -> sandbox_driver::Result> { + self.supervisor.current().await?.undelete(id, events).await + } + + async fn delete( + &self, + id: &SandboxId, + events: Option, + ) -> sandbox_driver::Result<()> { + self.supervisor.current().await?.delete(id, events).await + } + + async fn list(&self, filter: &SandboxFilter) -> sandbox_driver::Result> { + self.supervisor.current().await?.list(filter).await + } + + async fn health(&self) -> sandbox_driver::Result { + 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) -> 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"]); + } +} diff --git a/lib/components/fabro-sandbox/src/lib.rs b/lib/components/fabro-sandbox/src/lib.rs index 988eacf4f..98ae0473e 100644 --- a/lib/components/fabro-sandbox/src/lib.rs +++ b/lib/components/fabro-sandbox/src/lib.rs @@ -20,6 +20,8 @@ pub mod redact; pub mod details; +pub mod driver; + pub mod reconnect; pub mod terminal; diff --git a/lib/components/fabro-sandbox/tests/plugin_provider.rs b/lib/components/fabro-sandbox/tests/plugin_provider.rs new file mode 100644 index 000000000..1a5224489 --- /dev/null +++ b/lib/components/fabro-sandbox/tests/plugin_provider.rs @@ -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}"); +}