diff --git a/.config/nextest.toml b/.config/nextest.toml index 39f1c5520..43c024c88 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -16,7 +16,7 @@ leak-timeout = "500ms" slow-timeout = { period = "2s", terminate-after = 3 } # fabro-petri's adapter tests run whole workflows on the host sandbox - # through the sandbox-driver plugin, and one of them calls the twin. + # through the in-process provider, and one of them calls the twin. [[profile.default.overrides]] filter = "package(fabro-petri)" slow-timeout = { period = "5s", terminate-after = 4 } diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d97f111f2..70ddb282d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -144,31 +144,7 @@ jobs: with: cache-on-failure: true - uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest - # Every Petri run takes its scope through a sandbox-driver plugin - # executable that Petri finds on PATH: `sandbox-driver-host` for the - # `local` provider, `sandbox-driver-docker` for `docker`. Installed - # at the commit Cargo.lock resolves sandbox-driver to, so the plugins - # and the in-process driver are one build; a from-source build, so the - # two executables are cached by OS and commit and only rebuilt when the - # lockfile moves the driver. - - name: Read the sandbox-driver commit Cargo.lock resolves - id: sandbox-driver - run: | - rev="$(cargo metadata --locked --format-version 1 | jq -r '.packages[] | select(.name == "sandbox-driver") | .source' | sed 's/.*#//' | sort -u)" - [[ "$rev" =~ ^[0-9a-f]{40}$ ]] - echo "rev=$rev" >> "$GITHUB_OUTPUT" - - name: Restore the sandbox-driver plugin executables - id: sandbox-driver-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.cargo/bin/sandbox-driver-host - ~/.cargo/bin/sandbox-driver-docker - key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }} - - name: Install the sandbox-driver plugin executables - if: steps.sandbox-driver-cache.outputs.cache-hit != 'true' - run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker - # The images the suite's Docker tests run. The plugin pulls a missing + # The images the suite's Docker tests run. The provider pulls a missing # image on first use, but a 1 GiB pull inside a run's wait is a flake, # so pull them here, where a registry problem reads as one. Most tests # leave the image to Petri, whose Docker scope runs on its default @@ -195,9 +171,9 @@ jobs: permissions: contents: read env: - # The Docker scenarios skip when the executable, the daemon or the + # The Docker scenarios skip when the daemon or the # image is missing; in CI a skip is a failure. - FABRO_REQUIRE_SANDBOX_PLUGINS: "1" + FABRO_REQUIRE_SANDBOX_BACKENDS: "1" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -218,36 +194,11 @@ jobs: pin="$(sed -n 's/^const RUNNER_PIN: &str = "\([0-9a-f]*\)";$/\1/p' "$backend")" test -n "$pin" docker pull "ghcr.io/lithoscomputer/ubuntu-24.04:slim-$pin" - # Every Petri run takes its scope through a sandbox-driver plugin - # executable that Petri finds on PATH: `sandbox-driver-host` for the - # `local` provider, `sandbox-driver-docker` for `docker`. Installed - # at the commit Cargo.lock resolves sandbox-driver to, so the plugins - # and the in-process driver are one build; a from-source build, so the - # two executables are cached by OS and commit and only rebuilt when the - # lockfile moves the driver. - - name: Read the sandbox-driver commit Cargo.lock resolves - id: sandbox-driver - run: | - rev="$(cargo metadata --locked --format-version 1 | jq -r '.packages[] | select(.name == "sandbox-driver") | .source' | sed 's/.*#//' | sort -u)" - [[ "$rev" =~ ^[0-9a-f]{40}$ ]] - echo "rev=$rev" >> "$GITHUB_OUTPUT" - - name: Restore the sandbox-driver plugin executables - id: sandbox-driver-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.cargo/bin/sandbox-driver-host - ~/.cargo/bin/sandbox-driver-docker - key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }} - - name: Install the sandbox-driver plugin executables - if: steps.sandbox-driver-cache.outputs.cache-hit != 'true' - run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker # The workflow scenarios on the Docker provider. 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(/::docker_/)' - # The Petri runs (not ignored: they skip without the host plugin, which - # the environment above forbids). + # Petri adapter runs use the built-in providers; Docker is required here. - run: cargo nextest run --locked --profile ci --status-level slow -p fabro-petri test-macos: @@ -267,29 +218,5 @@ jobs: with: cache-on-failure: true - uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest - # Every Petri run takes its scope through a sandbox-driver plugin - # executable that Petri finds on PATH: `sandbox-driver-host` for the - # `local` provider, `sandbox-driver-docker` for `docker`. Installed - # at the commit Cargo.lock resolves sandbox-driver to, so the plugins - # and the in-process driver are one build; a from-source build, so the - # two executables are cached by OS and commit and only rebuilt when the - # lockfile moves the driver. - - name: Read the sandbox-driver commit Cargo.lock resolves - id: sandbox-driver - run: | - rev="$(cargo metadata --locked --format-version 1 | jq -r '.packages[] | select(.name == "sandbox-driver") | .source' | sed 's/.*#//' | sort -u)" - [[ "$rev" =~ ^[0-9a-f]{40}$ ]] - echo "rev=$rev" >> "$GITHUB_OUTPUT" - - name: Restore the sandbox-driver plugin executables - id: sandbox-driver-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: | - ~/.cargo/bin/sandbox-driver-host - ~/.cargo/bin/sandbox-driver-docker - key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }} - - name: Install the sandbox-driver plugin executables - if: steps.sandbox-driver-cache.outputs.cache-hit != 'true' - run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker # No Docker daemon on the macOS runner: the Docker tests skip there. - run: cargo nextest run --locked --workspace --status-level slow --profile ci diff --git a/Cargo.lock b/Cargo.lock index 0073596f7..33dc074fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2530,6 +2530,7 @@ dependencies = [ "fabro-interview", "fabro-llm", "fabro-petri", + "fabro-static", "fabro-store", "fabro-test", "fabro-tool", @@ -2547,6 +2548,10 @@ dependencies = [ "petri-runtime", "petri-store", "petri-testkit", + "sandbox-driver", + "sandbox-driver-daytona", + "sandbox-driver-docker", + "sandbox-driver-host", "serde", "serde_json", "sqlx", @@ -2654,8 +2659,6 @@ dependencies = [ "rand 0.9.4", "reqwest 0.12.28", "sandbox-driver", - "sandbox-driver-daytona", - "sandbox-driver-docker", "sandbox-driver-host", "sandbox-driver-protocol", "sandbox-driver-testing", @@ -5157,7 +5160,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petri-attractor-steps" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "globset", @@ -5188,7 +5191,7 @@ dependencies = [ [[package]] name = "petri-driver" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "getrandom 0.3.4", @@ -5208,7 +5211,7 @@ dependencies = [ [[package]] name = "petri-engine" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "petri-ir", "serde", @@ -5220,7 +5223,7 @@ dependencies = [ [[package]] name = "petri-execution" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "petri-driver", @@ -5244,7 +5247,7 @@ dependencies = [ [[package]] name = "petri-executor" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "libc", @@ -5259,7 +5262,7 @@ dependencies = [ [[package]] name = "petri-executor-sandbox" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "petri-executor", @@ -5281,7 +5284,7 @@ dependencies = [ [[package]] name = "petri-frontend" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "marked-yaml", "petri-ir", @@ -5295,7 +5298,7 @@ dependencies = [ [[package]] name = "petri-frontend-attractor" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "minijinja", "petri-frontend", @@ -5312,7 +5315,7 @@ dependencies = [ [[package]] name = "petri-frontend-fabro" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "petri-frontend", "petri-frontend-attractor", @@ -5328,7 +5331,7 @@ dependencies = [ [[package]] name = "petri-frontend-native" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "petri-frontend", "petri-ir", @@ -5339,7 +5342,7 @@ dependencies = [ [[package]] name = "petri-ir" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "regex", "serde", @@ -5352,7 +5355,7 @@ dependencies = [ [[package]] name = "petri-runtime" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "petri-driver", @@ -5373,7 +5376,7 @@ dependencies = [ [[package]] name = "petri-steps" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "petri-executor", @@ -5389,7 +5392,7 @@ dependencies = [ [[package]] name = "petri-store" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "getrandom 0.3.4", @@ -5404,7 +5407,7 @@ dependencies = [ [[package]] name = "petri-testkit" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#cbab2c5f134dcbd625f554b6fd6d9704a31bcfd9" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282" dependencies = [ "async-trait", "petri-driver", @@ -5415,6 +5418,7 @@ dependencies = [ "petri-steps", "petri-store", "sandbox-driver", + "sandbox-driver-host", "serde", "serde_json", "smol_str", diff --git a/Cargo.toml b/Cargo.toml index 6e3ba46cb..3c5925656 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,9 +88,7 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" # 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. The CI plugin jobs install the -# driver executables at the commit `Cargo.lock` resolves `sandbox-driver` to, -# so the plugins and the in-process driver are one build. +# plugins through sandbox-driver-protocol. sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" } sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" } sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" } diff --git a/clippy.toml b/clippy.toml index 066d42b88..0a9a8af84 100644 --- a/clippy.toml +++ b/clippy.toml @@ -31,6 +31,8 @@ disallowed-methods = [ { path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true }, { path = "reqwest::blocking::Client::builder", reason = "Use fabro_http::BlockingHttpClientBuilder::new()", allow-invalid = true }, { path = "reqwest::get", reason = "Build a fabro_http client and send the request explicitly", allow-invalid = true }, + { path = "runtime::Runtime::standard", reason = "Use fabro_petri::providers::standard_runtime, which installs the built-in in-process sandbox providers; without them Petri launches provider plugins, which a release build cannot verify", allow-invalid = true }, + { path = "runtime::Runtime::bare", reason = "Use fabro_petri::providers::bare_runtime, which installs the built-in in-process sandbox providers; without them Petri launches provider plugins, which a release build cannot verify", allow-invalid = true }, { path = "fabro_types::settings::interp::InterpString::as_source", reason = "Returns the unresolved template source, which leaks {{ ... }} tokens as literal text downstream. Resolve via resolve()/resolve_with() or substitute via substitute_with() instead; document intentional raw-source access (serialization, error messages, deliberate source preservation) with #[expect(clippy::disallowed_methods, reason = \"...\")]", allow-invalid = true }, ] disallowed-types = [ diff --git a/docs/public/administration/sandboxing.mdx b/docs/public/administration/sandboxing.mdx index a173a4c39..ef5ce54cd 100644 --- a/docs/public/administration/sandboxing.mdx +++ b/docs/public/administration/sandboxing.mdx @@ -7,6 +7,8 @@ Sandboxes isolate agent execution from the host machine. When an agent runs a sh Fabro bundles three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). Additional providers run as [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugins configured under `[server.sandbox.providers.]`; an environment selects one by its kind name. See [Environments](/execution/environments) for full provider-specific configuration and [Server configuration](/administration/server-configuration#serversandboxproviders-section) for plugin settings. +The built-in providers run in process for execution, resume, and pruning. Fabro does not need `sandbox-driver-host`, `sandbox-driver-docker`, or `sandbox-driver-daytona` executables or checksum pins for these operations. + Operators can enable or disable which providers the server may launch with `[server.sandbox.providers.]` in `settings.toml`. Missing bundled entries default to `enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled, diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index 48315c0e6..12e7b03ce 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -189,18 +189,20 @@ enabled = true enabled = true ``` +The built-in `local`, `docker`, and `daytona` providers run in process. `local` selects Petri's +Host provider. Built-in entries reject `path`, `sha256`, `dev`, `args`, `env`, and `inherit_env`; +no plugin executable or checksum is needed. The legacy built-in +`PETRI_SANDBOX_{HOST,DOCKER,DAYTONA}_{PLUGIN,SHA256}` variables are ignored. + 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. -The same executable serves both sides of a run. The server launches it to reach a run's -sandbox after the fact (the sandbox tab, files, terminal, Ask Fabro), and Petri launches it in -the run's worker to create the sandbox. The server hands the worker `path` and `sha256` as -`PETRI_SANDBOX__PLUGIN` and `PETRI_SANDBOX__SHA256` (the kind uppercased, hyphens -as underscores), and `PETRI_SANDBOX_PLUGIN_DEV=1` when any configured plugin sets `dev`, so a -plugin configured here needs no second configuration for the worker. +The server launches the plugin to reach a sandbox after the fact (the sandbox tab, files, +terminal, Ask Fabro). Runs execute only on the built-in providers for now, so a run's worker +never launches a plugin and receives none of these settings. ```toml title="settings.toml" [server.sandbox.providers.e2b] diff --git a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs index 681862e6d..6534ac3a7 100644 --- a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs +++ b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs @@ -78,6 +78,7 @@ use fabro_petri::hooks::HooksSpec; use fabro_petri::interview::{Approval, FabroInterviewer}; use fabro_petri::petri::OwnerId; use fabro_petri::platform_records::{HttpPlatformRecords, PlatformRecords}; +use fabro_petri::providers::{DaytonaCredentials, SandboxProviderConfig}; use fabro_petri::runtime::{self, RuntimeSpec}; use fabro_petri::secrets::VaultSecrets; use fabro_petri::{HttpRunStore, admission}; @@ -613,7 +614,14 @@ async fn runtime_spec( None } }; + let daytona = vault.read().await.get(EnvVars::DAYTONA_API_KEY).map(|key| { + // The same shared client the server attaches, so the worker's + // Daytona calls take the server's proxy and CA policy. + DaytonaCredentials::from_api_key(key.to_owned(), crate::process_env_var) + .with_http_client(fabro_http::http_client().ok()) + }); Ok(RuntimeSpec { + sandbox: SandboxProviderConfig::from_lookup(daytona, crate::process_env_var), settings_toml: None, mcp_catalog_toml: None, model_client, diff --git a/lib/apps/fabro-cli/src/main.rs b/lib/apps/fabro-cli/src/main.rs index d4c13101e..83deb5c3e 100644 --- a/lib/apps/fabro-cli/src/main.rs +++ b/lib/apps/fabro-cli/src/main.rs @@ -182,9 +182,9 @@ impl miette::Diagnostic for CliDiagnostic { #[expect( clippy::disallowed_methods, - reason = "CLI main reads documented process-env controls before telemetry and worker dispatch." + reason = "CLI main reads documented process-env controls before telemetry and worker dispatch, and the worker snapshots inherited sandbox provider selection." )] -fn process_env_var(name: &str) -> Option { +pub(crate) fn process_env_var(name: &str) -> Option { std::env::var(name).ok() } diff --git a/lib/apps/fabro-cli/tests/it/scenario/artifacts.rs b/lib/apps/fabro-cli/tests/it/scenario/artifacts.rs index 234c4fe61..bedf707f6 100644 --- a/lib/apps/fabro-cli/tests/it/scenario/artifacts.rs +++ b/lib/apps/fabro-cli/tests/it/scenario/artifacts.rs @@ -7,7 +7,7 @@ use std::time::Duration; use fabro_test::{fabro_snapshot, test_context}; -use super::petri::{RunningServer, host_plugin, run_detached, wait_for_success}; +use super::petri::{RunningServer, run_detached, wait_for_success}; use crate::cmd::support::{read_text, text_tree}; /// Three command stages that leave files under `assets/`. The second and @@ -45,9 +45,6 @@ fn artifact_workspace(context: &fabro_test::TestContext) -> PathBuf { #[tokio::test(flavor = "multi_thread")] async fn artifact_commands_read_the_artifacts_the_hooks_collected() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let workspace = artifact_workspace(&context); diff --git a/lib/apps/fabro-cli/tests/it/scenario/petri.rs b/lib/apps/fabro-cli/tests/it/scenario/petri.rs index 6767c6bdf..44e709fe2 100644 --- a/lib/apps/fabro-cli/tests/it/scenario/petri.rs +++ b/lib/apps/fabro-cli/tests/it/scenario/petri.rs @@ -5,18 +5,14 @@ //! //! Each test starts its own foreground server on disk storage, because the //! session's shared daemon keeps its object store in memory and the resume -//! scenario restarts the server. The runs take their host scope through the -//! sandbox-driver host plugin, so the tests skip, and say why, when the -//! executable is not found, unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. -//! The plugin's path override crosses into the server and its workers the -//! way `PATH` does. +//! scenario restarts the server. Host scopes run in process. //! //! The harness here (the server, the detached run, the status and event //! reads) is shared with the run-tools scenarios in `petri_tools.rs`. #![expect( clippy::disallowed_methods, - reason = "these scenarios start a real server subprocess, locate the plugin through the process environment, and poll processes" + reason = "these scenarios start a real server subprocess, poll processes" )] #![expect( clippy::disallowed_types, @@ -47,36 +43,9 @@ use fabro_vault::{SecretType, Vault}; use crate::cmd::support::created_run_id; use crate::support::{TEST_DEV_TOKEN, TEST_SESSION_SECRET, seed_dev_token_auth}; -const HOST_PLUGIN: &str = "sandbox-driver-host"; -pub(super) const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS"; pub(super) const RUN_TIMEOUT: Duration = Duration::from_mins(1); pub(super) const POLL: Duration = Duration::from_millis(50); -/// The host plugin as Petri's lookup finds it: the override variable, else -/// the executable on `PATH`. `None`, after saying so, when the test should -/// skip; a panic when the environment forbids a skip. -pub(super) fn host_plugin() -> Option { - let found = env::var_os(EnvVars::PETRI_SANDBOX_HOST_PLUGIN) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os(EnvVars::PATH)?) - .map(|dir| dir.join(HOST_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - if found.is_none() { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {} is unset", - EnvVars::PETRI_SANDBOX_HOST_PLUGIN - ); - eprintln!( - "skipping: {HOST_PLUGIN} is not on PATH and {} is unset", - EnvVars::PETRI_SANDBOX_HOST_PLUGIN - ); - } - found -} - /// A foreground server on its own disk storage, dev-token auth, started /// from the compiled `fabro` binary. Dropping it kills the process. pub(super) struct RunningServer { @@ -676,9 +645,6 @@ pub(super) fn wait_until_gate_is_polled(gate: &Path) { /// records through the HTTP store, and its lease ended with it. #[tokio::test(flavor = "multi_thread")] async fn a_petri_run_executes_in_the_server_launched_worker() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let workspace = write_petri_workspace(&context, "echo hello from petri"); @@ -730,9 +696,6 @@ async fn a_petri_run_executes_in_the_server_launched_worker() { /// resume mode, which finishes the run with one terminal lifecycle record. #[tokio::test(flavor = "multi_thread")] async fn a_petri_run_resumes_in_a_new_worker_after_the_server_restarts() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let mut server = RunningServer::start().await; let gate = context.temp_dir.join("resume.gate"); @@ -748,7 +711,7 @@ async fn a_petri_run_resumes_in_a_new_worker_after_the_server_restarts() { eprintln!("stage is waiting on the gate"); // The crash: the server first, so it never observes the worker exit, - // then the worker's whole process group, plugin and stage included. + // then the worker's whole process group, stage included. server.kill(); fabro_proc::sigkill_process_group(worker); let deadline = Instant::now() + Duration::from_secs(10); @@ -923,9 +886,6 @@ fn two_gates_dot(markers: &Path) -> String { /// and the run's stream records the interview. #[tokio::test(flavor = "multi_thread")] async fn a_human_gate_in_the_worker_is_answered_through_the_api() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let markers = context.temp_dir.join("markers"); @@ -980,9 +940,6 @@ async fn a_human_gate_in_the_worker_is_answered_through_the_api() { /// the API in the other order, binds to its own branch. #[tokio::test(flavor = "multi_thread")] async fn two_parallel_gates_in_the_worker_each_bind_their_own_answer() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let markers = context.temp_dir.join("markers"); @@ -1033,9 +990,6 @@ async fn two_parallel_gates_in_the_worker_each_bind_their_own_answer() { /// pending. #[tokio::test(flavor = "multi_thread")] async fn an_unanswered_gate_in_the_worker_expires_with_its_default() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let markers = context.temp_dir.join("markers"); @@ -1136,9 +1090,6 @@ fn pretty_filters(context: &fabro_test::TestContext) -> Vec<(String, String)> { /// `wait` and `runs inspect` read the projection. #[tokio::test(flavor = "multi_thread")] async fn a_finished_petri_run_reads_back_through_the_cli() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let workspace = write_petri_workspace(&context, "echo hello from petri"); @@ -1257,9 +1208,6 @@ async fn a_finished_petri_run_reads_back_through_the_cli() { /// the gate and the attach exits with the run's status. #[tokio::test(flavor = "multi_thread")] async fn attach_asks_a_petri_gate_at_the_terminal_and_answers_it() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let markers = context.temp_dir.join("markers"); @@ -1306,9 +1254,6 @@ async fn attach_asks_a_petri_gate_at_the_terminal_and_answers_it() { /// the run goes on follow, and the terminal lifecycle record ends it. #[tokio::test(flavor = "multi_thread")] async fn events_follow_streams_a_petri_run_live_to_its_end() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let gate = context.temp_dir.join("go"); @@ -1400,7 +1345,7 @@ fn three_stage_bundle(context: &fabro_test::TestContext, gate: &Path) -> PathBuf /// Kill the server first, so it never observes the worker exit, then the /// worker's whole process group, then the stage's own process group when a /// stage was waiting on `gate`: a stage process runs in a group of its own -/// under the host plugin, and a machine crash takes it with everything +/// under the Host provider, and a machine crash takes it with everything /// else, where a killed worker alone would leave it writing into the /// workspace. pub(super) fn crash(server: &mut RunningServer, worker: u32, gate: Option<&Path>) { @@ -1465,9 +1410,6 @@ fn three_stage_subjects(run_id: &str) -> Vec { /// snapshot (its partial output gone), and the next stage sees both. #[tokio::test(flavor = "multi_thread")] async fn a_crash_after_a_durable_finish_keeps_its_one_commit() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let mut server = RunningServer::start().await; let gate = context.temp_dir.join("two.gate"); @@ -1506,9 +1448,6 @@ async fn a_crash_after_a_durable_finish_keeps_its_one_commit() { /// is not durable, the stage reruns once, and one commit exists for it. #[tokio::test(flavor = "multi_thread")] async fn a_crash_before_the_commit_lands_reruns_the_stage_once() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let mut server = RunningServer::start().await; let gate = context.temp_dir.join("two.gate"); @@ -1547,9 +1486,6 @@ async fn a_crash_before_the_commit_lands_reruns_the_stage_once() { /// repository, the stage does not rerun, and one commit exists for it. #[tokio::test(flavor = "multi_thread")] async fn a_crash_before_the_record_reconciles_it_from_the_run_branch() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let mut server = RunningServer::start().await; let gate = context.temp_dir.join("two.gate"); @@ -1591,9 +1527,6 @@ async fn a_crash_before_the_record_reconciles_it_from_the_run_branch() { /// repository, and the next stage sees the checkpoint's files. #[tokio::test(flavor = "multi_thread")] async fn a_deleted_workspace_is_restored_from_its_snapshot() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let mut server = RunningServer::start().await; let gate = context.temp_dir.join("two.gate"); @@ -1628,9 +1561,6 @@ async fn a_deleted_workspace_is_restored_from_its_snapshot() { /// route reruns on the same files. #[tokio::test(flavor = "multi_thread")] async fn a_failure_route_sees_the_same_committed_files_after_a_crash() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let mut server = RunningServer::start().await; let gate = context.temp_dir.join("fix.gate"); @@ -1683,9 +1613,6 @@ async fn a_failure_route_sees_the_same_committed_files_after_a_crash() { /// leaves it failed without launching a worker. #[tokio::test(flavor = "multi_thread")] async fn a_failed_checkpoint_fails_the_run_and_a_restart_leaves_it_failed() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let mut server = RunningServer::start().await; let workspace = write_petri_workflow( @@ -1760,9 +1687,6 @@ async fn a_failed_checkpoint_fails_the_run_and_a_restart_leaves_it_failed() { /// exit after the delete brings nothing back. #[tokio::test(flavor = "multi_thread")] async fn a_delete_right_after_the_run_reads_ended_is_accepted() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let workspace = write_petri_workspace(&context, "true"); @@ -1805,3 +1729,71 @@ async fn a_delete_right_after_the_run_reads_ended_is_accepted() { ); server.shutdown(); } + +/// A Host run acquires and prunes a real scope with no plugin executable +/// anywhere the worker or server would look. The server is also handed +/// legacy Host plugin settings, which its prune must ignore; the worker +/// never receives them (its environment allowlist drops them). The release +/// workflow runs the suite in a release build, where no plugin checksum is +/// pinned, so this is the check that a release can run a sandbox at all. +#[tokio::test(flavor = "multi_thread")] +async fn built_in_host_runs_and_prunes_without_plugins() { + let context = test_context!(); + let path = "/usr/bin:/bin:/usr/sbin:/sbin"; + let binary_dir = Path::new(env!("CARGO_BIN_EXE_fabro")) + .parent() + .expect("binary directory"); + for kind in ["host", "docker", "daytona"] { + let executable = format!("sandbox-driver-{kind}"); + for directory in env::split_paths(path).chain([binary_dir.to_path_buf()]) { + assert!( + !directory.join(&executable).exists(), + "test requires no {executable} in {}", + directory.display() + ); + } + } + let server = RunningServer::start_with_env("", &[], &[ + (EnvVars::PATH, path), + ( + "PETRI_SANDBOX_HOST_PLUGIN", + "/nonexistent/sandbox-driver-host", + ), + ("PETRI_SANDBOX_HOST_SHA256", "invalid-pin"), + (EnvVars::PETRI_SANDBOX_PLUGIN_DEV, "0"), + ]) + .await; + let workspace = write_petri_workspace(&context, "echo built-in > built-in.txt"); + let run_id = run_detached(&context, &server, &workspace); + wait_for_success(&server, &run_id).await; + let run_dir = server.petri_run_dir(&run_id); + let scopes = run_dir.join("scopes"); + let entries = std::fs::read_dir(&scopes) + .expect("the real Host scope exists") + .map(|entry| entry.expect("the scope entry reads").path()) + .collect::>(); + let [scope] = entries.as_slice() else { + panic!("expected exactly one Host scope, found {entries:?}"); + }; + let scope = scope.clone(); + assert_eq!( + std::fs::read_to_string(scope.join("work/built-in.txt")) + .expect("the worker wrote its file"), + "built-in\n" + ); + let response = fabro_test::test_http_client() + .delete(format!("{}/api/v1/runs/{run_id}", server.api_base_url)) + .bearer_auth(TEST_DEV_TOKEN) + .send() + .await + .expect("the delete sends"); + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + assert_eq!( + status, + fabro_http::StatusCode::NO_CONTENT, + "prune failed: {detail}" + ); + assert!(!scope.exists(), "prune removed the managed Host workspace"); + server.shutdown(); +} diff --git a/lib/apps/fabro-cli/tests/it/scenario/petri_controls.rs b/lib/apps/fabro-cli/tests/it/scenario/petri_controls.rs index 79d2cab23..587d1b407 100644 --- a/lib/apps/fabro-cli/tests/it/scenario/petri_controls.rs +++ b/lib/apps/fabro-cli/tests/it/scenario/petri_controls.rs @@ -15,9 +15,8 @@ //! the worker never answers (a test hook mutes the worker's answers). //! //! The harness is `petri.rs`'s: a foreground server on disk storage, the -//! run started with `fabro run --detach`, and the host scope through the -//! sandbox-driver host plugin, so the tests skip, and say why, when the -//! plugin is not found. +//! run started with `fabro run --detach`, and the Host scope running in +//! process. #![expect( clippy::disallowed_methods, @@ -37,9 +36,9 @@ use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, test_context, twin_o use serde_json::{Value, json}; use super::petri::{ - RunningServer, answer, count_of, host_plugin, run_detached, run_detached_with, run_json, - run_status, run_stream, settled_stream, stream_names, wait_for_questions, wait_for_status, - wait_for_worker, wait_until_gate_is_polled, write_petri_workflow, + RunningServer, answer, count_of, run_detached, run_detached_with, run_json, run_status, + run_stream, settled_stream, stream_names, wait_for_questions, wait_for_status, wait_for_worker, + wait_until_gate_is_polled, write_petri_workflow, }; use crate::support::TEST_DEV_TOKEN; @@ -331,9 +330,6 @@ async fn assert_petri_succeeded(server: &RunningServer, run_id: &str) { /// both carry the pause and the unpause. #[tokio::test(flavor = "multi_thread")] async fn a_pause_holds_the_next_stage_until_the_unpause() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let gate = context.temp_dir.join("a.gate"); @@ -405,9 +401,6 @@ async fn a_pause_holds_the_next_stage_until_the_unpause() { /// no control request is recorded, since none went through the API. #[tokio::test(flavor = "multi_thread")] async fn the_user_signals_pause_and_unpause_the_worker() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let gate = context.temp_dir.join("a.gate"); @@ -482,9 +475,6 @@ async fn the_user_signals_pause_and_unpause_the_worker() { /// stream carries the `control.requested` record, and the run succeeds. #[tokio::test(flavor = "multi_thread")] async fn a_steer_reaches_the_agent_stage_on_the_twin() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let twin = twin_openai().await; let namespace = format!("{}::{}", module_path!(), line!()); @@ -614,9 +604,6 @@ async fn a_steer_reaches_the_agent_stage_on_the_twin() { /// reaches that stage's session and no other. #[tokio::test(flavor = "multi_thread")] async fn two_live_agent_stages_are_steered_apart_by_their_labels() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let twin = twin_openai().await; let namespace = format!("{}::{}", module_path!(), line!()); @@ -776,9 +763,6 @@ async fn two_live_agent_stages_are_steered_apart_by_their_labels() { /// `attractor.turn.interrupted` report, and the run succeeds. #[tokio::test(flavor = "multi_thread")] async fn an_interrupt_ends_the_turn_and_its_text_is_the_next_input() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let twin = twin_openai().await; let namespace = format!("{}::{}", module_path!(), line!()); @@ -912,9 +896,6 @@ const UNNAMED_INTERRUPT_REFUSAL: &str = /// answer routes the run to its end. #[tokio::test(flavor = "multi_thread")] async fn an_interrupt_of_a_gate_stage_is_refused_with_no_live_turn() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let marker = context.temp_dir.join("yes.marker"); @@ -993,9 +974,6 @@ async fn an_interrupt_of_a_gate_stage_is_refused_with_no_live_turn() { /// muted through the server's test hook, forwarded to the worker by name. #[tokio::test(flavor = "multi_thread")] async fn a_control_the_worker_never_answers_is_pending() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start_with_env("", &[], &[(EnvVars::FABRO_TEST_CONTROL_ACKS_MUTED, "1")]) @@ -1048,9 +1026,6 @@ async fn a_control_the_worker_never_answers_is_pending() { /// without a new admission: a pause holds admission, never running work.) #[tokio::test(flavor = "multi_thread")] async fn a_run_paused_before_a_crash_resumes_paused() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let mut server = RunningServer::start().await; let gate = context.temp_dir.join("a.gate"); diff --git a/lib/apps/fabro-cli/tests/it/scenario/petri_docker.rs b/lib/apps/fabro-cli/tests/it/scenario/petri_docker.rs index f890e51a8..91db4d090 100644 --- a/lib/apps/fabro-cli/tests/it/scenario/petri_docker.rs +++ b/lib/apps/fabro-cli/tests/it/scenario/petri_docker.rs @@ -9,17 +9,14 @@ //! Petri created and reads a file the workflow wrote there, its model the //! twin. //! -//! The runs take their scope through the sandbox-driver Docker plugin on -//! this machine's daemon, so the tests skip, and say why, when the -//! executable is not found or no daemon answers, unless -//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set and the plugin is missing. The -//! server, the detached run and the crash come from `petri.rs`. +//! The runs use the built-in Docker provider on this machine's daemon. +//! Tests skip when no daemon answers, unless `FABRO_REQUIRE_SANDBOX_BACKENDS` +//! requires it. The server, detached run and crash come from `petri.rs`. #![expect( clippy::disallowed_methods, - reason = "these scenarios locate the plugin through the process environment and drive the Docker daemon with its CLI" + reason = "these scenarios inspect backend availability and drive the Docker daemon with its CLI" )] -#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")] use std::env; use std::path::{Path, PathBuf}; @@ -33,53 +30,16 @@ use fabro_test::{ use serde_json::json; use super::petri::{ - REQUIRE_ENV, RunningServer, crash, run_detached_in, wait_for_status, wait_for_success, - wait_for_worker, write_petri_workflow, + RunningServer, crash, run_detached_in, wait_for_status, wait_for_success, wait_for_worker, + write_petri_workflow, }; use crate::support::TEST_DEV_TOKEN; -const DOCKER_PLUGIN: &str = "sandbox-driver-docker"; /// The server-side environment the runs select. const ENVIRONMENT: &str = "docker"; /// The twin's model, for the Ask Fabro session. const MODEL: &str = "gpt-5.4"; -/// The Docker plugin as Petri's lookup finds it, with a daemon that -/// answers. `None`, after saying so, when the test should skip; a panic -/// when the environment forbids a skip and the plugin is missing. -fn docker_plugin() -> Option { - let found = env::var_os(EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os(EnvVars::PATH)?) - .map(|dir| dir.join(DOCKER_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - let Some(found) = found else { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {DOCKER_PLUGIN} is not on PATH and {} is unset", - EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN - ); - eprintln!( - "skipping: {DOCKER_PLUGIN} is not on PATH and {} is unset", - EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN - ); - return None; - }; - let daemon = Command::new("docker") - .args(["version", "--format", "{{.Server.Version}}"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()); - if !daemon { - eprintln!("skipping: no Docker daemon answers"); - return None; - } - Some(found) -} - /// A server with a Docker environment beside the default local one. async fn docker_server() -> RunningServer { docker_server_with("", &[]).await @@ -294,7 +254,7 @@ fn restore_actions(server: &RunningServer, run_id: &str) -> Vec { /// nothing of the workspace is on the host. #[tokio::test(flavor = "multi_thread")] async fn a_docker_run_publishes_every_stages_checkpoint_from_the_container() { - if docker_plugin().is_none() { + if !fabro_test::docker_available() { return; } let context = test_context!(); @@ -325,7 +285,7 @@ async fn a_docker_run_publishes_every_stages_checkpoint_from_the_container() { /// the second stage sees the first stage's files and nothing else. #[tokio::test(flavor = "multi_thread")] async fn a_retained_container_whose_workspace_drifted_is_reset_on_restart() { - if docker_plugin().is_none() { + if !fabro_test::docker_available() { return; } let context = test_context!(); @@ -370,7 +330,7 @@ async fn a_retained_container_whose_workspace_drifted_is_reset_on_restart() { /// repository, and the second stage sees the first stage's files. #[tokio::test(flavor = "multi_thread")] async fn a_lost_container_is_replaced_and_its_workspace_restored_from_the_snapshot() { - if docker_plugin().is_none() { + if !fabro_test::docker_available() { return; } let context = test_context!(); @@ -463,7 +423,7 @@ async fn question_inputs(twin: &fabro_test::TwinOpenAi, namespace: &str) -> Vec< /// follow-up request carries the file's content back as the tool's answer. #[tokio::test(flavor = "multi_thread")] async fn an_ask_fabro_turn_reads_a_file_inside_the_runs_container() { - if docker_plugin().is_none() { + if !fabro_test::docker_available() { return; } let context = test_context!(); diff --git a/lib/apps/fabro-cli/tests/it/scenario/petri_fork.rs b/lib/apps/fabro-cli/tests/it/scenario/petri_fork.rs index 44ca3bf89..b1b8ab053 100644 --- a/lib/apps/fabro-cli/tests/it/scenario/petri_fork.rs +++ b/lib/apps/fabro-cli/tests/it/scenario/petri_fork.rs @@ -1,5 +1,5 @@ //! Fork, rewind, retry and the timeline over Petri runs, through a real -//! server and its worker subprocess (the integration plan's F5.1). +//! server and its worker subprocess. //! //! The harness is `petri.rs`'s: a foreground server on disk storage, a run //! created and started with `fabro run --detach`, executed by the worker @@ -20,8 +20,7 @@ use std::process::{Command, Output}; use fabro_test::test_context; use super::petri::{ - RunningServer, host_plugin, run_detached, run_json, wait_for_status, wait_for_success, - write_petri_workflow, + RunningServer, run_detached, run_json, wait_for_status, wait_for_success, write_petri_workflow, }; /// Three command stages that build on each other's files: `one` writes a @@ -178,9 +177,6 @@ fn read(workspace: &Path, name: &str) -> String { /// new run says where it came from. #[tokio::test(flavor = "multi_thread")] async fn a_fork_at_the_first_stage_continues_with_the_rest_on_its_files() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let bundle = write_petri_workflow(&context, &three_stage_dot()); @@ -260,9 +256,6 @@ async fn a_fork_at_the_first_stage_continues_with_the_rest_on_its_files() { /// and finishes the run. #[tokio::test(flavor = "multi_thread")] async fn a_retry_reruns_the_failed_stage_and_succeeds_when_the_failure_was_transient() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let marker = context.temp_dir.join("flaky.marker"); @@ -316,9 +309,6 @@ async fn a_retry_reruns_the_failed_stage_and_succeeds_when_the_failure_was_trans /// names the run that replaced it. #[tokio::test(flavor = "multi_thread")] async fn a_rewind_supersedes_its_source() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let bundle = write_petri_workflow(&context, &three_stage_dot()); @@ -381,9 +371,6 @@ async fn a_rewind_supersedes_its_source() { /// its position and commit, as the CLI prints it and as the API serves it. #[tokio::test(flavor = "multi_thread")] async fn the_timeline_lists_every_checkpoint_with_its_commit() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let bundle = write_petri_workflow(&context, &three_stage_dot()); @@ -453,9 +440,6 @@ async fn the_timeline_lists_every_checkpoint_with_its_commit() { /// refuses it, and the refusal says why. The join, in the root, is. #[tokio::test(flavor = "multi_thread")] async fn a_fork_inside_a_parallel_branch_is_refused() { - if host_plugin().is_none() { - return; - } let context = test_context!(); let server = RunningServer::start().await; let bundle = write_petri_workflow(&context, ¶llel_dot()); diff --git a/lib/apps/fabro-cli/tests/it/scenario/petri_tools.rs b/lib/apps/fabro-cli/tests/it/scenario/petri_tools.rs index 3c396905d..67c0b2592 100644 --- a/lib/apps/fabro-cli/tests/it/scenario/petri_tools.rs +++ b/lib/apps/fabro-cli/tests/it/scenario/petri_tools.rs @@ -1,4 +1,4 @@ -//! Fabro's run tools inside a Petri run (integration plan item F3.4): a +//! Fabro's run tools inside a Petri run: a //! workflow that enables `[run.agent] fabro_tools` runs on Petri in the //! worker the server launched, and the agent stage's model, the twin, calls //! the run tools the worker registered through Petri's host tool @@ -9,9 +9,7 @@ //! //! The harness is `petri.rs`'s: a foreground server on disk storage with //! the `openai` provider repointed at the twin, its key in the vault, and -//! the run started with `fabro run --detach`. The runs take their host -//! scope through the sandbox-driver host plugin, so the tests skip, and say -//! why, when it is not found. +//! the run started with `fabro run --detach`. Host scopes run in process. #![expect( clippy::disallowed_methods, @@ -33,7 +31,7 @@ use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, test_context, twin_o use fabro_types::{WorkflowPath, WorkflowVersion}; use serde_json::{Value, json}; -use super::petri::{RunningServer, host_plugin, run_detached_with, run_json, wait_for_status}; +use super::petri::{RunningServer, run_detached_with, run_json, wait_for_status}; use crate::support::TEST_DEV_TOKEN; const MODEL: &str = "gpt-5.4"; @@ -227,9 +225,6 @@ async fn wait_for_children(server: &RunningServer, parent_id: &str) -> Vec sandbox-driver-host sandbox-driver-docker`. +//! Petri uses the built-in Docker provider; no plugin executable is needed. //! A scenario configured here runs against its own server so the environment //! it creates never leaks 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" + reason = "a failed setup reads the isolated server's log synchronously" )] #![expect( clippy::print_stderr, - reason = "a skipped scenario says why on the test's stderr" + reason = "a failed setup prints the server log tail on the test's stderr" )] -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::path::Path; 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"; -const DOCKER_PLUGIN: &str = "sandbox-driver-docker"; /// The environment id the scenario selects with `--environment`. pub(crate) const ENVIRONMENT: &str = "docker"; @@ -38,24 +29,7 @@ pub(crate) const ENVIRONMENT: &str = "docker"; /// [`DOCKER_IMAGE`]. Returns the environment id, or `None` when the /// prerequisites are missing and the test should skip. pub(crate) fn configure(context: &mut TestContext) -> Option<&'static str> { - let required = std::env::var_os(REQUIRE_ENV).is_some(); - if plugin_executable().is_none() { - assert!( - !required, - "{REQUIRE_ENV} is set but {DOCKER_PLUGIN} is not on PATH" - ); - eprintln!( - "skipping: {DOCKER_PLUGIN} is not on PATH; install the sandbox-driver executables at \ - the rev Cargo.toml pins" - ); - return None; - } - if !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}"); + if !fabro_test::docker_image_available(DOCKER_IMAGE) { return None; } @@ -75,23 +49,6 @@ methods = ["dev-token"] Some(ENVIRONMENT) } -/// The Docker plugin executable on `PATH`, when installed. -fn plugin_executable() -> Option { - let path = std::env::var_os("PATH")?; - std::env::split_paths(&path) - .map(|dir| dir.join(DOCKER_PLUGIN)) - .find(|candidate| candidate.is_file()) -} - -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('\\', "/") } @@ -133,7 +90,7 @@ fn create_environment(storage_dir: &Path) { } /// 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 +/// the worker's stderr (and so a sandbox provider 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)); diff --git a/lib/apps/fabro-cli/tests/it/workflow/mod.rs b/lib/apps/fabro-cli/tests/it/workflow/mod.rs index 75302e425..4eb69ed23 100644 --- a/lib/apps/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/apps/fabro-cli/tests/it/workflow/mod.rs @@ -178,10 +178,9 @@ fn run_stream_items(run_dir: &Path) -> Vec { /// - `docker`: the Docker provider, an environment on `buildpack-deps:noble` /// created on an isolated server. /// -/// Petri serves each provider through the matching sandbox-driver plugin -/// executable on `PATH`. The `docker` variant skips without -/// `sandbox-driver-docker` or without a Docker daemon that has the image, -/// unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set, as CI sets it. +/// The built-in providers run in process. The `docker` variant skips when +/// no Docker daemon has the required image, unless CI requires the backend +/// with `FABRO_REQUIRE_SANDBOX_BACKENDS`. macro_rules! sandbox_tests { ($name:ident) => { sandbox_tests!($name, keys = []); diff --git a/lib/apps/fabro-server/Cargo.toml b/lib/apps/fabro-server/Cargo.toml index 2e1b9fd7b..8a4538203 100644 --- a/lib/apps/fabro-server/Cargo.toml +++ b/lib/apps/fabro-server/Cargo.toml @@ -35,8 +35,6 @@ fabro-workflow = { path = "../../components/fabro-workflow" } fabro-workflow-version = { path = "../../components/fabro-workflow-version" } sandbox-driver.workspace = true sandbox-driver-host.workspace = true -sandbox-driver-docker.workspace = true -sandbox-driver-daytona.workspace = true sandbox-driver-protocol.workspace = true fabro-github = { path = "../../components/fabro-github" } pebble-agent.workspace = true diff --git a/lib/apps/fabro-server/src/manifest_validation.rs b/lib/apps/fabro-server/src/manifest_validation.rs index 67f17aa35..04e1cd4e8 100644 --- a/lib/apps/fabro-server/src/manifest_validation.rs +++ b/lib/apps/fabro-server/src/manifest_validation.rs @@ -5,6 +5,7 @@ use anyhow::{Result, anyhow}; use fabro_api::types; use fabro_config::{RunLayer, SettingsLayer, WorkflowSettingsBuilder, project}; use fabro_manifest::CollectedWorkflowClosure; +use fabro_petri::providers::SandboxProviderConfig; use fabro_petri::run_graph; use fabro_petri::runtime::RuntimeSpec; @@ -55,6 +56,7 @@ fn offline_runtime(run: Option<&RunLayer>) -> RuntimeSpec { ..SettingsLayer::default() }; RuntimeSpec { + sandbox: SandboxProviderConfig::default(), settings_toml: toml::to_string(&layer).ok(), mcp_catalog_toml: None, model_client: None, diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 347a569df..d1b4ad105 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -233,7 +233,12 @@ pub(crate) async fn check_prepared_manifest( None, ); let dry_run = prepared.settings.run.execution.mode == RunMode::DryRun; - let runtime = petri_runs::runtime_spec(state, ready_providers, dry_run); + let runtime = petri_runs::runtime_spec( + state, + ready_providers, + dry_run, + state.sandbox_provider_config(None), + ); let has_ready_provider = !ready_providers.is_empty(); let prepared = prepared.clone(); task::spawn_blocking(move || { @@ -1530,7 +1535,12 @@ mod tests { None, None, ); - let runtime = crate::server::petri_runs::runtime_spec(state, ready_providers, false); + let runtime = crate::server::petri_runs::runtime_spec( + state, + ready_providers, + false, + state.sandbox_provider_config(None), + ); validate_prepared_manifest( prepared, &HashMap::new(), diff --git a/lib/apps/fabro-server/src/sandbox_access.rs b/lib/apps/fabro-server/src/sandbox_access.rs index d77abb6da..9c07adcaf 100644 --- a/lib/apps/fabro-server/src/sandbox_access.rs +++ b/lib/apps/fabro-server/src/sandbox_access.rs @@ -21,7 +21,7 @@ //! Credentials arrive explicitly. Nothing here reads the process //! environment for a secret: the Daytona key comes from the vault through //! [`DaytonaCredentials`]. The Docker client resolves its endpoint from the -//! same variables Petri forwards to its Docker plugin (`DOCKER_HOST` and +//! same variables Fabro forwards to its worker (`DOCKER_HOST` and //! its TLS companions), so the server and the run's containers meet on one //! daemon. @@ -32,7 +32,8 @@ use std::time::Duration; use anyhow::Context as _; use fabro_config::Storage; -use fabro_static::EnvVars; +use fabro_petri::providers; +pub(crate) use fabro_petri::providers::DaytonaCredentials; use fabro_types::settings::server::{ SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings, }; @@ -46,8 +47,6 @@ use sandbox_driver::{ Sandbox, SandboxFilter, SandboxId, SandboxProvider, SandboxSource, SandboxSpec, SandboxState, WaitOptions, }; -use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider}; -use sandbox_driver_docker::DockerProvider; use sandbox_driver_host::HostProvider; use sandbox_driver_protocol::{PluginConfig, PluginSupervisor}; use tokio::sync::OnceCell; @@ -63,78 +62,9 @@ pub(crate) const PETRI_RUN_LABEL: &str = "petri.run"; /// it up under the same name. const PLUGIN_BINARY_PREFIX: &str = "sandbox-driver"; -/// `User-Agent` Fabro presents to remote sandbox control planes. -const USER_AGENT: &str = concat!("fabro-server/", env!("CARGO_PKG_VERSION")); - /// Budget for the credential probe `fabro doctor` and the install flow run. pub(crate) const DAYTONA_CREDENTIAL_PROBE_TIMEOUT: Duration = Duration::from_secs(20); -/// Explicit Daytona credentials: the SDK's configuration with the API key -/// always present and a `Debug` that never prints it. The process -/// environment is never consulted. -#[derive(Clone)] -pub(crate) struct DaytonaCredentials(DaytonaConfig); - -impl DaytonaCredentials { - /// Credentials for `api_key` against Daytona's public control plane, - /// presenting Fabro's `User-Agent`. - #[must_use] - pub(crate) fn new(api_key: String) -> Self { - Self(DaytonaConfig { - api_key: Some(api_key), - user_agent: Some(USER_AGENT.to_string()), - ..DaytonaConfig::default() - }) - } - - /// Credentials for a vault API key, with the control-plane URL and - /// organization taken from `lookup` (server configuration). Nothing is - /// read implicitly. - pub(crate) fn from_api_key(api_key: String, lookup: impl Fn(&str) -> Option) -> Self { - Self::new(api_key) - .with_api_url( - lookup(EnvVars::DAYTONA_API_URL).or_else(|| lookup(EnvVars::DAYTONA_SERVER_URL)), - ) - .with_organization_id(lookup(EnvVars::DAYTONA_ORGANIZATION_ID)) - } - - /// The control-plane URL; Daytona's public API when `None`. - #[must_use] - pub(crate) fn with_api_url(mut self, api_url: Option) -> Self { - self.0.api_url = api_url; - self - } - - #[must_use] - pub(crate) fn with_organization_id(mut self, organization_id: Option) -> Self { - self.0.organization_id = organization_id; - self - } - - /// A shared HTTP client; tests pass a no-proxy client here. - #[must_use] - pub(crate) fn with_http_client(mut self, http_client: Option) -> Self { - self.0.http_client = http_client; - self - } - - /// The SDK configuration the driver's Daytona provider connects with. - #[must_use] - fn config(&self) -> &DaytonaConfig { - &self.0 - } -} - -impl std::fmt::Debug for DaytonaCredentials { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DaytonaCredentials") - .field("api_url", &self.0.api_url) - .field("organization_id", &self.0.organization_id) - .field("target", &self.0.target) - .finish_non_exhaustive() - } -} - /// What the server needs to reach every provider a run record can name: /// its provider settings (which kinds are enabled, which run as plugins), /// the Daytona credentials from the vault, and the storage root under @@ -214,7 +144,7 @@ pub(crate) enum ConnectError { /// a run's host sandbox is reached through the run's own registry by /// [`attach_run_sandbox`]. /// `docker` connects to the daemon the process environment names, the -/// same variables Petri hands its Docker plugin, without requiring the +/// same variables Fabro forwards to its worker, without requiring the /// daemon to answer: `health` reports an unreachable daemon so preflight /// and the doctor see the cause. `daytona` needs the vault key. Any other /// kind launches the plugin executable its settings name and supervises @@ -236,19 +166,15 @@ pub(crate) async fn connect_provider( }; Ok(match kind.bundled() { Some(BundledProvider::Local) => Arc::new(HostProvider::new()), - Some(BundledProvider::Docker) => { - Arc::new(DockerProvider::connect_unverified().map_err(driver)?) - } + Some(BundledProvider::Docker) => providers::connect_docker().map_err(driver)?, Some(BundledProvider::Daytona) => { let credentials = access .daytona .as_ref() .ok_or(ConnectError::MissingDaytonaCredentials)?; - Arc::new( - DaytonaProvider::connect_explicit(credentials.config().clone()) - .await - .map_err(driver)?, - ) + providers::connect_daytona(credentials) + .await + .map_err(driver)? } None => { let plugin = settings @@ -1241,22 +1167,6 @@ mod tests { assert_eq!(config.inherit_env, vec!["PATH"]); } - #[test] - fn daytona_credentials_debug_never_prints_the_key() { - let credentials = DaytonaCredentials::from_api_key("dtn_secret_key".to_string(), |name| { - (name == EnvVars::DAYTONA_ORGANIZATION_ID).then(|| "org-1".to_string()) - }); - // The rendering stays out of the assertion messages: a failure must - // not print the key it is checking for. - let rendered = format!("{credentials:?}"); - assert!(!rendered.contains("dtn_secret_key")); - assert!(rendered.contains("org-1")); - assert_eq!( - credentials.config().api_key.as_deref(), - Some("dtn_secret_key") - ); - } - #[test] fn missing_scopes_render_as_the_provider_reports_them() { let check = DaytonaKeyCheck { diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index e7f5860b3..2a6046844 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -63,6 +63,7 @@ use fabro_llm::{ClientOptions, FabroClient}; use fabro_mcp_store::McpServerStore; use fabro_petri::controls::{RunControls, SteerError}; use fabro_petri::projector::Projector; +use fabro_petri::providers::SandboxProviderConfig; use fabro_petri::prune::{self, PruneError, PruneRequest}; use fabro_redact::redact_jsonl_line; use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient}; @@ -150,7 +151,7 @@ use crate::sandbox_access::{ SandboxInventory, }; use crate::server_secrets::ServerSecrets; -use crate::spawn_env::{self, apply_render_graph_env}; +use crate::spawn_env::apply_render_graph_env; use crate::worker_control::{ LocalWorkerControlBus, WORKER_CONTROL_ACK_WAIT, WorkerControlAcks, WorkerControlBus, WorkerControlBusError, @@ -1545,6 +1546,33 @@ impl AppState { .with_http_client(self.http_client().ok()) } + /// The same provider selection for execution, fork and prune; credentials + /// arrive from the caller's vault read, never the process environment. + pub(crate) fn sandbox_provider_config( + &self, + daytona_api_key: Option, + ) -> SandboxProviderConfig { + SandboxProviderConfig::from_lookup( + daytona_api_key.map(|key| self.daytona_credentials(key)), + |name| self.config_env_lookup(name), + ) + } + + /// [`Self::sandbox_provider_config`] for a server-side prune of + /// `provider`'s sandboxes, with the Daytona key read from the vault only + /// when `provider` is Daytona. + pub(crate) async fn load_sandbox_provider_config( + &self, + provider: &SandboxProviderKind, + ) -> Result { + let daytona_api_key = if *provider == SandboxProviderKind::DAYTONA { + self.vault_secret(EnvVars::DAYTONA_API_KEY).await? + } else { + None + }; + Ok(self.sandbox_provider_config(daytona_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). @@ -2875,7 +2903,29 @@ async fn delete_run_sandbox_resource( .run_scratch(&id) .root() .join("petri"); + let sandbox = match state.load_sandbox_provider_config(&record.provider).await { + Ok(sandbox) => sandbox, + // A forced or restarted delete goes on without the sandboxes, as it + // does for any other prune failure below. + Err(err) if force || delete_started => { + tracing::warn!( + run_id = %id, + provider = %record.provider, + error = ?err, + "Skipping the sandbox prune after loading sandbox credentials failed during run deletion" + ); + return Ok(SandboxDeleteOutcome::Cleaned); + } + Err(err) => { + error!(error = ?err, "Loading sandbox credentials failed"); + return Err(ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "secret store operation failed", + )); + } + }; let report = prune::prune(PruneRequest { + sandbox, run_id: id.to_string(), run_dir, store: state.petri_runs.shared_store(), @@ -3777,7 +3827,6 @@ fn worker_launch_spec( run_dir: &std::path::Path, agent_fabro_tools_enabled: bool, github_app_private_key: Option, - daytona_api_key: Option, ) -> anyhow::Result { let current_exe = std::env::current_exe().context("reading current executable path")?; let executable = @@ -3816,11 +3865,7 @@ fn worker_launch_spec( fabro_log, active_config_path: state.active_config_path().to_path_buf(), github_app_private_key, - daytona_api_key, fabro_home: fabro_config::Home::from_env().root().to_path_buf(), - sandbox_plugin_env: spawn_env::sandbox_plugin_env( - &state.server_settings().server.sandbox.providers, - ), }) } @@ -4137,21 +4182,9 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { return; } - // A Daytona run's worker hands the vault's key to Petri's Daytona - // plugin through its own environment; any other run's worker never - // sees it. - let wants_daytona = - run_state.spec.settings.run.environment.provider == SandboxProviderKind::DAYTONA; - let secrets = async { - let github_app_private_key = state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY).await?; - let daytona_api_key = if wants_daytona { - state.vault_secret(EnvVars::DAYTONA_API_KEY).await? - } else { - None - }; - Ok::<_, SecretStoreError>((github_app_private_key, daytona_api_key)) - }; - let (github_app_private_key, daytona_api_key) = match secrets.await { + // The worker reads the Daytona key from the vault itself; only the + // GitHub App key crosses on its command. + let github_app_private_key = match state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY).await { Ok(value) => value, Err(err) => { fail_run_before_execution( @@ -4175,7 +4208,6 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { &run_dir_for_build, agent_fabro_tools_enabled, github_app_private_key, - daytona_api_key, ) }) .await diff --git a/lib/apps/fabro-server/src/server/handler/lineage.rs b/lib/apps/fabro-server/src/server/handler/lineage.rs index c640165a1..05fde2318 100644 --- a/lib/apps/fabro-server/src/server/handler/lineage.rs +++ b/lib/apps/fabro-server/src/server/handler/lineage.rs @@ -1,5 +1,5 @@ //! A run's checkpoint timeline, and the runs made from it: fork, rewind and -//! retry (the integration plan's F5.1). +//! retry. //! //! The timeline is the run's `checkpoint` platform records, labelled with //! the stages the projector folded them onto. A fork resolves a target on diff --git a/lib/apps/fabro-server/src/server/petri_runs.rs b/lib/apps/fabro-server/src/server/petri_runs.rs index 5a54f187a..db8954e86 100644 --- a/lib/apps/fabro-server/src/server/petri_runs.rs +++ b/lib/apps/fabro-server/src/server/petri_runs.rs @@ -46,10 +46,12 @@ use fabro_petri::hooks::HooksSpec; use fabro_petri::interview::{Approval, FabroInterviewer}; use fabro_petri::petri::{Access, Digest, LogId, Record, RunKey, RunLogs, RunStore, StoreError}; use fabro_petri::platform_records::SqlitePlatformRecords; +use fabro_petri::providers::SandboxProviderConfig; use fabro_petri::recovery::{self, Recovery, RecoveryRequest}; use fabro_petri::runtime::{self, RuntimeSpec}; use fabro_petri::secrets::VaultSecrets; use fabro_petri::{SqliteRunStore, admission, projection, run_graph}; +use fabro_static::EnvVars; use fabro_store::platform_records::{RunLifecycleKind, RunLifecycleRecord}; use fabro_types::settings::McpTransport; use fabro_types::settings::run::{ApprovalMode, McpServerSettings, RunMode}; @@ -72,11 +74,12 @@ use crate::run_compiler::{AdmittedRun, PreparedRun, RunCompilerError}; /// The runtime Petri gets, at create and at execution: the server's run /// defaults and environment catalog as the settings layer, the MCP /// catalog, the model client over the server's catalog and credentials for -/// the eligible providers, and the run mode. +/// the eligible providers, the sandbox providers, and the run mode. pub(crate) fn runtime_spec( state: &AppState, eligible: &[ProviderId], dry_run: bool, + sandbox: SandboxProviderConfig, ) -> RuntimeSpec { let settings_toml = settings_layer_toml(state); let mcp_catalog_toml = mcp_catalog_toml(&state.mcp_server_store().catalog_settings()); @@ -94,6 +97,7 @@ pub(crate) fn runtime_spec( } }; RuntimeSpec { + sandbox, settings_toml, mcp_catalog_toml, model_client, @@ -274,7 +278,12 @@ pub(crate) async fn admit( settings, prepared.vars(), launch, - runtime_spec(state, eligible, dry_run), + runtime_spec( + state, + eligible, + dry_run, + state.sandbox_provider_config(None), + ), false, ) .map_err(RunCompilerError::Workflow)?; @@ -456,6 +465,12 @@ pub(crate) async fn execute(state: Arc, run_id: RunId) { ))), &run_state.spec.settings.run, ); + let runtime = runtime_spec( + &state, + &eligible, + dry_run, + state.sandbox_provider_config(vault.get(EnvVars::DAYTONA_API_KEY).map(str::to_owned)), + ); let request = RunRequest { run_id: run_id.to_string(), run_dir: run_dir.join("petri"), @@ -468,7 +483,7 @@ pub(crate) async fn execute(state: Arc, run_id: RunId) { .observe_store(Arc::new(SqliteRunStore::new(state.db_pool.clone()))), state: Arc::clone(&state), }), - runtime: runtime_spec(&state, &eligible, dry_run), + runtime, provider: run_state.spec.settings.run.environment.provider.clone(), cancel, // The in-process test path drives no pause: the server's transport diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 94a5ab8db..30c0af478 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -2274,7 +2274,6 @@ fn worker_command_forwards_github_app_private_key_from_vault() { storage_dir.path(), false, Some("test-private-key".to_string()), - None, ) .unwrap(); let cmd = LocalWorkerRuntime::command_for_spec(&spec); @@ -2289,74 +2288,6 @@ fn worker_command_forwards_github_app_private_key_from_vault() { ); } -/// A Daytona run's worker carries the vault's key for Petri's Daytona -/// plugin. -#[cfg(unix)] -#[test] -fn worker_command_forwards_daytona_api_key_from_vault() { - let storage_dir = tempfile::tempdir().unwrap(); - let state = worker_command_test_state(storage_dir.path(), &["dev-token"], Some(TEST_DEV_TOKEN)); - let spec = worker_launch_spec( - state.as_ref(), - RunId::new(), - RunExecutionMode::Start, - storage_dir.path(), - false, - None, - Some("dtn_test-key".to_string()), - ) - .unwrap(); - let cmd = LocalWorkerRuntime::command_for_spec(&spec); - - assert_eq!( - command_env_value(&cmd, EnvVars::DAYTONA_API_KEY), - EnvOverride::Set("dtn_test-key".to_string()) - ); -} - -/// A plugin configured under `[server.sandbox.providers.]` reaches -/// the worker under the names Petri reads, so a run on that kind finds its -/// plugin without a second configuration. -#[cfg(unix)] -#[test] -fn worker_command_forwards_configured_sandbox_plugins() { - let storage_dir = tempfile::tempdir().unwrap(); - let state = worker_command_test_state_with_extra_config( - storage_dir.path(), - &["dev-token"], - Some(TEST_DEV_TOKEN), - r#" -[server.sandbox.providers.e2b] -path = "/opt/fabro/plugins/sandbox-driver-e2b" -sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" -dev = true -"#, - ); - let cmd = worker_command( - state.as_ref(), - RunId::new(), - RunExecutionMode::Start, - storage_dir.path(), - false, - ) - .unwrap(); - - assert_eq!( - command_env_value(&cmd, "PETRI_SANDBOX_E2B_PLUGIN"), - EnvOverride::Set("/opt/fabro/plugins/sandbox-driver-e2b".to_string()) - ); - assert_eq!( - command_env_value(&cmd, "PETRI_SANDBOX_E2B_SHA256"), - EnvOverride::Set( - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string() - ) - ); - assert_eq!( - command_env_value(&cmd, EnvVars::PETRI_SANDBOX_PLUGIN_DEV), - EnvOverride::Set("1".to_string()) - ); -} - #[cfg(unix)] #[test] fn worker_command_omits_github_app_private_key_when_unset() { @@ -2559,7 +2490,6 @@ fn worker_command( run_dir, agent_fabro_tools_enabled, None, - None, )?; Ok(LocalWorkerRuntime::command_for_spec(&spec)) } diff --git a/lib/apps/fabro-server/src/spawn_env.rs b/lib/apps/fabro-server/src/spawn_env.rs index 1ed46a372..333a48723 100644 --- a/lib/apps/fabro-server/src/spawn_env.rs +++ b/lib/apps/fabro-server/src/spawn_env.rs @@ -1,7 +1,6 @@ use std::ffi::OsString; use fabro_static::EnvVars; -use fabro_types::settings::server::ServerSandboxProvidersSettings; use tokio::process::Command; const WORKER_ENV_ALLOWLIST: &[&str] = &[ @@ -51,21 +50,11 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[ EnvVars::AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, EnvVars::AWS_CONTAINER_CREDENTIALS_FULL_URI, EnvVars::AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE, - // Petri's sandbox-driver plugins are resolved in the worker, where a - // Petri run executes: the plugin path, checksum and dev-mode overrides - // cross with `PATH`, so the worker finds the plugins the server would. - // A plugin the server's settings configure is set on top of these by - // `sandbox_plugin_env`, for every configured kind. - EnvVars::PETRI_SANDBOX_HOST_PLUGIN, - EnvVars::PETRI_SANDBOX_HOST_SHA256, - EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN, - EnvVars::PETRI_SANDBOX_DOCKER_SHA256, - EnvVars::PETRI_SANDBOX_DAYTONA_PLUGIN, - EnvVars::PETRI_SANDBOX_DAYTONA_SHA256, - EnvVars::PETRI_SANDBOX_PLUGIN_DEV, + // Petri's sandbox settings the worker's in-process providers read. No + // plugin settings cross: the worker never launches a provider plugin. EnvVars::PETRI_SANDBOX_DOCKER_HOST_ADDRESS, EnvVars::PETRI_SANDBOX_ACTION_HOST_IMAGE, - // The Docker daemon selection: the worker's Docker plugin reads these + // The Docker daemon selection: the worker's Docker provider reads these // from its own process, so the worker's sandboxes go to the daemon the // server uses (a remote or TLS daemon, a named context), not the // default socket. @@ -75,9 +64,8 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[ EnvVars::DOCKER_API_VERSION, EnvVars::DOCKER_CONFIG, EnvVars::DOCKER_CONTEXT, - // Daytona's control-plane selection, the non-secret half: the plugin - // reads them from the worker. The API key comes from the vault, set on - // the command by the launch (`WorkerLaunchSpec::daytona_api_key`). + // Daytona's non-secret selection. The worker reads the API key from + // the vault and supplies it explicitly to the in-process provider. EnvVars::DAYTONA_API_URL, EnvVars::DAYTONA_ORGANIZATION_ID, // A test's checkpoint gates: the worker's hooks hold at a named point @@ -90,55 +78,9 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[ const RENDER_GRAPH_ENV_ALLOWLIST: &[&str] = &[EnvVars::PATH, EnvVars::HOME, EnvVars::TMPDIR]; -/// The worker's environment: the allowlisted ambient variables, then the -/// plugin variables the server's settings derive, which win over an -/// ambient variable of the same name. -pub(crate) fn apply_worker_env(cmd: &mut Command, sandbox_plugins: &[(String, String)]) { - apply_worker_env_with(cmd, sandbox_plugins, &process_env_var_os); -} - -fn apply_worker_env_with( - cmd: &mut Command, - sandbox_plugins: &[(String, String)], - lookup: &dyn Fn(&str) -> Option, -) { - apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, lookup); - for (name, value) in sandbox_plugins { - cmd.env(name, value); - } -} - -/// The plugin variables Petri reads in the worker, derived from the -/// server's `[server.sandbox.providers.]` settings: for every enabled -/// kind that carries plugin settings, `PETRI_SANDBOX__PLUGIN` from -/// its `path` and `PETRI_SANDBOX__SHA256` from its `sha256`, and -/// `PETRI_SANDBOX_PLUGIN_DEV=1` when any of them sets `dev`. The kind is -/// uppercased with hyphens as underscores, as Petri names the variable. A -/// kind whose settings name no path is left to Petri's own lookup -/// (`sandbox-driver-` beside the executable, then on `PATH`), the -/// same lookup the server's attach uses. -pub(crate) fn sandbox_plugin_env( - providers: &ServerSandboxProvidersSettings, -) -> Vec<(String, String)> { - let mut env = Vec::new(); - let mut dev = false; - for (kind, plugin) in providers.enabled_plugins() { - let upper = kind.as_str().to_ascii_uppercase().replace('-', "_"); - if let Some(path) = &plugin.path { - env.push((format!("PETRI_SANDBOX_{upper}_PLUGIN"), path.clone())); - } - if let Some(sha256) = &plugin.sha256 { - env.push((format!("PETRI_SANDBOX_{upper}_SHA256"), sha256.clone())); - } - dev |= plugin.dev; - } - if dev { - env.push(( - EnvVars::PETRI_SANDBOX_PLUGIN_DEV.to_string(), - "1".to_string(), - )); - } - env +/// The worker's environment: the allowlisted ambient variables only. +pub(crate) fn apply_worker_env(cmd: &mut Command) { + apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, &process_env_var_os); } pub(crate) fn apply_render_graph_env(cmd: &mut Command) { @@ -168,15 +110,7 @@ mod tests { use std::ffi::OsString; use std::path::Path; - use fabro_types::SandboxProviderKind; - use fabro_types::settings::server::{ - SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings, - }; - - use super::{ - RENDER_GRAPH_ENV_ALLOWLIST, WORKER_ENV_ALLOWLIST, apply_allowlist, apply_worker_env_with, - sandbox_plugin_env, - }; + use super::{RENDER_GRAPH_ENV_ALLOWLIST, WORKER_ENV_ALLOWLIST, apply_allowlist}; fn env_command() -> tokio::process::Command { assert!(Path::new("/usr/bin/env").exists()); @@ -260,6 +194,11 @@ mod tests { "https://daytona.internal/api".to_string(), ), ("DAYTONA_ORGANIZATION_ID".to_string(), "org-1".to_string()), + ( + "DAYTONA_SERVER_URL".to_string(), + "https://daytona-alias.internal/api".to_string(), + ), + ("DAYTONA_TARGET".to_string(), "us".to_string()), ("DAYTONA_API_KEY".to_string(), "leak".to_string()), ]); let mut cmd = env_command(); @@ -295,18 +234,12 @@ mod tests { Some("xterm-256color") ); assert_eq!(actual.get("NO_COLOR").map(String::as_str), Some("1")); - // Petri's plugin overrides cross so the worker resolves the same - // sandbox-driver plugins the server would. - assert_eq!( - actual.get("PETRI_SANDBOX_HOST_PLUGIN").map(String::as_str), - Some("/opt/petri/sandbox-driver-host") - ); - assert_eq!( - actual.get("PETRI_SANDBOX_PLUGIN_DEV").map(String::as_str), - Some("1") - ); + // No plugin setting reaches the worker: it never launches a + // provider plugin. + assert!(!actual.contains_key("PETRI_SANDBOX_HOST_PLUGIN")); + assert!(!actual.contains_key("PETRI_SANDBOX_PLUGIN_DEV")); // The Docker daemon selection crosses whole, so the worker's Docker - // plugin drives the daemon the server uses. + // provider drives the daemon the server uses. assert_eq!( actual.get("DOCKER_HOST").map(String::as_str), Some("tcp://build-daemon.internal:2376") @@ -341,6 +274,11 @@ mod tests { actual.get("DAYTONA_ORGANIZATION_ID").map(String::as_str), Some("org-1") ); + // The URL alias and placement target never reached the Daytona + // plugin, so they stay out and plugin-era leases keep their + // fingerprint. + assert!(!actual.contains_key("DAYTONA_SERVER_URL")); + assert!(!actual.contains_key("DAYTONA_TARGET")); assert!(!actual.contains_key("DAYTONA_API_KEY")); assert_eq!(actual.get("CLICOLOR").map(String::as_str), Some("0")); assert_eq!(actual.get("CLICOLOR_FORCE").map(String::as_str), Some("1")); @@ -380,117 +318,6 @@ mod tests { assert!(!actual.contains_key("MY_API_KEY")); } - fn provider( - kind: &str, - enabled: bool, - plugin: SandboxPluginSettings, - ) -> (SandboxProviderKind, ServerSandboxProviderSettings) { - ( - SandboxProviderKind::try_new(kind).expect("a valid kind"), - ServerSandboxProviderSettings { - enabled, - plugin: Some(plugin), - }, - ) - } - - /// A configured plugin reaches the worker under the names Petri reads, - /// a configured path wins over the ambient variable of the same name, - /// a kind the settings leave to `PATH` keeps the ambient one, and a - /// disabled kind's plugin never crosses. - #[tokio::test] - async fn configured_plugins_reach_the_worker_and_win_over_ambient_variables() { - let mut providers = ServerSandboxProvidersSettings::default(); - providers.entries.extend([ - provider("e2b", true, SandboxPluginSettings { - path: Some("/opt/fabro/plugins/sandbox-driver-e2b".to_string()), - sha256: Some("0123abcd".to_string()), - dev: true, - ..SandboxPluginSettings::default() - }), - provider("docker", true, SandboxPluginSettings { - path: Some("/opt/fabro/plugins/sandbox-driver-docker".to_string()), - ..SandboxPluginSettings::default() - }), - provider("daytona", true, SandboxPluginSettings::default()), - provider("fly-io", false, SandboxPluginSettings { - path: Some("/opt/fabro/plugins/sandbox-driver-fly-io".to_string()), - ..SandboxPluginSettings::default() - }), - ]); - let env = HashMap::from([ - ("PATH".to_string(), "/bin".to_string()), - ( - "PETRI_SANDBOX_HOST_PLUGIN".to_string(), - "/ambient/sandbox-driver-host".to_string(), - ), - ( - "PETRI_SANDBOX_DOCKER_PLUGIN".to_string(), - "/ambient/sandbox-driver-docker".to_string(), - ), - ( - "PETRI_SANDBOX_DAYTONA_PLUGIN".to_string(), - "/ambient/sandbox-driver-daytona".to_string(), - ), - ]); - let mut cmd = env_command(); - apply_worker_env_with(&mut cmd, &sandbox_plugin_env(&providers), &|name| { - env.get(name).map(OsString::from) - }); - - let actual = env_output(cmd).await; - - assert_eq!( - actual.get("PETRI_SANDBOX_E2B_PLUGIN").map(String::as_str), - Some("/opt/fabro/plugins/sandbox-driver-e2b") - ); - assert_eq!( - actual.get("PETRI_SANDBOX_E2B_SHA256").map(String::as_str), - Some("0123abcd") - ); - assert_eq!( - actual.get("PETRI_SANDBOX_PLUGIN_DEV").map(String::as_str), - Some("1"), - "one plugin in dev mode puts the worker's lookup in dev mode" - ); - assert_eq!( - actual - .get("PETRI_SANDBOX_DOCKER_PLUGIN") - .map(String::as_str), - Some("/opt/fabro/plugins/sandbox-driver-docker"), - "the settings win over the ambient variable" - ); - assert_eq!( - actual.get("PETRI_SANDBOX_HOST_PLUGIN").map(String::as_str), - Some("/ambient/sandbox-driver-host"), - "a kind without settings keeps the allowlisted ambient variable" - ); - assert_eq!( - actual - .get("PETRI_SANDBOX_DAYTONA_PLUGIN") - .map(String::as_str), - Some("/ambient/sandbox-driver-daytona"), - "settings without a path leave the ambient variable in place" - ); - assert!( - !actual.contains_key("PETRI_SANDBOX_FLY_IO_PLUGIN"), - "a disabled kind's plugin does not cross" - ); - } - - #[test] - fn no_configured_plugin_derives_no_variables() { - assert!(sandbox_plugin_env(&ServerSandboxProvidersSettings::default()).is_empty()); - let mut providers = ServerSandboxProvidersSettings::default(); - providers - .entries - .extend([provider("docker", true, SandboxPluginSettings::default())]); - assert!( - sandbox_plugin_env(&providers).is_empty(), - "settings with neither a path nor a pin nor dev mode add nothing" - ); - } - #[tokio::test] async fn render_graph_allowlist_is_fail_closed() { let env = HashMap::from([ diff --git a/lib/apps/fabro-server/src/worker_runtime.rs b/lib/apps/fabro-server/src/worker_runtime.rs index 2f76c2409..7b4fa39b6 100644 --- a/lib/apps/fabro-server/src/worker_runtime.rs +++ b/lib/apps/fabro-server/src/worker_runtime.rs @@ -48,16 +48,9 @@ pub(crate) struct WorkerLaunchSpec { pub(crate) fabro_log: Option, pub(crate) active_config_path: PathBuf, pub(crate) github_app_private_key: Option, - /// The vault's Daytona API key, for a run on a Daytona environment: - /// Petri's Daytona plugin reads it from the worker's process. - pub(crate) daytona_api_key: Option, /// The Fabro home the server resolved, so a Petri run's skills step /// reads the same home whatever the worker's environment says. pub(crate) fabro_home: PathBuf, - /// The sandbox-driver plugin variables the server's provider settings - /// derive (`spawn_env::sandbox_plugin_env`), so Petri in the worker - /// launches the plugin the settings name for every configured kind. - pub(crate) sandbox_plugin_env: Vec<(String, String)>, } pub(crate) struct StartedWorker { @@ -105,7 +98,7 @@ impl LocalWorkerRuntime { .stdout(worker_stdout) .stderr(Stdio::piped()); - apply_worker_env(&mut cmd, &spec.sandbox_plugin_env); + apply_worker_env(&mut cmd); if let Some(level) = spec.fabro_log.as_deref() { cmd.env(EnvVars::FABRO_LOG, level); } @@ -116,9 +109,6 @@ impl LocalWorkerRuntime { if let Some(pem) = spec.github_app_private_key.as_deref() { cmd.env(EnvVars::GITHUB_APP_PRIVATE_KEY, pem); } - if let Some(key) = spec.daytona_api_key.as_deref() { - cmd.env(EnvVars::DAYTONA_API_KEY, key); - } #[cfg(unix)] fabro_proc::pre_exec_setpgid(cmd.as_std_mut()); diff --git a/lib/apps/fabro-server/tests/it/scenario/petri.rs b/lib/apps/fabro-server/tests/it/scenario/petri.rs index f5da19a1f..dd5a68422 100644 --- a/lib/apps/fabro-server/tests/it/scenario/petri.rs +++ b/lib/apps/fabro-server/tests/it/scenario/petri.rs @@ -7,19 +7,14 @@ //! run, which the CLI's scenario tests cover with the real binary //! (`lib/apps/fabro-cli/tests/it/scenario/petri.rs`). //! -//! The runs that execute take their host scope through the sandbox-driver -//! host plugin, so those tests skip, and say why, when the executable is not -//! found, unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. The create-time -//! refusals need no plugin and always run. +//! Built-in Host scopes run in process without a plugin executable. #![expect( clippy::disallowed_methods, - reason = "the tests locate the plugin executable through the process environment" + reason = "the tests inspect backend availability through the process environment" )] -#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")] use std::collections::BTreeMap; -use std::env; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Arc; @@ -46,12 +41,6 @@ use crate::helpers::{ test_settings, wait_for_run_status, }; -const HOST_PLUGIN: &str = "sandbox-driver-host"; -const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN"; -const DOCKER_PLUGIN: &str = "sandbox-driver-docker"; -const DOCKER_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_DOCKER_PLUGIN"; -const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS"; - const OPENAI_MODEL: &str = "gpt-5.4"; /// A command-only workflow: one script stage between start and exit. @@ -110,59 +99,6 @@ const PARALLEL_DOT: &str = r#"digraph Parallel { pub(super) const PLAIN_SETTINGS: &str = "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n"; -/// The host plugin as Petri's lookup finds it: the override variable, else -/// the executable on `PATH`. `None`, after saying so, when the test should -/// skip; a panic when the environment forbids a skip. -pub(super) fn host_plugin() -> Option { - let found = env::var_os(HOST_PLUGIN_OVERRIDE) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os("PATH")?) - .map(|dir| dir.join(HOST_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - if found.is_none() { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset" - ); - eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"); - } - found -} - -/// The Docker plugin as Petri's lookup finds it, with a daemon that -/// answers. `None`, after saying so, when the test should skip; a panic -/// when the environment forbids a skip and the plugin is missing. -fn docker_plugin() -> Option { - let found = env::var_os(DOCKER_PLUGIN_OVERRIDE) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os("PATH")?) - .map(|dir| dir.join(DOCKER_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - let Some(found) = found else { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} is unset" - ); - eprintln!("skipping: {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} is unset"); - return None; - }; - let daemon = Command::new("docker") - .args(["version", "--format", "{{.Server.Version}}"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()); - if !daemon { - eprintln!("skipping: no Docker daemon answers"); - return None; - } - Some(found) -} - /// Register a version whose entrypoint is `workflow.fabro`, with the given /// files beside it. pub(super) async fn register_version(app: &axum::Router, files: &[(&str, &str)]) -> String { @@ -290,9 +226,6 @@ async fn create_run_response(app: &axum::Router, intent: serde_json::Value) -> s /// run succeeded, and Petri's record of the run says the same. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn the_hello_bundle_runs_on_petri() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let twin = twin_openai().await; let namespace = format!("{}::{}", module_path!(), line!()); @@ -367,9 +300,6 @@ async fn the_hello_bundle_runs_on_petri() { /// A command-only bundle runs on Petri, and Petri's record agrees. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_command_bundle_runs_on_petri() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); let state = test_app_state_with_options(settings, 5); @@ -413,9 +343,6 @@ async fn a_command_bundle_runs_on_petri() { /// under the fork, and the fork carries the branch results. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_parallel_bundle_projects_its_branches_through_the_server() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); let state = test_app_state_with_options(settings, 5); @@ -574,9 +501,6 @@ async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Valu /// interview as a legacy stage's would. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_human_gate_is_answered_through_the_questions_api() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let markers = tempfile::tempdir().expect("marker tempdir"); let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); @@ -696,9 +620,6 @@ async fn a_human_gate_is_answered_through_the_questions_api() { /// `sandbox cp` reach the sandbox after the run. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_runs_projection_carries_its_host_sandbox_instance() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); let state = test_app_state_with_options(settings, 5); @@ -764,9 +685,6 @@ async fn a_runs_projection_carries_its_host_sandbox_instance() { /// host workspace Petri kept along with the run. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn deleting_a_run_prunes_its_host_workspace_through_petri() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); let state = test_app_state_with_options(settings, 5); @@ -884,9 +802,6 @@ fn delete(run_id: &str) -> Request { /// end after the delete brings nothing back. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_delete_right_after_the_run_reads_ended_is_accepted() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); let state = test_app_state_with_options(settings, 5); @@ -955,7 +870,7 @@ async fn a_delete_right_after_the_run_reads_ended_is_accepted() { /// attaches to it on the daemon. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_runs_projection_carries_its_docker_sandbox_instance() { - if docker_plugin().is_none() { + if !fabro_test::docker_available() { return; } let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"docker\"\n"); @@ -1072,7 +987,7 @@ async fn a_runs_projection_carries_its_docker_sandbox_instance() { /// run label. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn the_server_attaches_to_the_container_petri_created() { - if docker_plugin().is_none() { + if !fabro_test::docker_available() { return; } let twin = twin_openai().await; @@ -1293,7 +1208,7 @@ fn get(path: &str) -> Request { /// The image the server's `docker-small` environment names in the tests /// below: a runner image with `git` for the checkpoint commit, and not the -/// plugin's default, so the container proves the catalog's image reached it. +/// provider's default, so the container proves the catalog's image reached it. const CATALOG_IMAGE: &str = "ghcr.io/lithoscomputer/ubuntu-22.04:slim"; /// A Docker environment in the server's catalog, with the image it runs. @@ -1397,7 +1312,7 @@ async fn admitted_root_graph(app: &axum::Router, run_id: &str) -> serde_json::Va /// A bundle that names a server environment it does not declare admits: the /// catalog's `[environments.docker-small]` reaches Petri through the /// settings layer, its image lands on the lowered environment, and, with -/// the Docker plugin and a daemon, the run's container runs that image. +/// a Docker daemon, the run's container runs that image. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_bundle_naming_a_catalog_environment_runs_on_docker_with_its_image() { let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); @@ -1431,7 +1346,7 @@ async fn a_bundle_naming_a_catalog_environment_runs_on_docker_with_its_image() { graph["params"]["fabro.launch"] ); - if docker_plugin().is_none() { + if !fabro_test::docker_available() { return; } start_run(&app, &run_id).await; @@ -1583,9 +1498,6 @@ const AGENT_DOT: &str = r#"digraph Agent { /// server's tool to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_bundle_naming_a_catalog_mcp_server_lists_its_tools_to_the_model() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let twin = twin_openai().await; let namespace = format!("{}::{}", module_path!(), line!()); @@ -1770,9 +1682,6 @@ async fn assert_run_goal(app: &axum::Router, namespace: &str, run_id: &str, goal #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_goal_override_is_the_goal_the_stages_execute_with() { const GOAL: &str = "Add a limerick to the README instead of a haiku"; - if host_plugin().is_none() { - return; - } let [(workflow_path, workflow), (settings_path, settings)] = hello_files(); let (_state, app, namespace, run_id) = run_hello_agent( &[(workflow_path, &workflow), (settings_path, &settings)], @@ -1788,9 +1697,6 @@ async fn a_goal_override_is_the_goal_the_stages_execute_with() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_run_goal_file_layer_is_the_goal_the_stages_execute_with() { const GOAL: &str = "Write a limerick about workflow engines into the README"; - if host_plugin().is_none() { - return; - } let [(workflow_path, workflow), _] = hello_files(); let settings = "_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.goal]\nfile = \"goal.md\"\n"; diff --git a/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs b/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs index 94844f1b1..7d490d763 100644 --- a/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs +++ b/lib/apps/fabro-server/tests/it/scenario/petri_stream.rs @@ -6,9 +6,7 @@ //! concurrent child executions' events. //! //! The runs execute in the server process under the handler-registry test -//! override and take their host scope through the sandbox-driver host -//! plugin, so the tests skip, and say why, when the executable is not -//! found (see `petri.rs`). +//! override and take their Host scope through the built-in provider. //! //! With `FABRO_CAPTURE_PETRI_FIXTURES` set, a scenario also writes its //! settled projection and full stream as JSON under the web app's test @@ -17,7 +15,7 @@ #![expect( clippy::disallowed_methods, - reason = "the tests locate the plugin executable and the capture switch through the process environment" + reason = "the tests read the capture switch through the process environment" )] #![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")] @@ -35,7 +33,7 @@ use http_body_util::BodyExt; use tokio::time::timeout; use tower::ServiceExt; -use super::petri::{PLAIN_SETTINGS, host_plugin, intent, register_version, settled_state}; +use super::petri::{PLAIN_SETTINGS, intent, register_version, settled_state}; use crate::helpers::{ api, create_and_start_run_from_intent, repo_root, response_json, run_json, settings_from_toml, test_app_state_with_options, test_app_with_scheduler, wait_for_run_status, @@ -231,9 +229,6 @@ fn wait_for_marker(path: &std::path::Path) { /// gap, no duplicate, with the notice between the branches' events. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_reconnecting_client_receives_every_stream_item_once_in_order() { - if host_plugin().is_none() { - return; - } let workspace = tempfile::tempdir().expect("workspace tempdir"); let markers = tempfile::tempdir().expect("marker tempdir"); let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n"); diff --git a/lib/components/fabro-petri/Cargo.toml b/lib/components/fabro-petri/Cargo.toml index bb429a499..b5441de34 100644 --- a/lib/components/fabro-petri/Cargo.toml +++ b/lib/components/fabro-petri/Cargo.toml @@ -23,6 +23,11 @@ fabro-api = { path = "../../foundation/fabro-api" } fabro-client = { path = "../../foundation/fabro-client" } fabro-db = { path = "../../foundation/fabro-db" } fabro-http.workspace = true +fabro-static = { path = "../../foundation/fabro-static" } +sandbox-driver.workspace = true +sandbox-driver-host.workspace = true +sandbox-driver-docker.workspace = true +sandbox-driver-daytona.workspace = true fabro-interview = { path = "../fabro-interview" } fabro-store = { path = "../fabro-store" } fabro-types = { path = "../../foundation/fabro-types" } diff --git a/lib/components/fabro-petri/README.md b/lib/components/fabro-petri/README.md index cc86f7cc2..b398233e0 100644 --- a/lib/components/fabro-petri/README.md +++ b/lib/components/fabro-petri/README.md @@ -172,16 +172,15 @@ Integration tests live under `tests/`: - `runs.rs` runs the `hello` bundle in memory through `Runtime::standard()` with the Fabro frontend and the model-free stub registry, then a command-only workflow on the host sandbox through the real step registry. - Both skip, and say why, when the `sandbox-driver-host` plugin executable - is not on `PATH` (every run takes its scope's environment through it); - the sandbox-plugins CI job requires them. + Both acquire real Host scopes through the built-in in-process provider; + no plugin executable or checksum is required. - `check.rs` admits the `hello` bundle and round-trips its graph through the blob store, binds the launch, admits a version whose `workflow.toml` names `engine = "petri"`, reads the project settings from the map, and refuses an unknown attribute, an unknown `[workflow]` key (`unsupported.workflow_toml.key`, named in `workflow.toml`) and, with a model client over the test catalog, an unknown model - (`attractor.model.unknown`). No plugin is needed. + (`attractor.model.unknown`). No sandbox is acquired. - `sqlite_store.rs` runs Petri's store conformance suite (`petri_testkit::run_store::conformance`) against `SqliteRunStore`, plus the operator release, lease exclusivity, a crash between appends, and blob @@ -206,8 +205,7 @@ Integration tests live under `tests/`: client over a vault that holds the key, and checks the skills step searched the configured Fabro home. -Those four need the host plugin like `runs.rs` does, and `model.rs` also -starts the twin. +Those four use the in-process Host provider; `model.rs` also starts the twin. - `projection.rs` builds the view live (every append signals the projector) for the `hello` bundle on the stub registry, a command-only @@ -217,7 +215,7 @@ starts the twin. recovers a crash between the record commit and the view transaction by applying only the missing suffix, with the positions and `stream_seq` continuing; runs two projectors over one store with child executions; and - holds the view at a torn tail. All skip without the host plugin. + holds the view at a torn tail. These run without a Host plugin. The conformance suite over `HttpRunStore` needs a server to talk to, so it lives with the server's integration tests diff --git a/lib/components/fabro-petri/src/fork.rs b/lib/components/fabro-petri/src/fork.rs index 764777029..44f551aa8 100644 --- a/lib/components/fabro-petri/src/fork.rs +++ b/lib/components/fabro-petri/src/fork.rs @@ -1,6 +1,5 @@ //! Forking a Fabro run at a checkpoint: the seam over Petri's -//! `host::fork_from` that rewind, fork and retry are built on (the -//! integration plan's F5.1). +//! `host::fork_from` that rewind, fork and retry are built on. //! //! Fabro's checkpoint record ties a Petri position `(execution, firing)` to //! a Git commit. A fork seeds a new run from the source's records up to such @@ -48,8 +47,8 @@ use petri_execution::{ Access, CoordinatorEvent, ExecutionId, InvocationId, RunKey, RunStore, StoreError as CoordinatorStoreError, }; +use petri_runtime::RunOptions; use petri_runtime::ir::FiringId; -use petri_runtime::{RunOptions, Runtime}; use petri_store::StoreError; use tokio::fs; use tokio::process::Command; @@ -59,6 +58,7 @@ use crate::checkpoint::{CheckpointKey, RunWorkspaces}; use crate::platform_records::{PlatformRecordError, PlatformRecords}; use crate::projection::FoldState; use crate::projector::ProjectError; +use crate::providers::{self, SandboxProviderConfig}; /// One fork to seed. pub struct ForkRequest { @@ -177,7 +177,9 @@ pub async fn fork(request: ForkRequest) -> Result { let mut options = RunOptions::new(&request.fork_run_dir); options.run_key = Some(fork_key.clone()); - let runtime = Runtime::standard() + // A fork only copies records and acquires no sandbox, so it needs no + // provider configuration. + let runtime = providers::standard_runtime(&SandboxProviderConfig::default()) .options(options) .store(Arc::clone(&request.store)); let forked = host::fork_from(&runtime, &*source_logs, request.position, ForkOptions { diff --git a/lib/components/fabro-petri/src/lib.rs b/lib/components/fabro-petri/src/lib.rs index 6cb4c7d75..b42b49851 100644 --- a/lib/components/fabro-petri/src/lib.rs +++ b/lib/components/fabro-petri/src/lib.rs @@ -12,6 +12,8 @@ //! run's records are its source of truth in Fabro's tables; //! - [`runtime`]: the Petri runtime Fabro assembles, at create time and at //! execution; +//! - [`providers`]: lazy in-process Host, Docker and Daytona factories, sharing +//! explicit configuration with the server's sandbox access; //! - [`check`]: Petri compiles a workflow version's bundle at create time, and //! its diagnostics come back in a shape Fabro maps onto its own; //! - [`admission`]: the admitted graphs in Fabro's blob store, named on the run @@ -76,6 +78,7 @@ pub mod petri; pub mod platform_records; pub mod projection; pub mod projector; +pub mod providers; pub mod prune; pub mod recovery; pub mod run_graph; diff --git a/lib/components/fabro-petri/src/providers.rs b/lib/components/fabro-petri/src/providers.rs new file mode 100644 index 000000000..8a68eb3b4 --- /dev/null +++ b/lib/components/fabro-petri/src/providers.rs @@ -0,0 +1,354 @@ +//! Built-in sandbox providers shared by Petri execution and server access. +//! +//! Configuration is captured by the caller, with Daytona credentials from +//! the vault. Factories connect lazily per run; a Host-only run needs neither +//! Docker nor Daytona. Host registry ownership stays with Petri: server +//! attach uses an observer instead of these factories. +//! +//! Every Petri runtime Fabro builds starts from [`standard_runtime`] or +//! [`bare_runtime`], so none falls back to launching a provider plugin, +//! which a release build cannot verify. + +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use fabro_static::EnvVars; +use petri_runtime::{ + InProcessProviders, ProviderContext, ProviderFactory, ProviderNetwork, Runtime, fingerprint, +}; +use sandbox_driver::{AuthError, Error, ProviderKind, SandboxProvider}; +use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider}; +use sandbox_driver_docker::DockerProvider; +use sandbox_driver_host::HostProvider; + +const USER_AGENT: &str = concat!("fabro-server/", env!("CARGO_PKG_VERSION")); + +/// Explicit Daytona credentials: the SDK's configuration with the API key +/// always present and a `Debug` that never prints it. The process +/// environment is never consulted. +#[derive(Clone)] +pub struct DaytonaCredentials(DaytonaConfig); + +impl DaytonaCredentials { + /// Credentials for `api_key` against Daytona's public control plane, + /// presenting Fabro's `User-Agent`. + #[must_use] + pub fn new(api_key: String) -> Self { + Self(DaytonaConfig { + api_key: Some(api_key), + user_agent: Some(USER_AGENT.to_string()), + ..DaytonaConfig::default() + }) + } + + /// Credentials for a vault API key, with the control-plane URL and + /// organization taken from `lookup` (server configuration). Nothing is + /// read implicitly. + /// + /// These are the two settings the Daytona plugin read in the worker, so + /// a lease it recorded keeps its fingerprint: no URL alias and no + /// placement target, neither of which reached the plugin. + pub fn from_api_key(api_key: String, lookup: impl Fn(&str) -> Option) -> Self { + Self::new(api_key) + .with_api_url(lookup(EnvVars::DAYTONA_API_URL)) + .with_organization_id(lookup(EnvVars::DAYTONA_ORGANIZATION_ID)) + } + + /// The control-plane URL; Daytona's public API when `None`. + #[must_use] + pub fn with_api_url(mut self, api_url: Option) -> Self { + self.0.api_url = api_url; + self + } + + #[must_use] + pub fn with_organization_id(mut self, organization_id: Option) -> Self { + self.0.organization_id = organization_id; + self + } + + /// A shared HTTP client; tests pass a no-proxy client here. + #[must_use] + pub fn with_http_client(mut self, http_client: Option) -> Self { + self.0.http_client = http_client; + self + } + + /// The SDK configuration the driver's Daytona provider connects with. + #[must_use] + fn config(&self) -> &DaytonaConfig { + &self.0 + } +} + +impl std::fmt::Debug for DaytonaCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DaytonaCredentials") + .field("api_url", &self.0.api_url) + .field("organization_id", &self.0.organization_id) + .finish_non_exhaustive() + } +} + +/// The provider configuration supplied to a run or prune. Defaults are +/// local Docker selection and no Daytona credentials; constructing this +/// configuration never connects to a backend or requires a credential. +#[derive(Clone, Debug, Default)] +pub struct SandboxProviderConfig { + docker_host: Option, + docker_host_address: Option, + daytona: Option, +} + +impl SandboxProviderConfig { + /// The configuration for `daytona`'s credentials, with how a remote + /// Docker daemon's containers reach this machine from `lookup`. + /// + /// The Docker endpoint is read from this process's `DOCKER_HOST`, never + /// from `lookup`: the Docker client connects to the daemon that + /// variable names, so the lease fingerprint and network name the daemon + /// the sandboxes are actually created on. + pub fn from_lookup( + daytona: Option, + lookup: impl Fn(&str) -> Option, + ) -> Self { + #[expect( + clippy::disallowed_methods, + reason = "the Docker client reads DOCKER_HOST from this process; the fingerprint must name the same daemon" + )] + let docker_host = std::env::var(EnvVars::DOCKER_HOST).ok(); + Self { + docker_host, + docker_host_address: lookup(EnvVars::PETRI_SANDBOX_DOCKER_HOST_ADDRESS) + .filter(|value| !value.trim().is_empty()), + daytona, + } + } +} + +/// Petri's standard runtime with Fabro's built-in providers installed. +#[must_use] +pub fn standard_runtime(config: &SandboxProviderConfig) -> Runtime { + #[expect( + clippy::disallowed_methods, + reason = "the one place a standard runtime is built, with the built-in providers installed" + )] + let runtime = Runtime::standard(); + runtime.in_process_providers(built_in_providers(config)) +} + +/// Petri's bare runtime with Fabro's built-in providers installed. +#[must_use] +pub fn bare_runtime(config: &SandboxProviderConfig) -> Runtime { + #[expect( + clippy::disallowed_methods, + reason = "the one place a bare runtime is built, with the built-in providers installed" + )] + let runtime = Runtime::bare(); + runtime.in_process_providers(built_in_providers(config)) +} + +/// The Docker connection used by both the server and Petri. The driver reads +/// the caller process's Docker endpoint/TLS environment; health is checked +/// by the caller so diagnostics can report an unavailable daemon. +#[expect( + clippy::result_large_err, + reason = "preserve sandbox-driver's typed error, as required by Petri's ProviderFactory contract" +)] +pub fn connect_docker() -> sandbox_driver::Result> { + Ok(Arc::new(DockerProvider::connect_unverified()?)) +} + +/// Connect using only the vault credentials and explicit control-plane +/// configuration; no ambient secret fallback is permitted. +pub async fn connect_daytona( + credentials: &DaytonaCredentials, +) -> sandbox_driver::Result> { + Ok(Arc::new( + DaytonaProvider::connect_explicit(credentials.config().clone()).await?, + )) +} + +/// One lazy factory per built-in kind. Missing Daytona credentials fail +/// only when a Daytona scope is acquired, never for admission or a Host run. +fn built_in_providers(config: &SandboxProviderConfig) -> InProcessProviders { + InProcessProviders::new() + .with(Arc::new(HostFactory)) + .with(Arc::new(DockerFactory { + host: config.docker_host.clone(), + host_address: config.docker_host_address.clone(), + })) + .with(Arc::new(DaytonaFactory(config.daytona.clone()))) +} + +struct HostFactory; + +#[async_trait] +impl ProviderFactory for HostFactory { + fn kind(&self) -> &'static str { + "host" + } + + /// An empty registry path when Petri supplies none, as the plugin + /// recorded it. + fn fingerprint_seed(&self, context: &ProviderContext) -> String { + fingerprint::host(context.host_registry().unwrap_or(Path::new(""))) + } + + fn network(&self) -> ProviderNetwork { + ProviderNetwork::host() + } + + async fn connect( + &self, + context: &ProviderContext, + ) -> sandbox_driver::Result> { + let registry = context.host_registry().ok_or_else(|| { + Error::invalid_spec( + "host_registry", + "Petri supplied no Host registry for this run", + ) + })?; + Ok(Arc::new(HostProvider::with_registry(registry).await?)) + } +} + +struct DockerFactory { + host: Option, + host_address: Option, +} + +impl DockerFactory { + fn seed(&self) -> String { + fingerprint::docker(self.host.as_deref()) + } +} + +#[async_trait] +impl ProviderFactory for DockerFactory { + fn kind(&self) -> &'static str { + "docker" + } + + fn fingerprint_seed(&self, _context: &ProviderContext) -> String { + self.seed() + } + + fn network(&self) -> ProviderNetwork { + ProviderNetwork::docker(self.host.as_deref(), self.host_address.as_deref()) + } + + async fn connect( + &self, + _context: &ProviderContext, + ) -> sandbox_driver::Result> { + connect_docker() + } +} + +struct DaytonaFactory(Option); + +impl DaytonaFactory { + fn seed(&self) -> String { + let config = self.0.as_ref().map(DaytonaCredentials::config); + fingerprint::daytona( + config.and_then(|config| config.api_url.as_deref()), + config.and_then(|config| config.organization_id.as_deref()), + None, + ) + } +} + +#[async_trait] +impl ProviderFactory for DaytonaFactory { + fn kind(&self) -> &'static str { + "daytona" + } + + fn fingerprint_seed(&self, _context: &ProviderContext) -> String { + self.seed() + } + + fn network(&self) -> ProviderNetwork { + ProviderNetwork::none() + } + + async fn connect( + &self, + _context: &ProviderContext, + ) -> sandbox_driver::Result> { + let credentials = self.0.as_ref().ok_or_else(|| Error::Auth(AuthError::new( + ProviderKind::try_new("daytona").expect("the built-in provider kind is valid"), + "Daytona requires DAYTONA_API_KEY in the vault; run `fabro secret set DAYTONA_API_KEY`", + )))?; + connect_daytona(credentials).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_fingerprint_keeps_the_plugin_namespace_for_unset_and_configured_hosts() { + for (host, expected) in [ + (None, "docker:default"), + (Some(" "), "docker:default"), + (Some(" tcp://daemon:2376 "), "docker:tcp://daemon:2376"), + ( + Some("unix:///var/run/docker.sock"), + "docker:unix:///var/run/docker.sock", + ), + ] { + let factory = DockerFactory { + host: host.map(str::to_owned), + host_address: None, + }; + assert_eq!(factory.seed(), expected); + } + } + + #[test] + fn daytona_fingerprint_keeps_the_plugin_seed() { + let unset = DaytonaCredentials::from_api_key("test-key".to_string(), |_| None); + assert_eq!(DaytonaFactory(Some(unset)).seed(), "daytona:::"); + let configured = + DaytonaCredentials::from_api_key("test-key".to_string(), |name| match name { + EnvVars::DAYTONA_API_URL => Some("https://daytona.example".to_string()), + EnvVars::DAYTONA_SERVER_URL => Some("https://ignored.example".to_string()), + EnvVars::DAYTONA_ORGANIZATION_ID => Some("org-1".to_string()), + EnvVars::DAYTONA_TARGET => Some("us".to_string()), + _ => None, + }); + let factory = DaytonaFactory(Some(configured)); + assert_eq!(factory.seed(), "daytona:https://daytona.example:org-1:"); + assert_eq!(factory.region(), None); + } + + #[test] + fn daytona_configuration_ignores_the_url_alias_and_keeps_the_http_client() { + let credentials = DaytonaCredentials::from_api_key("test-key".to_string(), |name| { + (name == EnvVars::DAYTONA_SERVER_URL).then(|| "https://alias.example".to_string()) + }) + .with_http_client(Some(fabro_test::test_http_client())); + assert_eq!(credentials.config().api_url, None); + assert!(credentials.config().http_client.is_some()); + } + + #[test] + fn provider_configuration_keeps_the_key_but_never_prints_it() { + let key = "dtn_test_sensitive_value"; + let config = SandboxProviderConfig::from_lookup( + Some(DaytonaCredentials::from_api_key(key.to_string(), |name| { + (name == EnvVars::DAYTONA_ORGANIZATION_ID).then(|| "org-1".to_string()) + })), + |_| None, + ); + let daytona = config.daytona.as_ref().expect("the credentials are kept"); + assert_eq!(daytona.config().api_key.as_deref(), Some(key)); + let rendered = format!("{config:?}"); + assert!(!rendered.contains(key)); + assert!(rendered.contains("org-1")); + } +} diff --git a/lib/components/fabro-petri/src/prune.rs b/lib/components/fabro-petri/src/prune.rs index 9f19de8aa..8b9ccae48 100644 --- a/lib/components/fabro-petri/src/prune.rs +++ b/lib/components/fabro-petri/src/prune.rs @@ -6,8 +6,8 @@ //! sandbox prune` deletes them, over the store the run's records live in, //! rather than through a provider call of Fabro's own: Petri opens the run //! for writing, so a live worker that still holds the lease refuses the -//! delete; it checks each lease's fingerprint against the plugin it -//! launches, so a changed daemon or account is a problem to report, never +//! delete; it checks each lease's fingerprint against the provider it +//! connects, so a changed daemon or account is a problem to report, never //! a delete on another backend; it writes the delete intent before the //! provider call and the tombstone after, beside the run's other records; //! and each provider removes its sandbox's managed workspace, a host @@ -26,12 +26,15 @@ use fabro_types::SandboxProviderKind; pub use petri_execution::prune::PruneReport; use petri_execution::prune::{self as petri_prune}; use petri_execution::{RunKey, RunStore}; -use petri_runtime::{RunOptions, Runtime}; +use petri_runtime::RunOptions; use crate::engine; +use crate::providers::{self, SandboxProviderConfig}; /// One run whose sandboxes are to be deleted. pub struct PruneRequest { + /// The provider configuration used by this server-side operation. + pub sandbox: SandboxProviderConfig, /// The Fabro run id, which is Petri's run key. pub run_id: String, /// Where the run's worker ran Petri: its host registry and action-host @@ -69,7 +72,9 @@ pub async fn prune(request: PruneRequest) -> Result { options.run_key = Some(RunKey::new(request.run_id.as_str())); options.retention = engine::RETENTION; options.sandbox.backend = backend; - let runtime = Runtime::bare().store(request.store).options(options); + let runtime = providers::bare_runtime(&request.sandbox) + .store(request.store) + .options(options); petri_prune::prune(&runtime) .await .map_err(|error| match error { diff --git a/lib/components/fabro-petri/src/runtime.rs b/lib/components/fabro-petri/src/runtime.rs index b51a8a14f..a19603f9b 100644 --- a/lib/components/fabro-petri/src/runtime.rs +++ b/lib/components/fabro-petri/src/runtime.rs @@ -1,6 +1,9 @@ //! The Petri runtime Fabro runs its workflows on, assembled the same way at //! create time (for `Runtime::check`) and at execution. //! +//! Built-in sandbox factories are installed for every runtime and connect +//! only when a scope is acquired. +//! //! The pieces are Petri's own: [`Runtime::standard`] with the Fabro frontend //! carrying the server's settings layer, the Attractor step kinds (the real //! ones, or the simulated registry for a dry run), the model client as the @@ -26,10 +29,13 @@ use petri_runtime::Runtime; use tracing::debug; use crate::host_tools; +use crate::providers::{self, SandboxProviderConfig}; /// What every Petri runtime Fabro builds is configured with. #[derive(Clone, Default)] pub struct RuntimeSpec { + /// Explicit provider configuration. Factories connect only at acquire. + pub sandbox: SandboxProviderConfig, /// The operator's settings layer, as `~/.fabro/settings.toml` text: the /// lowest of the three layers the Fabro frontend reads (`[run.model]` /// defaults, `[[run.hooks]]`, `[run.agent.mcps]`, `[run.environment]` @@ -64,7 +70,7 @@ impl RuntimeSpec { /// registry: only execution swaps in the stubs. #[must_use] pub fn runtime(&self, for_execution: bool) -> Runtime { - let mut runtime = Runtime::standard().frontend( + let mut runtime = providers::standard_runtime(&self.sandbox).frontend( Fabro::new() .with_settings_toml(self.settings_toml.clone()) .with_mcp_catalog_toml(self.mcp_catalog_toml.clone()), diff --git a/lib/components/fabro-petri/tests/blobs.rs b/lib/components/fabro-petri/tests/blobs.rs index bad4600db..f93e9e5ef 100644 --- a/lib/components/fabro-petri/tests/blobs.rs +++ b/lib/components/fabro-petri/tests/blobs.rs @@ -1,9 +1,7 @@ //! A large stage value leaves the run's records for Fabro's blob table //! under `blob://sha256/`, and comes back from the same table. //! -//! The run takes its host scope through the sandbox-driver host plugin, so -//! the test skips, and says why, when the executable is not found, unless -//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. +//! Built-in Host scopes run in process without a plugin executable. mod support; @@ -17,7 +15,7 @@ use fabro_petri::runtime::RuntimeSpec; use fabro_store::{BlobStore, test_support}; use fabro_types::BlobHash; use petri_attractor_steps::blobs::{BLOB_REF_PREFIX, OFFLOAD_THRESHOLD, parse_blob_ref}; -use support::{SETTINGS, Silent, admit, all_records, host_plugin, no_questions, run_request}; +use support::{SETTINGS, Silent, admit, all_records, no_questions, run_request}; /// One line of the command's output. const LINE: &str = "xxxxxxxx"; @@ -38,9 +36,6 @@ fn workflow(lines: usize) -> String { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_large_output_round_trips_through_the_blob_table() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let pool = test_support::in_memory_pool_with(&[ fabro_db::BLOBS_MIGRATION_SQL, diff --git a/lib/components/fabro-petri/tests/hooks.rs b/lib/components/fabro-petri/tests/hooks.rs index 5849b4633..2ad46a608 100644 --- a/lib/components/fabro-petri/tests/hooks.rs +++ b/lib/components/fabro-petri/tests/hooks.rs @@ -4,16 +4,13 @@ //! failure route, the fatal checkpoint, and the run-end hooks are checked //! against the workspace's Git history and the run's records. //! -//! Every run acquires its scope through the sandbox-driver host plugin, so -//! the tests skip when that executable is not found, unless -//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. The crash cases of the recovery -//! protocol need a worker to kill and live in the CLI's scenario suite. +//! Built-in Host scopes run in process without a plugin executable. +//! The crash cases need a worker to kill and live in the CLI's scenario suite. #![expect( clippy::disallowed_methods, - reason = "the tests locate the plugin executable through the process environment and read the workspace's history with git" + reason = "the tests inspect backend availability and read the workspace's history with git" )] -#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")] use std::collections::BTreeMap; use std::env; @@ -31,6 +28,7 @@ use fabro_petri::controls::RunControls; use fabro_petri::engine::{self, Execution, RunRequest, RunStatus}; use fabro_petri::hooks::HooksSpec; use fabro_petri::platform_records::PlatformRecords; +use fabro_petri::providers::{DaytonaCredentials, SandboxProviderConfig}; use fabro_petri::recovery::{self, Recovery, RecoveryRequest}; use fabro_petri::runtime::RuntimeSpec; use fabro_petri::test_support::{MemoryBlobs, MemoryPlatformRecords}; @@ -45,33 +43,6 @@ use tokio_util::sync::CancellationToken; mod support; -const HOST_PLUGIN: &str = "sandbox-driver-host"; -const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN"; -const DOCKER_PLUGIN: &str = "sandbox-driver-docker"; -const DOCKER_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_DOCKER_PLUGIN"; -const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS"; - -/// The host plugin as Petri's lookup finds it: the override variable, else -/// the executable on `PATH`. `None`, after saying so, when the test should -/// skip; a panic when the environment forbids a skip. -fn host_plugin() -> Option { - let found = env::var_os(HOST_PLUGIN_OVERRIDE) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os("PATH")?) - .map(|dir| dir.join(HOST_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - if found.is_none() { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset" - ); - eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"); - } - found -} - /// A command-only bundle: the stage lines go between `start` and `exit`, /// the edge lines after them. fn workflow(stages: &str, edges: &str) -> String { @@ -107,39 +78,6 @@ fn admit(workflow: &str, settings: &str) -> AdmittedGraphs { } } -/// The Docker plugin as Petri's lookup finds it, with a daemon that -/// answers. `None`, after saying so, when the test should skip; a panic -/// when the environment forbids a skip and the plugin is missing. -fn docker_plugin() -> Option { - let found = env::var_os(DOCKER_PLUGIN_OVERRIDE) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os("PATH")?) - .map(|dir| dir.join(DOCKER_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - let Some(found) = found else { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} \ - is unset" - ); - eprintln!("skipping: {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} is unset"); - return None; - }; - let daemon = std::process::Command::new("docker") - .args(["version", "--format", "{{.Server.Version}}"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|status| status.success()); - if !daemon { - eprintln!("skipping: no Docker daemon answers"); - return None; - } - Some(found) -} - /// One run's pieces: the store, its platform records, where it ran. struct Harness { run_id: RunId, @@ -194,12 +132,22 @@ impl Harness { ) -> engine::RunOutcome { let (interviewer, observers) = no_questions(); let hooks = self.hooks(&provider); + let daytona = (provider == SandboxProviderKind::DAYTONA).then(|| { + DaytonaCredentials::from_api_key( + env::var("DAYTONA_API_KEY").expect("live Daytona credentials"), + |name| env::var(name).ok(), + ) + }); + let sandbox = SandboxProviderConfig::from_lookup(daytona, |name| env::var(name).ok()); let request = RunRequest { run_id: self.run_id.to_string(), run_dir: self.run_dir.clone(), execution: Execution::Start(admit(workflow, settings)), store: Arc::clone(&self.store) as Arc, - runtime: RuntimeSpec::default(), + runtime: RuntimeSpec { + sandbox, + ..RuntimeSpec::default() + }, provider, cancel: CancellationToken::new(), controls: RunControls::new(), @@ -359,9 +307,6 @@ fn stages(inspection: &RunInspection) -> Vec<(String, String)> { /// reached Petri's local service through Fabro's wrapper. #[tokio::test] async fn every_finish_is_committed_and_recorded() { - if host_plugin().is_none() { - return; - } let harness = Harness::new(); let workflow = workflow( " write [shape=parallelogram, script=\"echo one > out.txt\"]\n check \ @@ -436,9 +381,6 @@ async fn every_finish_is_committed_and_recorded() { /// end. #[tokio::test] async fn artifacts_the_branch_and_the_diffs_are_recorded() { - if host_plugin().is_none() { - return; - } let mut harness = Harness::new(); harness.artifacts = vec!["assets/**".to_string()]; let workflow = workflow( @@ -607,9 +549,6 @@ async fn checkpoint_nodes(harness: &Harness) -> Vec<(String, u64)> { /// and its failure route runs on the committed files. #[tokio::test] async fn a_failed_stage_is_committed_and_its_route_sees_the_files() { - if host_plugin().is_none() { - return; - } let harness = Harness::new(); let workflow = workflow( " work [shape=parallelogram, script=\"echo partial > out.txt; exit 1\"]\n fix \ @@ -650,9 +589,6 @@ async fn a_failed_stage_is_committed_and_its_route_sees_the_files() { /// checkpoint's error, and a restart reports it failed without resuming. #[tokio::test] async fn a_failed_checkpoint_ends_the_run_with_no_route() { - if host_plugin().is_none() { - return; - } let harness = Harness::new(); let workflow = workflow( " wreck [shape=parallelogram, script=\"rm -rf .git && echo garbage > .git && echo wrecked \ @@ -716,9 +652,6 @@ async fn a_failed_checkpoint_ends_the_run_with_no_route() { /// starts over, and a run that finished has nothing to bring back. #[tokio::test] async fn recovery_starts_an_unknown_run_and_resumes_a_finished_one() { - if host_plugin().is_none() { - return; - } let harness = Harness::new(); assert_eq!(harness.recover().await, Recovery::Start); @@ -756,9 +689,6 @@ async fn a_run_hook_blocks_a_tool_effect_through_the_forwarded_service() { use serde_json::json; const MODEL: &str = "gpt-5.6-sol"; - if host_plugin().is_none() { - return; - } let twin = fabro_test::twin_openai().await; let namespace = format!("{}::{}", module_path!(), line!()); TwinScenarios::new(namespace.clone()) @@ -865,9 +795,6 @@ async fn the_records_name_the_root_invocations_workspace() { use fabro_petri::workspace::WorkspaceLookup; use petri_execution::InvocationId; - if host_plugin().is_none() { - return; - } let harness = Harness::new(); let workflow = workflow( " write [shape=parallelogram, script=\"echo one > out.txt\"]", @@ -899,9 +826,6 @@ async fn the_records_name_the_root_invocations_workspace() { /// problem. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn parallel_branches_checkpoint_the_shared_workspace_in_turn() { - if host_plugin().is_none() { - return; - } let harness = Harness::new(); let workflow = workflow( " fork [shape=component]\n a [shape=parallelogram, script=\"echo a > a.txt\"]\n b \ @@ -955,7 +879,7 @@ async fn parallel_branches_checkpoint_the_shared_workspace_in_turn() { /// its ref, with the platform records naming the same commits. #[tokio::test] async fn a_docker_run_commits_inside_the_container_and_publishes_every_checkpoint() { - if docker_plugin().is_none() { + if !fabro_test::docker_available() { return; } assert_sandbox_run_publishes_every_checkpoint(SandboxProviderKind::DOCKER).await; @@ -963,8 +887,8 @@ async fn a_docker_run_commits_inside_the_container_and_publishes_every_checkpoin /// The same protocol on Daytona: the sandbox-driver facets are provider /// neutral, so the commit, the bundle and the restore take one path. Live: -/// it needs `DAYTONA_API_KEY` and the Daytona plugin, and provisions a -/// sandbox. +/// it needs `DAYTONA_API_KEY`, passed explicitly to the provider, and +/// provisions a sandbox. #[tokio::test] #[ignore = "requires live Daytona credentials and provisions a sandbox"] async fn a_daytona_run_commits_inside_the_sandbox_and_publishes_every_checkpoint() { diff --git a/lib/components/fabro-petri/tests/host_tools.rs b/lib/components/fabro-petri/tests/host_tools.rs index f641bb092..6a4829f83 100644 --- a/lib/components/fabro-petri/tests/host_tools.rs +++ b/lib/components/fabro-petri/tests/host_tools.rs @@ -1,23 +1,14 @@ -//! Fabro's run tools on a Petri run from this crate (integration plan item -//! F3.4): `RuntimeSpec::run_tools` installs the adapter as Petri's host -//! tool capability, a workflow with one agent stage runs on the real step -//! registry against a scripted model, and the stage's session gets the -//! tools the legacy worker registers, bound to the run: the model is -//! advertised every run tool, its `fabro_run_create` call reaches Fabro's -//! API with the Petri run as the child's parent, the API's answer comes -//! back to the model, and the call is in the run's record under the stage. +//! Fabro's run tools on a Petri run from this crate: `RuntimeSpec::run_tools` +//! installs the adapter as Petri's host tool capability, a workflow with one +//! agent stage runs on the real step registry against a scripted model, and the +//! stage's session gets the tools the legacy worker registers, bound to the +//! run: the model is advertised every run tool, its `fabro_run_create` call +//! reaches Fabro's API with the Petri run as the child's parent, the API's +//! answer comes back to the model, and the call is in the run's record under +//! the stage. //! -//! Every run takes its scope through the sandbox-driver host plugin, so -//! the tests skip when that executable is not found, unless -//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. +//! Built-in Host scopes run in process without a plugin executable. -#![expect( - clippy::disallowed_methods, - reason = "the tests locate the plugin executable through the process environment" -)] -#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")] - -use std::env; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -48,10 +39,6 @@ use petri_store::{Access, MemoryRunStore, RunKey, RunStore}; use serde_json::json; use tokio::fs; -const HOST_PLUGIN: &str = "sandbox-driver-host"; -const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN"; -const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS"; - /// One agent stage on the native backend, pinned to the scripted model. const AGENT_WORKFLOW: &str = r#"digraph Agent { graph [goal="Start a child run", backend="api", default_max_retries=0] @@ -63,27 +50,6 @@ const AGENT_WORKFLOW: &str = r#"digraph Agent { const AGENT_SETTINGS: &str = "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n"; -/// The host plugin as Petri's lookup finds it: the override variable, else -/// the executable on `PATH`. `None`, after saying so, when the test should -/// skip; a panic when the environment forbids a skip. -fn host_plugin() -> Option { - let found = env::var_os(HOST_PLUGIN_OVERRIDE) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os("PATH")?) - .map(|dir| dir.join(HOST_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - if found.is_none() { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset" - ); - eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"); - } - found -} - /// Write the agent bundle into `/.fabro/workflows/agent`; the /// workflow file. async fn install_bundle(root: &Path) -> PathBuf { @@ -187,9 +153,6 @@ fn advertised(request: &Request) -> Vec<(String, String)> { /// answer reaches the model; the call is in the record under the stage. #[tokio::test] async fn a_petri_stage_calls_a_run_tool_bound_to_the_run() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let workflow = install_bundle(root.path()).await; let run_id = RunId::new(); @@ -271,9 +234,6 @@ async fn a_petri_stage_calls_a_run_tool_bound_to_the_run() { /// one tool the model called marked invoked. #[tokio::test] async fn the_projection_lists_the_stages_tools_and_marks_the_one_called() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let workflow = install_bundle(root.path()).await; let run_id = RunId::new(); @@ -375,9 +335,6 @@ async fn the_projection_lists_the_stages_tools_and_marks_the_one_called() { /// rather than parenting a child run to the wrong run. #[tokio::test] async fn services_for_another_run_give_the_stage_no_run_tools() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let workflow = install_bundle(root.path()).await; let run_id = RunId::new(); diff --git a/lib/components/fabro-petri/tests/interview.rs b/lib/components/fabro-petri/tests/interview.rs index 78b86f429..e7310acd2 100644 --- a/lib/components/fabro-petri/tests/interview.rs +++ b/lib/components/fabro-petri/tests/interview.rs @@ -6,9 +6,7 @@ //! timeout with the gate's default, an auto-approved run answers itself, //! and a cancelled run interrupts its question. //! -//! Every run takes its host scope through the sandbox-driver host plugin, -//! so the tests skip, and say why, when the executable is not found, -//! unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. +//! Built-in Host scopes run in process without a plugin executable. mod support; @@ -25,7 +23,7 @@ use fabro_petri::runtime::RuntimeSpec; use fabro_types::{Principal, QuestionType, SystemActorKind}; use petri_execution::{Delivery, InterviewReceipt, RECEIPT_FILE, ReplyRecord}; use petri_store::MemoryRunStore; -use support::{SETTINGS, admit, all_records, host_plugin, run_request, wait_until}; +use support::{SETTINGS, admit, all_records, run_request, wait_until}; use tokio::fs; /// A board of every notice the adapter posted. @@ -175,9 +173,6 @@ impl Gate { /// submitted under the posted id, as the API delivers it, routes the gate. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_gate_answered_under_the_posted_id_routes_on_the_answer() { - if host_plugin().is_none() { - return; - } let gate = Gate::new(); let workflow = one_gate(&gate.markers, ""); let runtime = RuntimeSpec::default(); @@ -263,9 +258,6 @@ async fn a_gate_answered_under_the_posted_id_routes_on_the_answer() { /// the other order, lands on its own branch. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn two_parallel_gates_each_bind_their_own_answer() { - if host_plugin().is_none() { - return; - } let gate = Gate::new(); let workflow = two_gates(&gate.markers); let runtime = RuntimeSpec::default(); @@ -334,9 +326,6 @@ async fn two_parallel_gates_each_bind_their_own_answer() { /// the default's branch runs. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_unanswered_question_expires_with_the_gates_default() { - if host_plugin().is_none() { - return; - } let gate = Gate::new(); let workflow = one_gate( &gate.markers, @@ -384,9 +373,6 @@ async fn an_unanswered_question_expires_with_the_gates_default() { /// engine, and still posts the question and its answer. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_auto_approved_run_answers_yes_at_once() { - if host_plugin().is_none() { - return; - } let gate = Gate::new(); let workflow = one_gate(&gate.markers, ""); let runtime = RuntimeSpec::default(); @@ -427,9 +413,6 @@ async fn an_auto_approved_run_answers_yes_at_once() { /// question is interrupted, the gate fails closed and the run is cancelled. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_cancelled_run_interrupts_its_pending_question() { - if host_plugin().is_none() { - return; - } let gate = Gate::new(); let workflow = one_gate(&gate.markers, ""); let runtime = RuntimeSpec::default(); diff --git a/lib/components/fabro-petri/tests/model.rs b/lib/components/fabro-petri/tests/model.rs index 34db3b65b..7599826f8 100644 --- a/lib/components/fabro-petri/tests/model.rs +++ b/lib/components/fabro-petri/tests/model.rs @@ -4,10 +4,8 @@ //! The `hello` bundle's agent stage calls the OpenAI twin through a model //! client built over a vault that holds the key; the twin requires a //! bearer token and logs requests under it, so a request logged under the -//! vault's key proves the key came from the vault. The run takes its host -//! scope through the sandbox-driver host plugin, so the test skips, and -//! says why, when the executable is not found, unless -//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. +//! vault's key proves the key came from the vault. The Host scope runs in +//! process. mod support; @@ -24,7 +22,7 @@ use fabro_types::SecretType; use fabro_vault::Vault; use lithos_llm::catalog::ProviderId; use petri_store::MemoryRunStore; -use support::{Silent, all_records, hello_bundle, host_plugin, no_questions, run_request}; +use support::{Silent, all_records, hello_bundle, no_questions, run_request}; use tokio::fs; use tokio::sync::RwLock as AsyncRwLock; @@ -32,9 +30,6 @@ const OPENAI_MODEL: &str = "gpt-5.4"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_model_call_authenticates_through_the_vault_and_skills_read_the_home() { - if host_plugin().is_none() { - return; - } let twin = twin_openai().await; let namespace = format!("{}::{}", module_path!(), line!()); TwinScenarios::new(&namespace) diff --git a/lib/components/fabro-petri/tests/projection.rs b/lib/components/fabro-petri/tests/projection.rs index aa7ff9914..8e9a9f9b6 100644 --- a/lib/components/fabro-petri/tests/projection.rs +++ b/lib/components/fabro-petri/tests/projection.rs @@ -7,20 +7,11 @@ //! a live run costs its new records, with the cache that makes it so //! dropped at a restart, after the idle period and at the run's finish. //! -//! Every run here takes its scope's environment through the sandbox-driver -//! host plugin, so the tests skip, and say why, when the executable is not -//! found, unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. - -#![expect( - clippy::disallowed_methods, - reason = "the tests locate the plugin executable through the process environment" -)] -#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")] +//! Built-in Host scopes run in process without a plugin executable. mod support; use std::collections::BTreeSet; -use std::env; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -32,8 +23,9 @@ use fabro_petri::check::Launch; use fabro_petri::engine::{self, RunStatus as EngineRunStatus}; use fabro_petri::interview::{Approval, FabroInterviewer}; use fabro_petri::projector::{self, Projector}; +use fabro_petri::providers::SandboxProviderConfig; use fabro_petri::runtime::RuntimeSpec; -use fabro_petri::{SqliteRunStore, test_support as petri_support}; +use fabro_petri::{SqliteRunStore, providers, test_support as petri_support}; use fabro_store::platform_records::{ PlatformRecord, PlatformRecordStore, RunCreatedRecord, RunLifecycleKind, RunLifecycleRecord, }; @@ -44,18 +36,14 @@ use fabro_types::{ }; use petri_execution::host::{self, HostRun}; use petri_frontend_fabro::Fabro; +use petri_runtime::RunOptions; use petri_runtime::executor::Retention; use petri_runtime::frontend::CompileInputs; use petri_runtime::ir::RunStatus as PetriRunStatus; -use petri_runtime::{RunOptions, Runtime}; use petri_store::{RunKey, RunStore}; use tokio::fs; use tokio::time::sleep; -const HOST_PLUGIN: &str = "sandbox-driver-host"; -const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN"; -const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS"; - const COMMAND_WORKFLOW: &str = r#"digraph Command { graph [goal="Run one command"] start [shape=Mdiamond] @@ -127,24 +115,6 @@ fn described_gate_workflow(markers: &Path) -> String { ) } -fn host_plugin() -> Option { - let found = env::var_os(HOST_PLUGIN_OVERRIDE) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os("PATH")?) - .map(|dir| dir.join(HOST_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - if found.is_none() { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset" - ); - eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"); - } - found -} - /// A fresh in-memory database with every table the projection touches. fn pool() -> DbPool { test_support::in_memory_pool_with(&[ @@ -236,7 +206,8 @@ async fn run_workflow( workflow: &Path, stubs: bool, ) { - let runtime = Runtime::standard().frontend(Fabro::new()); + let runtime = + providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new()); let runtime = if stubs { petri_attractor_steps::register_stubs(runtime) } else { @@ -466,9 +437,6 @@ async fn stage_states(pool: &DbPool, run_id: RunId) -> Vec<(String, StageState)> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn the_hello_bundle_projects_live_as_it_rebuilds() { - if host_plugin().is_none() { - return; - } let scenario = hello_scenario().await; run_live(&scenario).await; assert_view_equals_rebuild(&scenario.pool, scenario.run_id).await; @@ -495,9 +463,6 @@ async fn the_hello_bundle_projects_live_as_it_rebuilds() { /// reference, never as the bytes the live log accumulated. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_large_output_projects_as_its_blob_reference() { - if host_plugin().is_none() { - return; - } let scenario = large_output_scenario().await; run_live(&scenario).await; assert_view_equals_rebuild(&scenario.pool, scenario.run_id).await; @@ -519,9 +484,6 @@ async fn a_large_output_projects_as_its_blob_reference() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_command_workflow_projects_live_as_it_rebuilds() { - if host_plugin().is_none() { - return; - } let scenario = command_scenario().await; run_live(&scenario).await; assert_view_equals_rebuild(&scenario.pool, scenario.run_id).await; @@ -548,9 +510,6 @@ async fn a_command_workflow_projects_live_as_it_rebuilds() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_parallel_workflow_projects_its_branches_as_child_executions() { - if host_plugin().is_none() { - return; - } let scenario = parallel_scenario().await; run_live(&scenario).await; assert_view_equals_rebuild(&scenario.pool, scenario.run_id).await; @@ -587,9 +546,6 @@ async fn a_parallel_workflow_projects_its_branches_as_child_executions() { /// folds everything. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn dropped_wake_ups_are_caught_up_by_the_next_signal() { - if host_plugin().is_none() { - return; - } let scenario = command_scenario().await; run_unobserved(&scenario).await; assert!( @@ -614,9 +570,6 @@ async fn dropped_wake_ups_are_caught_up_by_the_next_signal() { /// The same, through the startup pass. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn the_startup_pass_catches_up_a_view_nobody_signalled() { - if host_plugin().is_none() { - return; - } let scenario = command_scenario().await; run_unobserved(&scenario).await; let projector = Projector::new(scenario.pool.clone(), scenario.pool.clone()); @@ -638,9 +591,6 @@ async fn the_startup_pass_catches_up_a_view_nobody_signalled() { /// a duplicate. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_passes_over_one_run_commit_one_contiguous_stream() { - if host_plugin().is_none() { - return; - } let scenario = parallel_scenario().await; run_unobserved(&scenario).await; let projector = Projector::new(scenario.pool.clone(), scenario.pool.clone()); @@ -745,9 +695,6 @@ async fn copy_run_without_records(source: &DbPool, run_id: RunId) -> DbPool { /// sequence continuing from where the committed view stood. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_crash_between_the_record_commit_and_the_view_applies_only_the_suffix() { - if host_plugin().is_none() { - return; - } let scenario = parallel_scenario().await; run_unobserved(&scenario).await; let rows = petri_rows(&scenario.pool, scenario.run_id).await; @@ -863,9 +810,6 @@ async fn a_crash_between_the_record_commit_and_the_view_applies_only_the_suffix( /// agree with a projector that saw the run whole. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_restarted_projector_agrees_over_nested_child_executions() { - if host_plugin().is_none() { - return; - } let scenario = parallel_scenario().await; run_unobserved(&scenario).await; let rows = petri_rows(&scenario.pool, scenario.run_id).await; @@ -928,9 +872,6 @@ async fn a_restarted_projector_agrees_over_nested_child_executions() { /// reported incomplete with the reason. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_torn_tail_holds_the_view_and_reports_the_run_incomplete() { - if host_plugin().is_none() { - return; - } let scenario = command_scenario().await; run_unobserved(&scenario).await; let projector = Projector::new(scenario.pool.clone(), scenario.pool.clone()); @@ -1035,9 +976,6 @@ async fn committed_pass(projector: &Projector, run_id: RunId) -> projector::Pass #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_pass_over_a_live_run_costs_its_new_records_not_the_run() { const BATCH: usize = 7; - if host_plugin().is_none() { - return; - } let scenario = parallel_scenario().await; run_unobserved(&scenario).await; let rows = petri_rows(&scenario.pool, scenario.run_id).await; @@ -1088,9 +1026,6 @@ async fn a_pass_over_a_live_run_costs_its_new_records_not_the_run() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_restart_and_the_idle_period_drop_the_cache_and_one_full_replay_rebuilds_it() { const BATCH: usize = 5; - if host_plugin().is_none() { - return; - } let scenario = parallel_scenario().await; run_unobserved(&scenario).await; let rows = petri_rows(&scenario.pool, scenario.run_id).await; @@ -1272,9 +1207,6 @@ impl GateRun { /// rebuilds the same. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_expired_question_is_pending_while_the_gate_waits_and_closes_on_the_expiry() { - if host_plugin().is_none() { - return; - } let gate = Arc::new(gate_run(r#", timeout="1500ms", human.default_choice="no""#).await); let running = { let gate = Arc::clone(&gate); @@ -1354,9 +1286,6 @@ async fn an_expired_question_is_pending_while_the_gate_waits_and_closes_on_the_e /// on a choice that has none. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_pending_question_carries_its_choice_descriptions_previews_and_context() { - if host_plugin().is_none() { - return; - } let gate = Arc::new(gate_run_of(described_gate_workflow).await); let running = { let gate = Arc::clone(&gate); @@ -1401,9 +1330,6 @@ async fn a_pending_question_carries_its_choice_descriptions_previews_and_context /// the view rebuilds the same. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_auto_approved_answer_closes_the_question_in_the_projection() { - if host_plugin().is_none() { - return; - } let gate = gate_run("").await; gate.run(Approval::Auto).await; diff --git a/lib/components/fabro-petri/tests/prune.rs b/lib/components/fabro-petri/tests/prune.rs index 67bf9363b..c90d1b333 100644 --- a/lib/components/fabro-petri/tests/prune.rs +++ b/lib/components/fabro-petri/tests/prune.rs @@ -3,8 +3,7 @@ //! tombstoned in the run's record, a second prune has nothing to do, and a //! run a live handle holds is refused. //! -//! The run takes its scope through the sandbox-driver host plugin, so the -//! test skips, and says why, when the executable is not found. +//! The Host scope runs in process without a plugin executable. #![expect( clippy::disallowed_methods, @@ -18,12 +17,13 @@ use std::sync::Arc; use fabro_petri::check::Launch; use fabro_petri::engine::{self, RunStatus}; +use fabro_petri::providers::SandboxProviderConfig; use fabro_petri::prune::{PruneError, PruneRequest, prune}; use fabro_petri::runtime::RuntimeSpec; use fabro_petri::{SqliteRunStore, petri}; use fabro_store::test_support; use fabro_types::SandboxProviderKind; -use support::{Silent, admit, host_plugin, no_questions, run_request}; +use support::{Silent, admit, no_questions, run_request}; /// A command-only workflow whose one stage writes a file into its /// workspace. @@ -96,9 +96,6 @@ async fn lease_states(store: &SqliteRunStore, run_id: &str) -> Vec { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_finished_runs_host_workspace_is_deleted_once_and_a_held_run_is_refused() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let run_dir = root.path().join("run"); let pool = test_support::in_memory_pool_with(&[ @@ -137,7 +134,37 @@ async fn a_finished_runs_host_workspace_is_deleted_once_and_a_held_run_is_refuse ); assert_eq!(lease_states(&store, "prune").await, ["stopped"]); + let logs = petri::RunStore::open( + store.as_ref(), + &petri::RunKey::new("prune"), + petri::Access::Read, + ) + .await + .expect("the resources open"); + let resources = logs + .read(&petri::LogId::Resources) + .await + .expect("the resources read"); + let legacy_fingerprint = format!( + "host:{}", + run_dir + .join("host-registry") + .canonicalize() + .expect("canonical registry") + .display() + ); + let allocating = resources + .iter() + .find(|record| { + record.record["body"]["state"] == "allocating" + && record.record["body"]["fingerprint"].is_string() + }) + .expect("the allocation is recorded"); + assert_eq!(allocating.record["body"]["fingerprint"], legacy_fingerprint); + drop(logs); + let request = || PruneRequest { + sandbox: SandboxProviderConfig::default(), run_id: "prune".to_string(), run_dir: run_dir.clone(), store: store.clone(), @@ -187,6 +214,7 @@ async fn a_finished_runs_host_workspace_is_deleted_once_and_a_held_run_is_refuse async fn a_provider_petri_does_not_serve_is_refused_before_the_store_is_opened() { let store = Arc::new(petri_store::MemoryRunStore::new()); let error = prune(PruneRequest { + sandbox: SandboxProviderConfig::default(), run_id: "e2b-run".to_string(), run_dir: std::env::temp_dir().join("fabro-petri-prune-e2b"), store, diff --git a/lib/components/fabro-petri/tests/runs.rs b/lib/components/fabro-petri/tests/runs.rs index 54a1a7e9e..fe2e164e1 100644 --- a/lib/components/fabro-petri/tests/runs.rs +++ b/lib/components/fabro-petri/tests/runs.rs @@ -2,22 +2,13 @@ //! memory on the stub registry, and a command-only workflow on the host //! sandbox through the real step registry. //! -//! Every run, stubbed or real, acquires its scope's environment through the -//! sandbox-driver host plugin, so both tests skip when that executable is not -//! found, unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. Fabro's CI installs -//! the plugin on `PATH` in the sandbox-plugins job and requires it there. +//! Built-in Host scopes run in process without a plugin executable. -#![expect( - clippy::disallowed_methods, - reason = "the tests locate the plugin executable through the process environment" -)] -#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")] - -use std::env; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +use fabro_petri::providers::{self, SandboxProviderConfig}; use petri_execution::host::{self, HostRun}; use petri_execution::inspect::{self, RunInspection}; use petri_frontend_fabro::Fabro; @@ -28,10 +19,6 @@ use petri_runtime::{RunOptions, Runtime}; use petri_store::{Access, MemoryRunStore, RunKey, RunStore as _}; use tokio::fs; -const HOST_PLUGIN: &str = "sandbox-driver-host"; -const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN"; -const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS"; - /// A command-only workflow: one script stage between start and exit. const COMMAND_WORKFLOW: &str = r#"digraph Command { graph [goal="Run one command"] @@ -48,27 +35,6 @@ fn hello_bundle() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../.fabro/workflows/hello") } -/// The host plugin as Petri's lookup finds it: the override variable, else -/// the executable on `PATH`. `None`, after saying so, when the test should -/// skip; a panic when the environment forbids a skip. -fn host_plugin() -> Option { - let found = env::var_os(HOST_PLUGIN_OVERRIDE) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os("PATH")?) - .map(|dir| dir.join(HOST_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - if found.is_none() { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset" - ); - eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"); - } - found -} - /// Write a bundle's files into `/.fabro/workflows/` so the /// frontend sees a bundle root of its own, with no project settings layer /// above it. Returns the workflow file. @@ -131,12 +97,9 @@ async fn run_workflow( /// The `hello` bundle, whose one stage is a prompt, completes on the stub /// registry with no model, and its record in the memory store says so. The /// stubbed stages never run a command, but the run still takes its host -/// scope through the plugin. +/// scope through the in-process provider. #[tokio::test] async fn the_hello_bundle_runs_in_memory_on_the_stub_registry() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let bundle = hello_bundle(); let workflow_text = fs::read_to_string(bundle.join("workflow.fabro")) @@ -151,9 +114,11 @@ async fn the_hello_bundle_runs_in_memory_on_the_stub_registry() { ]) .await; let store = Arc::new(MemoryRunStore::new()); - let rt = petri_attractor_steps::register_stubs(Runtime::standard().frontend(Fabro::new())) - .store(store.clone()) - .options(run_options(&root.path().join("run"), "hello")); + let rt = petri_attractor_steps::register_stubs( + providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new()), + ) + .store(store.clone()) + .options(run_options(&root.path().join("run"), "hello")); let inspection = run_workflow(&rt, &store, "hello", &workflow).await; @@ -167,9 +132,6 @@ async fn the_hello_bundle_runs_in_memory_on_the_stub_registry() { /// real step registry, and its record in the memory store says so. #[tokio::test] async fn a_command_workflow_runs_on_the_host_sandbox() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let workflow = install_bundle(root.path(), "command", &[ ("workflow.fabro", COMMAND_WORKFLOW), @@ -177,9 +139,11 @@ async fn a_command_workflow_runs_on_the_host_sandbox() { ]) .await; let store = Arc::new(MemoryRunStore::new()); - let rt = petri_attractor_steps::register(Runtime::standard().frontend(Fabro::new())) - .store(store.clone()) - .options(run_options(&root.path().join("run"), "command")); + let rt = petri_attractor_steps::register( + providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new()), + ) + .store(store.clone()) + .options(run_options(&root.path().join("run"), "command")); let inspection = run_workflow(&rt, &store, "command", &workflow).await; diff --git a/lib/components/fabro-petri/tests/secrets.rs b/lib/components/fabro-petri/tests/secrets.rs index c799613f9..988e649ba 100644 --- a/lib/components/fabro-petri/tests/secrets.rs +++ b/lib/components/fabro-petri/tests/secrets.rs @@ -2,9 +2,7 @@ //! command's environment, and the value never reaches `petri_records`: //! Petri masks every record before it is appended. //! -//! The run takes its host scope through the sandbox-driver host plugin, so -//! the test skips, and says why, when the executable is not found, unless -//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. +//! Built-in Host scopes run in process without a plugin executable. mod support; @@ -19,7 +17,7 @@ use fabro_petri::secrets::VaultSecrets; use fabro_store::test_support; use fabro_types::SecretType; use fabro_vault::Vault; -use support::{Silent, admit, host_plugin, no_questions, run_request}; +use support::{Silent, admit, no_questions, run_request}; const TOKEN: &str = "hunter2-hunter2-hunter2"; @@ -50,9 +48,6 @@ TOKEN = "{{ secrets.TOKEN }}" #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_secret_reaches_the_command_and_is_masked_in_every_record() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let pool = test_support::in_memory_pool_with(&[ fabro_db::BLOBS_MIGRATION_SQL, @@ -106,9 +101,6 @@ async fn a_secret_reaches_the_command_and_is_masked_in_every_record() { /// ends the way Fabro's failure policy for a command ends it. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_secret_nobody_provides_fails_the_command() { - if host_plugin().is_none() { - return; - } let root = tempfile::tempdir().expect("a temp dir"); let store = Arc::new(petri_store::MemoryRunStore::new()); let runtime = RuntimeSpec::default(); diff --git a/lib/components/fabro-petri/tests/support/mod.rs b/lib/components/fabro-petri/tests/support/mod.rs index 7cc8fd0c3..0bc63d1cf 100644 --- a/lib/components/fabro-petri/tests/support/mod.rs +++ b/lib/components/fabro-petri/tests/support/mod.rs @@ -1,4 +1,4 @@ -//! What the adapter tests share: the host plugin lookup, a bundle admitted +//! What the adapter tests share: a bundle admitted //! through `check`, a run request over the engine assembly, and the run's //! records read back from its store. @@ -8,7 +8,6 @@ )] use std::collections::BTreeMap; -use std::env; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -25,39 +24,9 @@ use petri_store::{Access, LogId, RunKey, RunStore}; use tokio::time::sleep; use tokio_util::sync::CancellationToken; -const HOST_PLUGIN: &str = "sandbox-driver-host"; -const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN"; -const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS"; - pub(crate) const POLL: Duration = Duration::from_millis(10); pub(crate) const PATIENCE: Duration = Duration::from_secs(30); -/// The host plugin as Petri's lookup finds it: the override variable, else -/// the executable on `PATH`. `None`, after saying so, when the test should -/// skip; a panic when the environment forbids a skip. -#[expect( - clippy::disallowed_methods, - reason = "the tests locate the plugin executable through the process environment" -)] -#[expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")] -pub(crate) fn host_plugin() -> Option { - let found = env::var_os(HOST_PLUGIN_OVERRIDE) - .map(PathBuf::from) - .or_else(|| { - env::split_paths(&env::var_os("PATH")?) - .map(|dir| dir.join(HOST_PLUGIN)) - .find(|candidate| candidate.is_file()) - }); - if found.is_none() { - assert!( - env::var_os(REQUIRE_ENV).is_none(), - "{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset" - ); - eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"); - } - found -} - /// The `.fabro/workflows/hello` bundle checked into this repository. pub(crate) fn hello_bundle() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../.fabro/workflows/hello") diff --git a/lib/foundation/fabro-static/src/env_vars.rs b/lib/foundation/fabro-static/src/env_vars.rs index 8537c1127..53a45f158 100644 --- a/lib/foundation/fabro-static/src/env_vars.rs +++ b/lib/foundation/fabro-static/src/env_vars.rs @@ -49,29 +49,16 @@ impl EnvVars { pub const FABRO_WEB_URL: &'static str = "FABRO_WEB_URL"; pub const FABRO_WORKER_TOKEN: &'static str = "FABRO_WORKER_TOKEN"; - // Petri's sandbox-driver plugins: where each provider's plugin executable - // is, its checksum override, dev mode for unpinned plugins, and how a - // remote Docker daemon's containers reach this machine. A run's worker - // resolves the plugins, so these cross into the worker process. - pub const PETRI_SANDBOX_HOST_PLUGIN: &'static str = "PETRI_SANDBOX_HOST_PLUGIN"; - pub const PETRI_SANDBOX_HOST_SHA256: &'static str = "PETRI_SANDBOX_HOST_SHA256"; - pub const PETRI_SANDBOX_DOCKER_PLUGIN: &'static str = "PETRI_SANDBOX_DOCKER_PLUGIN"; - pub const PETRI_SANDBOX_DOCKER_SHA256: &'static str = "PETRI_SANDBOX_DOCKER_SHA256"; - pub const PETRI_SANDBOX_DAYTONA_PLUGIN: &'static str = "PETRI_SANDBOX_DAYTONA_PLUGIN"; - pub const PETRI_SANDBOX_DAYTONA_SHA256: &'static str = "PETRI_SANDBOX_DAYTONA_SHA256"; + // Petri's sandbox settings: dev mode for unpinned third-party plugins, + // how a remote Docker daemon's containers reach this machine, and the + // action-host image. These cross into a run's worker process. pub const PETRI_SANDBOX_PLUGIN_DEV: &'static str = "PETRI_SANDBOX_PLUGIN_DEV"; pub const PETRI_SANDBOX_DOCKER_HOST_ADDRESS: &'static str = "PETRI_SANDBOX_DOCKER_HOST_ADDRESS"; pub const PETRI_SANDBOX_ACTION_HOST_IMAGE: &'static str = "PETRI_SANDBOX_ACTION_HOST_IMAGE"; - /// Every Petri plugin variable, in one list for the process boundaries + /// Every Petri sandbox variable, in one list for the process boundaries /// that forward them. pub const PETRI_SANDBOX_PLUGIN_VARS: &'static [&'static str] = &[ - Self::PETRI_SANDBOX_HOST_PLUGIN, - Self::PETRI_SANDBOX_HOST_SHA256, - Self::PETRI_SANDBOX_DOCKER_PLUGIN, - Self::PETRI_SANDBOX_DOCKER_SHA256, - Self::PETRI_SANDBOX_DAYTONA_PLUGIN, - Self::PETRI_SANDBOX_DAYTONA_SHA256, Self::PETRI_SANDBOX_PLUGIN_DEV, Self::PETRI_SANDBOX_DOCKER_HOST_ADDRESS, Self::PETRI_SANDBOX_ACTION_HOST_IMAGE, @@ -168,6 +155,7 @@ impl EnvVars { pub const DAYTONA_API_KEY: &'static str = "DAYTONA_API_KEY"; pub const DAYTONA_API_URL: &'static str = "DAYTONA_API_URL"; pub const DAYTONA_ORGANIZATION_ID: &'static str = "DAYTONA_ORGANIZATION_ID"; + pub const DAYTONA_TARGET: &'static str = "DAYTONA_TARGET"; pub const DAYTONA_SERVER_URL: &'static str = "DAYTONA_SERVER_URL"; pub const SESSION_SECRET: &'static str = "SESSION_SECRET"; @@ -256,12 +244,6 @@ mod tests { EnvVars::FABRO_VERBOSE, EnvVars::FABRO_WEB_URL, EnvVars::FABRO_WORKER_TOKEN, - EnvVars::PETRI_SANDBOX_HOST_PLUGIN, - EnvVars::PETRI_SANDBOX_HOST_SHA256, - EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN, - EnvVars::PETRI_SANDBOX_DOCKER_SHA256, - EnvVars::PETRI_SANDBOX_DAYTONA_PLUGIN, - EnvVars::PETRI_SANDBOX_DAYTONA_SHA256, EnvVars::PETRI_SANDBOX_PLUGIN_DEV, EnvVars::PETRI_SANDBOX_DOCKER_HOST_ADDRESS, EnvVars::PETRI_SANDBOX_ACTION_HOST_IMAGE, @@ -330,6 +312,7 @@ mod tests { EnvVars::DAYTONA_API_URL, EnvVars::DAYTONA_ORGANIZATION_ID, EnvVars::DAYTONA_SERVER_URL, + EnvVars::DAYTONA_TARGET, EnvVars::SESSION_SECRET, EnvVars::CARGO_BIN_EXE_FABRO, EnvVars::CARGO_CFG_TARGET_OS, diff --git a/lib/foundation/fabro-test/src/lib.rs b/lib/foundation/fabro-test/src/lib.rs index 96f901de4..f0514e84f 100644 --- a/lib/foundation/fabro-test/src/lib.rs +++ b/lib/foundation/fabro-test/src/lib.rs @@ -155,6 +155,56 @@ pub fn require_env(name: &str) -> Option { } } +/// Set in CI so a missing sandbox backend (a Docker daemon or image) fails +/// the test instead of skipping it. +pub const REQUIRE_SANDBOX_BACKENDS: &str = "FABRO_REQUIRE_SANDBOX_BACKENDS"; + +/// A reachable Docker daemon. When [`REQUIRE_SANDBOX_BACKENDS`] is set, a +/// missing daemon fails the test instead of skipping it. +#[must_use] +pub fn docker_available() -> bool { + sandbox_backend_available( + docker_succeeds(&["version", "--format", "{{.Server.Version}}"]), + "no Docker daemon answers", + ) +} + +/// A Docker daemon that already holds `image`, under the same +/// fail-or-skip policy as [`docker_available`]. +#[must_use] +pub fn docker_image_available(image: &str) -> bool { + sandbox_backend_available( + docker_succeeds(&["image", "inspect", image]), + &format!("no Docker daemon with {image}"), + ) +} + +fn docker_succeeds(args: &[&str]) -> bool { + std::process::Command::new("docker") + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +/// `available`, or a skip notice for `missing` (a failure when +/// [`REQUIRE_SANDBOX_BACKENDS`] is set). +#[allow( + clippy::print_stderr, + reason = "Skip notices go to stderr so stdout stays assertable." +)] +fn sandbox_backend_available(available: bool, missing: &str) -> bool { + if !available { + assert!( + std::env::var_os(REQUIRE_SANDBOX_BACKENDS).is_none(), + "{REQUIRE_SANDBOX_BACKENDS} is set, but {missing}" + ); + eprintln!("skipping: {missing}"); + } + available +} + /// Apply baseline environment isolation to a `Command` that spawns the /// `fabro` binary (or a helper that will act like it). /// @@ -228,10 +278,10 @@ fn apply_test_isolation_with_lookup( if let Some(path) = lookup(EnvVars::PATH) { cmd.env(EnvVars::PATH, path); } - // Petri resolves its sandbox-driver plugins from these, in the server a - // test starts and in the workers that server launches; a developer's - // plugin override reaches them like `PATH` does, and so does the Docker - // daemon selection the Docker plugin needs. + // Petri reads its sandbox settings from these, in the server a test + // starts and in the workers that server launches; a developer's + // override reaches them like `PATH` does, and so does the Docker daemon + // selection the Docker provider needs. for name in EnvVars::PETRI_SANDBOX_PLUGIN_VARS .iter() .chain(EnvVars::DOCKER_VARS) diff --git a/lib/foundation/fabro-types/src/settings/server.rs b/lib/foundation/fabro-types/src/settings/server.rs index 4f4279ded..09466052b 100644 --- a/lib/foundation/fabro-types/src/settings/server.rs +++ b/lib/foundation/fabro-types/src/settings/server.rs @@ -147,12 +147,13 @@ impl ServerSandboxProvidersSettings { .map(|(kind, _)| kind) } - /// Enabled kinds that are served by a plugin executable. + /// Enabled third-party kinds that are served by a plugin executable. + /// Bundled kinds run in process, so plugin settings on them are ignored. pub fn enabled_plugins( &self, ) -> impl Iterator { self.entries.iter().filter_map(|(kind, entry)| { - (entry.enabled) + (entry.enabled && kind.bundled().is_none()) .then_some(entry.plugin.as_ref()) .flatten() .map(|plugin| (kind, plugin))