Merge pull request #900 from fabro-sh/codex/in-process-sandboxes
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Rustdoc (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Sandbox providers (Docker) (push) Waiting to run

Run Petri's built-in sandbox providers in process
This commit is contained in:
Scott Werner 2026-09-25 14:20:12 -04:00 • committed by GitHub
commit 03f5f8a69e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 823 additions and 1272 deletions

View file

@ -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 }

View file

@ -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

38
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -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 = [

View file

@ -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.<kind>]`; 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.<kind>]` in `settings.toml`. Missing bundled entries default to
`enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled,

View file

@ -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_<KIND>_PLUGIN` and `PETRI_SANDBOX_<KIND>_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]

View file

@ -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,

View file

@ -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<String> {
pub(crate) fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}

View file

@ -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);

View file

@ -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<PathBuf> {
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<String> {
/// 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::<Vec<_>>();
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();
}

View file

@ -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");

View file

@ -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<PathBuf> {
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<String> {
/// 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!();

View file

@ -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, &parallel_dot());

View file

@ -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<Value
/// Petri's record under the stage.
#[tokio::test(flavor = "multi_thread")]
async fn an_agent_starts_a_child_run_with_a_run_tool_inside_a_petri_run() {
if host_plugin().is_none() {
return;
}
let context = test_context!();
let twin = twin_openai().await;
let namespace = format!("{}::{}", module_path!(), line!());
@ -322,9 +317,6 @@ async fn an_agent_starts_a_child_run_with_a_run_tool_inside_a_petri_run() {
/// call.
#[tokio::test(flavor = "multi_thread")]
async fn a_run_hook_blocks_a_run_tool_inside_a_petri_run() {
if host_plugin().is_none() {
return;
}
let context = test_context!();
let twin = twin_openai().await;
let namespace = format!("{}::{}", module_path!(), line!());
@ -391,9 +383,6 @@ async fn a_run_hook_blocks_a_run_tool_inside_a_petri_run() {
/// parent session.
#[tokio::test(flavor = "multi_thread")]
async fn a_sub_agent_calls_an_inherited_run_tool_inside_a_petri_run() {
if host_plugin().is_none() {
return;
}
let context = test_context!();
let twin = twin_openai().await;
let namespace = format!("{}::{}", module_path!(), line!());

View file

@ -1,36 +1,27 @@
//! The Docker provider for the workflow scenarios: an environment on
//! [`DOCKER_IMAGE`], on an isolated server.
//!
//! Petri serves every provider through a sandbox-driver plugin executable it
//! finds on `PATH` (`sandbox-driver-docker` here); CI installs the
//! executables at the `sandbox-driver` commit in `Cargo.lock`, and a developer
//! installs them with
//! `cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev <rev> 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<PathBuf> {
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));

View file

@ -178,10 +178,9 @@ fn run_stream_items(run_dir: &Path) -> Vec<RunStreamItem> {
/// - `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 = []);

View file

@ -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

View file

@ -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,

View file

@ -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(),

View file

@ -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<String>) -> 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<String>) -> Self {
self.0.api_url = api_url;
self
}
#[must_use]
pub(crate) fn with_organization_id(mut self, organization_id: Option<String>) -> 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<fabro_http::HttpClient>) -> 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 {

View file

@ -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<String>,
) -> 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<SandboxProviderConfig, SecretStoreError> {
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<String>,
daytona_api_key: Option<String>,
) -> anyhow::Result<WorkerLaunchSpec> {
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<AppState>, 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<AppState>, run_id: RunId) {
&run_dir_for_build,
agent_fabro_tools_enabled,
github_app_private_key,
daytona_api_key,
)
})
.await

View file

@ -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

View file

@ -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<AppState>, 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<AppState>, 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

View file

@ -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.<kind>]` 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))
}

View file

@ -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<OsString>,
) {
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.<kind>]` settings: for every enabled
/// kind that carries plugin settings, `PETRI_SANDBOX_<KIND>_PLUGIN` from
/// its `path` and `PETRI_SANDBOX_<KIND>_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-<kind>` 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([

View file

@ -48,16 +48,9 @@ pub(crate) struct WorkerLaunchSpec {
pub(crate) fabro_log: Option<String>,
pub(crate) active_config_path: PathBuf,
pub(crate) github_app_private_key: Option<String>,
/// 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<String>,
/// 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());

View file

@ -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<PathBuf> {
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<PathBuf> {
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<Body> {
/// 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<Body> {
/// 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";

View file

@ -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");

View file

@ -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" }

View file

@ -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

View file

@ -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<Forked, ForkError> {
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 {

View file

@ -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;

View file

@ -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<String>) -> 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<String>) -> Self {
self.0.api_url = api_url;
self
}
#[must_use]
pub fn with_organization_id(mut self, organization_id: Option<String>) -> 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<fabro_http::HttpClient>) -> 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<String>,
docker_host_address: Option<String>,
daytona: Option<DaytonaCredentials>,
}
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<DaytonaCredentials>,
lookup: impl Fn(&str) -> Option<String>,
) -> 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<Arc<dyn SandboxProvider>> {
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<Arc<dyn SandboxProvider>> {
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<Arc<dyn SandboxProvider>> {
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<String>,
host_address: Option<String>,
}
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<Arc<dyn SandboxProvider>> {
connect_docker()
}
}
struct DaytonaFactory(Option<DaytonaCredentials>);
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<Arc<dyn SandboxProvider>> {
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"));
}
}

View file

@ -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<PruneReport, PruneError> {
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 {

View file

@ -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()),

View file

@ -1,9 +1,7 @@
//! A large stage value leaves the run's records for Fabro's blob table
//! under `blob://sha256/<hex>`, 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,

View file

@ -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<PathBuf> {
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<PathBuf> {
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<dyn petri_store::RunStore>,
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() {

View file

@ -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<PathBuf> {
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 `<root>/.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();

View file

@ -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();

View file

@ -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)

View file

@ -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<PathBuf> {
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;

View file

@ -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<String> {
#[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,

View file

@ -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<PathBuf> {
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 `<root>/.fabro/workflows/<name>` 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;

View file

@ -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();

View file

@ -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<PathBuf> {
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")

View file

@ -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,

View file

@ -155,6 +155,56 @@ pub fn require_env(name: &str) -> Option<String> {
}
}
/// 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)

View file

@ -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<Item = (&SandboxProviderKind, &SandboxPluginSettings)> {
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))