mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-30 01:53:45 +00:00
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
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:
commit
03f5f8a69e
49 changed files with 823 additions and 1272 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
81
.github/workflows/rust.yml
vendored
81
.github/workflows/rust.yml
vendored
|
|
@ -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
38
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Fork, rewind, retry and the timeline over Petri runs, through a real
|
||||
//! server and its worker subprocess (the integration plan's F5.1).
|
||||
//! server and its worker subprocess.
|
||||
//!
|
||||
//! The harness is `petri.rs`'s: a foreground server on disk storage, a run
|
||||
//! created and started with `fabro run --detach`, executed by the worker
|
||||
|
|
@ -20,8 +20,7 @@ use std::process::{Command, Output};
|
|||
use fabro_test::test_context;
|
||||
|
||||
use super::petri::{
|
||||
RunningServer, host_plugin, run_detached, run_json, wait_for_status, wait_for_success,
|
||||
write_petri_workflow,
|
||||
RunningServer, run_detached, run_json, wait_for_status, wait_for_success, write_petri_workflow,
|
||||
};
|
||||
|
||||
/// Three command stages that build on each other's files: `one` writes a
|
||||
|
|
@ -178,9 +177,6 @@ fn read(workspace: &Path, name: &str) -> String {
|
|||
/// new run says where it came from.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_fork_at_the_first_stage_continues_with_the_rest_on_its_files() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let bundle = write_petri_workflow(&context, &three_stage_dot());
|
||||
|
|
@ -260,9 +256,6 @@ async fn a_fork_at_the_first_stage_continues_with_the_rest_on_its_files() {
|
|||
/// and finishes the run.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_retry_reruns_the_failed_stage_and_succeeds_when_the_failure_was_transient() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let marker = context.temp_dir.join("flaky.marker");
|
||||
|
|
@ -316,9 +309,6 @@ async fn a_retry_reruns_the_failed_stage_and_succeeds_when_the_failure_was_trans
|
|||
/// names the run that replaced it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_rewind_supersedes_its_source() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let bundle = write_petri_workflow(&context, &three_stage_dot());
|
||||
|
|
@ -381,9 +371,6 @@ async fn a_rewind_supersedes_its_source() {
|
|||
/// its position and commit, as the CLI prints it and as the API serves it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_timeline_lists_every_checkpoint_with_its_commit() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let bundle = write_petri_workflow(&context, &three_stage_dot());
|
||||
|
|
@ -453,9 +440,6 @@ async fn the_timeline_lists_every_checkpoint_with_its_commit() {
|
|||
/// refuses it, and the refusal says why. The join, in the root, is.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_fork_inside_a_parallel_branch_is_refused() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let bundle = write_petri_workflow(&context, ¶llel_dot());
|
||||
|
|
|
|||
|
|
@ -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!());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 = []);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
354
lib/components/fabro-petri/src/providers.rs
Normal file
354
lib/components/fabro-petri/src/providers.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue