From 030e653abf9d55393e7aa889692077467bf484ce Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 19:54:57 -0600 Subject: [PATCH] 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 --- .github/workflows/rust.yml | 33 ++ .../administration/server-configuration.mdx | 8 +- lib/apps/fabro-cli/src/commands/run/runner.rs | 55 ++- .../tests/it/workflow/agent_linear.rs | 8 +- .../tests/it/workflow/command_agent_mixed.rs | 10 +- .../tests/it/workflow/command_pipeline.rs | 10 +- .../tests/it/workflow/command_routing.rs | 7 +- .../it/workflow/conditional_branching.rs | 8 +- .../fabro-cli/tests/it/workflow/full_stack.rs | 10 +- .../fabro-cli/tests/it/workflow/human_gate.rs | 8 +- lib/apps/fabro-cli/tests/it/workflow/mod.rs | 42 +- .../fabro-cli/tests/it/workflow/plugin.rs | 222 +++++++++++ lib/apps/fabro-server/src/run_files.rs | 6 +- lib/apps/fabro-server/src/run_manifest.rs | 54 ++- lib/apps/fabro-server/src/server.rs | 28 +- .../src/server/handler/environments.rs | 16 +- .../src/server/handler/sandbox.rs | 28 +- .../src/server/handler/sessions.rs | 6 +- lib/components/fabro-sandbox/src/daytona.rs | 128 ++++++- lib/components/fabro-sandbox/src/details.rs | 76 +--- lib/components/fabro-sandbox/src/docker.rs | 12 +- lib/components/fabro-sandbox/src/driver.rs | 59 ++- .../fabro-sandbox/src/driver_sandbox.rs | 154 ++++++-- lib/components/fabro-sandbox/src/lib.rs | 10 +- lib/components/fabro-sandbox/src/plugin.rs | 358 ++++++++++++++++++ lib/components/fabro-sandbox/src/reconnect.rs | 65 +++- lib/components/fabro-sandbox/src/sandbox.rs | 22 ++ .../fabro-sandbox/src/sandbox_spec.rs | 86 ++++- lib/components/fabro-sandbox/src/terminal.rs | 57 +-- .../fabro-workflow/src/operations/start.rs | 44 ++- .../src/pipeline/execute/tests.rs | 8 + .../fabro-workflow/src/pipeline/initialize.rs | 29 +- .../fabro-workflow/src/pipeline/types.rs | 4 + .../fabro-workflow/tests/it/cp_integration.rs | 25 +- .../tests/it/daytona_integration.rs | 8 +- .../fabro-config/src/resolve/server.rs | 61 ++- .../fabro-config/src/tests/resolve_server.rs | 32 +- lib/foundation/fabro-db/build.rs | 8 + 38 files changed, 1449 insertions(+), 356 deletions(-) create mode 100644 lib/apps/fabro-cli/tests/it/workflow/plugin.rs create mode 100644 lib/components/fabro-sandbox/src/plugin.rs create mode 100644 lib/foundation/fabro-db/build.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b3686f872..e17f5c5fd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -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-`; 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' diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index 9c0f53508..8adb4362c 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -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] diff --git a/lib/apps/fabro-cli/src/commands/run/runner.rs b/lib/apps/fabro-cli/src/commands/run/runner.rs index 78f04be0d..1dce83f7a 100644 --- a/lib/apps/fabro-cli/src/commands/run/runner.rs +++ b/lib/apps/fabro-cli/src/commands/run/runner.rs @@ -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> { - 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)); } } } diff --git a/lib/apps/fabro-cli/tests/it/workflow/agent_linear.rs b/lib/apps/fabro-cli/tests/it/workflow/agent_linear.rs index 031d437a7..a8b79bf2b 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/agent_linear.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/agent_linear.rs @@ -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")); diff --git a/lib/apps/fabro-cli/tests/it/workflow/command_agent_mixed.rs b/lib/apps/fabro-cli/tests/it/workflow/command_agent_mixed.rs index b25b5b12d..6ada11d41 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/command_agent_mixed.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/command_agent_mixed.rs @@ -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"); diff --git a/lib/apps/fabro-cli/tests/it/workflow/command_pipeline.rs b/lib/apps/fabro-cli/tests/it/workflow/command_pipeline.rs index 21a7a7da2..b68e8002b 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/command_pipeline.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/command_pipeline.rs @@ -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"); diff --git a/lib/apps/fabro-cli/tests/it/workflow/command_routing.rs b/lib/apps/fabro-cli/tests/it/workflow/command_routing.rs index 1052ae476..190418118 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/command_routing.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/command_routing.rs @@ -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")); diff --git a/lib/apps/fabro-cli/tests/it/workflow/conditional_branching.rs b/lib/apps/fabro-cli/tests/it/workflow/conditional_branching.rs index c8f703edf..e5e93cf6f 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/conditional_branching.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/conditional_branching.rs @@ -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")); diff --git a/lib/apps/fabro-cli/tests/it/workflow/full_stack.rs b/lib/apps/fabro-cli/tests/it/workflow/full_stack.rs index 60a95f5f1..6966dd28f 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/full_stack.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/full_stack.rs @@ -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"); diff --git a/lib/apps/fabro-cli/tests/it/workflow/human_gate.rs b/lib/apps/fabro-cli/tests/it/workflow/human_gate.rs index 0f83199e9..ea355f13d 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/human_gate.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/human_gate.rs @@ -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")); diff --git a/lib/apps/fabro-cli/tests/it/workflow/mod.rs b/lib/apps/fabro-cli/tests/it/workflow/mod.rs index 5e0dd3738..7ea94125a 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/mod.rs @@ -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 { 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"); + [](&fabro_test::test_context!(), "local"); } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY") $(, live($key))*)] fn []() { - []("daytona"); + [](&fabro_test::test_context!(), "daytona"); + } + + #[fabro_macros::e2e_test($(live($key)),*)] + fn []() { + 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, || { + [](&context, environment); + }); + } + } + + #[fabro_macros::e2e_test($(live($key)),*)] + fn []() { + 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, || { + [](&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), } } diff --git a/lib/apps/fabro-cli/tests/it/workflow/plugin.rs b/lib/apps/fabro-cli/tests/it/workflow/plugin.rs new file mode 100644 index 000000000..26d26c484 --- /dev/null +++ b/lib/apps/fabro-cli/tests/it/workflow/plugin.rs @@ -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(®istry).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(®istry), + ), + 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 { + 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") +} diff --git a/lib/apps/fabro-server/src/run_files.rs b/lib/apps/fabro-server/src/run_files.rs index 8c17fb745..0dc38f04a 100644 --- a/lib/apps/fabro-server/src/run_files.rs +++ b/lib/apps/fabro-server/src/run_files.rs @@ -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 diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 9c63e780f..84b153733 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -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, - daytona: Option, + access: &ProviderAccess, ) -> std::result::Result { 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, - daytona: Option, + 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 { diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 3a88bdc8f..e3f150f53 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -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, 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 { + 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, 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, diff --git a/lib/apps/fabro-server/src/server/handler/environments.rs b/lib/apps/fabro-server/src/server/handler/environments.rs index a5ba66f44..83bc3f111 100644 --- a/lib/apps/fabro-server/src/server/handler/environments.rs +++ b/lib/apps/fabro-server/src/server/handler/environments.rs @@ -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 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", + ) + } } } } diff --git a/lib/apps/fabro-server/src/server/handler/sandbox.rs b/lib/apps/fabro-server/src/server/handler/sandbox.rs index b906c9255..28ad514e8 100644 --- a/lib/apps/fabro-server/src/server/handler/sandbox.rs +++ b/lib/apps/fabro-server/src/server/handler/sandbox.rs @@ -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::(details).into_response(), Err(err) => { let detail = format!("{err:#}"); @@ -219,7 +221,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc, 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, 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 { - 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, Response> { - state.vault_daytona_credentials().await.map_err(|err| { +async fn load_provider_access(state: &AppState) -> Result { + state.provider_access().await.map_err(|err| { tracing::error!(error = ?err, "Loading Daytona API key failed"); ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 6b299f1e1..676a87f3d 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -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 diff --git a/lib/components/fabro-sandbox/src/daytona.rs b/lib/components/fabro-sandbox/src/daytona.rs index 9322dece3..54a2e0173 100644 --- a/lib/components/fabro-sandbox/src/daytona.rs +++ b/lib/components/fabro-sandbox/src/daytona.rs @@ -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 { 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 { + 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 = 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"), + } + } +} diff --git a/lib/components/fabro-sandbox/src/details.rs b/lib/components/fabro-sandbox/src/details.rs index e14f7260b..cbdd9c09a 100644 --- a/lib/components/fabro-sandbox/src/details.rs +++ b/lib/components/fabro-sandbox/src/details.rs @@ -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, + access: &ProviderAccess, run_id: Option, ) -> Result { - 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, - run_id: Option, -) -> Result { - 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, -) -> Result { - 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; diff --git a/lib/components/fabro-sandbox/src/docker.rs b/lib/components/fabro-sandbox/src/docker.rs index e8f442b4d..85e96f750 100644 --- a/lib/components/fabro-sandbox/src/docker.rs +++ b/lib/components/fabro-sandbox/src/docker.rs @@ -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, ) -> crate::Result { 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, diff --git a/lib/components/fabro-sandbox/src/driver.rs b/lib/components/fabro-sandbox/src/driver.rs index 702861b1e..d6b54df04 100644 --- a/lib/components/fabro-sandbox/src/driver.rs +++ b/lib/components/fabro-sandbox/src/driver.rs @@ -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, +} + +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 { + 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(), diff --git a/lib/components/fabro-sandbox/src/driver_sandbox.rs b/lib/components/fabro-sandbox/src/driver_sandbox.rs index c6693116d..ba95faa02 100644 --- a/lib/components/fabro-sandbox/src/driver_sandbox.rs +++ b/lib/components/fabro-sandbox/src/driver_sandbox.rs @@ -63,8 +63,8 @@ pub async fn local_sandbox(working_directory: impl Into) -> 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, plan: WorkspacePlan, credentials: PushCredentialState, repo_cloned: OnceLock, @@ -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, ) -> 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 { + 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 { + let cell = OnceLock::new(); + if let Self::Fixed(layout) = self { + let _ = cell.set(layout); + } + cell } } @@ -319,6 +392,7 @@ impl DriverSandbox { handle: Arc, 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 { + 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) diff --git a/lib/components/fabro-sandbox/src/lib.rs b/lib/components/fabro-sandbox/src/lib.rs index 035d30cb4..7c8fade41 100644 --- a/lib/components/fabro-sandbox/src/lib.rs +++ b/lib/components/fabro-sandbox/src/lib.rs @@ -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}; diff --git a/lib/components/fabro-sandbox/src/plugin.rs b/lib/components/fabro-sandbox/src/plugin.rs new file mode 100644 index 000000000..d8571b216 --- /dev/null +++ b/lib/components/fabro-sandbox/src/plugin.rs @@ -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, + /// Inline Dockerfile, when the environment names one instead of an + /// image. + pub dockerfile: Option, + /// Environment variables for the sandbox, resolved. + pub env: BTreeMap, + pub network: PluginNetwork, + pub cpu_cores: Option, + pub memory_mb: Option, + pub disk_mb: Option, + /// Labels from the environment; fabro's managed labels are added. + pub labels: BTreeMap, + /// Maximum Git history depth fetched during clone; `None` fetches full + /// history. + pub clone_depth: Option, + /// 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), +} + +/// 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, +) -> 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 = 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> { + 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, + clone_origin_url: Option, + clone_branch: Option, + clone_tag: Option, + clone_commit_sha: Option, +) -> crate::Result { + 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, + run_id: Option, +) -> crate::Result { + 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()); + } +} diff --git a/lib/components/fabro-sandbox/src/reconnect.rs b/lib/components/fabro-sandbox/src/reconnect.rs index 26424e5a3..ce7d2162e 100644 --- a/lib/components/fabro-sandbox/src/reconnect.rs +++ b/lib/components/fabro-sandbox/src/reconnect.rs @@ -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, + access: &ProviderAccess, ) -> Result> { - reconnect_for_run(record, daytona, None).await + reconnect_for_run(record, access, None).await } pub async fn reconnect_for_run( record: &RunSandboxInstance, - daytona: Option, + access: &ProviderAccess, run_id: Option, ) -> Result> { - 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, + access: &ProviderAccess, run_id: Option, event_callback: Option, ) -> Result> { - 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, + access: &ProviderAccess, run_id: Option, event_callback: Option, ) -> Result { 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) + } } } diff --git a/lib/components/fabro-sandbox/src/sandbox.rs b/lib/components/fabro-sandbox/src/sandbox.rs index b6158f8c4..9d779237c 100644 --- a/lib/components/fabro-sandbox/src/sandbox.rs +++ b/lib/components/fabro-sandbox/src/sandbox.rs @@ -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, + pub primary_repo_link: Option, +} + /// 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 { + 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 diff --git a/lib/components/fabro-sandbox/src/sandbox_spec.rs b/lib/components/fabro-sandbox/src/sandbox_spec.rs index cddf27ca7..06f576e0a 100644 --- a/lib/components/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/components/fabro-sandbox/src/sandbox_spec.rs @@ -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, }, + /// A provider served by a sandbox-driver plugin executable. + Plugin { + kind: SandboxProviderKind, + settings: Box, + options: Box, + github_app: Option, + run_id: Option, + clone_origin_url: Option, + clone_branch: Option, + clone_tag: Option, + clone_commit_sha: Option, + }, } 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)) + } } } } diff --git a/lib/components/fabro-sandbox/src/terminal.rs b/lib/components/fabro-sandbox/src/terminal.rs index aa7a89b65..a7cc72bd0 100644 --- a/lib/components/fabro-sandbox/src/terminal.rs +++ b/lib/components/fabro-sandbox/src/terminal.rs @@ -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, + access: &ProviderAccess, run_id: Option, size: TerminalSize, ) -> crate::Result> { - 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?)) } diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index 1ad0ba2a2..06b59716d 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -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>, run_control: Option>, vault: Arc>, + sandbox_providers: ServerSandboxProvidersSettings, catalog: Arc, fabro_run_tools: Option, } @@ -117,6 +120,10 @@ pub struct StartServices { /// env. Empty when the github integration requests no token. pub github_integration: ResolvedGithubIntegration, pub vault: Arc>, + /// 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, pub on_node: crate::OnNodeCallback, pub registry_override: Option>, @@ -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), diff --git a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs index b78de7e48..584c4531e 100644 --- a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs @@ -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)), diff --git a/lib/components/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs index 8d898c7db..250be871e 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -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 = 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, diff --git a/lib/components/fabro-workflow/src/pipeline/types.rs b/lib/components/fabro-workflow/src/pipeline/types.rs index c65f1fdf6..65c9a53c9 100644 --- a/lib/components/fabro-workflow/src/pipeline/types.rs +++ b/lib/components/fabro-workflow/src/pipeline/types.rs @@ -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>, + /// The server's sandbox provider settings, for reattaching a run's + /// sandbox on resume. + pub sandbox_providers: ServerSandboxProvidersSettings, pub git: Option, pub registry_override: Option>, pub artifact_sink: Option, diff --git a/lib/components/fabro-workflow/tests/it/cp_integration.rs b/lib/components/fabro-workflow/tests/it/cp_integration.rs index f90322caa..4087efb8d 100644 --- a/lib/components/fabro-workflow/tests/it/cp_integration.rs +++ b/lib/components/fabro-workflow/tests/it/cp_integration.rs @@ -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 = (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 = (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"); diff --git a/lib/components/fabro-workflow/tests/it/daytona_integration.rs b/lib/components/fabro-workflow/tests/it/daytona_integration.rs index b193a4d8f..b4c677bd5 100644 --- a/lib/components/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/components/fabro-workflow/tests/it/daytona_integration.rs @@ -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"); diff --git a/lib/foundation/fabro-config/src/resolve/server.rs b/lib/foundation/fabro-config/src/resolve/server.rs index a4321c1a4..6e097a0fc 100644 --- a/lib/foundation/fabro-config/src/resolve/server.rs +++ b/lib/foundation/fabro-config/src/resolve/server.rs @@ -72,20 +72,18 @@ fn resolve_sandbox( layer: Option<&ServerSandboxLayer>, errors: &mut Vec, ) -> 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, -) { +/// 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 { 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 { diff --git a/lib/foundation/fabro-config/src/tests/resolve_server.rs b/lib/foundation/fabro-config/src/tests/resolve_server.rs index 0fc982b98..c066825a4 100644 --- a/lib/foundation/fabro-config/src/tests/resolve_server.rs +++ b/lib/foundation/fabro-config/src/tests/resolve_server.rs @@ -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] diff --git a/lib/foundation/fabro-db/build.rs b/lib/foundation/fabro-db/build.rs new file mode 100644 index 000000000..179420a9e --- /dev/null +++ b/lib/foundation/fabro-db/build.rs @@ -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"); +}