Use pebble's sandbox-driver adapter and delete fabro-pebble-sandbox

`lib/components/fabro-pebble-sandbox` moved into pebble as
`pebble_coding_agent::sandbox_driver` (lithoscomputer/pebble#27): the
`Environment` over a driver handle (`SandboxEnvironment`, was
`PebbleSandbox`), the `SandboxExec` policy, the port routes, and
`display_for_log`. The pebble pin moves to that branch head, 6d03b3b,
with the `sandbox-driver` feature on (`sandbox-driver-test-util` for the
server's tests, which take `MockSandbox` from pebble now). Nothing in the
crate was Fabro's by design; what was Fabro's stays: `SecretRedactor`
moves to `fabro-redact` as pebble's `Redactor` over `redact_string`, and
the log renderer takes it where a driver failure is rendered.

`fabro-petri` hands pebble types to Petri's crates, so Petri must pin the
same pebble revision: the petri pins move to lithoscomputer/petri#36
(9ee3f85), which pins pebble at the same head. Both re-pin to the pebble
merge commit together once #27 merges.

The 14 pebble commits between the pins fold the session projection's
lifetime tallies into `SessionProjection::totals` (and `PromptDelta`'s
into a flattened `totals`, which renames the prompt's `subagents` key to
`subagent_counts`, as the projection's already was). The stage progress
fold, the runs handler, the OpenAPI schema, the generated client model,
and the round-trip test follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-21 17:42:15 -04:00
parent 7518045203
commit 45d94ce711
No known key found for this signature in database
27 changed files with 112 additions and 2258 deletions

View file

@ -117,7 +117,6 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
- **fabro-dot** — The workflow graph as written, read through Petri's DOT parser: its name, goal, node and edge counts, and the files it references (`import`, `stack.child_workflow`, `@file` prompts, the goal). The bundler and the workflow-version store walk references through it; `fabro-graphviz` re-emits Fabro DOT for Graphviz through it
- **fabro-graphviz** — SVG rendering of workflow graphs through the vendored Graphviz (`graphviz-sys`)
- **fabro-pebble-sandbox** — A `sandbox-driver` handle as the `Environment` pebble's coding agent runs its tools through (`PebbleSandbox`), with Fabro's exec policy, port routes, and secret redactor. Petri creates and owns every run sandbox through the sandbox driver; Fabro attaches to one for Ask Fabro, and `fabro exec` creates a host sandbox of its own. Agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and runs the operator's Docker daemon; daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
@ -229,7 +228,6 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
- **fabro-dot** — The workflow graph as written, read through Petri's DOT parser: its name, goal, node and edge counts, and the files it references (`import`, `stack.child_workflow`, `@file` prompts, the goal). The bundler and the workflow-version store walk references through it; `fabro-graphviz` re-emits Fabro DOT for Graphviz through it
- **fabro-graphviz** — SVG rendering of workflow graphs through the vendored Graphviz (`graphviz-sys`)
- **fabro-pebble-sandbox** — A `sandbox-driver` handle as the `Environment` pebble's coding agent runs its tools through (`PebbleSandbox`), with Fabro's exec policy, port routes, and secret redactor. Petri creates and owns every run sandbox through the sandbox driver; Fabro attaches to one for Ask Fabro, and `fabro exec` creates a host sandbox of its own. Agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and runs the operator's Docker daemon; daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
@ -246,7 +244,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec
### Key design patterns
- **Direct sandbox access** — Petri creates every run sandbox through the sandbox driver and records its provider, id and working directory on the run (`RunSandboxInstance`); every Docker and Daytona sandbox carries the `petri.run` label. The server reaches a run's sandbox (the sandbox tab, Run Files, terminal, SSH, preview URLs, VNC, `fabro cp`, Ask Fabro) through `fabro-server/src/sandbox_access.rs`: it connects the record's provider itself, keys ownership on `petri.run`, and works on the driver's `Arc<dyn Sandbox>` facets (exec, filesystem, search, git, pty). Deleting a run deletes its sandboxes through Petri's lease ledger (`fabro_petri::prune`, what `petri sandbox prune` does), not through a provider call of Fabro's own. There is no fabro-side sandbox trait; tests use `fabro_pebble_sandbox::test_support::MockSandbox` over the driver's scripted doubles.
- **Direct sandbox access** — Petri creates every run sandbox through the sandbox driver and records its provider, id and working directory on the run (`RunSandboxInstance`); every Docker and Daytona sandbox carries the `petri.run` label. The server reaches a run's sandbox (the sandbox tab, Run Files, terminal, SSH, preview URLs, VNC, `fabro cp`, Ask Fabro) through `fabro-server/src/sandbox_access.rs`: it connects the record's provider itself, keys ownership on `petri.run`, and works on the driver's `Arc<dyn Sandbox>` facets (exec, filesystem, search, git, pty). Deleting a run deletes its sandboxes through Petri's lease ledger (`fabro_petri::prune`, what `petri sandbox prune` does), not through a provider call of Fabro's own. Pebble's tools run over the sandbox through pebble's own `sandbox_driver` module (`SandboxEnvironment`, the `sandbox-driver` feature), with `fabro_redact::SecretRedactor` as the redactor; Fabro keeps no sandbox adapter of its own. There is no fabro-side sandbox trait; tests use `pebble_coding_agent::sandbox_driver::test_support::MockSandbox` over the driver's scripted doubles.
- **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes
- **OpenAPI-first** — `fabro-api.yaml` drives Rust type + client generation (progenitor) and TypeScript client generation (openapi-generator)
- **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed

62
Cargo.lock generated
View file

@ -2108,7 +2108,6 @@ dependencies = [
"fabro-manifest",
"fabro-mcp-server",
"fabro-oauth",
"fabro-pebble-sandbox",
"fabro-petri",
"fabro-proc",
"fabro-redact",
@ -2514,26 +2513,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "fabro-pebble-sandbox"
version = "0.362.0-nightly.0"
dependencies = [
"async-trait",
"fabro-redact",
"fabro-types",
"fabro-util",
"pebble-coding-agent",
"sandbox-driver",
"sandbox-driver-host",
"sandbox-driver-testing",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "fabro-petri"
version = "0.362.0-nightly.0"
@ -2596,6 +2575,7 @@ name = "fabro-redact"
version = "0.362.0-nightly.0"
dependencies = [
"aho-corasick",
"pebble-coding-agent",
"ref-cast",
"regex",
"serde",
@ -2640,7 +2620,6 @@ dependencies = [
"fabro-macros",
"fabro-manifest",
"fabro-mcp-store",
"fabro-pebble-sandbox",
"fabro-petri",
"fabro-proc",
"fabro-redact",
@ -2972,7 +2951,6 @@ dependencies = [
"fabro-http",
"fabro-llm",
"fabro-macros",
"fabro-pebble-sandbox",
"fabro-redact",
"fabro-store",
"fabro-test",
@ -5091,7 +5069,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pebble-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=67c9f486dd28f15c04e8d590a91e6f5563f7605d#67c9f486dd28f15c04e8d590a91e6f5563f7605d"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d03b3ba58c501e0b4ee8341bfb69d288dda9408#6d03b3ba58c501e0b4ee8341bfb69d288dda9408"
dependencies = [
"async-trait",
"futures-util",
@ -5108,7 +5086,7 @@ dependencies = [
[[package]]
name = "pebble-cli-core"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=67c9f486dd28f15c04e8d590a91e6f5563f7605d#67c9f486dd28f15c04e8d590a91e6f5563f7605d"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d03b3ba58c501e0b4ee8341bfb69d288dda9408#6d03b3ba58c501e0b4ee8341bfb69d288dda9408"
dependencies = [
"anyhow",
"async-trait",
@ -5137,7 +5115,7 @@ dependencies = [
[[package]]
name = "pebble-coding-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=67c9f486dd28f15c04e8d590a91e6f5563f7605d#67c9f486dd28f15c04e8d590a91e6f5563f7605d"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d03b3ba58c501e0b4ee8341bfb69d288dda9408#6d03b3ba58c501e0b4ee8341bfb69d288dda9408"
dependencies = [
"async-trait",
"futures-util",
@ -5146,6 +5124,8 @@ dependencies = [
"reqwest 0.13.4",
"rmcp",
"rustix",
"sandbox-driver",
"sandbox-driver-testing",
"serde",
"serde_json",
"sha2 0.10.9",
@ -5177,7 +5157,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petri-attractor-steps"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"globset",
@ -5208,7 +5188,7 @@ dependencies = [
[[package]]
name = "petri-driver"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"getrandom 0.3.4",
@ -5228,7 +5208,7 @@ dependencies = [
[[package]]
name = "petri-engine"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"petri-ir",
"serde",
@ -5240,7 +5220,7 @@ dependencies = [
[[package]]
name = "petri-execution"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"petri-driver",
@ -5264,7 +5244,7 @@ dependencies = [
[[package]]
name = "petri-executor"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"libc",
@ -5279,7 +5259,7 @@ dependencies = [
[[package]]
name = "petri-executor-sandbox"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"petri-executor",
@ -5301,7 +5281,7 @@ dependencies = [
[[package]]
name = "petri-frontend"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"marked-yaml",
"petri-ir",
@ -5315,7 +5295,7 @@ dependencies = [
[[package]]
name = "petri-frontend-attractor"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"minijinja",
"petri-frontend",
@ -5332,7 +5312,7 @@ dependencies = [
[[package]]
name = "petri-frontend-fabro"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"petri-frontend",
"petri-frontend-attractor",
@ -5348,7 +5328,7 @@ dependencies = [
[[package]]
name = "petri-frontend-native"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"petri-frontend",
"petri-ir",
@ -5359,7 +5339,7 @@ dependencies = [
[[package]]
name = "petri-ir"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"regex",
"serde",
@ -5372,7 +5352,7 @@ dependencies = [
[[package]]
name = "petri-runtime"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"petri-driver",
@ -5393,7 +5373,7 @@ dependencies = [
[[package]]
name = "petri-steps"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"petri-executor",
@ -5409,7 +5389,7 @@ dependencies = [
[[package]]
name = "petri-store"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"getrandom 0.3.4",
@ -5424,7 +5404,7 @@ dependencies = [
[[package]]
name = "petri-testkit"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?rev=dfdecdc6990cd49ff7a2d070ffccf32563347fa9#dfdecdc6990cd49ff7a2d070ffccf32563347fa9"
source = "git+https://github.com/lithoscomputer/petri.git?rev=9ee3f851cbbe124ebee46a4a5129987509aa8001#9ee3f851cbbe124ebee46a4a5129987509aa8001"
dependencies = [
"async-trait",
"petri-driver",

View file

@ -105,26 +105,28 @@ sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandb
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "07600aa5c6695ec4c999da93c05d2cb78fe11b0c" }
# pebble: the coding agent loop fabro runs its agent stages, Ask Fabro
# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to pebble
# `main`. Pebble owns the `PortRoutes` trait fabro implements over its run
# sandbox, so the pebble and sandbox-driver pins move independently. Pebble
# pins the same lithos-llm rev as fabro, and its lockfile policy is that
# every shared crate resolves to the version lithos-llm locks.
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "67c9f486dd28f15c04e8d590a91e6f5563f7605d" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "67c9f486dd28f15c04e8d590a91e6f5563f7605d", features = ["mcp", "search-providers"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "67c9f486dd28f15c04e8d590a91e6f5563f7605d" }
# `main`. The `Environment` over a sandbox-driver handle is pebble's
# `sandbox_driver` module (the `sandbox-driver` feature), so pebble pins the
# same sandbox-driver rev as this file and the workspace links one copy of
# the driver; a move of either pin moves the other. Pebble pins the same
# lithos-llm rev as fabro, and its lockfile policy is that every shared crate
# resolves to the version lithos-llm locks.
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6d03b3ba58c501e0b4ee8341bfb69d288dda9408" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6d03b3ba58c501e0b4ee8341bfb69d288dda9408", features = ["mcp", "search-providers", "sandbox-driver"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "6d03b3ba58c501e0b4ee8341bfb69d288dda9408" }
# petri: the workflow engine Fabro runs its workflows on. Pinned by rev, the
# same way pebble and sandbox-driver are. Petri pins the same pebble,
# lithos-llm and sandbox-driver revisions as this file, so the workspace links
# one copy of each. Only `fabro-petri` and `fabro-dot` (the DOT parser alone)
# may depend on these packages; the keys carry the `petri_` prefix so the crate
# names say where they come from.
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "dfdecdc6990cd49ff7a2d070ffccf32563347fa9", package = "petri-runtime" }
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "dfdecdc6990cd49ff7a2d070ffccf32563347fa9", package = "petri-execution" }
petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "dfdecdc6990cd49ff7a2d070ffccf32563347fa9", package = "petri-store" }
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "dfdecdc6990cd49ff7a2d070ffccf32563347fa9", package = "petri-attractor-steps" }
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "dfdecdc6990cd49ff7a2d070ffccf32563347fa9", package = "petri-frontend-attractor" }
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "dfdecdc6990cd49ff7a2d070ffccf32563347fa9", package = "petri-frontend-fabro" }
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "dfdecdc6990cd49ff7a2d070ffccf32563347fa9", package = "petri-testkit" }
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-runtime" }
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-execution" }
petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-store" }
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-attractor-steps" }
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-frontend-attractor" }
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-frontend-fabro" }
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "9ee3f851cbbe124ebee46a4a5129987509aa8001", package = "petri-testkit" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -12295,7 +12295,7 @@ components:
- context_window
- tool_calls
- descendants
- subagents
- subagent_counts
- compactions
- files_touched
- last_file_touched
@ -12338,7 +12338,7 @@ components:
additionalProperties:
$ref: "#/components/schemas/AgentSessionDescendantAccount"
description: What each descendant spent during the prompt, by session id.
subagents:
subagent_counts:
$ref: "#/components/schemas/AgentSessionSubagentCounts"
description: Child lifecycle events during the prompt.
compactions:

View file

@ -33,7 +33,6 @@ fabro-mcp-server = { path = "../fabro-mcp-server" }
fabro-petri = { path = "../../components/fabro-petri" }
fabro-manifest = { path = "../../components/fabro-manifest" }
fabro-proc = { path = "../../foundation/fabro-proc" }
fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox" }
sandbox-driver.workspace = true
sandbox-driver-host.workspace = true
fabro-graphviz = { path = "../../components/fabro-graphviz" }

View file

@ -22,7 +22,7 @@ use fabro_llm::gateway::{GatewayAdapter, GatewayError, GatewayTransport};
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::middleware::{Call, Middleware, Next, Output};
use fabro_llm::{Client, ClientOptions, Error as LlmError, ErrorKind};
use fabro_pebble_sandbox::{PebbleSandbox, SecretRedactor};
use fabro_redact::SecretRedactor;
use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
use fabro_types::settings::run::{McpServerSettings, ResolvedMcpEntry};
@ -35,6 +35,7 @@ use pebble_cli_core::approval::TerminalApproval;
use pebble_cli_core::render::{self, JsonStream, RenderOptions, Style};
use pebble_cli_core::session::{SessionOptions, run_prompt_with};
use pebble_coding_agent::environment::Environment;
use pebble_coding_agent::sandbox_driver::SandboxEnvironment;
use pebble_coding_agent::subagents::SubagentOptions;
use pebble_coding_agent::tools::{PermissionLevelPolicy, PermissionMiddleware};
use pebble_coding_agent::{CodingAgent, CodingAgentOptions, MemoryDiscovery, SkillDiscovery};
@ -522,7 +523,9 @@ async fn run_session(
/// removed, brought to `Running` with its Bash verified. The provider is
/// returned beside the sandbox because the session's processes are the
/// provider's process groups; it must outlive the session.
async fn host_sandbox(working_directory: PathBuf) -> AnyResult<(HostProvider, Arc<PebbleSandbox>)> {
async fn host_sandbox(
working_directory: PathBuf,
) -> AnyResult<(HostProvider, Arc<SandboxEnvironment>)> {
let provider = HostProvider::new();
let handle = provider
.create(
@ -536,7 +539,7 @@ async fn host_sandbox(working_directory: PathBuf) -> AnyResult<(HostProvider, Ar
.await
.context("failed to start the local sandbox")?;
let working_directory = handle.working_directory().to_string();
let sandbox = PebbleSandbox::attach(handle, working_directory)
let sandbox = SandboxEnvironment::attach(handle, working_directory)
.await
.context("failed to read the local sandbox's platform")?;
Ok((provider, Arc::new(sandbox)))

View file

@ -33,7 +33,6 @@ fabro-interview = { path = "../../components/fabro-interview" }
fabro-slack = { path = "../../components/fabro-slack" }
fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox" }
sandbox-driver.workspace = true
sandbox-driver-host.workspace = true
sandbox-driver-docker.workspace = true
@ -126,7 +125,7 @@ tracing-subscriber.workspace = true
tokio-util.workspace = true
tokio-tungstenite.workspace = true
fabro-macros = { path = "../../foundation/fabro-macros" }
fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox", features = ["test-support"] }
pebble-coding-agent = { workspace = true, features = ["sandbox-driver-test-util"] }
sandbox-driver-testing.workspace = true
fabro-store = { path = "../../components/fabro-store", features = ["test-support"] }
fabro-test = { workspace = true }

View file

@ -34,7 +34,7 @@ use fabro_api::types::{
RunFilesMeta, RunFilesMetaDegradedReason, RunFilesMetaScope, RunFilesMetaSource,
RunFilesMetaToSha,
};
use fabro_pebble_sandbox::{SandboxExec, display_for_log};
use fabro_redact::SecretRedactor;
use fabro_types::RunId;
use fabro_util::shell;
use fabro_workflow::sandbox_git::{
@ -42,6 +42,7 @@ use fabro_workflow::sandbox_git::{
list_diff_numstat, stream_blob_metadata, stream_blobs,
};
use futures_util::FutureExt;
use pebble_coding_agent::sandbox_driver::{SandboxExec, display_for_log};
use sandbox_driver::{
Git as _, GitCommit, GitDiffOptions, GitFacet, GitLogOptions, GitRevisionRange, Sandbox,
Termination,
@ -796,7 +797,7 @@ fn sandbox_git_error(op: &str, error: &sandbox_driver::Error) -> ApiError {
if timed_out {
return transient_503(op, "command timed out");
}
transient_503(op, &display_for_log(error))
transient_503(op, &display_for_log(error, &SecretRedactor))
}
/// Build the degraded response from the stored terminal diff patch.
@ -1259,7 +1260,12 @@ async fn resolve_ref_sha_and_time(
None,
)
.await
.map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, display_for_log(&err)))?;
.map_err(|err| {
ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
display_for_log(&err, &SecretRedactor),
)
})?;
if !res.success() {
return Err(ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
@ -1731,8 +1737,8 @@ fn count_flags(data: &[FileDiff]) -> (u64, u64, u64, u64) {
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use fabro_pebble_sandbox::test_support::{MockSandbox, exec_result};
use fabro_types::{PetriAdmission, RunId, test_support};
use pebble_coding_agent::sandbox_driver::test_support::{MockSandbox, exec_result};
use sandbox_driver::ExecResult;
use tokio::time::{Duration, sleep};

View file

@ -1425,7 +1425,7 @@ async fn get_run_stage_context_window(
let Some(snapshot) = stage
.agent
.as_ref()
.and_then(|agent| agent.context_window.as_ref())
.and_then(|agent| agent.totals.context_window.as_ref())
else {
return Json(StageContextWindow::unavailable(
stage_id,
@ -1446,7 +1446,7 @@ fn is_agent_context_window_stage(stage: &StageProjection) -> bool {
if stage
.agent
.as_ref()
.is_some_and(|agent| agent.context_window.is_some())
.is_some_and(|agent| agent.totals.context_window.is_some())
{
return true;
}

View file

@ -5,10 +5,11 @@ use std::time::Duration;
use anyhow::Context as _;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use fabro_pebble_sandbox::{display_for_log, resolve_path};
use fabro_redact::SecretRedactor;
use fabro_types::{RunSandboxInstance, SandboxProviderKind};
use futures_util::FutureExt;
use futures_util::future::BoxFuture;
use pebble_coding_agent::sandbox_driver::{display_for_log, resolve_path};
use sandbox_driver::{FileKind, ListeningPort, PtyOptions, PtySize, Sandbox, Services as _};
use super::super::{
@ -272,7 +273,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: Run
Ok(WsMessage::Binary(bytes)) => {
if let Err(err) = session.write_input(&bytes).await {
let _ = socket
.send(terminal_server_text("error", Some(&display_for_log(&err))))
.send(terminal_server_text("error", Some(&display_for_log(&err, &SecretRedactor))))
.await;
break;
}
@ -282,7 +283,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: Run
Ok(TerminalClientMessage::Resize(size)) => {
if let Err(err) = session.resize(size).await {
let _ = socket
.send(terminal_server_text("error", Some(&display_for_log(&err))))
.send(terminal_server_text("error", Some(&display_for_log(&err, &SecretRedactor))))
.await;
break;
}
@ -317,7 +318,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: Run
}
Err(err) => {
let _ = socket
.send(terminal_server_text("error", Some(&display_for_log(&err))))
.send(terminal_server_text("error", Some(&display_for_log(&err, &SecretRedactor))))
.await;
break;
}
@ -326,7 +327,7 @@ async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: Run
}
}
if let Err(err) = session.close().await {
tracing::warn!(error = %display_for_log(&err), run_id = %id, "failed to close run terminal session");
tracing::warn!(error = %display_for_log(&err, &SecretRedactor), run_id = %id, "failed to close run terminal session");
}
}
@ -574,7 +575,11 @@ async fn list_sandbox_files(
})
.into_response()
}
Err(err) => ApiError::new(StatusCode::NOT_FOUND, display_for_log(&err)).into_response(),
Err(err) => ApiError::new(
StatusCode::NOT_FOUND,
display_for_log(&err, &SecretRedactor),
)
.into_response(),
}
}
@ -695,7 +700,11 @@ async fn get_sandbox_file(
};
let path = resolve_path(&params.path, &record.runtime.working_directory);
if let Err(err) = sandbox.fs().download(&path, temp.path()).await {
return ApiError::new(StatusCode::NOT_FOUND, display_for_log(&err)).into_response();
return ApiError::new(
StatusCode::NOT_FOUND,
display_for_log(&err, &SecretRedactor),
)
.into_response();
}
match fs::read(temp.path()).await {
Ok(bytes) => octet_stream_response(bytes.into()),
@ -736,9 +745,11 @@ async fn put_sandbox_file(
let path = resolve_path(&params.path, &record.runtime.working_directory);
match sandbox.fs().upload(temp.path(), &path).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, display_for_log(&err)).into_response()
}
Err(err) => ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
display_for_log(&err, &SecretRedactor),
)
.into_response(),
}
}

View file

@ -15,7 +15,7 @@ use fabro_api::types::{
};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{FabroClient, ModelSelectionError, selection};
use fabro_pebble_sandbox::{PebbleSandbox, SecretRedactor};
use fabro_redact::SecretRedactor;
use fabro_store::{ProjectedRunSession, project_run_session, project_run_sessions};
use fabro_tool::fabro_client::ClientBackend;
use fabro_types::session_event::{
@ -34,6 +34,7 @@ use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, ToolSummary};
use pebble_coding_agent::extensions::{
EnvContext, SystemPromptContext, SystemPromptDecision, SystemPromptTransform,
};
use pebble_coding_agent::sandbox_driver::SandboxEnvironment;
use pebble_coding_agent::tools::{
PermissionMiddleware, ToolPermission, ToolPermissionPolicy, canonical_tool_name,
};
@ -734,7 +735,7 @@ async fn build_agent(
.await
.map_err(AskFabroBuildError::SandboxUnavailable)?;
let environment: Arc<dyn Environment> = Arc::new(
PebbleSandbox::attach(handle, &sandbox_instance.runtime.working_directory)
SandboxEnvironment::attach(handle, &sandbox_instance.runtime.working_directory)
.await
.map_err(|err| AskFabroBuildError::SandboxUnavailable(anyhow::Error::new(err)))?,
);

View file

@ -1,36 +0,0 @@
[package]
name = "fabro-pebble-sandbox"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "A sandbox-driver handle as the Environment pebble's coding agent runs in"
[features]
test-support = ["dep:sandbox-driver-testing"]
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
sandbox-driver.workspace = true
sandbox-driver-testing = { workspace = true, optional = true }
pebble-coding-agent.workspace = true
async-trait.workspace = true
tokio-util.workspace = true
tracing.workspace = true
fabro-redact.workspace = true
fabro-util = { path = "../../foundation/fabro-util" }
fabro-types = { path = "../../foundation/fabro-types" }
[dev-dependencies]
pebble-coding-agent = { workspace = true, features = ["test-util"] }
sandbox-driver-host.workspace = true
sandbox-driver-testing.workspace = true
serde_json.workspace = true
tempfile = "3"
thiserror.workspace = true
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -1,900 +0,0 @@
//! A sandbox-driver handle as the [`Environment`] pebble's coding agent
//! runs in.
//!
//! Pebble's tools speak the `Environment` contract; the sandbox driver
//! speaks facets. [`PebbleSandbox`] is the mapping between the two, and
//! nothing else: every path resolves the way Fabro resolves it (a relative
//! path against the run's working directory, which may sit below the
//! provider's own), every command runs through [`SandboxExec`] with Fabro's
//! exec policy, and every failure keeps its driver cause.
//!
//! Where the two contracts differ, pebble's wins here because the model reads
//! pebble's: a glob that pebble rejects is rejected before the driver sees it,
//! a directory listing is in tree order, and a command with no retention cap
//! still drains under the driver's default buffer rather than without bound.
//! Output a provider lost on its own transport
//! ([`ExecStreamingResult::output_loss`]) has no slot in pebble's contract,
//! so it is written where the model already reads: one line at the end of
//! stderr.
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use fabro_util::workspace_glob::WorkspaceGlob;
use pebble_coding_agent::environment::support::{capture_stats, tree_order, validate_glob};
use pebble_coding_agent::environment::{
DirEntry, EnvResult, Environment, EnvironmentError, EnvironmentErrorKind, ExecOutcome,
ExecOutputSink, ExecOutputStream, ExecRequest, ExecResult, GrepOptions,
};
use pebble_coding_agent::mcp::PortRoutes;
use sandbox_driver::{
ExecControls, ExecSpec, ExecStreamingResult, FileKind, OutputLoss, OutputSink, OutputStream,
Sandbox, Search as _, WalkOptions,
};
use tracing::warn;
use crate::exec::{ExecResultExt as _, SandboxExec, command_termination, program_exit_code};
use crate::path::{join_sandbox_path, resolve_path};
use crate::ports;
/// A sandbox-driver handle working in one directory, as pebble's
/// [`Environment`].
///
/// The handle is a sandbox someone else brought to `Running`: Petri for a
/// run's sandbox, `fabro exec` for the host directory it starts in. The
/// working directory is the run's, which may sit below the handle's own.
pub struct PebbleSandbox {
handle: Arc<dyn Sandbox>,
working_dir: String,
platform: String,
os_version: String,
}
impl PebbleSandbox {
/// Wraps a running `handle` working in `working_dir`, asking the sandbox
/// for its platform once.
pub async fn attach(
handle: Arc<dyn Sandbox>,
working_dir: impl Into<String>,
) -> sandbox_driver::Result<Self> {
let info = handle.platform_info().await?;
let platform = fabro_platform_name(&info.os).to_string();
let os_version = if info.version.is_empty() {
platform.clone()
} else {
format!("{platform} {}", info.version)
};
Ok(Self::with_platform(
handle,
working_dir,
platform,
os_version,
))
}
/// Wraps `handle` with a platform already known, so no round trip to
/// the sandbox is needed before pebble reads it.
#[must_use]
pub fn with_platform(
handle: Arc<dyn Sandbox>,
working_dir: impl Into<String>,
platform: impl Into<String>,
os_version: impl Into<String>,
) -> Self {
Self {
handle,
working_dir: working_dir.into(),
platform: platform.into(),
os_version: os_version.into(),
}
}
/// The driver handle underneath, for the facets pebble's contract does
/// not carry.
#[must_use]
pub fn handle(&self) -> &Arc<dyn Sandbox> {
&self.handle
}
/// The directory the agent works in.
#[must_use]
pub fn working_directory(&self) -> &str {
&self.working_dir
}
/// Fabro's exec policy over the handle's exec facet, working in the
/// agent's directory.
#[must_use]
pub fn exec(&self) -> SandboxExec<'_> {
SandboxExec::new(self.handle.exec()).with_working_dir(self.working_dir.clone())
}
/// Pebble's port routes over the handle's preview URLs, when the
/// provider has them; see [`ports::port_routes`].
#[must_use]
pub fn port_routes(&self) -> Option<Arc<dyn PortRoutes>> {
ports::port_routes(&self.handle)
}
/// A caller path as the driver will see it.
fn resolve(&self, path: &str) -> String {
resolve_path(path, &self.working_dir)
}
async fn file_exists(&self, path: &str) -> EnvResult<bool> {
self.handle
.fs()
.exists(&self.resolve(path))
.await
.map_err(|error| environment_error(&format!("Failed to stat {path}"), error))
}
/// The traversal base the driver walks. A base at the working directory
/// walks relative to it so every path component of `relative_start` is
/// checked against symlinks; any other base is walked as given.
fn walk_base(&self, base: &str, relative_start: &str) -> String {
if base == self.working_dir || base.is_empty() || base == "." {
if relative_start.is_empty() {
".".to_string()
} else {
relative_start.to_string()
}
} else {
join_sandbox_path(&self.resolve(base), relative_start)
}
}
}
/// Fabro names the macOS platform `darwin`, as `uname -s` does.
fn fabro_platform_name(os: &str) -> &str {
match os {
"macos" => "darwin",
other => other,
}
}
#[async_trait]
impl Environment for PebbleSandbox {
fn working_directory(&self) -> &str {
&self.working_dir
}
fn platform(&self) -> &str {
&self.platform
}
fn os_version(&self) -> String {
self.os_version.clone()
}
async fn read_file_bytes(&self, path: &str) -> EnvResult<Vec<u8>> {
self.handle
.fs()
.read(&self.resolve(path))
.await
.map_err(|error| environment_error(&format!("Failed to read {path}"), error))
}
async fn write_file(&self, path: &str, content: &str) -> EnvResult<()> {
self.handle
.fs()
.write(&self.resolve(path), content.as_bytes())
.await
.map_err(|error| environment_error(&format!("Failed to write {path}"), error))
}
async fn rename_file(&self, source: &str, destination: &str) -> EnvResult<()> {
let resolved_source = self.resolve(source);
let resolved_destination = self.resolve(destination);
if !self.file_exists(source).await? {
return Err(EnvironmentError::new(
EnvironmentErrorKind::NotFound,
format!("Failed to move {source}: file does not exist"),
));
}
// The same path spelled twice is a move to itself, which must leave
// the file where it is. Aliases the sandbox's own filesystem would
// resolve (a symlinked parent, a hard link) are not checked: Fabro has
// no remote `realpath`, and a driver `mv a a` is a no-op anyway.
if normalize(&resolved_source) == normalize(&resolved_destination) {
return Ok(());
}
// The destination's parent is created first, and a parent that is a
// file fails here, before anything has moved, so the source stays
// intact as the contract requires.
if let Some(parent) = parent_directory(&resolved_destination) {
self.handle.fs().create_dir(parent).await.map_err(|error| {
environment_error(
&format!("Failed to create the parent directory of {destination}"),
error,
)
})?;
}
self.handle
.fs()
.rename(&resolved_source, &resolved_destination)
.await
.map_err(|error| {
environment_error(&format!("Failed to move {source} to {destination}"), error)
})
}
async fn delete_file(&self, path: &str) -> EnvResult<()> {
// The driver's delete is idempotent; pebble's is a `remove_file`, which
// reports a path that is not there.
if !self.file_exists(path).await? {
return Err(EnvironmentError::new(
EnvironmentErrorKind::NotFound,
format!("Failed to delete {path}: file does not exist"),
));
}
self.handle
.fs()
.delete(&self.resolve(path), false)
.await
.map_err(|error| environment_error(&format!("Failed to delete {path}"), error))
}
async fn file_exists(&self, path: &str) -> EnvResult<bool> {
Self::file_exists(self, path).await
}
async fn list_directory(&self, path: &str, depth: Option<usize>) -> EnvResult<Vec<DirEntry>> {
let mut entries: Vec<DirEntry> = self
.handle
.fs()
.list_dir(&self.resolve(path), depth.unwrap_or(1))
.await
.map_err(|error| environment_error(&format!("Failed to list {path}"), error))?
.into_iter()
.map(|entry| DirEntry {
is_dir: entry.kind == FileKind::Directory,
size: (entry.kind == FileKind::File)
.then_some(entry.size)
.flatten(),
name: entry.path,
})
.collect();
// The driver lists in flat lexicographic order of the whole relative
// path, where `foo-bar` sorts between `foo` and `foo/x`. Pebble lists
// in tree order, and says how.
tree_order(&mut entries);
Ok(entries)
}
async fn grep(
&self,
pattern: &str,
path: &str,
options: &GrepOptions,
) -> EnvResult<Vec<String>> {
let search = self.handle.search().ok_or_else(|| {
EnvironmentError::new(
EnvironmentErrorKind::Unsupported,
"Sandbox provider does not support search",
)
})?;
let mut driver_options = sandbox_driver::GrepOptions::default();
driver_options.case_insensitive = options.case_insensitive;
driver_options.max_matches = options.max_results;
driver_options.include = options.glob_filter.clone();
let matches = search
.grep(pattern, &self.resolve(path), &driver_options)
.await
.map_err(|error| environment_error("Failed to search file contents", error))?;
Ok(matches
.into_iter()
.map(|found| format!("{}:{}:{}", found.path, found.line_number, found.line))
.collect())
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> EnvResult<Vec<String>> {
// Validated by pebble's own grammar before the driver sees the
// pattern, so the reason reaches the model in pebble's words and the
// patterns pebble rejects are rejected even where Fabro's glob would
// accept them.
validate_glob(pattern)?;
let glob = WorkspaceGlob::try_new(pattern).map_err(|error| {
EnvironmentError::with_source(EnvironmentErrorKind::Io, "Invalid glob pattern", error)
})?;
let search = self.handle.search().ok_or_else(|| {
EnvironmentError::new(
EnvironmentErrorKind::Unsupported,
"Sandbox provider does not support search",
)
})?;
let base = path.unwrap_or(&self.working_dir);
let relative_start = glob.traversal_root();
let walked = search
.walk(
&self.walk_base(base, relative_start),
&WalkOptions::default(),
)
.await
.map_err(|error| environment_error("Failed to match files", error))?;
let mut relative_paths: Vec<String> = walked
.into_iter()
.map(|file| join_sandbox_path(relative_start, &file.path))
.filter(|relative_path| glob.is_match(relative_path))
.collect();
relative_paths.sort();
Ok(relative_paths
.into_iter()
.map(|relative_path| join_sandbox_path(base, &relative_path))
.collect())
}
async fn exec(&self, request: ExecRequest<'_>) -> EnvResult<ExecOutcome> {
let ExecRequest {
command,
timeout_ms,
working_dir,
env_vars,
cancel_token,
output_bytes_cap,
output_sink,
} = request;
let mut spec = ExecSpec::bash(command).no_timeout();
if let Some(timeout_ms) = timeout_ms {
spec = spec.timeout(Duration::from_millis(timeout_ms));
}
if let Some(dir) = working_dir {
spec = spec.working_dir(dir);
}
for (key, value) in env_vars.into_iter().flatten() {
spec = spec.env_var(key, value);
}
let controls = ExecControls {
term: cancel_token,
sink: output_sink.map(adapt_output_sink),
// `None` asks pebble for no cap at all. Fabro's exec policy fills
// its default buffer when the cap is unset, so a command with no
// cap drains under that default rather than without bound; the
// capture counts still say what was dropped.
retained_output_limit: output_bytes_cap,
..ExecControls::default()
};
let streaming = self
.exec()
.run_streaming(spec, controls)
.await
.map_err(|error| {
let kind = match &error {
sandbox_driver::Error::Transport(_) => EnvironmentErrorKind::Io,
sandbox_driver::Error::Unsupported { .. } => EnvironmentErrorKind::Unsupported,
_ => EnvironmentErrorKind::Spawn,
};
EnvironmentError::with_source(kind, "Failed to run the command", error)
})?;
Ok(exec_outcome(
streaming,
output_bytes_cap,
program_name(command),
))
}
}
/// Pebble's outcome for a finished command: the driver's result read the way
/// Fabro reads it, plus the provider's own output loss written where the
/// model reads stderr.
///
/// A provider whose transport tore (Daytona's text-only toolbox) completes
/// the command and reports what it discarded in
/// [`ExecStreamingResult::output_loss`] rather than failing it. The frames
/// are gone, the stream they belonged to is unknown, and the counts are of
/// encoded bytes, so they cannot be folded into either stream's capture
/// accounting without guessing; the loss is one line at the end of stderr,
/// where the model and the run log see it, and one log event for the
/// operator. The driver's `truncated` flags on the captures already say the
/// counts undercount.
fn exec_outcome(
streaming: ExecStreamingResult,
output_bytes_cap: Option<usize>,
program: &str,
) -> ExecOutcome {
let loss = streaming.output_loss;
let result = streaming.result;
let mut stderr = result.stderr_lossy();
if loss.is_lossy() {
warn!(
program = %program,
dropped_frames = loss.dropped_frames,
dropped_bytes = loss.dropped_bytes,
"Sandbox provider dropped command output"
);
if !stderr.is_empty() && !stderr.ends_with('\n') {
stderr.push('\n');
}
stderr.push_str(&output_loss_line(loss));
}
ExecOutcome {
result: ExecResult {
stdout: result.stdout_lossy(),
stderr,
exit_code: program_exit_code(result.termination, result.exit_code),
termination: command_termination(result.termination),
duration_ms: result.duration_ms(),
},
streams_separated: streaming.streams_separated,
stdout_capture: capture_stats(streaming.stdout_capture.observed_bytes, output_bytes_cap),
stderr_capture: capture_stats(streaming.stderr_capture.observed_bytes, output_bytes_cap),
}
}
/// The line stderr ends with when the provider dropped output.
fn output_loss_line(loss: OutputLoss) -> String {
format!(
"[sandbox] {} output frame(s), {} bytes dropped by the provider\n",
loss.dropped_frames, loss.dropped_bytes
)
}
/// Bytes of a command's first word a log event carries.
const PROGRAM_NAME_BYTES: usize = 64;
/// The word a command starts with, bounded, for a log event that must not
/// carry the command itself.
fn program_name(command: &str) -> &str {
let word = command.split_whitespace().next().unwrap_or_default();
&word[..word.floor_char_boundary(PROGRAM_NAME_BYTES)]
}
/// A path with its redundant separators and `.` segments removed, for
/// deciding whether two spellings name the same file.
fn normalize(path: &str) -> String {
let absolute = path.starts_with('/');
let joined = path
.split('/')
.filter(|segment| !segment.is_empty() && *segment != ".")
.collect::<Vec<_>>()
.join("/");
if absolute {
format!("/{joined}")
} else {
joined
}
}
/// The directory a path is in, when the path names one.
fn parent_directory(path: &str) -> Option<&str> {
let trimmed = path.trim_end_matches('/');
let (parent, _) = trimmed.rsplit_once('/')?;
if parent.is_empty() {
return Some("/");
}
Some(parent)
}
/// Feeds the driver's asynchronous chunk callback into pebble's synchronous
/// sink.
fn adapt_output_sink(sink: ExecOutputSink) -> OutputSink {
Arc::new(move |stream, chunk: Vec<u8>| {
let stream = match stream {
OutputStream::Stdout => ExecOutputStream::Stdout,
OutputStream::Stderr => ExecOutputStream::Stderr,
};
sink(stream, &chunk);
Box::pin(async { Ok(()) })
})
}
/// A sandbox failure as pebble classifies it, keeping the driver cause.
fn environment_error(message: &str, error: sandbox_driver::Error) -> EnvironmentError {
let kind = match &error {
sandbox_driver::Error::NotFound { .. } => EnvironmentErrorKind::NotFound,
sandbox_driver::Error::Unsupported { .. } => EnvironmentErrorKind::Unsupported,
_ => EnvironmentErrorKind::Io,
};
EnvironmentError::with_source(kind, message, error)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use pebble_coding_agent::test_support::EnvironmentContract;
use sandbox_driver::{
Capabilities, Exec, Filesystem, PlatformInfo, SandboxId, SandboxProvider as _,
SandboxSource, SandboxSpec, SandboxStatus, Search, SpawnSpec, StdioProcess, Termination,
};
use sandbox_driver_host::HostProvider;
use sandbox_driver_testing::ScriptedSandbox;
use tokio::fs;
use super::*;
use crate::test_support::{MockSandbox, exec_result};
/// The environment over the driver's Host provider, in a directory that
/// goes away with the test.
async fn host_environment() -> (tempfile::TempDir, HostProvider, PebbleSandbox) {
let directory = tempfile::tempdir().expect("a temporary directory");
let provider = HostProvider::new();
let handle = provider
.create(
&SandboxSpec::new(SandboxSource::HostDirectory)
.working_directory(directory.path().display().to_string()),
None,
)
.await
.expect("a host sandbox");
let working_dir = handle.working_directory().to_string();
let sandbox = PebbleSandbox::attach(handle, working_dir)
.await
.expect("the host platform");
(directory, provider, sandbox)
}
#[tokio::test]
async fn host_files_satisfy_pebbles_environment_contract() {
let (_directory, _provider, sandbox) = host_environment().await;
EnvironmentContract::new(&sandbox, "contract")
.verify_files()
.await
.expect("file contract");
}
#[tokio::test]
async fn host_search_satisfies_pebbles_environment_contract() {
let (_directory, _provider, sandbox) = host_environment().await;
EnvironmentContract::new(&sandbox, "contract")
.verify_search()
.await
.expect("search contract");
}
#[tokio::test]
async fn host_commands_satisfy_pebbles_environment_contract() {
let (_directory, _provider, sandbox) = host_environment().await;
EnvironmentContract::new(&sandbox, "contract")
.verify_commands()
.await
.expect("command contract");
}
#[tokio::test]
async fn the_platform_is_learned_from_the_sandbox() {
let (_directory, _provider, sandbox) = host_environment().await;
let expected = if cfg!(target_os = "macos") {
"darwin"
} else {
std::env::consts::OS
};
assert_eq!(Environment::platform(&sandbox), expected);
assert!(sandbox.os_version().starts_with(expected));
}
#[tokio::test]
async fn a_directory_listing_is_in_tree_order() {
let (directory, provider, sandbox) = host_environment().await;
for name in ["foo/x.txt", "foo-bar/y.txt", "foo.txt"] {
Environment::write_file(&sandbox, name, "content")
.await
.expect("fixture");
}
let names: Vec<String> = Environment::list_directory(&sandbox, ".", Some(2))
.await
.expect("listing")
.into_iter()
.map(|entry| entry.name)
.collect();
assert_eq!(names, [
"foo",
"foo/x.txt",
"foo-bar",
"foo-bar/y.txt",
"foo.txt"
]);
drop((directory, provider));
}
#[tokio::test]
async fn glob_reports_paths_under_the_declared_base_and_skips_symlinks() {
let (directory, provider, sandbox) = host_environment().await;
let root = directory.path();
fs::create_dir_all(root.join(".ai/reports")).await.unwrap();
fs::create_dir_all(root.join(".ai/target")).await.unwrap();
fs::write(root.join(".ai/reports/result.md"), "report")
.await
.unwrap();
fs::write(root.join(".ai/reports/empty.md"), "")
.await
.unwrap();
fs::write(root.join(".ai/target/ignored.md"), "ignored")
.await
.unwrap();
let working_dir = sandbox.working_directory().to_string();
let globbed = Environment::glob(&sandbox, "**/*.md", None).await.unwrap();
assert_eq!(globbed, vec![
format!("{working_dir}/.ai/reports/empty.md"),
format!("{working_dir}/.ai/reports/result.md"),
format!("{working_dir}/.ai/target/ignored.md"),
]);
let scoped = Environment::glob(&sandbox, "*.md", Some(".ai/reports"))
.await
.unwrap();
assert_eq!(scoped.len(), 2);
#[cfg(unix)]
{
let target = root.join("elsewhere");
fs::create_dir_all(&target).await.unwrap();
fs::write(target.join("lib.rs"), "").await.unwrap();
std::os::unix::fs::symlink(&target, root.join("linked")).unwrap();
let results = Environment::glob(&sandbox, "linked/**/*.rs", None)
.await
.unwrap();
assert!(results.is_empty(), "{results:?}");
}
drop((directory, provider));
}
#[tokio::test]
async fn grep_returns_path_line_content_triples() {
let (directory, provider, sandbox) = host_environment().await;
fs::write(
directory.path().join("test.rs"),
"fn main() {\n println!(\"hello\");\n}\n",
)
.await
.unwrap();
let results = Environment::grep(&sandbox, "println", "test.rs", &GrepOptions::default())
.await
.unwrap();
// The path is resolved against the working directory before the
// driver sees it, and comes back as the driver reports it.
let working_dir = sandbox.working_directory();
assert_eq!(results, [format!(
"{working_dir}/test.rs:2: println!(\"hello\");"
)]);
drop((directory, provider));
}
#[test]
fn a_path_spelled_two_ways_is_one_path() {
assert_eq!(normalize("/work//a/./b.txt"), "/work/a/b.txt");
assert_eq!(parent_directory("/work/a/b.txt"), Some("/work/a"));
assert_eq!(parent_directory("/b.txt"), Some("/"));
assert_eq!(parent_directory("b.txt"), None);
}
fn request(command: &str) -> ExecRequest<'_> {
ExecRequest {
command,
timeout_ms: Some(10_000),
working_dir: None,
env_vars: None,
cancel_token: None,
output_bytes_cap: None,
output_sink: None,
}
}
fn output_loss(dropped_frames: u64, dropped_bytes: u64) -> OutputLoss {
let mut loss = OutputLoss::default();
loss.dropped_frames = dropped_frames;
loss.dropped_bytes = dropped_bytes;
loss
}
#[tokio::test]
async fn a_lossless_command_hands_back_stderr_as_the_provider_wrote_it() {
let mock = MockSandbox {
exec_result: exec_result(
"built\n",
"warning: unused\n",
Some(0),
Termination::Exited,
7,
),
..MockSandbox::linux()
};
let outcome = Environment::exec(&*mock.sandbox(), request("cargo build"))
.await
.expect("a scripted command");
assert_eq!(outcome.result.stdout, "built\n");
assert_eq!(outcome.result.stderr, "warning: unused\n");
assert_eq!(outcome.result.exit_code, Some(0));
assert_eq!(
outcome.stderr_capture.observed_bytes,
"warning: unused\n".len()
);
// The command ran in the mock's working directory under Fabro's
// stop grace.
let spec = mock.driver().scripted_exec().recorded().pop().unwrap();
assert_eq!(spec.working_dir.as_deref(), Some("/home/test"));
assert_eq!(spec.stop_grace, Some(crate::DEFAULT_STOP_GRACE));
}
#[test]
fn a_provider_output_loss_ends_stderr_with_one_line() {
let mut streaming = ExecStreamingResult::new(exec_result(
"built\n",
"warning: torn",
Some(1),
Termination::Exited,
7,
));
streaming.output_loss = output_loss(2, 4096);
let outcome = exec_outcome(streaming, Some(1024), "cargo");
assert_eq!(outcome.result.stdout, "built\n");
assert_eq!(
outcome.result.stderr,
"warning: torn\n[sandbox] 2 output frame(s), 4096 bytes dropped by the provider\n"
);
assert_eq!(outcome.result.exit_code, Some(1));
assert_eq!(outcome.result.duration_ms, 7);
// The loss is not folded into either stream's accounting.
assert_eq!(outcome.stdout_capture.observed_bytes, "built\n".len());
assert_eq!(outcome.stderr_capture.observed_bytes, "warning: torn".len());
}
#[test]
fn a_provider_output_loss_with_no_stderr_is_the_line_alone() {
let mut streaming =
ExecStreamingResult::new(exec_result("", "", Some(0), Termination::Exited, 1));
streaming.output_loss = output_loss(1, 80);
let outcome = exec_outcome(streaming, None, "sh");
assert_eq!(
outcome.result.stderr,
"[sandbox] 1 output frame(s), 80 bytes dropped by the provider\n"
);
}
#[test]
fn a_log_event_names_the_first_word_of_a_command_bounded() {
assert_eq!(program_name("cargo build --release"), "cargo");
assert_eq!(program_name(" \n ls"), "ls");
assert_eq!(program_name(""), "");
let long = "x".repeat(PROGRAM_NAME_BYTES + 10);
assert_eq!(program_name(&long).len(), PROGRAM_NAME_BYTES);
let multibyte = "é".repeat(PROGRAM_NAME_BYTES);
assert!(program_name(&multibyte).len() <= PROGRAM_NAME_BYTES);
}
/// The driver's scripted sandbox with an exec facet that reports a
/// provider output loss on every command, as Daytona does after a torn
/// frame. The scripted double itself has no knob for the loss.
struct LossySandbox {
inner: Arc<ScriptedSandbox>,
exec: LossyExec,
}
struct LossyExec {
inner: Arc<ScriptedSandbox>,
loss: OutputLoss,
}
impl LossySandbox {
fn new(inner: Arc<ScriptedSandbox>, loss: OutputLoss) -> Self {
Self {
exec: LossyExec {
inner: Arc::clone(&inner),
loss,
},
inner,
}
}
}
#[async_trait]
impl Exec for LossyExec {
async fn run(&self, spec: &ExecSpec) -> sandbox_driver::Result<sandbox_driver::ExecResult> {
self.inner.scripted_exec().run(spec).await
}
async fn run_streaming(
&self,
spec: &ExecSpec,
controls: ExecControls,
) -> sandbox_driver::Result<ExecStreamingResult> {
let mut streaming = self
.inner
.scripted_exec()
.run_streaming(spec, controls)
.await?;
streaming.output_loss = self.loss;
streaming.stdout_capture.truncated = true;
streaming.stderr_capture.truncated = true;
Ok(streaming)
}
async fn spawn_stdio(&self, spec: &SpawnSpec) -> sandbox_driver::Result<StdioProcess> {
self.inner.scripted_exec().spawn_stdio(spec).await
}
}
#[async_trait]
impl Sandbox for LossySandbox {
fn id(&self) -> &SandboxId {
self.inner.id()
}
fn capabilities(&self) -> &Capabilities {
// The scripted sandbox's builder method of the same name shadows
// the trait's.
Sandbox::capabilities(&*self.inner)
}
async fn describe(&self) -> sandbox_driver::Result<SandboxStatus> {
self.inner.describe().await
}
fn working_directory(&self) -> &str {
self.inner.working_directory()
}
async fn environment(&self) -> sandbox_driver::Result<BTreeMap<String, String>> {
self.inner.environment().await
}
fn runtime_directory(&self) -> Option<&str> {
Sandbox::runtime_directory(&*self.inner)
}
async fn platform_info(&self) -> sandbox_driver::Result<PlatformInfo> {
self.inner.platform_info().await
}
async fn start(&self) -> sandbox_driver::Result<()> {
self.inner.start().await
}
async fn stop(&self) -> sandbox_driver::Result<()> {
self.inner.stop().await
}
async fn delete(&self) -> sandbox_driver::Result<()> {
self.inner.delete().await
}
fn exec(&self) -> &dyn Exec {
&self.exec
}
fn fs(&self) -> &dyn Filesystem {
self.inner.fs()
}
fn provider_search(&self) -> Option<&dyn Search> {
self.inner.provider_search()
}
}
#[tokio::test]
async fn a_lossy_command_tells_the_model_what_the_provider_dropped() {
let scripted =
Arc::new(
ScriptedSandbox::with_id_and_working_dir("lossy", "/work")
.platform(PlatformInfo::new("linux", "x86_64", "Linux 6.1.0")),
);
scripted.scripted_exec().set_default(exec_result(
"built\n",
"warning: torn",
Some(0),
Termination::Exited,
7,
));
let sandbox = PebbleSandbox::with_platform(
Arc::new(LossySandbox::new(scripted, output_loss(3, 512))),
"/work",
"linux",
"Linux 6.1.0",
);
let outcome = Environment::exec(&sandbox, request("cargo build"))
.await
.expect("a lossy command completes rather than fails");
assert_eq!(outcome.result.stdout, "built\n");
assert_eq!(
outcome.result.stderr,
"warning: torn\n[sandbox] 3 output frame(s), 512 bytes dropped by the provider\n"
);
assert_eq!(outcome.result.exit_code, Some(0));
assert!(outcome.streams_separated);
}
}

View file

@ -1,641 +0,0 @@
//! Fabro's command execution policy over the sandbox-driver [`Exec`] facet.
//!
//! The vocabulary is the driver's own: an [`ExecSpec`] and [`ExecControls`]
//! go in, an [`ExecResult`] or [`ExecStreamingResult`] comes out. This
//! module adds Fabro's policy on the way in and Fabro's reading of a result
//! on the way out.
//!
//! A command runs as Bash source under `bash -c` with `BASH_ENV` blanked by
//! the driver whatever the caller passed, and ends in one of three ways:
//!
//! - **timeout**: the spec's timeout fires and the provider runs the stop
//! ladder Fabro asks for: `TERM`, then `KILL` after
//! [`SandboxExec::stop_grace`]. The result reports [`Termination::TimedOut`].
//! - **cancellation**: the caller's [`CancellationToken`] is the `term` stop;
//! the provider escalates to `KILL` after the same grace. The result reports
//! [`Termination::Cancelled`].
//! - **exit**: the process ended on its own.
//!
//! Output is drained regardless of the retention cap and delivered live
//! through the caller's [`sandbox_driver::OutputSink`]. Fabro reads command
//! output as text, so the policy asks the driver for
//! [`OutputSanitization::StripAll`]: terminal escape sequences and stray
//! control characters never reach a result, a sink chunk, or a tail. Secret
//! redaction stays Fabro's job and happens only when a tail is rendered for
//! events or logs ([`redacted_output_tail`]). The explicit environment
//! reaches the provider as the caller composed it: the driver filters
//! credential-shaped names out of the *inherited* host environment itself
//! and treats the spec's own variables as the deliberate channel for
//! secrets, so Fabro adds no filter of its own.
use std::collections::HashMap;
use std::time::Duration;
use fabro_types::{CommandTermination, ExecOutputTail};
use sandbox_driver::{
Exec, ExecControls, ExecResult, ExecSpec, ExecStreamingResult, OutputSanitization, Termination,
};
use tokio_util::sync::CancellationToken;
/// Time between `TERM` and `KILL` when Fabro stops a command.
pub const DEFAULT_STOP_GRACE: Duration = Duration::from_secs(2);
/// Retention when a caller sets no cap: enough for any build log Fabro
/// renders, bounded so a runaway command cannot exhaust memory.
pub const DEFAULT_RETAINED_OUTPUT_BYTES: usize = sandbox_driver::DEFAULT_BUFFER_BYTES;
/// How much of each output stream a redacted tail keeps by default.
pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024;
/// Fabro's exec policy bound to one driver [`Exec`] facet.
pub struct SandboxExec<'a> {
exec: &'a dyn Exec,
stop_grace: Duration,
/// Where a command runs when the caller names no directory. `None`
/// leaves the choice to the provider's own working directory.
working_dir: Option<String>,
}
impl<'a> SandboxExec<'a> {
#[must_use]
pub fn new(exec: &'a dyn Exec) -> Self {
Self {
exec,
stop_grace: DEFAULT_STOP_GRACE,
working_dir: None,
}
}
/// The directory commands run in when the caller names none. Fabro's
/// working directory can sit below the provider's, so it is passed
/// explicitly.
#[must_use]
pub fn with_working_dir(mut self, working_dir: impl Into<String>) -> Self {
self.working_dir = Some(working_dir.into());
self
}
/// Time between `TERM` and `KILL` when a command is stopped; the
/// provider runs the ladder.
#[must_use]
pub fn with_stop_grace(mut self, stop_grace: Duration) -> Self {
self.stop_grace = stop_grace;
self
}
#[must_use]
pub fn stop_grace(&self) -> Duration {
self.stop_grace
}
/// Runs Bash source to completion and returns its captured output.
///
/// Equivalent to `bash -c <command>` with a clean, non-login shell: no
/// `errexit`, no `pipefail`, `BASH_ENV` blanked. A caller that wants
/// different semantics writes them into the command. `None` for
/// `timeout` runs without a deadline.
pub async fn run(
&self,
command: &str,
timeout: Option<Duration>,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> sandbox_driver::Result<ExecResult> {
let mut spec = ExecSpec::bash(command).no_timeout();
if let Some(timeout) = timeout {
spec = spec.timeout(timeout);
}
if let Some(dir) = working_dir {
spec = spec.working_dir(dir);
}
for (key, value) in env_vars.into_iter().flatten() {
spec = spec.env_var(key, value);
}
let controls = ExecControls {
term: cancel_token,
..ExecControls::default()
};
Ok(self.run_streaming(spec, controls).await?.result)
}
/// Runs `spec` under Fabro's policy, delivering output through
/// `controls.sink` as it arrives.
///
/// The policy fills what the spec leaves open: the stop grace, the
/// working directory, and the text output policy. The spec's environment
/// goes to the provider as the caller composed it. The caller's
/// `controls.term` is the `term` stop; the provider runs the grace and
/// the `kill` itself. Output beyond `controls.retained_output_limit`
/// (Fabro's default when unset) is drained and counted, not kept.
pub async fn run_streaming(
&self,
spec: ExecSpec,
mut controls: ExecControls,
) -> sandbox_driver::Result<ExecStreamingResult> {
let spec = self.apply_policy(spec);
if controls.retained_output_limit.is_none() {
controls.retained_output_limit = Some(DEFAULT_RETAINED_OUTPUT_BYTES);
}
self.exec.run_streaming(&spec, controls).await
}
/// Fills what a spec leaves open. The output policy has no "unset"
/// state: the driver's default is raw, and Fabro reads command output
/// as text, so a spec still at that default gets
/// [`OutputSanitization::StripAll`]; a caller that chose another policy
/// keeps it.
fn apply_policy(&self, mut spec: ExecSpec) -> ExecSpec {
if spec.stop_grace.is_none() {
spec.stop_grace = Some(self.stop_grace);
}
if spec.working_dir.is_none() {
spec.working_dir.clone_from(&self.working_dir);
}
if spec.output_sanitization == OutputSanitization::default() {
spec.output_sanitization = OutputSanitization::StripAll;
}
spec
}
}
/// The driver says how the command ended; Fabro's event vocabulary has two
/// stops. A timeout is the provider's deadline (the ladder ran for it); a
/// cancelled or killed command was stopped by the caller's token, by a
/// foreign `kill`, or by a provider-side abort: it did not finish and no
/// deadline passed. `Exited`, or a provider that could not tell, is a
/// completed process; nothing asserts success here.
#[must_use]
pub fn command_termination(termination: Termination) -> CommandTermination {
match termination {
Termination::TimedOut => CommandTermination::TimedOut,
Termination::Cancelled | Termination::Killed => CommandTermination::Cancelled,
_ => CommandTermination::Exited,
}
}
/// An exit code is only the command's own when it exited on its own. A
/// stopped command may still report the shell's `128 + signal` (143 for a
/// trapped `TERM`), which events must not present as a program result.
#[must_use]
pub fn program_exit_code(termination: Termination, exit_code: Option<i32>) -> Option<i32> {
// `CommandTermination` is pebble's and non-exhaustive: only a command
// that exited on its own owns its exit code.
match command_termination(termination) {
CommandTermination::Exited => exit_code,
_ => None,
}
}
/// Fabro's reading of a driver [`ExecResult`]: the event-facing numbers.
pub trait ExecResultExt {
/// The provider's measured run time in whole milliseconds.
fn duration_ms(&self) -> u64;
/// The exit code when the command ended on its own; see
/// [`program_exit_code`].
fn program_exit_code(&self) -> Option<i32>;
}
impl ExecResultExt for ExecResult {
fn duration_ms(&self) -> u64 {
u64::try_from(self.duration.as_millis()).unwrap_or(u64::MAX)
}
fn program_exit_code(&self) -> Option<i32> {
program_exit_code(self.termination, self.exit_code)
}
}
/// A redacted [`ExecOutputTail`] from stdout/stderr text. Each stream is
/// redacted, then capped to its newest `max_bytes_per_stream`. Terminal
/// control sequences are not stripped here: command output reaches Fabro
/// with them already removed by the driver under [`SandboxExec`]'s output
/// policy. Pass `""` for either stream that isn't relevant. Returns `None`
/// when both streams are empty.
#[must_use]
pub fn redacted_output_tail(
stdout: &str,
stderr: &str,
max_bytes_per_stream: usize,
) -> Option<ExecOutputTail> {
let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream);
let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream);
let tail = ExecOutputTail {
stdout,
stderr,
stdout_truncated,
stderr_truncated,
};
(!tail.is_empty()).then_some(tail)
}
fn redacted_tail(text: &str, max_bytes: usize) -> (Option<String>, bool) {
if text.is_empty() || max_bytes == 0 {
return (None, !text.is_empty());
}
let redacted = fabro_redact::redact_string(text);
let truncated = redacted.len() > max_bytes;
let start = if truncated {
redacted.floor_char_boundary(redacted.len() - max_bytes)
} else {
0
};
let tail = redacted[start..].to_string();
((!tail.is_empty()).then_some(tail), truncated)
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use std::time::Instant;
use sandbox_driver::{
BASH_ENV_VAR, OutputSink, OutputStream, SandboxProvider as _, SandboxSource, SandboxSpec,
TransportError,
};
use sandbox_driver_host::HostProvider;
use tokio::{fs, time};
use super::*;
struct HostFixture {
workspace: tempfile::TempDir,
_provider: HostProvider,
sandbox: Arc<dyn sandbox_driver::Sandbox>,
}
impl HostFixture {
async fn new() -> Self {
let workspace = tempfile::tempdir().unwrap();
let provider = HostProvider::new();
let sandbox = provider
.create(
&SandboxSpec::new(SandboxSource::HostDirectory)
.working_directory(workspace.path().display().to_string()),
None,
)
.await
.unwrap();
Self {
workspace,
_provider: provider,
sandbox,
}
}
fn exec(&self) -> SandboxExec<'_> {
SandboxExec::new(self.sandbox.exec())
}
}
async fn run(fixture: &HostFixture, command: &str) -> ExecResult {
fixture
.exec()
.run(command, Some(Duration::from_secs(10)), None, None, None)
.await
.unwrap()
}
fn exec_result(stdout: &str, exit_code: Option<i32>, duration_ms: u64) -> ExecResult {
let mut result = ExecResult::new(
Termination::Exited,
exit_code,
Duration::from_millis(duration_ms),
);
result.stdout = stdout.as_bytes().to_vec();
result
}
#[tokio::test]
async fn runs_bash_source_and_reports_exit_code_and_streams() {
let fixture = HostFixture::new().await;
let result = run(&fixture, "echo out; echo err >&2; exit 3").await;
assert_eq!(result.stdout_lossy(), "out\n");
assert_eq!(result.stderr_lossy(), "err\n");
assert_eq!(result.exit_code, Some(3));
assert_eq!(result.termination, Termination::Exited);
assert!(!result.success());
assert!(run(&fixture, "true").await.success());
}
#[tokio::test]
async fn runs_bash_only_syntax_in_a_clean_non_login_shell() {
let fixture = HostFixture::new().await;
let result = run(
&fixture,
"[[ -n ${BASH_VERSION:-} ]] && shopt -q login_shell && echo login || echo nonlogin; \
set -o | grep -E '^(errexit|pipefail)' | awk '{print $2}' | sort -u",
)
.await;
assert_eq!(result.stdout_lossy(), "nonlogin\noff\n", "{result:?}");
}
#[tokio::test]
async fn a_caller_supplied_bash_env_never_runs() {
let fixture = HostFixture::new().await;
let startup = fixture.workspace.path().join("startup.sh");
fs::write(&startup, "echo startup-source-loaded\n")
.await
.unwrap();
let env = HashMap::from([(BASH_ENV_VAR.to_string(), startup.display().to_string())]);
let result = fixture
.exec()
.run(
"echo body",
Some(Duration::from_secs(10)),
None,
Some(&env),
None,
)
.await
.unwrap();
assert_eq!(result.stdout_lossy(), "body\n");
}
#[tokio::test]
async fn explicit_variables_reach_the_command_as_composed() {
let fixture = HostFixture::new().await;
let env = HashMap::from([
("FABRO_WORKER_TOKEN".to_string(), "deliberate".to_string()),
("MY_VAR".to_string(), "ok".to_string()),
]);
let stdout = fixture
.exec()
.run("env", Some(Duration::from_secs(10)), None, Some(&env), None)
.await
.unwrap()
.stdout_lossy();
assert!(stdout.contains("FABRO_WORKER_TOKEN=deliberate"), "{stdout}");
assert!(stdout.contains("MY_VAR=ok"), "{stdout}");
}
#[tokio::test]
async fn the_working_directory_applies_when_the_caller_names_none() {
let fixture = HostFixture::new().await;
let nested = fixture.workspace.path().join("nested");
fs::create_dir_all(&nested).await.unwrap();
let stdout = SandboxExec::new(fixture.sandbox.exec())
.with_working_dir(nested.display().to_string())
.run("pwd", Some(Duration::from_secs(10)), None, None, None)
.await
.unwrap()
.stdout_lossy();
assert_eq!(
std::path::Path::new(stdout.trim()).canonicalize().unwrap(),
nested.canonicalize().unwrap()
);
}
#[tokio::test]
async fn timeout_runs_the_ladder_and_reports_timed_out() {
let fixture = HostFixture::new().await;
let started = Instant::now();
let result = fixture
.exec()
.run(
"sleep 10",
Some(Duration::from_millis(200)),
None,
None,
None,
)
.await
.unwrap();
assert_eq!(result.termination, Termination::TimedOut);
assert_eq!(result.program_exit_code(), None);
assert!(
started.elapsed() < Duration::from_secs(5),
"sleep honours TERM, so KILL should not have been needed"
);
}
#[tokio::test]
async fn a_command_that_ignores_term_is_killed_after_the_grace_period() {
let fixture = HostFixture::new().await;
let started = Instant::now();
let result = fixture
.exec()
.with_stop_grace(Duration::from_millis(300))
.run(
"trap '' TERM; sleep 10",
Some(Duration::from_millis(100)),
None,
None,
None,
)
.await
.unwrap();
assert_eq!(result.termination, Termination::TimedOut);
let elapsed = started.elapsed();
assert!(elapsed >= Duration::from_millis(400), "{elapsed:?}");
assert!(elapsed < Duration::from_secs(5), "{elapsed:?}");
}
#[tokio::test]
async fn cancellation_reports_cancelled() {
let fixture = HostFixture::new().await;
let token = CancellationToken::new();
let cancel = token.clone();
tokio::spawn(async move {
time::sleep(Duration::from_millis(100)).await;
cancel.cancel();
});
let result = fixture
.exec()
.run(
"sleep 10",
Some(Duration::from_secs(30)),
None,
None,
Some(token),
)
.await
.unwrap();
assert_eq!(result.termination, Termination::Cancelled);
assert_eq!(result.program_exit_code(), None);
}
#[tokio::test]
async fn streaming_delivers_live_chunks_and_drains_past_the_retention_cap() {
let fixture = HostFixture::new().await;
let seen = Arc::new(Mutex::new(Vec::<u8>::new()));
let sink_seen = Arc::clone(&seen);
let sink: OutputSink = Arc::new(move |stream, chunk| {
let seen = Arc::clone(&sink_seen);
Box::pin(async move {
assert_eq!(stream, OutputStream::Stdout);
seen.lock().unwrap().extend_from_slice(&chunk);
Ok(())
})
});
let streaming = fixture
.exec()
.run_streaming(
ExecSpec::bash("for i in $(seq 1 200); do echo line-$i; done")
.timeout(Duration::from_secs(10)),
ExecControls {
sink: Some(sink),
retained_output_limit: Some(64),
..ExecControls::default()
},
)
.await
.unwrap();
assert!(streaming.result.success());
assert!(streaming.live_streaming);
assert!(streaming.streams_separated);
let delivered = seen.lock().unwrap().len();
assert_eq!(streaming.stdout_capture.observed_bytes, delivered);
assert!(streaming.stdout_capture.omitted_bytes > 0);
assert!(streaming.result.stdout.len() <= 64);
assert!(streaming.result.stdout.starts_with(b"line-1\n"));
assert!(streaming.result.stdout.ends_with(b"line-200\n"));
}
#[tokio::test]
async fn stdin_bytes_are_written_exactly_then_closed() {
let fixture = HostFixture::new().await;
let stdin = b"first line\n$(touch must-not-run)\nlast line".to_vec();
let streaming = fixture
.exec()
.run_streaming(
ExecSpec::bash("cat; test -e must-not-run && echo RAN")
.timeout(Duration::from_secs(10))
.stdin(stdin.clone()),
ExecControls::default(),
)
.await
.unwrap();
assert_eq!(streaming.result.stdout, stdin);
}
#[tokio::test]
async fn a_failing_output_sink_stops_the_command_with_an_error() {
let fixture = HostFixture::new().await;
let sink: OutputSink = Arc::new(|_, _| {
Box::pin(async {
Err(sandbox_driver::Error::Transport(TransportError::new(
"consumer gave up",
)))
})
});
let error = fixture
.exec()
.run_streaming(
ExecSpec::bash("echo hello; sleep 5").timeout(Duration::from_secs(10)),
ExecControls {
sink: Some(sink),
..ExecControls::default()
},
)
.await
.map(|streaming| streaming.result.termination);
// The driver either surfaces the sink failure or reports the command
// cancelled by it; both keep the consumer's error visible.
match error {
Ok(termination) => assert_eq!(termination, Termination::Cancelled),
Err(error) => assert!(error.to_string().contains("consumer gave up"), "{error}"),
}
}
#[test]
fn termination_mapping_reads_the_drivers_verdict() {
assert_eq!(
command_termination(Termination::TimedOut),
CommandTermination::TimedOut
);
assert_eq!(
command_termination(Termination::Cancelled),
CommandTermination::Cancelled
);
assert_eq!(
command_termination(Termination::Killed),
CommandTermination::Cancelled
);
assert_eq!(
command_termination(Termination::Exited),
CommandTermination::Exited
);
}
#[test]
fn program_exit_code_is_the_commands_own_only_when_it_exited() {
assert_eq!(program_exit_code(Termination::Exited, Some(3)), Some(3));
assert_eq!(program_exit_code(Termination::TimedOut, Some(143)), None);
assert_eq!(program_exit_code(Termination::Cancelled, Some(143)), None);
assert_eq!(program_exit_code(Termination::Killed, Some(137)), None);
assert_eq!(exec_result("", Some(3), 42).duration_ms(), 42);
}
#[test]
fn output_tail_redacts_before_truncating() {
let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
let tail =
redacted_output_tail(&format!("{} {secret} done", "context ".repeat(20)), "", 32)
.expect("redacted output tail");
let stdout = tail.stdout.expect("stdout tail");
assert!(stdout.contains("REDACTED"), "{stdout}");
assert!(!stdout.contains("F0gH3jE6pA"), "{stdout}");
assert!(tail.stdout_truncated);
assert!(redacted_output_tail("", "", 32).is_none());
}
#[tokio::test]
async fn command_output_arrives_stripped_of_terminal_control_sequences() {
let fixture = HostFixture::new().await;
let result = run(
&fixture,
"printf '\\033[31mred\\033[0m \\033]0;window-title\\007shown \\033(Bset \\033Mtwo-byte \
\\bbackspace'",
)
.await;
assert!(result.success(), "{result:?}");
assert_eq!(result.stdout_lossy(), "red shown set two-byte backspace");
}
#[tokio::test]
async fn policy_strips_output_unless_the_caller_chose_another_policy() {
let fixture = HostFixture::new().await;
let exec = fixture.exec();
assert_eq!(
exec.apply_policy(ExecSpec::bash("true"))
.output_sanitization,
OutputSanitization::StripAll
);
assert_eq!(
exec.apply_policy(
ExecSpec::bash("true").output_sanitization(OutputSanitization::StripAnsi)
)
.output_sanitization,
OutputSanitization::StripAnsi
);
}
#[test]
fn default_output_tail_serialized_budget_stays_below_40_kib() {
let tail = redacted_output_tail(
&"o".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128),
&"e".repeat(DEFAULT_EXEC_OUTPUT_TAIL_BYTES + 128),
DEFAULT_EXEC_OUTPUT_TAIL_BYTES,
)
.expect("tail present");
assert_eq!(
tail.stdout.as_deref().map(str::len),
Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)
);
assert_eq!(
tail.stderr.as_deref().map(str::len),
Some(DEFAULT_EXEC_OUTPUT_TAIL_BYTES)
);
assert!(tail.stdout_truncated);
assert!(tail.stderr_truncated);
let serialized = serde_json::to_vec(&tail).expect("serialize tail");
assert!(
serialized.len() < 40 * 1024,
"tail JSON was {} bytes",
serialized.len()
);
}
}

View file

@ -1,35 +0,0 @@
//! A sandbox-driver handle as the [`Environment`] pebble's coding agent runs
//! in.
//!
//! Petri creates and owns every run sandbox through the sandbox driver;
//! Fabro attaches to one for Ask Fabro, and `fabro exec` creates a host
//! sandbox of its own. Pebble's tools speak the `Environment` contract; the
//! driver speaks facets. This crate is the mapping between the two, and
//! Fabro's policy on the way through: [`PebbleSandbox`] resolves paths the
//! way Fabro resolves them and runs commands under [`SandboxExec`]'s exec
//! policy; [`SandboxPortRoutes`] answers pebble's port routing with the
//! driver's preview URLs; [`SecretRedactor`] is Fabro's secret scanner on
//! the text pebble hands the model; [`display_for_log`] renders a driver
//! failure with its redacted output tail.
//!
//! [`Environment`]: pebble_coding_agent::environment::Environment
mod environment;
mod exec;
mod log;
mod path;
mod ports;
mod redact;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub use environment::PebbleSandbox;
pub use exec::{
DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DEFAULT_RETAINED_OUTPUT_BYTES, DEFAULT_STOP_GRACE,
ExecResultExt, SandboxExec, command_termination, program_exit_code, redacted_output_tail,
};
pub use log::{default_redacted_output_tail, display_for_log};
pub use path::{join_sandbox_path, resolve_path};
pub use ports::{SandboxPortRoutes, port_routes};
pub use redact::SecretRedactor;

View file

@ -1,151 +0,0 @@
//! A sandbox failure rendered for a log or an error response: the cause
//! chain, and the redacted tail of the output a failed command or git
//! operation left behind.
use std::fmt::Write as _;
use fabro_types::ExecOutputTail;
use fabro_util::error::{collect_causes, render_with_causes};
use crate::exec::{DEFAULT_EXEC_OUTPUT_TAIL_BYTES, redacted_output_tail};
/// The redacted output tail of the first sandbox-driver failure in `err`'s
/// cause chain that carries command output: a command that ran and failed,
/// or a git operation whose command output the driver kept as evidence.
#[must_use]
pub fn default_redacted_output_tail(
err: &(dyn std::error::Error + 'static),
) -> Option<ExecOutputTail> {
let mut current = Some(err);
while let Some(err) = current {
if let Some(driver) = err.downcast_ref::<sandbox_driver::Error>() {
if let Some(tail) = driver_output_tail(driver) {
return Some(tail);
}
}
current = err.source();
}
None
}
fn driver_output_tail(error: &sandbox_driver::Error) -> Option<ExecOutputTail> {
let failure = match error {
sandbox_driver::Error::Exec(failure) => failure,
sandbox_driver::Error::Git(git) => git.output()?,
_ => return None,
};
redacted_output_tail(
&String::from_utf8_lossy(failure.stdout()),
&String::from_utf8_lossy(failure.stderr()),
DEFAULT_EXEC_OUTPUT_TAIL_BYTES,
)
}
/// `err` with its causes, followed by the redacted output tail when a
/// driver failure in the chain carries one.
#[must_use]
pub fn display_for_log(err: &(dyn std::error::Error + 'static)) -> String {
let mut rendered = render_with_causes(&err.to_string(), &collect_causes(err));
if let Some(tail) = default_redacted_output_tail(err) {
append_tail_for_log(
&mut rendered,
"stderr",
tail.stderr.as_deref(),
tail.stderr_truncated,
);
append_tail_for_log(
&mut rendered,
"stdout",
tail.stdout.as_deref(),
tail.stdout_truncated,
);
}
rendered
}
fn append_tail_for_log(rendered: &mut String, stream: &str, tail: Option<&str>, truncated: bool) {
let tail = tail.unwrap_or("");
let _ = write!(
rendered,
"\n--- {stream} (truncated={truncated}, bytes={}) ---\n{tail}",
tail.len()
);
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use sandbox_driver::{ExecFailure, Termination};
use super::*;
const SECRET: &str = "ghs_xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
fn failed_push(stdout: &str, stderr: &str) -> sandbox_driver::Error {
sandbox_driver::Error::from(
ExecFailure::new(
"git push origin refs/heads/run",
Termination::Exited,
Some(128),
stdout.as_bytes().to_vec(),
stderr.as_bytes().to_vec(),
)
.with_duration(Duration::from_millis(210)),
)
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
struct Wrapped {
message: String,
#[source]
source: sandbox_driver::Error,
}
#[test]
fn display_for_log_walks_the_chain_and_emits_the_tail() {
let error = Wrapped {
message: "metadata push failed".to_string(),
source: failed_push("last stdout line", "last stderr line"),
};
let rendered = display_for_log(&error);
assert!(rendered.contains("metadata push failed"));
assert!(rendered.contains("git push origin refs/heads/run"));
assert!(rendered.contains("--- stderr (truncated=false, bytes=16) ---"));
assert!(rendered.contains("last stderr line"));
assert!(rendered.contains("--- stdout (truncated=false, bytes=16) ---"));
assert!(rendered.contains("last stdout line"));
}
#[test]
fn display_for_log_redacts_secrets() {
let error = failed_push(
&format!("stdout secret {SECRET}"),
&format!("stderr secret {SECRET}"),
);
let rendered = display_for_log(&error);
assert!(
!rendered.contains(SECRET),
"log rendering leaked raw secret: {rendered}"
);
assert!(rendered.contains("REDACTED"));
}
#[test]
fn display_for_log_for_a_plain_error_is_the_chain_alone() {
let error =
sandbox_driver::Error::io("reading the file", std::io::Error::other("leaf failure"));
let rendered = display_for_log(&error);
assert!(rendered.contains("leaf failure"), "{rendered}");
assert!(!rendered.contains("--- stderr"));
assert!(!rendered.contains("--- stdout"));
assert!(default_redacted_output_tail(&error).is_none());
}
}

View file

@ -1,50 +0,0 @@
//! Paths as Fabro resolves them inside a sandbox: a relative path is
//! against the run's working directory, which may sit below the provider's
//! own.
/// `path` as the driver will see it: absolute as given, relative against
/// `working_dir`.
#[must_use]
pub fn resolve_path(path: &str, working_dir: &str) -> String {
if std::path::Path::new(path).is_absolute() {
path.to_string()
} else {
join_sandbox_path(working_dir, path)
}
}
/// `relative_path` under `base` with one separator between them; either
/// side empty yields the other.
#[must_use]
pub fn join_sandbox_path(base: &str, relative_path: &str) -> String {
if relative_path.is_empty() {
return base.to_string();
}
if base.is_empty() {
return relative_path.to_string();
}
if base == "/" {
return format!("/{relative_path}");
}
format!("{}/{relative_path}", base.trim_end_matches('/'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_paths_resolve_against_the_working_directory() {
assert_eq!(resolve_path("src/main.rs", "/work"), "/work/src/main.rs");
assert_eq!(resolve_path("/etc/hosts", "/work"), "/etc/hosts");
assert_eq!(resolve_path("", "/work"), "/work");
}
#[test]
fn joins_keep_one_separator() {
assert_eq!(join_sandbox_path("/work/", "a"), "/work/a");
assert_eq!(join_sandbox_path("/", "a"), "/a");
assert_eq!(join_sandbox_path("", "a"), "a");
assert_eq!(join_sandbox_path("/work", ""), "/work");
}
}

View file

@ -1,84 +0,0 @@
//! Pebble's port routing over the driver's preview URLs.
use std::sync::Arc;
use pebble_coding_agent::mcp::{PortRoute, PortRouteError, PortRoutes};
use sandbox_driver::{PreviewUrls, Sandbox};
/// The route from Fabro to a port inside `handle`'s sandbox, as pebble's
/// MCP support takes it: pebble's [`PortRoutes`] over the driver's preview
/// URLs, when the provider has them. `None` for a provider without
/// forwarding, which is where pebble reaches the port on the loopback
/// address instead.
#[must_use]
pub fn port_routes(handle: &Arc<dyn Sandbox>) -> Option<Arc<dyn PortRoutes>> {
handle.preview_urls()?;
Some(Arc::new(SandboxPortRoutes(Arc::clone(handle))))
}
/// Pebble's [`PortRoutes`] over a sandbox handle: the driver's preview-URL
/// facet answers with the URL and headers that reach a port.
pub struct SandboxPortRoutes(Arc<dyn Sandbox>);
impl SandboxPortRoutes {
/// The driver's facet, present whenever [`port_routes`] handed this out.
/// A missing facet is the environment routing to none of its ports.
fn facet(&self) -> Result<&dyn PreviewUrls, PortRouteError> {
self.0.preview_urls().ok_or(PortRouteError::Unsupported)
}
}
#[async_trait::async_trait]
impl PortRoutes for SandboxPortRoutes {
async fn route(&self, port: u16) -> Result<PortRoute, PortRouteError> {
let preview = self.facet()?.preview_url(port).await.map_err(|error| {
PortRouteError::failed_with_source(
format!("Failed to open a route to sandbox port {port}"),
error,
)
})?;
Ok(PortRoute {
url: preview.url,
headers: preview.headers,
})
}
async fn release(&self, port: u16) -> Result<(), PortRouteError> {
self.facet()?
.release_preview_url(port)
.await
.map_err(|error| {
PortRouteError::failed_with_source(
format!("Failed to release the route to sandbox port {port}"),
error,
)
})
}
}
#[cfg(test)]
mod tests {
use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec};
use sandbox_driver_host::HostProvider;
use super::*;
#[tokio::test]
async fn port_routes_answer_pebble_with_the_access_facets_preview_url() {
let dir = tempfile::tempdir().unwrap();
let provider = HostProvider::new();
let handle = provider
.create(
&SandboxSpec::new(SandboxSource::HostDirectory)
.working_directory(dir.path().display().to_string()),
None,
)
.await
.unwrap();
let routes = port_routes(&handle).expect("the host provider routes to its ports");
let route = routes.route(8080).await.unwrap();
assert_eq!(route, PortRoute::new("http://127.0.0.1:8080"));
routes.release(8080).await.unwrap();
drop((dir, provider));
}
}

View file

@ -1,259 +0,0 @@
//! Test doubles for Fabro's Pebble sandbox glue.
//!
//! [`MockSandbox`] is a configuration over the sandbox driver's scripted
//! double: a test writes down the files, the command answer, and the
//! failures it wants, and takes a [`PebbleSandbox`] or the bare driver
//! handle from it. What the code under test ran or wrote is read back from
//! the driver double itself, through [`MockSandbox::driver`]; the few
//! accessors here convert what a spec records into the shape Fabro's tests
//! assert on. Nothing here fakes Fabro's own logic: every call goes through
//! the real [`PebbleSandbox`] and Fabro's exec policy, down to the scripted
//! driver.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use sandbox_driver::{ExecResult, GrepMatch, PlatformInfo, Sandbox, Termination};
pub use sandbox_driver_testing::{ScriptedExec, ScriptedProvider, ScriptedSandbox};
use crate::environment::PebbleSandbox;
/// A driver [`ExecResult`] with the given streams, for scripting a mock
/// sandbox's answers.
#[must_use]
pub fn exec_result(
stdout: &str,
stderr: &str,
exit_code: Option<i32>,
termination: Termination,
duration_ms: u64,
) -> ExecResult {
let mut result = ExecResult::new(termination, exit_code, Duration::from_millis(duration_ms));
result.stdout = stdout.as_bytes().to_vec();
result.stderr = stderr.as_bytes().to_vec();
result
}
/// What a test wants its sandbox to be, and what the code under test did
/// with it.
///
/// Build it with a struct literal over [`MockSandbox::default`] (or
/// [`MockSandbox::linux`]), then take the sandbox with
/// [`MockSandbox::sandbox`] or its driver handle with
/// [`MockSandbox::handle`]. Every command answers with `exec_result` unless
/// `exec_error` is set, in which case every command fails as a transport
/// error. Files seed an in-memory filesystem under `working_dir`; absolute
/// paths are kept as given.
pub struct MockSandbox {
pub files: HashMap<String, String>,
pub exec_result: ExecResult,
/// Fails every command before any process runs, so callers see a
/// transport error rather than an `ExecResult`.
pub exec_error: Option<String>,
pub working_dir: &'static str,
pub platform_str: &'static str,
pub os_version_str: String,
/// Lines every grep returns, as `path:line:content`.
pub grep_results: Vec<String>,
/// Reported by streaming execution. Set to `false` to model a provider
/// that cannot separate stdout from stderr.
pub streams_separated: bool,
/// The sandbox once built. Public only so `..Default::default()` works
/// from other crates; leave it at its default.
pub built: OnceLock<Built>,
}
/// The lazily built sandbox and its scripted driver.
pub struct Built {
sandbox: Arc<PebbleSandbox>,
driver: Arc<ScriptedSandbox>,
}
impl Default for MockSandbox {
fn default() -> Self {
Self {
files: HashMap::new(),
exec_result: exec_result("mock output", "", Some(0), Termination::Exited, 10),
exec_error: None,
working_dir: "/work",
platform_str: "darwin",
os_version_str: "Darwin 24.0.0".into(),
grep_results: Vec::new(),
streams_separated: true,
built: OnceLock::new(),
}
}
}
impl MockSandbox {
#[must_use]
pub fn linux() -> Self {
Self {
working_dir: "/home/test",
platform_str: "linux",
os_version_str: "Linux 6.1.0".into(),
..Self::default()
}
}
/// The Pebble sandbox this configuration describes, built once:
/// repeated calls return the same sandbox over the same recorder.
pub fn sandbox(&self) -> Arc<PebbleSandbox> {
Arc::clone(&self.built().sandbox)
}
/// The scripted driver as a bare sandbox handle, for code that takes
/// `&dyn Sandbox` beside a working directory.
pub fn handle(&self) -> Arc<dyn Sandbox> {
Arc::clone(&self.built().driver) as Arc<dyn Sandbox>
}
/// The scripted driver double behind [`MockSandbox::sandbox`], for
/// scripting beyond what the fields express.
pub fn driver(&self) -> Arc<ScriptedSandbox> {
Arc::clone(&self.built().driver)
}
/// Answers commands by their Bash source, ahead of the queue and
/// `exec_result`: a responder that returns `Some` decides the result,
/// `None` falls through. For tests that interleave different commands
/// and want each answered by what it is rather than by its position.
pub fn respond_with(
&self,
responder: impl Fn(&str) -> Option<ExecResult> + Send + Sync + 'static,
) -> &Self {
self.driver().scripted_exec().respond_with(move |spec| {
let command = spec.args.last().map(String::as_str).unwrap_or_default();
responder(command)
});
self
}
fn built(&self) -> &Built {
self.built.get_or_init(|| {
let driver = Arc::new(self.build_driver());
let sandbox = PebbleSandbox::with_platform(
Arc::clone(&driver) as Arc<dyn Sandbox>,
self.working_dir,
self.platform_str,
self.os_version_str.clone(),
);
Built {
sandbox: Arc::new(sandbox),
driver,
}
})
}
fn build_driver(&self) -> ScriptedSandbox {
let mut driver =
ScriptedSandbox::with_id_and_working_dir("mock-sandbox", self.working_dir).platform(
PlatformInfo::new(self.platform_str, "x86_64", self.os_version_str.clone()),
);
for (path, content) in &self.files {
driver = driver.file(path, content);
}
let exec = driver.scripted_exec();
match &self.exec_error {
Some(message) => exec.fail_by_default(message.clone()),
None => exec.set_default(self.exec_result.clone()),
};
exec.set_streams_separated(self.streams_separated);
driver.scripted_search().set_grep(
self.grep_results
.iter()
.map(|line| {
let mut parts = line.splitn(3, ':');
let path = parts.next().unwrap_or_default();
let line_number = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0);
GrepMatch::new(path, line_number, parts.next().unwrap_or_default())
})
.collect(),
);
driver
}
fn recorded(&self) -> Vec<sandbox_driver::ExecSpec> {
self.built
.get()
.map(|built| built.driver.scripted_exec().recorded())
.unwrap_or_default()
}
/// The last command's Bash source. Every command, in order, is
/// `driver().scripted_exec().commands()`.
pub fn captured_command(&self) -> Option<String> {
self.recorded()
.last()
.and_then(|spec| spec.args.last().cloned())
}
/// The explicit variables of the last command as the caller passed them.
/// The driver's Bash helper records its own `BASH_ENV` blank on the
/// spec; that is not the caller's.
pub fn captured_env_vars(&self) -> Option<HashMap<String, String>> {
self.recorded().last().map(|spec| {
spec.env
.iter()
.filter(|(key, _)| key.as_str() != sandbox_driver::BASH_ENV_VAR)
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
}
/// Every file written so far as `(path, content)`, in order.
pub fn written_files(&self) -> Vec<(String, String)> {
self.built
.get()
.map(|built| {
built
.driver
.memory_fs()
.writes()
.into_iter()
.map(|(path, bytes)| (path, String::from_utf8_lossy(&bytes).into_owned()))
.collect()
})
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use pebble_coding_agent::environment::Environment;
use super::*;
#[tokio::test]
async fn the_mock_answers_commands_and_records_what_ran() {
let mock = MockSandbox {
files: HashMap::from([("README.md".to_string(), "hello".to_string())]),
..MockSandbox::default()
};
let sandbox = mock.sandbox();
assert_eq!(Environment::platform(&*sandbox), "darwin");
assert_eq!(
Environment::read_file_bytes(&*sandbox, "README.md")
.await
.unwrap(),
b"hello"
);
Environment::write_file(&*sandbox, "notes.txt", "written")
.await
.unwrap();
assert_eq!(mock.written_files(), vec![(
"/work/notes.txt".to_string(),
"written".to_string()
)]);
let env = HashMap::from([("KEY".to_string(), "value".to_string())]);
let result = sandbox
.exec()
.run("echo hi", None, None, Some(&env), None)
.await
.unwrap();
assert_eq!(result.stdout_lossy(), "mock output");
assert_eq!(mock.captured_command().as_deref(), Some("echo hi"));
assert_eq!(mock.captured_env_vars(), Some(env));
}
}

View file

@ -243,7 +243,10 @@ impl RunView {
let agent = stage.agent.get_or_insert_default();
agent.apply(envelope);
if stage.completion.is_none() {
stage.usage = agent.usage.saturating_add(agent.descendant_usage());
stage.usage = agent
.totals
.usage
.saturating_add(agent.totals.descendant_usage());
}
// A tool the stage's list names was called, by any of its sessions.
if let CodingEvent::ToolCallStarted { tool_name, .. } = &envelope.event {

View file

@ -23,7 +23,6 @@ workspace = true
anyhow.workspace = true
fabro-auth = { path = "../../foundation/fabro-auth" }
fabro-config = { path = "../../foundation/fabro-config" }
fabro-pebble-sandbox = { path = "../fabro-pebble-sandbox" }
sandbox-driver.workspace = true
pebble-coding-agent.workspace = true
fabro-github = { path = "../fabro-github" }

View file

@ -13,7 +13,8 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use fabro_pebble_sandbox::display_for_log;
use fabro_redact::SecretRedactor;
use pebble_coding_agent::sandbox_driver::display_for_log;
use sandbox_driver::{
Git as _, GitChange, GitDiffEntry, GitDiffOptions, GitFacet, GitFailureKind, GitRevisionRange,
Sandbox,
@ -159,7 +160,7 @@ fn diff_facet(sandbox: &dyn Sandbox) -> std::result::Result<GitFacet<'_>, DiffEr
/// retry reads the same object; a timeout, a transport failure, or anything
/// else is transient and surfaces as a 503 for the client to retry.
fn diff_error(error: &sandbox_driver::Error) -> DiffError {
let message = display_for_log(error);
let message = display_for_log(error, &SecretRedactor);
match error {
sandbox_driver::Error::Io { .. } => DiffError::Permanent { message },
sandbox_driver::Error::Git(failure) => {

View file

@ -86,19 +86,19 @@ fn a_populated_projection_matches_its_openapi_schema() {
assert_eq!(projection.route.model.as_deref(), Some("claude-fable-5"));
assert_eq!(projection.prompts, 1);
assert!(projection.prompt.completed);
assert_eq!(projection.messages, 2);
assert_eq!(projection.retries, 1);
assert_eq!(projection.descendants.len(), 1);
assert_eq!(projection.totals.messages, 2);
assert_eq!(projection.totals.retries, 1);
assert_eq!(projection.totals.descendants.len(), 1);
assert_eq!(projection.tools.len(), 2);
assert_eq!(projection.mcp_servers.len(), 2);
assert_eq!(projection.skills.activated.len(), 1);
assert_eq!(projection.todos.len(), 1);
assert_eq!(projection.subagents.len(), 2);
assert_eq!(projection.compactions.len(), 1);
assert_eq!(projection.totals.compactions.len(), 1);
assert_eq!(projection.failovers.len(), 1);
assert!(projection.failover_stopped.is_some());
assert_eq!(projection.files_touched, ["/workspace/src/lib.rs"]);
assert!(projection.context_window.is_some());
assert_eq!(projection.totals.files_touched, ["/workspace/src/lib.rs"]);
assert!(projection.totals.context_window.is_some());
let value = serde_json::to_value(&projection).unwrap();
assert!(

View file

@ -14,6 +14,10 @@ workspace = true
[dependencies]
aho-corasick.workspace = true
# The `Redactor` seam pebble's coding agent and its sandbox-driver adapter
# call on text a process wrote; `SecretRedactor` is this crate's scanner
# behind it.
pebble-coding-agent.workspace = true
ref-cast.workspace = true
regex.workspace = true
serde_json.workspace = true

View file

@ -7,9 +7,11 @@
mod entropy;
mod gitleaks;
mod jsonl;
mod redactor;
mod safe_url;
pub use jsonl::{redact_json_value, redact_jsonl_line};
pub use redactor::SecretRedactor;
pub use safe_url::{DisplaySafeUrl, DisplaySafeUrlError};
pub(crate) const REDACTION_MARKER: &str = "REDACTED";

View file

@ -7,18 +7,20 @@ use pebble_coding_agent::extensions::Redactor;
/// Fabro's secret scanner as pebble's [`Redactor`].
///
/// Pebble calls it where text a process or the operating system wrote leaves
/// a session: the output tail a shell tool puts on the event stream and the
/// model-facing message of a failed tool call. It runs the same
/// `fabro_redact::redact_string` pass the run's stored events go through, so
/// what the model reads back matches what the log keeps. The final pass over
/// every stored `RunEvent` stays in place: this one covers the text pebble
/// hands the model and does not replace redaction of the stored event.
/// a session: the output tail a shell tool puts on the event stream, the
/// model-facing message of a failed tool call, and the output tail
/// `pebble_coding_agent::sandbox_driver::display_for_log` renders for a
/// driver failure. It runs the same [`redact_string`](crate::redact_string)
/// pass the run's stored events go through, so what the model reads back
/// matches what the log keeps. The final pass over every stored `RunEvent`
/// stays in place: this one covers the text pebble hands the model and does
/// not replace redaction of the stored event.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SecretRedactor;
impl Redactor for SecretRedactor {
fn redact<'a>(&self, text: &'a str) -> Cow<'a, str> {
let redacted = fabro_redact::redact_string(text);
let redacted = crate::redact_string(text);
if redacted == text {
Cow::Borrowed(text)
} else {

View file

@ -65,7 +65,7 @@ export interface AgentSessionPromptDelta {
/**
* Child lifecycle events during the prompt.
*/
'subagents': AgentSessionSubagentCounts;
'subagent_counts': AgentSessionSubagentCounts;
/**
* Compactions the root completed during the prompt.
*/