Run plugin providers end to end and gate them in CI

Closes the sandbox-driver adoption: any provider a sandbox-driver plugin
executable serves can now host a fabro run, and fabro's own bundled
providers can be served the same way.

- `SandboxSpec::Plugin` builds a normalized driver spec from the
  environment (image or Dockerfile source, or a provider-managed
  directory; resources; network policy; labels; env) and lays fabro's
  repository checkout out inside the provider's working directory. The
  layout is recorded on the run through the new `workspace_layout` trait
  method.
- Plugin settings on a bundled kind (`[server.sandbox.providers.docker]
  path = ...`) serve that kind out of process through the driver's
  executable; the config layer no longer rejects them.
- `ProviderAccess` carries the server's provider settings and the vault's
  Daytona credentials to every reconnect: run resume, sandbox details,
  terminals, previews, and the worker's start path. The worker receives
  the settings through `StartServices`. No "plugin not wired" errors
  remain.
- The CLI worker requires GitHub credentials only when a repository will
  be cloned; a `none` target on a clone-based provider creates an empty
  workspace and needs none.
- fabro-db tracks its migrations directory so a new migration file
  recompiles the crate; the environment provider migration had been
  silently missing from stale builds. Environment store 500s now log
  their cause.
- The CLI workflow scenarios run against `host-plugin` (the driver's
  Host executable under the non-bundled `host` kind) and `docker-plugin`
  (the bundled `docker` kind served over stdio), each on an isolated
  server, printing the server log on failure. A live Daytona gate runs
  the native git clone over the JSON-RPC wire. A new CI job runs the
  plugin scenarios and the driver-backed Docker integration tests with
  the plugin executables built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-09 19:54:57 -06:00
parent 28d4242df0
commit 030e653abf
No known key found for this signature in database
38 changed files with 1449 additions and 356 deletions

View file

@ -133,6 +133,39 @@ jobs:
# strict mode, which fails (rather than skips) live tests without keys.
- run: cargo nextest run --locked --workspace --status-level slow --profile ci --run-ignored only -E 'package(fabro-agent) + package(fabro-llm) + package(twin-openai)'
sandbox-plugins:
name: Sandbox plugins (stdio)
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
env:
# The plugin scenarios skip when an executable or daemon is missing;
# in CI a skip is a failure.
FABRO_REQUIRE_SANDBOX_PLUGINS: "1"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- run: docker pull buildpack-deps:noble
# The driver's Host and Docker executables fabro ships as
# `fabro-sandbox-<kind>`; the CLI scenarios launch them over stdio.
- run: cargo build --locked -p fabro-sandbox --bins
# Host and Docker served as plugins through the workflow scenarios. The
# scenarios are e2e tests (ignored by default); the key-free ones run
# here, the LLM-backed ones self-skip without credentials.
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/host_plugin_|docker_plugin_/)'
# The driver-backed Docker integration tests and the stdio plugin proof.
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-sandbox --test docker_streaming --test plugin_provider
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-agent --test it -E 'test(docker_shell)'
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-workflow --test it -E 'test(asset_collection_docker_sandbox)'
test-macos:
name: Test (macOS)
if: github.event_name == 'workflow_dispatch'

View file

@ -201,8 +201,12 @@ enabled = true
Any other key names a [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugin:
an executable that speaks the sandbox-driver JSON-RPC protocol on stdin and stdout. The kind must
be lowercase ASCII letters, digits, and interior hyphens. The plugin starts with a scrubbed
environment: only `env` and the ambient variables listed in `inherit_env` reach it. Bundled
providers reject these plugin keys.
environment: only `env` and the ambient variables listed in `inherit_env` reach it.
A bundled provider accepts the same plugin keys. Setting any of them runs that provider out of
process through the driver's executable for the kind (`fabro-sandbox-docker` for `docker`), which
isolates the server from provider crashes at the cost of a process per connection. Without them
the bundled provider links in-process.
```toml title="settings.toml"
[server.sandbox.providers.e2b]

View file

@ -20,10 +20,7 @@ use fabro_server::run_tool_manifest;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_tool::fabro_client::ClientBackend;
use fabro_types::settings::run::{RunMode, RunNamespace};
use fabro_types::{
ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId,
WorkflowSettings,
};
use fabro_types::{ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId};
use fabro_vault::{SecretStore, Vault};
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
use fabro_workflow::event::{Emitter, RunEventSink};
@ -139,8 +136,11 @@ pub(crate) async fn execute(
let vault = load_worker_vault(&storage_dir).await?;
let github_app = {
let vault_guard = vault.read().await;
maybe_build_github_credentials(&run_spec.settings, &vault_guard)?
maybe_build_github_credentials(run_spec, &vault_guard)?
};
let sandbox_providers = ServerSettingsBuilder::load_default()
.map(|settings| settings.server.sandbox.providers)
.unwrap_or_default();
let services = StartServices {
run_id,
cancel_token: cancel_token.clone(),
@ -169,6 +169,7 @@ pub(crate) async fn execute(
.resolve_integration()
.context("failed to resolve github integration")?,
vault,
sandbox_providers,
catalog,
on_node: None,
registry_override: None,
@ -1100,10 +1101,13 @@ fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
}
fn maybe_build_github_credentials(
settings: &WorkflowSettings,
run_spec: &fabro_types::RunSpec,
vault: &fabro_vault::Vault,
) -> Result<Option<fabro_github::GitHubCredentials>> {
let resolved_run = &settings.run;
let resolved_run = &run_spec.settings.run;
let has_repo_origin = run_spec
.repo_origin_url()
.is_some_and(|origin| !origin.trim().is_empty());
let resolved_server = ServerSettingsBuilder::load_default().ok();
let server_ns = resolved_server.as_ref().map(|s| &s.server);
let strategy = server_ns
@ -1112,7 +1116,7 @@ fn maybe_build_github_credentials(
let app_id = server_ns.and_then(|server| server.integrations.github.app_id.clone());
let app_slug = server_ns.and_then(|server| server.integrations.github.slug.clone());
if requires_github_credentials(resolved_run) {
if requires_github_credentials(resolved_run, has_repo_origin) {
return build_github_credentials(strategy, app_id.as_deref(), app_slug.as_deref(), vault);
}
@ -1133,14 +1137,17 @@ fn maybe_build_github_credentials(
}
/// Hard-gate for the CLI worker path: a run-level token is requested, or
/// a clone-based sandbox in non-dry-run mode will need credentials to
/// pull the repository. Pull-request-driven credential acquisition is
/// handled separately by the caller as a soft fallback.
fn requires_github_credentials(run: &RunNamespace) -> bool {
/// a clone-based sandbox in non-dry-run mode will clone a repository and
/// needs credentials to pull it. A run without a repository origin creates
/// an empty workspace and needs none. Pull-request-driven credential
/// acquisition is handled separately by the caller as a soft fallback.
fn requires_github_credentials(run: &RunNamespace, has_repo_origin: bool) -> bool {
if run.integrations.github.is_token_requested() {
return true;
}
run.execution.mode != RunMode::DryRun && run.environment.provider.clones_workspace()
run.execution.mode != RunMode::DryRun
&& run.environment.provider.clones_workspace()
&& has_repo_origin
}
fn install_signal_handlers(
@ -1776,28 +1783,38 @@ mod tests {
// Even with local sandbox + dry-run, non-empty permissions
// force credential acquisition.
let run = run_with(permissions, "local", RunMode::DryRun);
assert!(requires_github_credentials(&run));
assert!(requires_github_credentials(&run, false));
}
#[test]
fn requires_github_credentials_for_clone_based_provider() {
fn requires_github_credentials_for_clone_based_provider_with_an_origin() {
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
assert!(requires_github_credentials(&run));
assert!(requires_github_credentials(&run, true));
let daytona = run_with(HashMap::new(), "daytona", RunMode::Normal);
assert!(requires_github_credentials(&daytona));
assert!(requires_github_credentials(&daytona, true));
let plugin = run_with(HashMap::new(), "host", RunMode::Normal);
assert!(requires_github_credentials(&plugin, true));
}
#[test]
fn does_not_require_github_credentials_without_a_repository_origin() {
// A `none` target creates an empty workspace; nothing is cloned.
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
assert!(!requires_github_credentials(&run, false));
}
#[test]
fn does_not_require_github_credentials_for_local_clean_run() {
let run = run_with(HashMap::new(), "local", RunMode::Normal);
assert!(!requires_github_credentials(&run));
assert!(!requires_github_credentials(&run, true));
}
#[test]
fn does_not_require_github_credentials_for_clone_provider_in_dry_run() {
let run = run_with(HashMap::new(), "docker", RunMode::DryRun);
assert!(!requires_github_credentials(&run));
assert!(!requires_github_credentials(&run, true));
}
}
}

View file

@ -1,4 +1,4 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, find_run_dir, fixture, has_event, read_conclusion, sandbox_tests, timeout_for,
@ -6,9 +6,7 @@ use super::{
sandbox_tests!(agent_linear, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_agent_linear(sandbox: &str) {
let context = test_context!();
fn scenario_agent_linear(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -23,7 +21,7 @@ fn scenario_agent_linear(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

View file

@ -3,7 +3,7 @@
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for,
@ -12,9 +12,7 @@ use super::{
sandbox_tests!(command_agent_mixed, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_command_agent_mixed(sandbox: &str) {
let context = test_context!();
fn scenario_command_agent_mixed(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -29,7 +27,7 @@ fn scenario_command_agent_mixed(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
@ -47,7 +45,7 @@ fn scenario_command_agent_mixed(sandbox: &str) {
"verify should be completed"
);
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let export_dir = dump_export(context, &run_id_for(&run_dir));
let stdout =
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("output.log"))
.expect("verify output.log should exist");

View file

@ -3,7 +3,7 @@
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for,
@ -12,9 +12,7 @@ use super::{
sandbox_tests!(command_pipeline);
fn scenario_command_pipeline(sandbox: &str) {
let context = test_context!();
fn scenario_command_pipeline(context: &TestContext, sandbox: &str) {
context
.validate()
.arg(fixture("command_pipeline.fabro"))
@ -29,7 +27,7 @@ fn scenario_command_pipeline(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(
conclusion["status"].as_str(),
@ -47,7 +45,7 @@ fn scenario_command_pipeline(sandbox: &str) {
"step2 should be completed"
);
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let export_dir = dump_export(context, &run_id_for(&run_dir));
let stdout1 =
std::fs::read_to_string(stage_dump_dir(&export_dir, "step1@1").join("output.log"))
.expect("step1 output.log should exist");

View file

@ -1,11 +1,10 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
sandbox_tests!(command_routing);
fn scenario_command_routing(sandbox: &str) {
let context = test_context!();
fn scenario_command_routing(context: &TestContext, sandbox: &str) {
let workflow = fixture("command_routing.fabro");
context.validate().arg(&workflow).assert().success();
@ -18,7 +17,7 @@ fn scenario_command_routing(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

View file

@ -1,12 +1,10 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
sandbox_tests!(conditional_branching);
fn scenario_conditional_branching(sandbox: &str) {
let context = test_context!();
fn scenario_conditional_branching(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args(["--auto-approve", "--environment", sandbox])
@ -15,7 +13,7 @@ fn scenario_conditional_branching(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

View file

@ -3,7 +3,7 @@
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, dump_export, find_run_dir, fixture, has_event, read_conclusion, read_run_spec,
@ -12,9 +12,7 @@ use super::{
sandbox_tests!(full_stack, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_full_stack(sandbox: &str) {
let context = test_context!();
fn scenario_full_stack(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -29,7 +27,7 @@ fn scenario_full_stack(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(
conclusion["status"].as_str(),
@ -72,7 +70,7 @@ fn scenario_full_stack(sandbox: &str) {
}
// Verify node stdout should contain PASS
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let export_dir = dump_export(context, &run_id_for(&run_dir));
let stdout =
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("output.log"))
.expect("verify output.log should exist");

View file

@ -1,12 +1,10 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
sandbox_tests!(human_gate, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_human_gate(sandbox: &str) {
let context = test_context!();
fn scenario_human_gate(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -21,7 +19,7 @@ fn scenario_human_gate(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

View file

@ -14,6 +14,7 @@ mod dry_run_examples;
mod full_stack;
mod hooks;
mod human_gate;
pub(super) mod plugin;
use std::path::{Path, PathBuf};
use std::time::Duration;
@ -167,6 +168,18 @@ fn run_events(run_dir: &Path) -> Vec<EventEnvelope> {
crate::support::parse_event_envelopes(&response)
}
/// Runs a scenario against every sandbox provider fabro supports:
///
/// - `local`: the bundled Host provider in-process.
/// - `daytona`: the bundled Daytona provider, live credentials required.
/// - `host-plugin`: the driver's Host executable over stdio under the
/// non-bundled `host` kind, a clone-based managed workspace.
/// - `docker-plugin`: the bundled `docker` kind served out of process by the
/// driver's Docker executable.
///
/// The plugin variants need the executables `cargo` builds for
/// `fabro-sandbox`; without them (or without a Docker daemon) they skip,
/// unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set, as CI sets it.
macro_rules! sandbox_tests {
($name:ident) => {
sandbox_tests!($name, keys = []);
@ -175,12 +188,36 @@ macro_rules! sandbox_tests {
paste::paste! {
#[fabro_macros::e2e_test($(live($key)),*)]
fn [<local_ $name>]() {
[<scenario_ $name>]("local");
[<scenario_ $name>](&fabro_test::test_context!(), "local");
}
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY") $(, live($key))*)]
fn [<daytona_ $name>]() {
[<scenario_ $name>]("daytona");
[<scenario_ $name>](&fabro_test::test_context!(), "daytona");
}
#[fabro_macros::e2e_test($(live($key)),*)]
fn [<host_plugin_ $name>]() {
let mut context = fabro_test::test_context!();
if let Some(environment) =
$crate::workflow::plugin::configure(&mut context, $crate::workflow::plugin::Plugin::Host)
{
$crate::workflow::plugin::run_with_server_log(&context, || {
[<scenario_ $name>](&context, environment);
});
}
}
#[fabro_macros::e2e_test($(live($key)),*)]
fn [<docker_plugin_ $name>]() {
let mut context = fabro_test::test_context!();
if let Some(environment) =
$crate::workflow::plugin::configure(&mut context, $crate::workflow::plugin::Plugin::Docker)
{
$crate::workflow::plugin::run_with_server_log(&context, || {
[<scenario_ $name>](&context, environment);
});
}
}
}
};
@ -190,6 +227,7 @@ pub(super) use sandbox_tests;
pub(super) fn timeout_for(sandbox: &str) -> Duration {
match sandbox {
"daytona" => Duration::from_mins(10),
"docker-plugin" => Duration::from_mins(5),
_ => Duration::from_mins(3),
}
}

View file

@ -0,0 +1,222 @@
//! Sandbox providers served by sandbox-driver plugin executables, for the
//! workflow scenarios.
//!
//! The executables come from the `fabro-sandbox` package's `[[bin]]` targets,
//! which `cargo` places beside the `fabro` binary under test. A scenario
//! configured here runs against its own server so the plugin settings and the
//! environment it creates never leak into the shared session server.
#![expect(
clippy::disallowed_methods,
reason = "test setup reads the process environment for its opt-in gate and probes Docker synchronously"
)]
#![expect(
clippy::print_stderr,
reason = "a skipped scenario says why on the test's stderr"
)]
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use fabro_test::{TestContext, expect_reqwest_status};
use serde_json::json;
use crate::cmd::support::server_endpoint;
/// Set in CI so a missing executable or daemon fails the test instead of
/// skipping it.
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
const DOCKER_IMAGE: &str = "buildpack-deps:noble";
#[derive(Clone, Copy, Debug)]
pub(crate) enum Plugin {
/// The driver's Host executable under the non-bundled `host` kind.
Host,
/// The driver's Docker executable serving the bundled `docker` kind out
/// of process.
Docker,
}
impl Plugin {
fn kind(self) -> &'static str {
match self {
Self::Host => "host",
Self::Docker => "docker",
}
}
fn executable(self) -> &'static str {
match self {
Self::Host => "fabro-sandbox-host",
Self::Docker => "fabro-sandbox-docker",
}
}
/// The environment id the scenario selects with `--environment`.
fn environment(self) -> &'static str {
match self {
Self::Host => "host-plugin",
Self::Docker => "docker-plugin",
}
}
}
/// Point `context` at an isolated server that serves `plugin` and has an
/// environment for it. Returns the environment id, or `None` when the
/// prerequisites are missing and the test should skip.
pub(crate) fn configure(context: &mut TestContext, plugin: Plugin) -> Option<&'static str> {
let required = std::env::var_os(REQUIRE_ENV).is_some();
let Some(executable) = plugin_executable(plugin) else {
assert!(
!required,
"{REQUIRE_ENV} is set but the {} executable is not built",
plugin.executable()
);
eprintln!(
"skipping: {} is not built; run `cargo build -p fabro-sandbox --bins`",
plugin.executable()
);
return None;
};
if matches!(plugin, Plugin::Docker) && !docker_image_available() {
assert!(
!required,
"{REQUIRE_ENV} is set but no Docker daemon with {DOCKER_IMAGE} is available"
);
eprintln!("skipping: no Docker daemon with {DOCKER_IMAGE}");
return None;
}
let storage_dir = context.temp_dir.join("plugin-server-storage");
let registry = context.temp_dir.join("host-registry");
std::fs::create_dir_all(&registry).expect("registry dir should be created");
let settings = match plugin {
Plugin::Host => format!(
r#"[server.storage]
root = "{storage}"
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.host]
path = "{path}"
dev = true
inherit_env = ["PATH", "HOME"]
[server.sandbox.providers.host.env]
SANDBOX_DRIVER_HOST_REGISTRY = "{registry}"
"#,
storage = toml_path(&storage_dir),
path = toml_path(&executable),
registry = toml_path(&registry),
),
Plugin::Docker => format!(
r#"[server.storage]
root = "{storage}"
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.docker]
path = "{path}"
dev = true
inherit_env = ["PATH", "HOME", "DOCKER_HOST", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY"]
"#,
storage = toml_path(&storage_dir),
path = toml_path(&executable),
),
};
context.write_home(".fabro/settings.toml", settings);
context.isolated_server();
create_environment(&context.storage_dir, plugin);
Some(plugin.environment())
}
/// The plugin executable `cargo` built beside the `fabro` binary under test.
fn plugin_executable(plugin: Plugin) -> Option<PathBuf> {
let fabro = Path::new(env!("CARGO_BIN_EXE_fabro"));
let candidate = fabro.with_file_name(plugin.executable());
candidate.is_file().then_some(candidate)
}
fn docker_image_available() -> bool {
Command::new("docker")
.args(["image", "inspect", DOCKER_IMAGE])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn toml_path(path: &Path) -> String {
path.display().to_string().replace('\\', "/")
}
fn create_environment(storage_dir: &Path, plugin: Plugin) {
let body = json!({
"id": plugin.environment(),
"provider": plugin.kind(),
"image": {
"docker": match plugin {
Plugin::Host => serde_json::Value::Null,
Plugin::Docker => json!(DOCKER_IMAGE),
},
"dockerfile": null
},
"resources": { "cpu": null, "memory": null, "disk": null },
"network": { "mode": "allow_all", "allow": [] },
"lifecycle": { "preserve": false, "stop_on_terminal": true, "auto_stop": null },
"labels": {},
"env": {}
});
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build")
.block_on(async {
let (client, base_url) =
server_endpoint(storage_dir).expect("isolated server endpoint should exist");
let response = client
.post(format!("{base_url}/api/v1/environments"))
.json(&body)
.send()
.await
.expect("environment create request should send");
if response.status() != fabro_http::StatusCode::CREATED {
eprintln!("server log tail:\n{}", server_log_tail(storage_dir));
}
expect_reqwest_status(
response,
fabro_http::StatusCode::CREATED,
"POST /api/v1/environments",
)
.await;
});
}
/// Run a scenario; when it fails, print the isolated server's log first, since
/// the worker's stderr (and so a plugin's launch failure) lands only there
/// and the server root is removed when the context drops.
pub(crate) fn run_with_server_log(context: &TestContext, scenario: impl FnOnce()) {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(scenario));
if let Err(panic) = outcome {
eprintln!(
"server log tail:\n{}",
server_log_tail(&context.storage_dir)
);
std::panic::resume_unwind(panic);
}
}
/// The last lines of the isolated server's log, for a failure message.
pub(crate) fn server_log_tail(storage_dir: &Path) -> String {
let path = fabro_config::Storage::new(storage_dir)
.runtime_directory()
.log_path();
let Ok(contents) = std::fs::read_to_string(&path) else {
return format!("(no server log at {})", path.display());
};
let lines: Vec<&str> = contents.lines().collect();
let start = lines.len().saturating_sub(60);
lines[start..].join("\n")
}

View file

@ -1208,11 +1208,11 @@ async fn reconnect_run_sandbox(
.and_then(fabro_types::RunSandbox::instance)
.cloned()
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Run sandbox was not created."))?;
let daytona = state
.vault_daytona_credentials()
let access = state
.provider_access()
.await
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
let sandbox = reconnect_for_run(&record, daytona, Some(*run_id))
let sandbox = reconnect_for_run(&record, &access, Some(*run_id))
.await
.map_err(|err| ApiError::new(StatusCode::CONFLICT, err.to_string()))?;
sandbox

View file

@ -22,8 +22,9 @@ use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment,
local_working_directory_from_environment,
};
use fabro_sandbox::plugin::plugin_options_from_environment;
use fabro_sandbox::redact::redact_auth_url;
use fabro_sandbox::{DaytonaCredentials, DockerSandboxOptions, Sandbox, SandboxSpec};
use fabro_sandbox::{DockerSandboxOptions, ProviderAccess, Sandbox, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::settings::ModelRef;
use fabro_types::settings::cli::OutputVerbosity;
@ -484,14 +485,14 @@ async fn build_preflight_report(
None
};
let daytona = state.vault_daytona_credentials().await?;
let access = state.provider_access().await?;
let sandbox_ok = run_sandbox_check(
&mut checks,
&sandbox_provider,
prepared,
&resolved_run,
github_app.clone(),
daytona,
&access,
)
.await;
let repository_access_ok = run_repository_access_check(
@ -919,7 +920,7 @@ fn preflight_sandbox_spec(
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
daytona: Option<DaytonaCredentials>,
access: &ProviderAccess,
) -> std::result::Result<SandboxSpec, fabro_sandbox::Error> {
let clone_origin_url = prepared
.git
@ -959,13 +960,44 @@ fn preflight_sandbox_spec(
clone_branch,
clone_tag: None,
clone_commit_sha: None,
credentials: daytona,
credentials: access.daytona.clone(),
}
}
None => {
return Err(fabro_sandbox::Error::message(format!(
"sandbox provider `{sandbox_provider}` is not bundled; plugin providers are constructed by the server"
)));
let settings = access.settings_for(sandbox_provider).ok_or_else(|| {
fabro_sandbox::Error::message(format!(
"sandbox provider `{sandbox_provider}` is not configured; add [server.sandbox.providers.{sandbox_provider}] to settings.toml"
))
})?;
// No vault is available on this path, so a `{{ secrets.* }}` value
// keeps its source form, as the Docker preflight does.
#[expect(
clippy::disallowed_methods,
reason = "preflight has no vault; an unresolved secret token is carried in source form"
)]
let env = resolved_run
.environment
.env
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect();
let mut options = plugin_options_from_environment(
&resolved_run.environment,
&resolved_run.clone,
env,
);
options.skip_clone = true;
SandboxSpec::Plugin {
kind: sandbox_provider.clone(),
settings: Box::new(settings),
options: Box::new(options),
github_app,
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
}
}
})
}
@ -976,14 +1008,14 @@ async fn run_sandbox_check(
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
daytona: Option<DaytonaCredentials>,
access: &ProviderAccess,
) -> bool {
let spec = match preflight_sandbox_spec(
sandbox_provider,
prepared,
resolved_run,
github_app.clone(),
daytona,
access,
) {
Ok(spec) => spec,
Err(err) => {
@ -2242,7 +2274,7 @@ provider = "local"
&prepared,
&resolved,
None,
None,
&ProviderAccess::default(),
);
match spec {

View file

@ -68,7 +68,7 @@ use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{BilledTokenCounts, Catalog, ModelRef, ModelTestMode, ProviderId};
use fabro_redact::redact_jsonl_line;
use fabro_sandbox::details::sandbox_details;
use fabro_sandbox::driver::{DaytonaCredentials, ProviderConnectOptions};
use fabro_sandbox::driver::{DaytonaCredentials, ProviderAccess, ProviderConnectOptions};
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_sandbox::{
DriverInventoryProvider, LocalSandboxProvider, Sandbox, SandboxProvider,
@ -1487,14 +1487,17 @@ impl AppState {
}
}
/// Daytona credentials from the vault, `None` when no key is stored.
pub(crate) async fn vault_daytona_credentials(
&self,
) -> Result<Option<DaytonaCredentials>, SecretStoreError> {
Ok(self
.vault_secret(EnvVars::DAYTONA_API_KEY)
.await?
.map(|api_key| self.daytona_credentials(api_key)))
/// Everything a reconnect needs to reach a run's provider: the server's
/// provider settings and the Daytona credentials from the vault (`None`
/// when no key is stored).
pub(crate) async fn provider_access(&self) -> Result<ProviderAccess, SecretStoreError> {
Ok(ProviderAccess {
providers: self.server_settings().server.sandbox.providers.clone(),
daytona: self
.vault_secret(EnvVars::DAYTONA_API_KEY)
.await?
.map(|api_key| self.daytona_credentials(api_key)),
})
}
pub(crate) async fn check_daytona_api_key(
@ -2790,11 +2793,11 @@ async fn delete_run_sandbox_resource(
}));
}
let daytona = state
.vault_daytona_credentials()
let access = state
.provider_access()
.await
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
let sandbox = match reconnect_for_run(&record, daytona, Some(id)).await {
let sandbox = match reconnect_for_run(&record, &access, Some(id)).await {
Ok(sandbox) => sandbox,
Err(err) if force || delete_started => {
tracing::warn!(
@ -4202,6 +4205,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
github_app,
github_integration,
vault: Arc::new(AsyncRwLock::new(vault.into_vault())),
sandbox_providers: state.server_settings().server.sandbox.providers.clone(),
catalog: state.catalog(),
on_node: None,
registry_override,

View file

@ -9,6 +9,7 @@ use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkSettings, EnvironmentResourcesSettings, EnvironmentSettings,
};
use fabro_util::error::{collect_chain, render_with_causes};
use serde::de::IgnoredAny;
use serde::{Deserialize, Serialize};
@ -267,10 +268,17 @@ impl From<EnvironmentStoreError> for ApiError {
| EnvironmentStoreError::JsonDecode { .. }
| EnvironmentStoreError::Db { .. }
| EnvironmentStoreError::RowCountOverflow { .. }
| EnvironmentStoreError::Io { .. } => Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"environment store operation failed",
),
| EnvironmentStoreError::Io { .. } => {
// The response hides the cause; the log keeps it.
tracing::error!(
error = %render_with_causes(&err.to_string(), &collect_chain(&err)),
"environment store operation failed"
);
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"environment store operation failed",
)
}
}
}
}

View file

@ -5,7 +5,9 @@ use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use fabro_sandbox::{DriverSandbox, TerminalSize, open_terminal_for_run, reconnect_driver_for_run};
use fabro_sandbox::{
DriverSandbox, ProviderAccess, TerminalSize, open_terminal_for_run, reconnect_driver_for_run,
};
use fabro_types::{
RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta,
};
@ -13,9 +15,9 @@ use futures_util::FutureExt;
use futures_util::future::BoxFuture;
use super::super::{
ApiError, AppState, Bytes, DaytonaCredentials, HeaderMap, IntoResponse, Json, NamedTempFile,
Path, PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId,
Sandbox, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
ApiError, AppState, Bytes, HeaderMap, IntoResponse, Json, NamedTempFile, Path,
PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId, Sandbox,
SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State, StatusCode,
VncPreviewResponse, collect_causes, fs, get, octet_stream_response, parse_run_id_path, post,
reject_if_archived, render_with_causes, sandbox_details,
@ -98,11 +100,11 @@ async fn retrieve_run_sandbox(
Ok(record) => record,
Err(response) => return response,
};
let daytona = match load_daytona_credentials(&state).await {
let access = match load_provider_access(&state).await {
Ok(value) => value,
Err(response) => return response,
};
match sandbox_details(&record, daytona, Some(id)).await {
match sandbox_details(&record, &access, Some(id)).await {
Ok(details) => Json::<SandboxDetails>(details).into_response(),
Err(err) => {
let detail = format!("{err:#}");
@ -219,7 +221,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: Run
return;
}
};
let daytona = match load_daytona_credentials(&state).await {
let access = match load_provider_access(&state).await {
Ok(value) => value,
Err(response) => {
let _ = socket
@ -233,7 +235,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: Run
}
};
let session =
match open_terminal_for_run(&record, daytona, Some(id), TerminalSize::default()).await {
match open_terminal_for_run(&record, &access, Some(id), TerminalSize::default()).await {
Ok(session) => session,
Err(err) => {
let _ = socket
@ -892,8 +894,8 @@ async fn reconnect_driver_sandbox_instance(
run_id: &RunId,
record: &RunSandboxInstance,
) -> Result<DriverSandbox, Response> {
let daytona = load_daytona_credentials(state).await?;
let sandbox = reconnect_driver_for_run(record, daytona, Some(*run_id), None)
let access = load_provider_access(state).await?;
let sandbox = reconnect_driver_for_run(record, &access, Some(*run_id), None)
.await
.map_err(|err| {
let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref()));
@ -905,10 +907,8 @@ async fn reconnect_driver_sandbox_instance(
Ok(sandbox)
}
async fn load_daytona_credentials(
state: &AppState,
) -> Result<Option<DaytonaCredentials>, Response> {
state.vault_daytona_credentials().await.map_err(|err| {
async fn load_provider_access(state: &AppState) -> Result<ProviderAccess, Response> {
state.provider_access().await.map_err(|err| {
tracing::error!(error = ?err, "Loading Daytona API key failed");
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,

View file

@ -714,11 +714,11 @@ async fn build_agent_session(
let sandbox_instance = sandbox_record.instance().ok_or_else(|| {
AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!("run sandbox was not created"))
})?;
let daytona = state
.vault_daytona_credentials()
let access = state
.provider_access()
.await
.map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))?;
let sandbox = reconnect_for_run(sandbox_instance, daytona, Some(run_id))
let sandbox = reconnect_for_run(sandbox_instance, &access, Some(run_id))
.await
.map_err(AskFabroBuildError::SandboxUnavailable)?;
sandbox

View file

@ -29,7 +29,7 @@ pub use crate::config::{
pub use crate::driver::DaytonaCredentials;
use crate::driver::{ProviderConnectOptions, connect_provider};
use crate::driver_sandbox::{
CreatePlan, DriverSandbox, PreparedCreate, RepoWorkspace, WorkspaceLayout,
CreatePlan, DriverSandbox, LayoutSource, PreparedCreate, RepoWorkspace, WorkspaceLayout,
};
use crate::managed_labels;
use crate::sandbox::SandboxEvent;
@ -512,7 +512,7 @@ pub async fn daytona_sandbox(
credentials: &DaytonaCredentials,
) -> crate::Result<DriverSandbox> {
let workspace = RepoWorkspace::plan(
layout(),
LayoutSource::Fixed(layout()),
config.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
@ -571,8 +571,12 @@ pub async fn attach_daytona(
&status.labels,
run_id.as_ref(),
)?;
let workspace =
RepoWorkspace::attached(layout(), repo_cloned, working_directory, clone_origin_url);
let workspace = RepoWorkspace::attached(
LayoutSource::Fixed(layout()),
repo_cloned,
working_directory,
clone_origin_url,
);
let sandbox = DriverSandbox::attached(SandboxProviderKind::DAYTONA, handle, workspace);
if let Some(snapshot) = status.source {
sandbox.set_snapshot(snapshot);
@ -869,3 +873,119 @@ mod tests {
);
}
}
/// The git clone contract over the plugin wire against live Daytona.
///
/// Host and Docker derive their git facet from `Exec`, so only Daytona
/// exercises the driver's native clone through the JSON-RPC protocol. The
/// provider is served over an in-process duplex pipe exactly as a plugin
/// executable would serve it on stdio.
#[cfg(test)]
mod wire_gate {
use std::sync::Arc;
use fabro_static::EnvVars;
use fabro_types::SandboxProviderKind;
use sandbox_driver::{SandboxProvider, SandboxSource, SandboxSpec as DriverSpec};
use sandbox_driver_protocol::{PluginProvider, serve};
use tokio::io::{duplex, split};
use super::*;
use crate::Sandbox as _;
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace};
#[expect(
clippy::disallowed_methods,
reason = "the live gate takes Daytona credentials from the developer's environment"
)]
fn live_credentials() -> Option<DaytonaCredentials> {
let api_key = std::env::var(EnvVars::DAYTONA_API_KEY).ok()?;
Some(DaytonaCredentials {
api_key,
api_url: std::env::var(EnvVars::DAYTONA_API_URL)
.or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL))
.ok(),
organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(),
target: None,
http_client: None,
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
async fn native_clone_over_the_wire_lays_out_the_repository() {
let credentials = live_credentials().expect("DAYTONA_API_KEY must be set");
let in_process = connect(&credentials).await.expect("connect to Daytona");
let (host_side, plugin_side) = duplex(1024 * 1024);
let (host_read, host_write) = split(host_side);
let (plugin_read, plugin_write) = split(plugin_side);
tokio::spawn(serve(Arc::clone(&in_process), plugin_read, plugin_write));
let remote = PluginProvider::connect(host_read, host_write)
.await
.expect("protocol handshake");
assert_eq!(remote.kind().as_str(), "daytona");
let remote: Arc<dyn SandboxProvider> = Arc::new(remote);
let workspace = RepoWorkspace::plan(
LayoutSource::Fixed(layout()),
false,
Some("https://github.com/brynary/rack-test"),
None,
None,
None,
Some(100),
None,
)
.expect("clone plan");
let snapshot = SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("snapshot id");
let spec = DriverSpec::new(SandboxSource::Snapshot { id: snapshot });
let spec = driver_spec(&DaytonaConfig::default(), None, &snapshot_id_of(&spec));
let sandbox = DriverSandbox::pending(
SandboxProviderKind::DAYTONA,
remote,
spec,
Some(DEFAULT_SNAPSHOT.to_string()),
workspace,
);
sandbox
.initialize()
.await
.expect("initialize over the wire");
let checks = async {
assert_eq!(
sandbox.working_directory(),
"/home/daytona/workspace/rack-test"
);
let result = sandbox
.exec_command(
"test -d /home/daytona/repos/brynary/rack-test/.git && \
test -L /home/daytona/workspace/rack-test && \
git rev-parse --is-inside-work-tree",
30_000,
None,
None,
None,
)
.await
.expect("layout check");
assert!(result.is_success(), "{result:?}");
assert!(result.stdout.contains("true"));
let layout = sandbox.workspace_layout().expect("layout record");
assert_eq!(
layout.primary_repo_path.as_deref(),
Some("/home/daytona/repos/brynary/rack-test")
);
};
checks.await;
sandbox.cleanup().await.expect("cleanup");
}
fn snapshot_id_of(spec: &DriverSpec) -> SnapshotId {
match &spec.source {
SandboxSource::Snapshot { id } => id.clone(),
_ => unreachable!("the gate builds a snapshot source"),
}
}
}

View file

@ -7,29 +7,31 @@ use fabro_types::{
SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,
};
use crate::driver::DaytonaCredentials;
use crate::{daytona, docker};
use crate::driver::ProviderAccess;
use crate::reconnect;
/// Inspect the sandbox identified by `record` and return provider-neutral
/// details for control-plane display.
///
/// - `local` always returns a minimal record describing the host.
/// - `docker` describes the managed container through the sandbox driver.
/// - `daytona` describes the sandbox through the sandbox driver.
/// `local` always returns a minimal record describing the host; every other
/// provider is described through the sandbox driver.
pub async fn sandbox_details(
record: &RunSandboxInstance,
daytona: Option<DaytonaCredentials>,
access: &ProviderAccess,
run_id: Option<RunId>,
) -> Result<SandboxDetails> {
match record.provider.bundled() {
Some(BundledProvider::Local) => Ok(local_details(record)),
Some(BundledProvider::Docker) => docker_details(record, run_id).await,
Some(BundledProvider::Daytona) => daytona_details(record, daytona, run_id).await,
_ => Err(anyhow::anyhow!(
"Sandbox provider '{}' has no details implementation",
record.provider
)),
if record.provider.bundled() == Some(BundledProvider::Local) {
return Ok(local_details(record));
}
let sandbox = reconnect::reconnect_driver_for_run(record, access, run_id, None).await?;
let status = sandbox.handle()?.describe().await.map_err(|err| {
anyhow::anyhow!(
"Failed to describe {} sandbox '{}': {err}",
record.provider,
record.runtime.id
)
})?;
Ok(details_from_status(record, &status))
}
fn local_details(record: &RunSandboxInstance) -> SandboxDetails {
@ -148,52 +150,6 @@ pub(crate) fn normalize_driver_state(state: sandbox_driver::SandboxState) -> San
}
}
async fn daytona_details(
record: &RunSandboxInstance,
daytona: Option<DaytonaCredentials>,
run_id: Option<RunId>,
) -> Result<SandboxDetails> {
let runtime = &record.runtime;
let credentials = daytona.ok_or_else(|| {
anyhow::anyhow!("Daytona sandbox details require DAYTONA_API_KEY in the vault")
})?;
let sandbox = daytona::attach_daytona(
&runtime.id,
runtime.repo_cloned.unwrap_or(false),
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
&credentials,
)
.await?;
let status = sandbox.handle()?.describe().await.map_err(|err| {
anyhow::anyhow!("Failed to describe Daytona sandbox '{}': {err}", runtime.id)
})?;
Ok(details_from_status(record, &status))
}
async fn docker_details(
record: &RunSandboxInstance,
run_id: Option<RunId>,
) -> 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(test)]
mod tests {
use sandbox_driver::SandboxId;

View file

@ -17,7 +17,7 @@ use sandbox_driver::{
use sandbox_driver_docker_config::DockerProviderConfig;
use crate::driver::{ProviderConnectOptions, connect_provider};
use crate::driver_sandbox::{DriverSandbox, RepoWorkspace, WorkspaceLayout};
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace, WorkspaceLayout};
use crate::managed_labels;
pub const WORKING_DIRECTORY: &str = "/workspace";
@ -140,7 +140,7 @@ pub async fn docker_sandbox(
clone_commit_sha: Option<String>,
) -> crate::Result<DriverSandbox> {
let workspace = RepoWorkspace::plan(
layout(),
LayoutSource::Fixed(layout()),
options.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
@ -191,8 +191,12 @@ pub async fn attach_docker(
&status.labels,
run_id.as_ref(),
)?;
let workspace =
RepoWorkspace::attached(layout(), repo_cloned, working_directory, clone_origin_url);
let workspace = RepoWorkspace::attached(
LayoutSource::Fixed(layout()),
repo_cloned,
working_directory,
clone_origin_url,
);
Ok(DriverSandbox::attached(
SandboxProviderKind::DOCKER,
handle,

View file

@ -18,7 +18,9 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_static::EnvVars;
use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings};
use fabro_types::settings::server::{
SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings,
};
use fabro_types::{BundledProvider, SandboxProviderKind};
use sandbox_driver::{
Capabilities, EventContext, ProviderHealth, ProviderKind, Sandbox, SandboxFilter, SandboxId,
@ -73,6 +75,37 @@ impl std::fmt::Debug for DaytonaCredentials {
}
}
/// What a process needs to reach every provider a run record can name: the
/// server's provider settings (which kinds are enabled, which run as
/// plugins) and the Daytona credentials from the vault.
#[derive(Clone, Debug, Default)]
pub struct ProviderAccess {
pub providers: ServerSandboxProvidersSettings,
pub daytona: Option<DaytonaCredentials>,
}
impl ProviderAccess {
/// The settings entry for `kind`. A bundled kind without an entry is
/// enabled with defaults; any other kind must be configured.
pub fn settings_for(
&self,
kind: &SandboxProviderKind,
) -> Option<ServerSandboxProviderSettings> {
match self.providers.get(kind) {
Some(settings) => Some(settings.clone()),
None if kind.bundled().is_some() => Some(ServerSandboxProviderSettings::default()),
None => None,
}
}
pub fn connect_options(&self) -> ProviderConnectOptions {
ProviderConnectOptions {
host_registry_root: None,
daytona: self.daytona.clone(),
}
}
}
/// Everything besides the settings entry that a provider connection needs.
#[derive(Clone, Debug, Default)]
pub struct ProviderConnectOptions {
@ -121,10 +154,12 @@ pub enum ConnectError {
/// 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.
/// Bundled kinds return the in-process driver provider unless their entry
/// names a plugin executable, in which case the same kind is served out of
/// process. Any other kind launches the plugin named by `settings.plugin`.
/// A plugin 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,
@ -133,6 +168,12 @@ pub async fn connect_provider(
if !settings.enabled {
return Err(ConnectError::Disabled { kind: kind.clone() });
}
if let Some(plugin) = &settings.plugin {
return Ok(ConnectedProvider {
kind: kind.clone(),
provider: Arc::new(PluginBackedProvider::launch(kind, plugin).await?),
});
}
let driver = |source| ConnectError::Driver {
kind: kind.clone(),
source,
@ -167,13 +208,7 @@ pub async fn connect_provider(
.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?)
}
None => return Err(ConnectError::MissingPluginSettings { kind: kind.clone() }),
};
Ok(ConnectedProvider {
kind: kind.clone(),

View file

@ -63,8 +63,8 @@ pub async fn local_sandbox(working_directory: impl Into<PathBuf>) -> crate::Resu
use crate::exec::{ExplicitEnvPolicy, SandboxExec};
use crate::sandbox::{
self, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult, GrepOptions, PushError,
PushReport, Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StdioProcess,
WalkOptions,
PushReport, Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, SandboxWorkspaceLayout,
StdioProcess, WalkOptions,
};
/// Where a clone-based provider puts its files: the run works under
@ -75,6 +75,27 @@ pub(crate) struct WorkspaceLayout {
pub(crate) repos_root: String,
}
impl WorkspaceLayout {
/// The layout for a provider whose working directory fabro does not
/// choose: repositories check out beside the workspace contents under
/// `.repos`, and the run works in the link the workspace root carries.
pub(crate) fn within(working_directory: &str) -> Self {
Self {
workspace_root: working_directory.to_string(),
repos_root: sandbox::join_sandbox_path(working_directory, ".repos"),
}
}
}
/// How a workspace learns its layout.
pub(crate) enum LayoutSource {
/// Fabro fixes the roots before the sandbox exists.
Fixed(WorkspaceLayout),
/// The roots follow the provider's working directory, known once the
/// sandbox exists.
ProviderWorkingDirectory,
}
/// What `initialize` does to the workspace once the sandbox runs.
enum WorkspacePlan {
/// Clone this GitHub repository into the layout.
@ -88,7 +109,7 @@ enum WorkspacePlan {
/// 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,
layout: OnceLock<WorkspaceLayout>,
plan: WorkspacePlan,
credentials: PushCredentialState,
repo_cloned: OnceLock<bool>,
@ -110,7 +131,7 @@ impl RepoWorkspace {
reason = "the clone selectors are validated together by decide_clone"
)]
pub(crate) fn plan(
layout: WorkspaceLayout,
layout: LayoutSource,
skip_clone: bool,
clone_origin_url: Option<&str>,
clone_branch: Option<&str>,
@ -146,7 +167,7 @@ impl RepoWorkspace {
}),
};
Ok(Self {
layout,
layout: layout.into_cell(),
plan,
credentials,
repo_cloned: OnceLock::new(),
@ -160,45 +181,97 @@ impl RepoWorkspace {
/// record. Pushes from a reattached sandbox use whatever credentials the
/// checkout's `origin` already carries.
pub(crate) fn attached(
layout: WorkspaceLayout,
layout: LayoutSource,
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(),
layout: layout.into_cell(),
plan: WorkspacePlan::Attached,
credentials: PushCredentialState::new(None),
repo_cloned: OnceLock::new(),
origin_url: OnceLock::new(),
execution_directory: OnceLock::new(),
checkout_path: 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.derive_checkout_path();
workspace
}
/// Settle a provider-dependent layout from the sandbox's working
/// directory. A fixed layout is left alone.
fn resolve_layout(&self, provider_working_directory: &str) -> &WorkspaceLayout {
let layout = self
.layout
.get_or_init(|| WorkspaceLayout::within(provider_working_directory));
self.derive_checkout_path();
layout
}
/// The checkout behind an attached clone, once the layout is known.
fn derive_checkout_path(&self) {
if self.checkout_path.get().is_some() || !self.repo_cloned() {
return;
}
let (Some(layout), Some(origin)) = (self.layout.get(), self.origin_url.get()) else {
return;
};
if let Ok(repo_layout) =
clone_source::github_repo_layout(origin, &layout.workspace_root, &layout.repos_root)
{
let _ = self.checkout_path.set(repo_layout.primary_repo_path);
}
}
fn repo_cloned(&self) -> bool {
self.repo_cloned.get().copied().unwrap_or(false)
}
fn working_directory(&self) -> &str {
fn working_directory(&self) -> Option<&str> {
self.execution_directory
.get()
.map_or(self.layout.workspace_root.as_str(), String::as_str)
.map(String::as_str)
.or_else(|| {
self.layout
.get()
.map(|layout| layout.workspace_root.as_str())
})
}
fn record(&self) -> Option<SandboxWorkspaceLayout> {
let layout = self.layout.get()?;
let repo = if self.repo_cloned() {
self.origin_url.get().and_then(|origin| {
clone_source::github_repo_layout(origin, &layout.workspace_root, &layout.repos_root)
.ok()
})
} else {
None
};
Some(SandboxWorkspaceLayout {
workspace_root: layout.workspace_root.clone(),
repos_root: layout.repos_root.clone(),
primary_repo_path: repo.as_ref().map(|repo| repo.primary_repo_path.clone()),
primary_repo_link: repo.as_ref().map(|repo| repo.primary_repo_link.clone()),
})
}
}
impl LayoutSource {
fn into_cell(self) -> OnceLock<WorkspaceLayout> {
let cell = OnceLock::new();
if let Self::Fixed(layout) = self {
let _ = cell.set(layout);
}
cell
}
}
@ -319,6 +392,7 @@ impl DriverSandbox {
handle: Arc<dyn DriverHandle>,
workspace: RepoWorkspace,
) -> Self {
workspace.resolve_layout(handle.working_directory());
let mut sandbox = Self::new(kind, handle);
sandbox.workspace = Some(workspace);
sandbox
@ -378,11 +452,13 @@ impl DriverSandbox {
/// 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(),
match self
.workspace
.as_ref()
.and_then(|workspace| workspace.execution_directory.get())
{
Some(working_directory) => sandbox::resolve_path(path, working_directory),
None => path.to_string(),
}
}
@ -446,6 +522,9 @@ impl DriverSandbox {
let Some(workspace) = &self.workspace else {
return Ok(());
};
let layout = workspace
.resolve_layout(self.handle()?.working_directory())
.clone();
match &workspace.plan {
WorkspacePlan::Attached => Ok(()),
WorkspacePlan::Empty(reason) => {
@ -458,15 +537,18 @@ impl DriverSandbox {
}
self.handle()?
.fs()
.create_dir(&workspace.layout.workspace_root)
.create_dir(&layout.workspace_root)
.await
.map_err(|error| {
crate::Error::context(
format!("Failed to create {}", workspace.layout.workspace_root),
format!("Failed to create {}", layout.workspace_root),
error,
)
})?;
let _ = workspace.repo_cloned.set(false);
let _ = workspace
.execution_directory
.set(layout.workspace_root.clone());
Ok(())
}
WorkspacePlan::Clone(plan) => {
@ -484,8 +566,8 @@ impl DriverSandbox {
handle.as_ref(),
&exec,
plan,
&workspace.layout.workspace_root,
&workspace.layout.repos_root,
&layout.workspace_root,
&layout.repos_root,
&workspace.credentials,
)
.await;
@ -1021,8 +1103,12 @@ impl Sandbox for DriverSandbox {
/// 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 {
if let Some(workspace) = &self.workspace {
return workspace.working_directory();
if let Some(directory) = self
.workspace
.as_ref()
.and_then(RepoWorkspace::working_directory)
{
return directory;
}
self.handle
.get()
@ -1066,6 +1152,10 @@ impl Sandbox for DriverSandbox {
self.snapshot.get().cloned()
}
fn workspace_layout(&self) -> Option<SandboxWorkspaceLayout> {
self.workspace.as_ref().and_then(RepoWorkspace::record)
}
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
let mut timers = LifecycleTimers::default();
timers.auto_stop_after_idle = u64::try_from(minutes)

View file

@ -28,6 +28,7 @@ pub mod terminal;
mod clone;
pub mod docker;
pub mod plugin;
pub mod daytona;
@ -37,7 +38,7 @@ pub mod test_support;
pub use daytona::{DaytonaConfig, attach_daytona, daytona_sandbox};
pub use details::sandbox_details;
pub use docker::{DockerSandboxOptions, attach_docker, check_docker_daemon, docker_sandbox};
pub use driver::DaytonaCredentials;
pub use driver::{DaytonaCredentials, ProviderAccess};
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};
@ -48,6 +49,7 @@ pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};
pub use git_retry::{
CredentialContext, GitRetryReason, RetryPlan, classify_failure, retry_git_operation,
};
pub use plugin::{PluginSandboxOptions, attach_plugin, plugin_sandbox};
pub use provider::driver::DriverInventoryProvider;
pub use provider::{
LocalSandboxProvider, SandboxLookupError, SandboxProvider, SandboxProviderRegistry,
@ -60,9 +62,9 @@ pub use sandbox::{
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
ExecStreamingRequest, ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions,
OutputCaptureStats, PushAttempt, PushError, PushReport, RefreshOutcome, RemoteCredentialAction,
Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StderrCollector, StdioProcess,
StdioProcessHandle, StdioProcessTermination, WalkOptions, format_lines_numbered,
redacted_output_tail, setup_git_via_exec, shell_quote,
Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, SandboxWorkspaceLayout,
StderrCollector, StdioProcess, StdioProcessHandle, StdioProcessTermination, WalkOptions,
format_lines_numbered, redacted_output_tail, setup_git_via_exec, shell_quote,
};
pub use sandbox_spec::SandboxSpec;
pub use terminal::{DriverTerminalSession, TerminalSession, TerminalSize, open_terminal_for_run};

View file

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

View file

@ -1,38 +1,38 @@
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result};
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
use crate::driver::DaytonaCredentials;
use crate::driver::ProviderAccess;
use crate::driver_sandbox::{DriverSandbox, local_sandbox};
use crate::{SandboxEventCallback, daytona, docker};
use crate::{SandboxEventCallback, daytona, docker, plugin};
/// Reconnect to a sandbox from a saved record.
///
/// `daytona` carries the vault credentials a `"daytona"` record needs; the
/// process environment is never consulted.
/// `access` carries the provider settings and vault credentials the record's
/// provider needs; the process environment is never consulted.
pub async fn reconnect(
record: &RunSandboxInstance,
daytona: Option<DaytonaCredentials>,
access: &ProviderAccess,
) -> Result<Box<dyn crate::Sandbox>> {
reconnect_for_run(record, daytona, None).await
reconnect_for_run(record, access, None).await
}
pub async fn reconnect_for_run(
record: &RunSandboxInstance,
daytona: Option<DaytonaCredentials>,
access: &ProviderAccess,
run_id: Option<RunId>,
) -> Result<Box<dyn crate::Sandbox>> {
reconnect_for_run_with_callback(record, daytona, run_id, None).await
reconnect_for_run_with_callback(record, access, run_id, None).await
}
pub async fn reconnect_for_run_with_callback(
record: &RunSandboxInstance,
daytona: Option<DaytonaCredentials>,
access: &ProviderAccess,
run_id: Option<RunId>,
event_callback: Option<SandboxEventCallback>,
) -> Result<Box<dyn crate::Sandbox>> {
let sandbox = reconnect_driver_for_run(record, daytona, run_id, event_callback).await?;
let sandbox = reconnect_driver_for_run(record, access, run_id, event_callback).await?;
Ok(Box::new(sandbox))
}
@ -41,12 +41,19 @@ pub async fn reconnect_for_run_with_callback(
/// (VNC, signed previews, leased SSH).
pub async fn reconnect_driver_for_run(
record: &RunSandboxInstance,
daytona: Option<DaytonaCredentials>,
access: &ProviderAccess,
run_id: Option<RunId>,
event_callback: Option<SandboxEventCallback>,
) -> Result<DriverSandbox> {
let runtime = &record.runtime;
match record.provider.bundled() {
let settings = access.settings_for(&record.provider);
// A bundled kind whose entry names a plugin executable is served out of
// process, exactly as a third-party kind is.
let plugin_served = settings
.as_ref()
.is_some_and(|settings| settings.plugin.is_some());
let bundled = record.provider.bundled().filter(|_| !plugin_served);
match bundled {
// A local sandbox is its working directory: rebuilding the handle
// over that directory is the reconnect. The per-process Host
// registry holds no state worth attaching to.
@ -81,7 +88,7 @@ pub async fn reconnect_driver_for_run(
let repo_cloned = runtime
.repo_cloned
.context("Daytona run sandbox missing repo_cloned metadata")?;
let credentials = daytona.context(
let credentials = access.daytona.clone().context(
"Daytona run sandbox cannot be reconnected without DAYTONA_API_KEY in the vault",
)?;
let mut sandbox = daytona::attach_daytona(
@ -99,9 +106,31 @@ pub async fn reconnect_driver_for_run(
}
Ok(sandbox)
}
None => bail!(
"sandbox provider `{}` is not bundled; plugin reconnect is not wired yet",
record.provider
),
None => {
let settings = settings.with_context(|| {
format!(
"sandbox provider `{}` is not configured; add [server.sandbox.providers.{}] to settings.toml",
record.provider, record.provider
)
})?;
let repo_cloned = runtime
.repo_cloned
.context("run sandbox missing repo_cloned metadata")?;
let mut sandbox = plugin::attach_plugin(
record.provider.clone(),
&settings,
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
)
.await
.with_context(|| format!("Failed to reconnect {} sandbox", record.provider))?;
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(sandbox)
}
}
}

View file

@ -27,6 +27,17 @@ pub(crate) const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0";
pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024;
/// Where a clone-based sandbox put its files, as persisted on the run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxWorkspaceLayout {
pub workspace_root: String,
pub repos_root: String,
/// The repository checkout and its link in the workspace, when a
/// repository was cloned.
pub primary_repo_path: Option<String>,
pub primary_repo_link: Option<String>,
}
/// Information returned when a sandbox sets up git for a workflow run.
#[derive(Debug, Clone)]
pub struct GitRunInfo {
@ -194,6 +205,10 @@ macro_rules! delegate_sandbox {
self.$field.snapshot_info()
}
fn workspace_layout(&self) -> Option<$crate::SandboxWorkspaceLayout> {
self.$field.workspace_layout()
}
async fn refresh_push_credentials(&self) -> $crate::Result<$crate::RefreshOutcome> {
self.$field.refresh_push_credentials().await
}
@ -1364,6 +1379,13 @@ pub trait Sandbox: Send + Sync {
None
}
/// The clone-based workspace layout of an initialized sandbox, for the
/// run record. `None` for a sandbox that works in a designated
/// directory.
fn workspace_layout(&self) -> Option<SandboxWorkspaceLayout> {
None
}
/// Refresh git push credentials (e.g. rotate an expiring GitHub App token).
/// Default is a no-op; Docker/Daytona override to resolve a token through
/// the shared source and update the remote URL when the embedded

View file

@ -3,12 +3,14 @@ use std::sync::Arc;
use anyhow::Context as _;
use fabro_github::GitHubCredentials;
use fabro_types::settings::server::ServerSandboxProviderSettings;
use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
use crate::daytona::{self, DaytonaConfig};
use crate::docker::{self, DockerSandboxOptions};
use crate::driver::DaytonaCredentials;
use crate::driver_sandbox::local_sandbox;
use crate::plugin::{self, PluginSandboxOptions};
use crate::{Sandbox, SandboxEventCallback, clone_source};
/// Options for sandbox initialization and construction.
@ -38,6 +40,18 @@ pub enum SandboxSpec {
/// environment.
credentials: Option<DaytonaCredentials>,
},
/// A provider served by a sandbox-driver plugin executable.
Plugin {
kind: SandboxProviderKind,
settings: Box<ServerSandboxProviderSettings>,
options: Box<PluginSandboxOptions>,
github_app: Option<GitHubCredentials>,
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
},
}
impl SandboxSpec {
@ -46,15 +60,12 @@ impl SandboxSpec {
Self::Local { .. } => SandboxProviderKind::LOCAL,
Self::Docker { .. } => SandboxProviderKind::DOCKER,
Self::Daytona { .. } => SandboxProviderKind::DAYTONA,
Self::Plugin { kind, .. } => kind.clone(),
}
}
pub fn provider_name(&self) -> &'static str {
match self {
Self::Local { .. } => "local",
Self::Docker { .. } => "docker",
Self::Daytona { .. } => "daytona",
}
pub fn provider_name(&self) -> String {
self.provider().to_string()
}
/// Build initialized sandbox metadata for persistence.
@ -152,6 +163,40 @@ impl SandboxSpec {
},
}
}
Self::Plugin {
options,
clone_origin_url,
clone_branch,
..
} => {
let repo_cloned = clone_source::repo_cloned_for_record(
options.skip_clone,
clone_origin_url.as_deref(),
);
let layout = sandbox.workspace_layout();
RunSandboxInstance {
provider: self.provider(),
image: options.image.clone(),
snapshot: sandbox.snapshot_info(),
runtime: RunSandboxRuntime {
id,
working_directory,
repo_cloned,
clone_origin_url: clone_source::clean_clone_origin_for_record(
clone_origin_url.as_deref(),
),
clone_branch: clone_branch.clone(),
workspace_root: layout.as_ref().map(|layout| layout.workspace_root.clone()),
repos_root: layout.as_ref().map(|layout| layout.repos_root.clone()),
primary_repo_path: layout
.as_ref()
.and_then(|layout| layout.primary_repo_path.clone()),
primary_repo_link: layout
.as_ref()
.and_then(|layout| layout.primary_repo_link.clone()),
},
}
}
Self::Local { .. } => RunSandboxInstance {
provider: self.provider(),
image: None,
@ -240,6 +285,35 @@ impl SandboxSpec {
}
Ok(Arc::new(sandbox))
}
Self::Plugin {
kind,
settings,
options,
github_app,
run_id,
clone_origin_url,
clone_branch,
clone_tag,
clone_commit_sha,
} => {
let mut sandbox = plugin::plugin_sandbox(
kind.clone(),
settings,
options.as_ref().clone(),
github_app.as_ref(),
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_tag.clone(),
clone_commit_sha.clone(),
)
.await
.with_context(|| format!("Failed to create {kind} sandbox"))?;
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
Ok(Arc::new(sandbox))
}
}
}
}

View file

@ -1,8 +1,8 @@
use async_trait::async_trait;
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
use crate::driver::DaytonaCredentials;
use crate::{Sandbox, daytona, docker};
use crate::driver::ProviderAccess;
use crate::{Sandbox, reconnect};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalSize {
@ -75,53 +75,18 @@ impl TerminalSession for DriverTerminalSession {
pub async fn open_terminal_for_run(
record: &RunSandboxInstance,
daytona: Option<DaytonaCredentials>,
access: &ProviderAccess,
run_id: Option<RunId>,
size: TerminalSize,
) -> crate::Result<Box<dyn TerminalSession>> {
let runtime = &record.runtime;
match record.provider.bundled() {
Some(BundledProvider::Daytona) => {
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
crate::Error::message("Daytona run sandbox is missing clone metadata")
})?;
let credentials = daytona.ok_or_else(|| {
crate::Error::message("Daytona terminals require DAYTONA_API_KEY in the vault")
})?;
let sandbox = daytona::attach_daytona(
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
&credentials,
)
.await?;
sandbox.activate().await?;
Ok(Box::new(sandbox.open_terminal(size).await?))
}
Some(BundledProvider::Docker) => {
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
crate::Error::message("Docker run sandbox is missing clone metadata")
})?;
let sandbox = docker::attach_docker(
&runtime.id,
repo_cloned,
runtime.working_directory.clone(),
runtime.clone_origin_url.clone(),
run_id,
)
.await?;
sandbox.activate().await?;
Ok(Box::new(sandbox.open_terminal(size).await?))
}
Some(BundledProvider::Local) => Err(crate::Error::message(
if record.provider.bundled() == Some(BundledProvider::Local) {
return Err(crate::Error::message(
"Local sandboxes do not support embedded terminals",
)),
None => Err(crate::Error::message(format!(
"Sandbox provider '{}' does not support embedded terminals yet",
record.provider
))),
));
}
let sandbox = reconnect::reconnect_driver_for_run(record, access, run_id, None)
.await
.map_err(|err| crate::Error::context_anyhow("Failed to reconnect sandbox", err))?;
sandbox.activate().await?;
Ok(Box::new(sandbox.open_terminal(size).await?))
}

View file

@ -14,6 +14,7 @@ use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment_with_secrets,
local_working_directory_from_environment,
};
use fabro_sandbox::plugin::plugin_options_from_environment;
use fabro_sandbox::{DaytonaCredentials, DockerSandboxOptions, SandboxSpec};
use fabro_static::EnvVars;
#[cfg(test)]
@ -23,6 +24,7 @@ use fabro_types::settings::run::{
ResolvedGithubIntegration, ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings,
RunPrepareSettings as ResolvedRunPrepareSettings,
};
use fabro_types::settings::server::ServerSandboxProvidersSettings;
use fabro_types::{
BundledProvider, ManifestPath, RunId, RunRunnableSource, RunSpec, RunTarget,
SandboxProviderKind, TargetValidationError,
@ -91,6 +93,7 @@ struct RunSession {
workflow_bundle: Option<Arc<WorkflowBundle>>,
run_control: Option<Arc<RunControlState>>,
vault: Arc<AsyncRwLock<Vault>>,
sandbox_providers: ServerSandboxProvidersSettings,
catalog: Arc<Catalog>,
fabro_run_tools: Option<FabroRunToolServices>,
}
@ -117,6 +120,10 @@ pub struct StartServices {
/// env. Empty when the github integration requests no token.
pub github_integration: ResolvedGithubIntegration,
pub vault: Arc<AsyncRwLock<Vault>>,
/// The server's sandbox provider settings: which kinds are enabled and
/// which run as plugins. The worker builds and reattaches sandboxes
/// with them.
pub sandbox_providers: ServerSandboxProvidersSettings,
pub catalog: Arc<Catalog>,
pub on_node: crate::OnNodeCallback,
pub registry_override: Option<Arc<HandlerRegistry>>,
@ -560,9 +567,37 @@ impl RunSession {
}
}
None => {
return Err(Error::engine(format!(
"sandbox provider `{sandbox_provider}` is not bundled; plugin providers are not wired into run start yet"
)));
let settings = services
.sandbox_providers
.get(&sandbox_provider)
.cloned()
.ok_or_else(|| {
Error::engine(format!(
"sandbox provider `{sandbox_provider}` is not configured; add [server.sandbox.providers.{sandbox_provider}] to settings.toml"
))
})?;
let env = resolved
.environment
.resolve_env(secret_lookup)
.map_err(|err| {
Error::engine_with_source("failed to resolve environment variables", err)
})?
.into_iter()
.collect();
let mut options =
plugin_options_from_environment(&resolved.environment, &resolved.clone, env);
options.skip_clone |= clone_source.skip_clone;
SandboxSpec::Plugin {
kind: sandbox_provider.clone(),
settings: Box::new(settings),
options: Box::new(options),
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
clone_branch: clone_source.branch,
clone_tag: clone_source.tag,
clone_commit_sha: clone_source.commit_sha,
}
}
};
@ -632,6 +667,7 @@ impl RunSession {
workflow_path,
workflow_bundle,
vault: services.vault,
sandbox_providers: services.sandbox_providers,
catalog,
fabro_run_tools: services.fabro_run_tools,
})
@ -1002,6 +1038,7 @@ impl RunSession {
hooks: self.hooks,
sandbox_env: self.sandbox_env,
vault: self.vault,
sandbox_providers: self.sandbox_providers,
git: self.git,
registry_override: self.registry_override,
artifact_sink: self.artifact_sink,
@ -2582,6 +2619,7 @@ reasoning = false
github_app: None,
github_integration: ResolvedGithubIntegration::default(),
vault: Arc::new(AsyncRwLock::new(start_vault(&[]))),
sandbox_providers: ServerSandboxProvidersSettings::default(),
catalog: test_catalog(),
on_node: None,
registry_override: Some(registry),

View file

@ -295,6 +295,8 @@ async fn execute_test_run_with_options(
origin_url: None,
},
vault: auth_test_support::empty_vault(),
sandbox_providers:
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
git: git_options,
run_control: None,
registry_override,
@ -357,6 +359,8 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
origin_url: None,
},
vault: auth_test_support::empty_vault(),
sandbox_providers:
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
git: None,
run_control: None,
registry_override: None,
@ -496,6 +500,8 @@ async fn resumed_in_flight_node_starts_a_new_stage_execution() {
origin_url: None,
},
vault: auth_test_support::empty_vault(),
sandbox_providers:
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
git: None,
run_control: None,
registry_override: Some(Arc::new(make_registry())),
@ -607,6 +613,8 @@ async fn run_with_lifecycle(
origin_url: None,
},
vault: auth_test_support::empty_vault(),
sandbox_providers:
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
git: None,
run_control: None,
registry_override: Some(Arc::new(registry)),

View file

@ -12,7 +12,7 @@ use fabro_graphviz::graph;
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner};
use fabro_model::Catalog;
use fabro_sandbox::{
DaytonaCredentials, GitSetupIntent, SandboxEventCallback, SandboxSpec,
DaytonaCredentials, GitSetupIntent, ProviderAccess, SandboxEventCallback, SandboxSpec,
reconnect_for_run_with_callback, shell_quote,
};
use fabro_static::EnvVars;
@ -432,15 +432,20 @@ pub async fn initialize(
};
let attach_existing = attach_instance.is_some();
let sandbox: Arc<dyn Sandbox> = if let Some(instance) = attach_instance {
let daytona = options
.vault
.read()
.await
.get(EnvVars::DAYTONA_API_KEY)
.map(|api_key| DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var));
let access = ProviderAccess {
providers: options.sandbox_providers.clone(),
daytona: options
.vault
.read()
.await
.get(EnvVars::DAYTONA_API_KEY)
.map(|api_key| {
DaytonaCredentials::from_api_key(api_key.to_string(), process_env_var)
}),
};
let sandbox = reconnect_for_run_with_callback(
&instance,
daytona,
&access,
Some(options.run_options.run_id),
Some(Arc::clone(&sandbox_event_callback)),
)
@ -943,6 +948,8 @@ mod tests {
origin_url: None,
},
vault: auth_test_support::empty_vault(),
sandbox_providers:
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
git: None,
run_control: None,
registry_override: None,
@ -1388,6 +1395,8 @@ mod tests {
origin_url: None,
},
vault,
sandbox_providers:
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
git: None,
run_control: None,
registry_override: None,
@ -1491,6 +1500,8 @@ mod tests {
origin_url: None,
},
vault: auth_test_support::empty_vault(),
sandbox_providers:
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
git: None,
run_control: None,
registry_override: None,
@ -1633,6 +1644,8 @@ mod tests {
origin_url: None,
},
vault: auth_test_support::empty_vault(),
sandbox_providers:
fabro_types::settings::server::ServerSandboxProvidersSettings::default(),
git: None,
run_control: None,
registry_override: None,

View file

@ -11,6 +11,7 @@ use fabro_template::TemplateContext;
use fabro_types::settings::run::{
PullRequestSettings, ResolvedGithubIntegration, RunModelControls,
};
use fabro_types::settings::server::ServerSandboxProvidersSettings;
use fabro_types::{ManifestPath, RunId, RunProjection};
use fabro_validate::{Diagnostic, Severity};
use fabro_vault::Vault;
@ -306,6 +307,9 @@ pub struct InitOptions {
pub hooks: fabro_hooks::HookSettings,
pub sandbox_env: SandboxEnvSpec,
pub vault: Arc<AsyncRwLock<Vault>>,
/// The server's sandbox provider settings, for reattaching a run's
/// sandbox on resume.
pub sandbox_providers: ServerSandboxProvidersSettings,
pub git: Option<GitCheckpointOptions>,
pub registry_override: Option<Arc<HandlerRegistry>>,
pub artifact_sink: Option<ArtifactSink>,

View file

@ -14,6 +14,7 @@
reason = "This integration test stages sandbox fixtures with sync std::fs."
)]
use fabro_sandbox::ProviderAccess;
use fabro_sandbox::reconnect::reconnect;
use fabro_types::{RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
@ -49,7 +50,9 @@ async fn local_cp_upload_download_round_trip() {
let scratch = tempfile::tempdir().unwrap();
let record = local_record(sandbox_dir.path());
let sandbox = reconnect(&record, None).await.expect("reconnect local");
let sandbox = reconnect(&record, &ProviderAccess::default())
.await
.expect("reconnect local");
// Upload a text file
let content = b"hello from local cp test\n";
@ -80,7 +83,9 @@ async fn local_cp_binary_round_trip() {
let scratch = tempfile::tempdir().unwrap();
let record = local_record(sandbox_dir.path());
let sandbox = reconnect(&record, None).await.expect("reconnect local");
let sandbox = reconnect(&record, &ProviderAccess::default())
.await
.expect("reconnect local");
// All 256 byte values
let binary: Vec<u8> = (0..=255).collect();
@ -107,7 +112,9 @@ async fn local_cp_creates_parent_dirs() {
let scratch = tempfile::tempdir().unwrap();
let record = local_record(sandbox_dir.path());
let sandbox = reconnect(&record, None).await.expect("reconnect local");
let sandbox = reconnect(&record, &ProviderAccess::default())
.await
.expect("reconnect local");
let content = b"nested file\n";
let local_src = scratch.path().join("nested.txt");
@ -232,7 +239,9 @@ async fn docker_cp_upload_download_round_trip() {
let scratch = tempfile::tempdir().unwrap();
let record = docker_record(&container.id);
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
let sandbox = reconnect(&record, &ProviderAccess::default())
.await
.expect("reconnect docker");
// Upload a text file
let content = b"hello from docker cp test\n";
@ -261,7 +270,9 @@ async fn docker_cp_binary_round_trip() {
let scratch = tempfile::tempdir().unwrap();
let record = docker_record(&container.id);
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
let sandbox = reconnect(&record, &ProviderAccess::default())
.await
.expect("reconnect docker");
let binary: Vec<u8> = (0..=255).collect();
let local_src = scratch.path().join("binary.bin");
@ -288,7 +299,9 @@ async fn docker_cp_creates_parent_dirs() {
let scratch = tempfile::tempdir().unwrap();
let record = docker_record(&container.id);
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
let sandbox = reconnect(&record, &ProviderAccess::default())
.await
.expect("reconnect docker");
let content = b"nested docker file\n";
let local_src = scratch.path().join("nested.txt");

View file

@ -25,7 +25,7 @@ use std::sync::Arc;
use fabro_agent::Sandbox;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::{DaytonaCredentials, DriverSandbox, daytona_sandbox};
use fabro_sandbox::{DaytonaCredentials, DriverSandbox, ProviderAccess, daytona_sandbox};
use fabro_static::EnvVars;
use fabro_store::{ArtifactKey, ArtifactStore};
use fabro_types::{RunId, StageId, WorkflowSettings, parse_blob_ref};
@ -1576,7 +1576,11 @@ async fn daytona_cp_upload_download_round_trip() {
// 3. Reconnect via the real cp::reconnect path
let tmp = tempfile::tempdir().unwrap();
let reconnected = reconnect(&record, None)
let access = ProviderAccess {
daytona: Some(live_daytona_credentials()),
..ProviderAccess::default()
};
let reconnected = reconnect(&record, &access)
.await
.expect("reconnect should succeed");

View file

@ -72,20 +72,18 @@ fn resolve_sandbox(
layer: Option<&ServerSandboxLayer>,
errors: &mut Vec<ResolveError>,
) -> ServerSandboxSettings {
let _ = errors;
let configured = layer
.and_then(|sandbox| sandbox.providers.as_ref())
.map(|providers| &providers.entries);
let mut entries = BTreeMap::new();
// Bundled providers always have a policy entry; missing means enabled.
// Bundled providers always have a policy entry; missing means enabled
// and served in-process.
for kind in SandboxProviderKind::bundled_kinds() {
let layer = configured.and_then(|entries| entries.get(&kind));
let path = format!("server.sandbox.providers.{kind}");
if let Some(layer) = layer {
reject_plugin_fields_for_bundled(layer, &path, errors);
}
entries.insert(kind, ServerSandboxProviderSettings {
enabled: layer.and_then(|provider| provider.enabled).unwrap_or(true),
plugin: None,
plugin: layer.and_then(plugin_settings),
});
}
for (kind, layer) in configured.into_iter().flatten() {
@ -94,14 +92,7 @@ fn resolve_sandbox(
}
entries.insert(kind.clone(), ServerSandboxProviderSettings {
enabled: layer.enabled.unwrap_or(true),
plugin: Some(SandboxPluginSettings {
path: layer.path.clone(),
sha256: layer.sha256.clone(),
dev: layer.dev.unwrap_or(false),
args: layer.args.clone().unwrap_or_default(),
env: layer.env.clone().unwrap_or_default(),
inherit_env: layer.inherit_env.clone().unwrap_or_default(),
}),
plugin: Some(plugin_settings(layer).unwrap_or_default()),
});
}
ServerSandboxSettings {
@ -109,37 +100,33 @@ fn resolve_sandbox(
}
}
fn reject_plugin_fields_for_bundled(
layer: &ServerSandboxProviderLayer,
path: &str,
errors: &mut Vec<ResolveError>,
) {
/// Plugin launch settings when the entry names any. A bundled kind with
/// none runs in-process; a bundled kind with any runs out of process
/// through the driver's executable for that kind.
fn plugin_settings(layer: &ServerSandboxProviderLayer) -> Option<SandboxPluginSettings> {
let ServerSandboxProviderLayer {
enabled: _,
path: plugin_path,
path,
sha256,
dev,
args,
env,
inherit_env,
} = layer;
let set = [
("path", plugin_path.is_some()),
("sha256", sha256.is_some()),
("dev", dev.is_some()),
("args", args.is_some()),
("env", env.is_some()),
("inherit_env", inherit_env.is_some()),
];
for (field, is_set) in set {
if is_set {
errors.push(ResolveError::Invalid {
path: format!("{path}.{field}"),
reason: "bundled sandbox providers run in-process and take no plugin settings"
.to_string(),
});
}
}
let any_set = path.is_some()
|| sha256.is_some()
|| dev.is_some()
|| args.is_some()
|| env.is_some()
|| inherit_env.is_some();
any_set.then(|| SandboxPluginSettings {
path: path.clone(),
sha256: sha256.clone(),
dev: dev.unwrap_or(false),
args: args.clone().unwrap_or_default(),
env: env.clone().unwrap_or_default(),
inherit_env: inherit_env.clone().unwrap_or_default(),
})
}
fn resolve_storage(layer: Option<&ServerStorageLayer>) -> ServerStorageSettings {

View file

@ -285,8 +285,8 @@ E2B_API_URL = "https://api.e2b.example"
}
#[test]
fn server_sandbox_rejects_plugin_settings_on_bundled_providers() {
let err = ServerSettingsBuilder::from_toml(
fn server_sandbox_plugin_settings_on_a_bundled_provider_serve_it_out_of_process() {
let settings = ServerSettingsBuilder::from_toml(
r#"
_version = 1
@ -295,15 +295,33 @@ methods = ["dev-token"]
[server.sandbox.providers.docker]
path = "/usr/local/bin/fabro-sandbox-docker"
dev = true
inherit_env = ["PATH", "DOCKER_HOST"]
"#,
)
.expect_err("bundled providers take no plugin settings");
.expect("bundled providers accept plugin settings");
assert!(
err.to_string()
.contains("server.sandbox.providers.docker.path"),
"unexpected error: {err}"
let providers = &settings.server.sandbox.providers;
let docker = providers
.get(&SandboxProviderKind::DOCKER)
.expect("docker entry");
assert!(docker.enabled);
let plugin = docker.plugin.as_ref().expect("docker runs as a plugin");
assert_eq!(
plugin.path.as_deref(),
Some("/usr/local/bin/fabro-sandbox-docker")
);
assert!(plugin.dev);
assert_eq!(plugin.inherit_env, vec!["PATH", "DOCKER_HOST"]);
assert!(
providers
.get(&SandboxProviderKind::LOCAL)
.expect("local entry")
.plugin
.is_none(),
"an entry without plugin keys stays in-process"
);
assert_eq!(providers.enabled_plugins().count(), 1);
}
#[test]

View file

@ -0,0 +1,8 @@
//! `sqlx::migrate!` embeds every file under `migrations/` at compile time,
//! but on stable Rust the macro cannot tell Cargo about the directory. Without
//! this hint a newly added migration file does not recompile the crate, so a
//! stale build silently ships without it.
fn main() {
println!("cargo:rerun-if-changed=migrations");
}