From aa8b919f1c206b7277ba5fd924e73a757ffb8c90 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 13:45:44 -0600 Subject: [PATCH 01/57] Pin daytona-sdk-rust to the merged main commit Both fabro and sandbox-driver now pin the same daytona-sdk-rust commit on main. The newer SDK adds region and sandbox class fields to snapshot creation; fabro leaves both unset and keeps its current behavior. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 28 +++++++++---------- Cargo.toml | 4 +-- .../fabro-sandbox/src/daytona/mod.rs | 2 ++ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0d32aa79..e406bb846 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,7 +1870,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -1884,7 +1884,7 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" dependencies = [ "daytona-api-client", "daytona-toolbox-client", @@ -1904,7 +1904,7 @@ dependencies = [ [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -2074,7 +2074,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2201,7 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4469,7 +4469,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -5435,7 +5435,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6432,7 +6432,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6909,7 +6909,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6968,7 +6968,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7492,7 +7492,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.2.8", + "errno 0.3.14", "libc", ] @@ -8082,7 +8082,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8128,7 +8128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9191,7 +9191,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c642467a5..c2b23454d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,8 +97,8 @@ twin-openai = { path = "test/twin/openai" } twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" } +daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "a3d267e18025151d9e61840ac7a5e6b6b63799ce", package = "daytona-sdk" } +daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "a3d267e18025151d9e61840ac7a5e6b6b63799ce", package = "daytona-api-client" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/lib/components/fabro-sandbox/src/daytona/mod.rs b/lib/components/fabro-sandbox/src/daytona/mod.rs index 66173286f..f80d2fe24 100644 --- a/lib/components/fabro-sandbox/src/daytona/mod.rs +++ b/lib/components/fabro-sandbox/src/daytona/mod.rs @@ -239,6 +239,8 @@ fn create_snapshot_params( ..Default::default() }), entrypoint: None, + region_id: None, + sandbox_class: None, }) } From 07c52b07cacac74c1a03468c60747308106f9c17 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 14:18:58 -0600 Subject: [PATCH 02/57] Depend on the sandbox-driver crates by git revision Fabro pins the sandbox-driver workspace the same way it pins the Daytona SDK: every crate at one commit on main. The bundled Host, Docker, and Daytona provider libraries link in-process, and the protocol crate reaches third-party providers over stdio. Nothing uses the crates yet; the following commits move fabro onto them one layer at a time. The driver pins tracing-subscriber exactly, so the lockfile settles on that version for the whole workspace. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 155 +++++++++++++++++++++--- Cargo.toml | 14 ++- lib/components/fabro-sandbox/Cargo.toml | 7 ++ 3 files changed, 158 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e406bb846..101226b80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,7 +1870,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -1884,7 +1884,7 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "daytona-api-client", "daytona-toolbox-client", @@ -1904,7 +1904,7 @@ dependencies = [ [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=a3d267e18025151d9e61840ac7a5e6b6b63799ce#a3d267e18025151d9e61840ac7a5e6b6b63799ce" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -2074,7 +2074,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2201,7 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3006,6 +3006,13 @@ dependencies = [ "rand 0.9.4", "reqwest-middleware", "rustls", + "sandbox-driver", + "sandbox-driver-daytona", + "sandbox-driver-daytona-config", + "sandbox-driver-docker", + "sandbox-driver-docker-config", + "sandbox-driver-host", + "sandbox-driver-protocol", "serde", "serde_json", "sha2 0.10.9", @@ -4469,7 +4476,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -5435,7 +5442,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6432,7 +6439,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6909,7 +6916,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6968,7 +6975,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7010,6 +7017,122 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox-driver" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "async-trait", + "globset", + "rand 0.10.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "sandbox-driver-daytona" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "daytona-api-client", + "daytona-sdk", + "rand 0.10.1", + "reqwest 0.13.2", + "sandbox-driver", + "sandbox-driver-daytona-config", + "sandbox-driver-docker", + "sandbox-driver-docker-config", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-daytona-config" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "sandbox-driver-docker-config", + "serde", + "serde_json", +] + +[[package]] +name = "sandbox-driver-docker" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "anyhow", + "async-trait", + "bollard", + "futures-util", + "sandbox-driver", + "sandbox-driver-docker-config", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tar", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-docker-config" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "sandbox-driver-host" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "anyhow", + "async-trait", + "nix 0.30.1", + "sandbox-driver", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-protocol" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=b9d07cf3ef1861173f8134498e2115b041f00b7d#b9d07cf3ef1861173f8134498e2115b041f00b7d" +dependencies = [ + "async-trait", + "base64", + "rand 0.10.1", + "sandbox-driver", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "schannel" version = "0.1.28" @@ -7492,7 +7615,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.3.14", + "errno 0.2.8", "libc", ] @@ -8082,7 +8205,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8128,7 +8251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8545,9 +8668,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", @@ -9191,7 +9314,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c2b23454d..6e27bed20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,8 +97,18 @@ twin-openai = { path = "test/twin/openai" } twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "a3d267e18025151d9e61840ac7a5e6b6b63799ce", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "a3d267e18025151d9e61840ac7a5e6b6b63799ce", package = "daytona-api-client" } +daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "5e86990418e21f4288ce537c9852dfdf78768abc", package = "daytona-sdk" } +daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "5e86990418e21f4288ce537c9852dfdf78768abc", package = "daytona-api-client" } +# sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and +# Daytona providers link in-process; third-party providers run as stdio +# plugins through sandbox-driver-protocol. Pinned by rev like the Daytona SDK. +sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } +sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "b9d07cf3ef1861173f8134498e2115b041f00b7d" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/lib/components/fabro-sandbox/Cargo.toml b/lib/components/fabro-sandbox/Cargo.toml index 251e6bd77..d5672f3f4 100644 --- a/lib/components/fabro-sandbox/Cargo.toml +++ b/lib/components/fabro-sandbox/Cargo.toml @@ -20,6 +20,13 @@ doctest = false workspace = true [dependencies] +sandbox-driver.workspace = true +sandbox-driver-protocol.workspace = true +sandbox-driver-host.workspace = true +sandbox-driver-docker.workspace = true +sandbox-driver-docker-config.workspace = true +sandbox-driver-daytona.workspace = true +sandbox-driver-daytona-config.workspace = true anyhow.workspace = true async-trait.workspace = true thiserror.workspace = true From d88c6064af057e9f5b8da6c3c1437472d1384412 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 14:30:32 -0600 Subject: [PATCH 03/57] Benchmark agent tool calls through the sandbox driver Phase 2 of the sandbox-driver adoption: an ignored test that unpacks fabro's own lib tree into each provider and times file reads and content searches through fabro's current providers, the driver providers in-process, and the driver providers served over JSON-RPC on an in-process pipe. Docker reads match, Docker grep is faster through the driver, Host grep costs about 20 ms more through the derived search, and the wire hop adds about 0.1 ms per call against the plan's 100 ms per tool call budget. The plan records the full table. Co-Authored-By: Claude Fable 5.1 --- .../fabro-sandbox/tests/driver_bench.rs | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 lib/components/fabro-sandbox/tests/driver_bench.rs diff --git a/lib/components/fabro-sandbox/tests/driver_bench.rs b/lib/components/fabro-sandbox/tests/driver_bench.rs new file mode 100644 index 000000000..af6a69ae5 --- /dev/null +++ b/lib/components/fabro-sandbox/tests/driver_bench.rs @@ -0,0 +1,412 @@ +//! Phase 2 of the sandbox-driver adoption: measure agent tool-call latency +//! through the driver against fabro's current providers before any cutover. +//! +//! Three comparisons, each over the same medium repository (fabro's own +//! `lib/` tree, about 1,100 Rust files): +//! +//! - Docker file reads and content search: fabro's `DockerSandbox` (archive API +//! reads, `docker exec` grep) against the driver `DockerProvider` (archive +//! API reads, exec-derived search) in-process. +//! - Host tool calls: fabro's `LocalSandbox` against the driver `HostProvider` +//! in-process, to confirm no regression on the path every local run takes. +//! - The wire: the driver Host and Docker providers served over the JSON-RPC +//! protocol on an in-process duplex pipe, to size the budget for running a +//! provider out of process later (the plan allows 100 ms per tool call). +//! +//! Ignored: it needs a Docker daemon with `buildpack-deps:noble` present and +//! takes a minute. Run with +//! `cargo nextest run -p fabro-sandbox --features docker --test driver_bench +//! --run-ignored only --no-capture`. + +#![cfg(feature = "docker")] +#![allow( + clippy::print_stderr, + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a benchmark reports through stderr and rounds durations for display" +)] +#![expect( + clippy::disallowed_methods, + reason = "the fixture is packed and enumerated synchronously before the timed section starts" +)] + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use bollard::Docker; +use fabro_sandbox::{DockerSandbox, DockerSandboxOptions, LocalSandbox, Sandbox as FabroSandbox}; +use sandbox_driver::{ + ExecSpec, GrepOptions, Sandbox as DriverSandbox, SandboxProvider, SandboxSource, SandboxSpec, + Search, +}; +use sandbox_driver_docker::DockerProvider; +use sandbox_driver_host::HostProvider; +use sandbox_driver_protocol::{PluginProvider, serve}; +use tokio::io::{duplex, split}; + +const IMAGE: &str = "buildpack-deps:noble"; +const READS: usize = 200; +const GREPS: usize = 20; +const GREP_PATTERN: &str = "async fn "; + +/// The medium repository: fabro's `lib/` tree, packed once per run. +struct Repository { + tarball: PathBuf, + /// Repository-relative paths of the files the read benchmark samples. + files: Vec, + _dir: tempfile::TempDir, +} + +impl Repository { + fn pack() -> Self { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("workspace lib dir"); + let dir = tempfile::tempdir().expect("tempdir"); + let tarball = dir.path().join("repo.tar"); + let status = Command::new("tar") + .args(["-cf"]) + .arg(&tarball) + .args(["--exclude", "target", "--exclude", "node_modules", "-C"]) + .arg(&root) + .arg(".") + .status() + .expect("tar available"); + assert!(status.success(), "packing the repository failed"); + let mut files: Vec = walkdir(&root) + .into_iter() + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")) + .filter_map(|path| { + path.strip_prefix(&root) + .ok() + .map(|rel| rel.to_string_lossy().into_owned()) + }) + .collect(); + files.sort(); + // A fixed stride samples the tree evenly and identically for every + // provider under test. + let stride = (files.len() / READS).max(1); + let files = files.into_iter().step_by(stride).take(READS).collect(); + Self { + tarball, + files, + _dir: dir, + } + } +} + +fn walkdir(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if path.file_name().is_some_and(|name| name == "target") { + continue; + } + stack.push(path); + } else { + out.push(path); + } + } + } + out +} + +#[derive(Default)] +struct Samples(Vec); + +impl Samples { + fn record(&mut self, duration: Duration) { + self.0.push(duration); + } + + fn percentile(&self, pct: f64) -> Duration { + let mut sorted = self.0.clone(); + sorted.sort(); + if sorted.is_empty() { + return Duration::ZERO; + } + let index = ((sorted.len() - 1) as f64 * pct).round() as usize; + sorted[index] + } + + fn mean(&self) -> Duration { + if self.0.is_empty() { + return Duration::ZERO; + } + self.0.iter().sum::() / self.0.len() as u32 + } +} + +struct Row { + label: &'static str, + op: &'static str, + n: usize, + stats: Samples, +} + +fn report(rows: &[Row]) { + eprintln!(); + eprintln!( + "{:<34} {:<8} {:>5} {:>9} {:>9} {:>9}", + "provider", "op", "n", "p50 ms", "p95 ms", "mean ms" + ); + for row in rows { + eprintln!( + "{:<34} {:<8} {:>5} {:>9.2} {:>9.2} {:>9.2}", + row.label, + row.op, + row.n, + row.stats.percentile(0.5).as_secs_f64() * 1000.0, + row.stats.percentile(0.95).as_secs_f64() * 1000.0, + row.stats.mean().as_secs_f64() * 1000.0, + ); + } + eprintln!(); +} + +/// The two operations an agent issues most: a file read and a content +/// search, expressed against fabro's current trait. +async fn bench_fabro( + label: &'static str, + sandbox: &dyn FabroSandbox, + repo: &Repository, +) -> Vec { + let mut reads = Samples::default(); + for file in &repo.files { + let started = Instant::now(); + let bytes = sandbox + .read_file_bytes(&format!("repo/{file}")) + .await + .expect("read"); + assert!(!bytes.is_empty()); + reads.record(started.elapsed()); + } + let mut greps = Samples::default(); + let options = fabro_sandbox::GrepOptions { + glob_filter: Some("*.rs".to_owned()), + case_insensitive: false, + max_results: Some(50), + }; + for _ in 0..GREPS { + let started = Instant::now(); + let matches = sandbox + .grep(GREP_PATTERN, "repo", &options) + .await + .expect("grep"); + assert!(!matches.is_empty()); + greps.record(started.elapsed()); + } + vec![ + Row { + label, + op: "read", + n: repo.files.len(), + stats: reads, + }, + Row { + label, + op: "grep", + n: GREPS, + stats: greps, + }, + ] +} + +/// The same two operations against the driver's facets. +async fn bench_driver( + label: &'static str, + sandbox: &dyn DriverSandbox, + repo: &Repository, +) -> Vec { + let mut reads = Samples::default(); + for file in &repo.files { + let started = Instant::now(); + let bytes = sandbox + .fs() + .read(&format!("repo/{file}")) + .await + .expect("read"); + assert!(!bytes.is_empty()); + reads.record(started.elapsed()); + } + let search = sandbox.search().expect("search facet"); + let mut options = GrepOptions::default(); + options.include = Some("*.rs".to_owned()); + options.max_matches = Some(50); + let mut greps = Samples::default(); + for _ in 0..GREPS { + let started = Instant::now(); + let matches = search + .grep(GREP_PATTERN, "repo", &options) + .await + .expect("grep"); + assert!(!matches.is_empty()); + greps.record(started.elapsed()); + } + vec![ + Row { + label, + op: "read", + n: repo.files.len(), + stats: reads, + }, + Row { + label, + op: "grep", + n: GREPS, + stats: greps, + }, + ] +} + +async fn unpack_fabro(sandbox: &dyn FabroSandbox, repo: &Repository) { + sandbox + .upload_file_from_local(&repo.tarball, "/tmp/repo.tar") + .await + .expect("upload"); + let result = sandbox + .exec_command( + "mkdir -p repo && tar -xf /tmp/repo.tar -C repo", + 120_000, + None, + None, + None, + ) + .await + .expect("unpack exec"); + assert!(result.is_success(), "unpack failed: {}", result.stderr); +} + +async fn unpack_driver(sandbox: &dyn DriverSandbox, repo: &Repository) { + sandbox + .fs() + .upload(&repo.tarball, "/tmp/repo.tar") + .await + .expect("upload"); + let result = sandbox + .exec() + .run( + &ExecSpec::bash("mkdir -p repo && tar -xf /tmp/repo.tar -C repo") + .timeout(Duration::from_secs(120)), + ) + .await + .expect("unpack exec"); + assert!(result.success(), "unpack failed: {}", result.stderr_lossy()); +} + +fn docker_spec() -> SandboxSpec { + SandboxSpec::new(SandboxSource::Image { + reference: IMAGE.to_owned(), + }) + .working_directory("/workspace") +} + +async fn serve_over_duplex(provider: Arc) -> PluginProvider { + 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(provider, plugin_read, plugin_write)); + PluginProvider::connect(host_read, host_write) + .await + .expect("handshake") +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "benchmark: needs a Docker daemon with buildpack-deps:noble and takes about a minute"] +async fn agent_tool_call_latency_through_the_driver() { + let Ok(docker) = Docker::connect_with_local_defaults() else { + eprintln!("no Docker daemon; skipping"); + return; + }; + if docker.inspect_image(IMAGE).await.is_err() { + eprintln!("{IMAGE} is not present locally; skipping"); + return; + } + let repo = Repository::pack(); + let mut rows = Vec::new(); + + // -- Host, in-process: fabro LocalSandbox vs driver HostProvider. + let host_dir = tempfile::tempdir().expect("tempdir"); + let local = LocalSandbox::new(host_dir.path().to_path_buf()); + local.initialize().await.expect("local init"); + unpack_fabro(&local, &repo).await; + rows.extend(bench_fabro("fabro LocalSandbox", &local, &repo).await); + + let host_provider = Arc::new(HostProvider::new()); + let host = host_provider + .create( + &SandboxSpec::new(SandboxSource::HostDirectory) + .working_directory(host_dir.path().to_string_lossy().into_owned()), + None, + ) + .await + .expect("host create"); + rows.extend(bench_driver("driver Host (in-process)", host.as_ref(), &repo).await); + + // -- Host over the wire (duplex pipe, no process boundary). + let remote_host = serve_over_duplex(host_provider.clone()).await; + let wire_host = remote_host.attach(host.id(), None).await.expect("attach"); + rows.extend(bench_driver("driver Host (JSON-RPC, duplex)", wire_host.as_ref(), &repo).await); + drop(wire_host); + remote_host.shutdown().await.expect("shutdown"); + host.delete().await.expect("host delete"); + + // -- Docker, in-process: fabro DockerSandbox vs driver DockerProvider. + let fabro_docker = DockerSandbox::new( + DockerSandboxOptions { + image: IMAGE.to_owned(), + auto_pull: false, + skip_clone: true, + ..DockerSandboxOptions::default() + }, + None, + None, + None, + None, + None, + None, + ) + .expect("fabro docker sandbox"); + fabro_docker.initialize().await.expect("fabro docker init"); + unpack_fabro(&fabro_docker, &repo).await; + rows.extend(bench_fabro("fabro DockerSandbox", &fabro_docker, &repo).await); + fabro_docker.cleanup().await.expect("fabro docker cleanup"); + + let docker_provider = Arc::new(DockerProvider::connect().await.expect("docker connect")); + let container = docker_provider + .create(&docker_spec(), None) + .await + .expect("driver docker create"); + unpack_driver(container.as_ref(), &repo).await; + rows.extend(bench_driver("driver Docker (in-process)", container.as_ref(), &repo).await); + + // -- Docker over the wire (duplex pipe, no process boundary). + let remote_docker = serve_over_duplex(docker_provider.clone()).await; + let wire_docker = remote_docker + .attach(container.id(), None) + .await + .expect("attach"); + rows.extend( + bench_driver( + "driver Docker (JSON-RPC, duplex)", + wire_docker.as_ref(), + &repo, + ) + .await, + ); + drop(wire_docker); + remote_docker.shutdown().await.expect("shutdown"); + container.delete().await.expect("driver docker delete"); + + report(&rows); +} From 124065ac5279a082ecd57e98076fe61e4fac6952 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 15:08:34 -0600 Subject: [PATCH 04/57] Fix duration lint in the sandbox driver benchmark Co-Authored-By: Claude Fable 5.1 --- lib/components/fabro-sandbox/tests/driver_bench.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/fabro-sandbox/tests/driver_bench.rs b/lib/components/fabro-sandbox/tests/driver_bench.rs index af6a69ae5..a3e11718c 100644 --- a/lib/components/fabro-sandbox/tests/driver_bench.rs +++ b/lib/components/fabro-sandbox/tests/driver_bench.rs @@ -297,7 +297,7 @@ async fn unpack_driver(sandbox: &dyn DriverSandbox, repo: &Repository) { .exec() .run( &ExecSpec::bash("mkdir -p repo && tar -xf /tmp/repo.tar -C repo") - .timeout(Duration::from_secs(120)), + .timeout(Duration::from_mins(2)), ) .await .expect("unpack exec"); From 80bc51c40e819b427efd406bf19453b218f4504c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 15:17:32 -0600 Subject: [PATCH 05/57] Open sandbox provider identity to plugin kinds SandboxProviderKind is now a validated string newtype instead of a closed enum. The bundled kinds (local, docker, daytona) keep their constants and a BundledProvider enum for the code paths that still dispatch on them; any other well-formed sandbox-driver kind name is accepted and names a plugin executable. EnvironmentProvider is gone: environment settings carry SandboxProviderKind directly, and is_clone_based is replaced by a workspace policy where local runs in a designated directory and every other provider clones. Server sandbox policy is keyed by kind. [server.sandbox.providers.] accepts the bundled kinds with `enabled` and any plugin kind with its launch settings (path, sha256, dev, args, env, inherit_env); bundled kinds reject the plugin keys and a kind with no entry is disabled. The OpenAPI schema, generated Rust and TypeScript clients, web settings pages, and docs follow. The environments table drops its provider CHECK enumeration in favour of the kind name rules so a plugin environment can be stored. Bundled-only code paths (run start, preflight, reconnect, terminal, details) now fail with an explicit message for a plugin kind until the driver construction function lands in the next step. Co-Authored-By: Claude Fable 5.1 --- .../app/components/environment-form.tsx | 20 +- .../app/lib/environment-providers.ts | 47 ++- .../app/routes/settings-environments.tsx | 6 +- .../app/routes/settings-sandboxes.tsx | 83 +++-- docs/public/administration/sandboxing.mdx | 9 +- .../administration/server-configuration.mdx | 35 ++- docs/public/api-reference/fabro-api.yaml | 66 ++-- docs/public/execution/environments.mdx | 2 +- lib/apps/fabro-cli/src/commands/run/create.rs | 9 +- .../src/commands/run/run_progress/mod.rs | 4 +- lib/apps/fabro-cli/src/commands/run/runner.rs | 16 +- lib/apps/fabro-cli/src/commands/runs/mod.rs | 2 +- lib/apps/fabro-server/src/demo/mod.rs | 8 +- lib/apps/fabro-server/src/diagnostics.rs | 4 +- lib/apps/fabro-server/src/install.rs | 9 +- lib/apps/fabro-server/src/run_manifest.rs | 120 ++++---- lib/apps/fabro-server/src/server.rs | 14 +- .../src/server/handler/automations.rs | 8 +- .../src/server/handler/environments.rs | 8 +- .../fabro-server/src/server/handler/runs.rs | 21 +- .../src/server/handler/sandbox.rs | 52 ++-- .../src/server/handler/sandboxes.rs | 26 +- lib/apps/fabro-server/src/server/tests.rs | 59 ++-- lib/apps/fabro-server/src/test_support.rs | 15 +- lib/apps/fabro-server/tests/it/api/install.rs | 16 +- .../fabro-server/tests/it/api/run_files.rs | 2 +- lib/apps/fabro-server/tests/it/api/runs.rs | 4 +- .../2026082801_environment_selectors.rs | 2 +- lib/components/fabro-dump/src/lib.rs | 4 +- lib/components/fabro-environment/src/store.rs | 25 +- .../fabro-environment/tests/store.rs | 27 +- lib/components/fabro-install/src/lib.rs | 19 +- .../fabro-sandbox/src/daytona/mod.rs | 2 +- lib/components/fabro-sandbox/src/details.rs | 32 +- lib/components/fabro-sandbox/src/docker.rs | 2 +- .../fabro-sandbox/src/from_environment.rs | 13 +- lib/components/fabro-sandbox/src/git_retry.rs | 14 +- lib/components/fabro-sandbox/src/provider.rs | 44 +-- .../fabro-sandbox/src/provider/daytona.rs | 2 +- .../fabro-sandbox/src/provider/docker.rs | 2 +- lib/components/fabro-sandbox/src/reconnect.rs | 18 +- .../fabro-sandbox/src/sandbox_spec.rs | 16 +- lib/components/fabro-sandbox/src/terminal.rs | 18 +- .../fabro-sandbox/src/test_support.rs | 2 +- lib/components/fabro-store/src/run_state.rs | 25 +- .../tests/serializable_projection.rs | 4 +- .../fabro-workflow/src/event/convert.rs | 2 +- .../fabro-workflow/src/operations/retry.rs | 2 +- .../fabro-workflow/src/operations/start.rs | 53 ++-- .../src/pipeline/execute/tests.rs | 2 +- .../fabro-workflow/tests/it/cp_integration.rs | 4 +- .../tests/it/daytona_integration.rs | 2 +- lib/foundation/fabro-api/build.rs | 5 + .../fabro-api/tests/run_sandbox_round_trip.rs | 4 +- .../tests/sandbox_details_round_trip.rs | 4 +- .../tests/sandbox_inventory_round_trip.rs | 4 +- lib/foundation/fabro-client/src/client.rs | 7 +- ...26050101_legacy_sandbox_to_environments.rs | 48 +-- lib/foundation/fabro-config/src/builders.rs | 7 +- .../fabro-config/src/layers/combine.rs | 11 +- .../fabro-config/src/layers/server.rs | 46 ++- .../fabro-config/src/resolve/environment.rs | 80 ++--- .../fabro-config/src/resolve/server.rs | 101 +++++-- .../fabro-config/src/tests/resolve_root.rs | 6 +- .../fabro-config/src/tests/resolve_run.rs | 15 +- .../fabro-config/src/tests/resolve_server.rs | 93 +++++- .../2026090901_environment_provider_kinds.sql | 56 ++++ lib/foundation/fabro-db/tests/sqlite.rs | 6 +- lib/foundation/fabro-types/src/lib.rs | 4 +- .../fabro-types/src/sandbox_details.rs | 4 +- .../fabro-types/src/sandbox_provider.rs | 284 ++++++++++++++++-- .../fabro-types/src/settings/mod.rs | 16 +- .../fabro-types/src/settings/run.rs | 51 +--- .../fabro-types/src/settings/server.rs | 103 +++++-- .../tests/sandbox_inventory_serde.rs | 6 +- .../fabro-types/tests/sandbox_model_serde.rs | 20 +- .../src/.openapi-generator/FILES | 4 +- .../fabro-api-client/src/api/runs-api.ts | 8 +- .../src/models/create-environment-request.ts | 8 +- .../src/models/delete-run-sandbox.ts | 8 +- .../src/models/environment-provider.ts | 27 -- .../src/models/environment-settings.ts | 8 +- .../src/models/environment.ts | 8 +- .../fabro-api-client/src/models/index.ts | 4 +- .../src/models/replace-environment-request.ts | 8 +- .../src/models/run-environment-settings.ts | 8 +- .../src/models/run-sandbox-instance.ts | 8 +- .../src/models/run-sandbox-plan.ts | 8 +- .../src/models/sandbox-info.ts | 8 +- .../src/models/sandbox-plugin-settings.ts | 36 +++ .../src/models/sandbox-provider-kind.ts | 27 -- .../models/sandbox-provider-lookup-error.ts | 8 +- .../server-sandbox-provider-settings.ts | 4 + .../server-sandbox-providers-settings.ts | 24 -- .../src/models/server-sandbox-settings.ts | 7 +- 95 files changed, 1390 insertions(+), 813 deletions(-) create mode 100644 lib/foundation/fabro-db/migrations/2026090901_environment_provider_kinds.sql delete mode 100644 lib/packages/fabro-api-client/src/models/environment-provider.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-plugin-settings.ts delete mode 100644 lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts delete mode 100644 lib/packages/fabro-api-client/src/models/server-sandbox-providers-settings.ts diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 688f0adbf..a78a6cc10 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -3,7 +3,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { EnvironmentApiDockerfileSourceInlineTypeEnum, EnvironmentNetworkMode, - EnvironmentProvider, } from "@qltysh/fabro-api-client"; import type { CreateEnvironmentRequest, @@ -15,6 +14,7 @@ import type { ReplaceEnvironmentRequest, } from "@qltysh/fabro-api-client"; +import { DOCKER_PROVIDER, isCloneBasedProvider } from "../lib/environment-providers"; import { Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { @@ -25,11 +25,15 @@ import { } from "./key-value-editor"; // Parse the `provider` query param used by the create flow into a creatable -// provider, defaulting to Docker for anything unexpected. -export function parseCreatableProvider(value: string | null): EnvironmentProvider { - return value === EnvironmentProvider.DAYTONA - ? EnvironmentProvider.DAYTONA - : EnvironmentProvider.DOCKER; +// provider, defaulting to Docker for anything that cannot back a managed +// environment. Kind names are validated server-side on create. +const PROVIDER_KIND_PATTERN = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/; + +export function parseCreatableProvider(value: string | null): string { + if (value && PROVIDER_KIND_PATTERN.test(value) && isCloneBasedProvider(value)) { + return value; + } + return DOCKER_PROVIDER; } // Environment ids are server-managed file names: lowercase, digits, hyphens. @@ -49,7 +53,7 @@ type ImageSource = "image" | "dockerfile"; export interface EnvironmentFormValues { id: string; - provider: EnvironmentProvider; + provider: string; imageSource: ImageSource; dockerRef: string; dockerfile: string; @@ -69,7 +73,7 @@ export interface EnvironmentFormValues { export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { id: "", - provider: EnvironmentProvider.DOCKER, + provider: DOCKER_PROVIDER, imageSource: "image", dockerRef: "", dockerfile: "", diff --git a/apps/fabro-web/app/lib/environment-providers.ts b/apps/fabro-web/app/lib/environment-providers.ts index 8002cf6a4..300fe54cb 100644 --- a/apps/fabro-web/app/lib/environment-providers.ts +++ b/apps/fabro-web/app/lib/environment-providers.ts @@ -1,17 +1,44 @@ -import { EnvironmentProvider, type Environment } from "@qltysh/fabro-api-client"; +import type { Environment, ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; -// Providers a managed environment can be created with. `local` is a reserved, -// in-memory environment, never a managed-environment provider, so it is never -// offered. The provider is fixed at creation time and cannot be changed. -export const CREATABLE_PROVIDERS = [ - EnvironmentProvider.DOCKER, - EnvironmentProvider.DAYTONA, -] as const; +// The providers linked into the server. Any other provider kind names a +// sandbox-driver plugin the operator configured under +// `server.sandbox.providers.`. +export const LOCAL_PROVIDER = "local"; +export const DOCKER_PROVIDER = "docker"; +export const DAYTONA_PROVIDER = "daytona"; + +export const BUNDLED_PROVIDERS = [LOCAL_PROVIDER, DOCKER_PROVIDER, DAYTONA_PROVIDER] as const; + +export type ProviderSettingsMap = { [kind: string]: ServerSandboxProviderSettings }; + +// `local` runs in the caller's directory and never clones. Every other +// provider owns an isolated workspace that Fabro clones into. +export function isCloneBasedProvider(provider: string): boolean { + return provider !== LOCAL_PROVIDER; +} // Whether a server-managed environment can back Git-targeted work such as -// automations: only the clone-based (creatable) providers qualify. +// automations: only clone-based providers qualify. export function isCloneBasedEnvironment(environment: Environment): boolean { - return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider); + return isCloneBasedProvider(environment.provider); +} + +// Providers a managed environment can be created with: every enabled +// clone-based provider. `local` is a reserved, in-memory environment, never a +// managed-environment provider, so it is never offered. +export function creatableProviders(providers: ProviderSettingsMap): string[] { + return Object.keys(providers) + .filter((kind) => isCloneBasedProvider(kind) && providers[kind]?.enabled) + .sort(compareProviderKinds); +} + +// Bundled kinds first, in their canonical order, then plugins alphabetically. +export function compareProviderKinds(left: string, right: string): number { + const rank = (kind: string) => { + const index = (BUNDLED_PROVIDERS as readonly string[]).indexOf(kind); + return index === -1 ? BUNDLED_PROVIDERS.length : index; + }; + return rank(left) - rank(right) || left.localeCompare(right); } export function providerLabel(provider: string): string { diff --git a/apps/fabro-web/app/routes/settings-environments.tsx b/apps/fabro-web/app/routes/settings-environments.tsx index b3059ab52..fdd13aee4 100644 --- a/apps/fabro-web/app/routes/settings-environments.tsx +++ b/apps/fabro-web/app/routes/settings-environments.tsx @@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client"; import { ApiError, apiData, environmentsApi } from "../lib/api-client"; import { useEnvironments, useServerSettings } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; -import { CREATABLE_PROVIDERS, providerLabel } from "../lib/environment-providers"; +import { creatableProviders, providerLabel } from "../lib/environment-providers"; import { Badge, Muted, @@ -67,9 +67,7 @@ const NEW_BUTTON_CLASS = // environment's lifetime. `local` is never offered (it's reserved/in-memory). function NewEnvironmentMenu() { const { data } = useServerSettings(); - const providers = data - ? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled) - : []; + const providers = data ? creatableProviders(data.server.sandbox.providers) : []; if (providers.length === 0) { return ( diff --git a/apps/fabro-web/app/routes/settings-sandboxes.tsx b/apps/fabro-web/app/routes/settings-sandboxes.tsx index d51197c73..577f81866 100644 --- a/apps/fabro-web/app/routes/settings-sandboxes.tsx +++ b/apps/fabro-web/app/routes/settings-sandboxes.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { Link } from "react-router"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ComputerDesktopIcon } from "@heroicons/react/24/outline"; -import type { ServerSandboxProvidersSettings } from "@qltysh/fabro-api-client"; +import type { ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; import { useServerSettings } from "../lib/queries"; import { Dot, @@ -12,24 +12,56 @@ import { SettingsPageIntro, } from "../components/settings-panel"; import { plural } from "../lib/plural"; +import { + DAYTONA_PROVIDER, + DOCKER_PROVIDER, + LOCAL_PROVIDER, + compareProviderKinds, + providerLabel, + type ProviderSettingsMap, +} from "../lib/environment-providers"; export function meta() { return [{ title: "Sandboxes — Fabro" }]; } -type SandboxProviderId = "local" | "docker" | "daytona"; - type SandboxProvider = { - id: SandboxProviderId; + id: string; name: string; description: string; enabled: boolean; + bundled: boolean; secretName?: string; }; const DESCRIPTION = "Runtime environments where workflow stages execute. Configured via settings.toml."; +// Display copy for the providers linked into the server. Any other kind is a +// sandbox-driver plugin configured under `server.sandbox.providers.`. +const BUNDLED_PROVIDER_COPY: Record> = { + [LOCAL_PROVIDER]: { + name: "Local", + description: "Run stages directly on the Fabro host.", + }, + [DOCKER_PROVIDER]: { + name: "Docker", + description: "Run stages in isolated Docker containers on the host daemon.", + }, + [DAYTONA_PROVIDER]: { + name: "Daytona", + description: "Run stages in cloud sandboxes managed by Daytona.", + secretName: "DAYTONA_API_KEY", + }, +}; + +function pluginDescription(settings: ServerSandboxProviderSettings): string { + const path = settings.plugin?.path; + return path + ? `Sandbox plugin executable at ${path}.` + : "Sandbox plugin executable resolved from PATH."; +} + export default function SettingsSandboxes() { const query = useServerSettings(); const settings = query.data; @@ -42,29 +74,24 @@ export default function SettingsSandboxes() { ); } -function ProvidersPanel({ settings }: { settings: ServerSandboxProvidersSettings }) { +function ProvidersPanel({ settings }: { settings: ProviderSettingsMap }) { const providers: SandboxProvider[] = useMemo( - () => [ - { - id: "local", - name: "Local", - description: "Run stages directly on the Fabro host.", - enabled: settings.local.enabled, - }, - { - id: "docker", - name: "Docker", - description: "Run stages in isolated Docker containers on the host daemon.", - enabled: settings.docker.enabled, - }, - { - id: "daytona", - name: "Daytona", - description: "Run stages in cloud sandboxes managed by Daytona.", - enabled: settings.daytona.enabled, - secretName: "DAYTONA_API_KEY", - }, - ], + () => + Object.keys(settings) + .sort(compareProviderKinds) + .map((id) => { + const entry = settings[id]; + const copy = BUNDLED_PROVIDER_COPY[id]; + return copy + ? { id, enabled: entry.enabled, bundled: true, ...copy } + : { + id, + enabled: entry.enabled, + bundled: false, + name: providerLabel(id), + description: pluginDescription(entry), + }; + }), [settings], ); @@ -138,7 +165,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) { "grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong"; const dim = provider.enabled ? "" : "opacity-60"; - if (provider.id === "local") { + if (provider.id === LOCAL_PROVIDER) { return (