Merge pull request #849 from fabro-sh/sandbox-driver-adoption

Run every sandbox through the sandbox driver
This commit is contained in:
Bryan Helmkamp 2026-09-11 17:03:21 -06:00 • committed by GitHub
commit 570d949e57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
263 changed files with 13897 additions and 26788 deletions

View file

@ -133,6 +133,47 @@ jobs:
# strict mode, which fails (rather than skips) live tests without keys.
- run: cargo nextest run --locked --workspace --status-level slow --profile ci --run-ignored only -E 'package(fabro-agent) + package(fabro-llm)'
sandbox-plugins:
name: Sandbox plugins (stdio)
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
env:
# The plugin scenarios skip when an executable or daemon is missing;
# in CI a skip is a failure.
FABRO_REQUIRE_SANDBOX_PLUGINS: "1"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
- run: docker pull buildpack-deps:noble
# The driver's own Host and Docker executables, installed at the rev the
# workspace pins so the plugins and the in-process providers are one
# build; the CLI scenarios find them on PATH and launch them over stdio.
- name: Install the sandbox-driver plugin executables
run: |
rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)"
test -n "$rev"
cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "$rev" sandbox-driver-host sandbox-driver-docker
# Host and Docker served as plugins through the workflow scenarios. The
# scenarios are e2e tests (ignored by default); the key-free ones run
# here, the LLM-backed ones self-skip without credentials.
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/host_plugin_|docker_plugin_/)'
# The stdio plugin proof (not ignored: it skips without the executable,
# which the environment above forbids) and the driver-backed Docker
# integration tests.
- run: cargo nextest run --locked --profile ci --status-level slow -p fabro-sandbox --test plugin_provider
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-sandbox --test docker_streaming
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-agent --test it -E 'test(docker_shell)'
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-workflow --test it -E 'test(asset_collection_docker_sandbox)'
test-macos:
name: Test (macOS)
if: github.event_name == 'workflow_dispatch'

View file

@ -32,17 +32,19 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
- The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs.
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted.
- The sandbox layer also accepts an optional exact commit for future admitted
runs. An exact commit always requires a non-empty branch. Docker initializes
an empty repository, shallow-fetches the SHA at the same depth as a branch
clone, and checks it out; Daytona uses its official SDK clone with both
`branch` and `commit_id`. Both providers then point the admitted branch at
the commit and verify HEAD, so the workspace still reports the admitted
branch name. Keep those provider transports distinct, never fall back to a
newer branch HEAD, and do not wire this capability directly from legacy
`GitContext.sha`. The sandbox layer does not verify that the commit is
reachable from the branch; admission owns that check. Current production
callers remain branch-only until the RunIntent admission cutover supplies a
validated branch/SHA pair.
runs. An exact commit always requires a non-empty branch. The sandbox driver
performs the pin the same way on every provider: it initializes an empty
repository, fetches the SHA directly at the requested depth, and attaches
the admitted branch to it, so the workspace reports the admitted branch
name. Daytona's native toolbox clone serves plain branch clones only; its
commit pin checks the branch head out first, so the driver does not use
it. A successful clone has the pin checked out; the driver's
conformance suite verifies that on every provider, and fabro does not
re-verify HEAD. Never fall back to a newer branch HEAD, and do not wire
this capability directly from legacy `GitContext.sha`. The sandbox layer
does not verify that the commit is reachable from the branch; admission
owns that check. Current production callers remain branch-only until the
RunIntent admission cutover supplies a validated branch/SHA pair.
### Release automation
- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.
@ -122,7 +124,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`)
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`
- **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, and human-in-the-loop interactions
- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments
- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). Tools run through `RunSandbox`, fabro's one sandbox type over the sandbox driver
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **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
@ -139,7 +141,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
- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection.
- **RunSandbox** — One concrete sandbox type for local, Docker, and Daytona execution environments, over the `sandbox-driver` facets (exec, filesystem, search, git). There is no fabro-side sandbox trait; tests use `fabro_sandbox::test_support::MockSandbox` over the driver's scripted doubles. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection.
- **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

172
Cargo.lock generated
View file

@ -1859,7 +1859,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc"
dependencies = [
"reqwest 0.13.4",
"reqwest-middleware",
@ -1873,7 +1873,7 @@ dependencies = [
[[package]]
name = "daytona-sdk"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc"
dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
@ -1893,7 +1893,7 @@ dependencies = [
[[package]]
name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc"
dependencies = [
"reqwest 0.13.4",
"reqwest-middleware",
@ -2294,6 +2294,8 @@ dependencies = [
"libc",
"lithos-llm",
"paste",
"sandbox-driver",
"sandbox-driver-testing",
"serde",
"serde_json",
"sha2 0.10.9",
@ -2326,6 +2328,7 @@ dependencies = [
"progenitor-client",
"regress",
"reqwest 0.13.4",
"sandbox-driver",
"serde",
"serde_json",
"serde_yaml",
@ -2415,7 +2418,6 @@ dependencies = [
"cli-table",
"console 0.15.11",
"core-foundation 0.9.4",
"daytona-sdk",
"dialoguer",
"dirs",
"dotenvy",
@ -2478,6 +2480,7 @@ dependencies = [
"reqwest 0.13.4",
"ring",
"rustls",
"sandbox-driver",
"scopeguard",
"semver",
"serde",
@ -2941,13 +2944,8 @@ dependencies = [
"anyhow",
"async-trait",
"base64",
"bollard",
"chrono",
"daytona-api-client",
"daytona-sdk",
"fabro-config",
"fabro-github",
"fabro-http",
"fabro-proc",
"fabro-redact",
"fabro-static",
@ -2955,23 +2953,21 @@ dependencies = [
"fabro-types",
"fabro-util",
"futures",
"futures-util",
"git2",
"hex",
"hmac 0.12.1",
"httpmock",
"rand 0.9.4",
"reqwest-middleware",
"rustls",
"reqwest 0.13.4",
"sandbox-driver",
"sandbox-driver-daytona",
"sandbox-driver-daytona-config",
"sandbox-driver-docker",
"sandbox-driver-docker-config",
"sandbox-driver-host",
"sandbox-driver-protocol",
"sandbox-driver-testing",
"serde",
"serde_json",
"sha2 0.10.9",
"strum 0.28.0",
"tar",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.26.2",
"tokio-util",
"toml 0.8.23",
"tracing",
@ -3047,6 +3043,7 @@ dependencies = [
"rand 0.9.4",
"regex",
"reqwest 0.12.28",
"sandbox-driver",
"semver",
"serde",
"serde_json",
@ -3250,6 +3247,7 @@ dependencies = [
"fabro-util",
"hex",
"lithos-llm",
"sandbox-driver",
"serde",
"serde_json",
"sha2 0.10.9",
@ -3387,6 +3385,7 @@ dependencies = [
"predicates",
"rand 0.9.4",
"regex",
"sandbox-driver",
"scopeguard",
"serde",
"serde_json",
@ -6994,6 +6993,135 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "sandbox-driver"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47"
dependencies = [
"async-trait",
"globset",
"humantime",
"rand 0.10.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "sandbox-driver-daytona"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47"
dependencies = [
"anyhow",
"async-trait",
"base64",
"daytona-api-client",
"daytona-sdk",
"hmac 0.12.1",
"rand 0.10.1",
"reqwest 0.13.4",
"sandbox-driver",
"sandbox-driver-daytona-config",
"sandbox-driver-docker",
"sandbox-driver-docker-config",
"sandbox-driver-protocol",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "sandbox-driver-daytona-config"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47"
dependencies = [
"sandbox-driver-docker-config",
"serde",
"serde_json",
]
[[package]]
name = "sandbox-driver-docker"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47"
dependencies = [
"anyhow",
"async-trait",
"bollard",
"futures-util",
"sandbox-driver",
"sandbox-driver-docker-config",
"sandbox-driver-protocol",
"serde",
"serde_json",
"tar",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "sandbox-driver-docker-config"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "sandbox-driver-host"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47"
dependencies = [
"anyhow",
"async-trait",
"nix 0.30.1",
"sandbox-driver",
"sandbox-driver-protocol",
"serde",
"serde_json",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "sandbox-driver-protocol"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47"
dependencies = [
"async-trait",
"base64",
"rand 0.10.1",
"sandbox-driver",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "sandbox-driver-testing"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47"
dependencies = [
"async-trait",
"sandbox-driver",
"tokio",
]
[[package]]
name = "schannel"
version = "0.1.28"
@ -8529,9 +8657,9 @@ dependencies = [
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"matchers",
"nu-ansi-term",

View file

@ -61,8 +61,6 @@ clap_complete = "4"
jsonschema = { version = "0.42", default-features = false }
chrono = { version = "0.4", features = ["clock", "serde"] }
dashmap = "6"
bollard = "0.18"
tar = "0.4"
cli-table = { version = "0.5", default-features = false }
console = "0.15"
dialoguer = "0.12"
@ -101,8 +99,22 @@ twin-openai = { git = "https://github.com/lithoscomputer/twins", rev = "ca45f0e5
twin-github = { path = "test/twin/github" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" }
# sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and
# Daytona providers link in-process; third-party providers run as stdio
# plugins through sandbox-driver-protocol. Pinned by rev; currently the head of
# the sandbox-driver `section-4-driver-items` branch (provider-owned scopes, the
# supervisor as provider, Host attach by directory, git retry and verbs in the
# driver, status image/snapshot/network, Daytona snapshot caching, services port
# wait and list, RFC 3339 timestamps), to move to main on merge. The CI plugin
# job installs the driver executables at the same rev, read from this file.
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -3,7 +3,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid";
import {
EnvironmentApiDockerfileSourceInlineTypeEnum,
EnvironmentNetworkMode,
EnvironmentProvider,
} from "@qltysh/fabro-api-client";
import type {
CreateEnvironmentRequest,
@ -15,6 +14,7 @@ import type {
ReplaceEnvironmentRequest,
} from "@qltysh/fabro-api-client";
import { DOCKER_PROVIDER, isCloneBasedProvider } from "../lib/environment-providers";
import { Label, Panel, Row } from "./settings-panel";
import { INPUT_CLASS } from "./ui";
import {
@ -25,11 +25,15 @@ import {
} from "./key-value-editor";
// Parse the `provider` query param used by the create flow into a creatable
// provider, defaulting to Docker for anything unexpected.
export function parseCreatableProvider(value: string | null): EnvironmentProvider {
return value === EnvironmentProvider.DAYTONA
? EnvironmentProvider.DAYTONA
: EnvironmentProvider.DOCKER;
// provider, defaulting to Docker for anything that cannot back a managed
// environment. Kind names are validated server-side on create.
const PROVIDER_KIND_PATTERN = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
export function parseCreatableProvider(value: string | null): string {
if (value && PROVIDER_KIND_PATTERN.test(value) && isCloneBasedProvider(value)) {
return value;
}
return DOCKER_PROVIDER;
}
// Environment ids are server-managed file names: lowercase, digits, hyphens.
@ -49,7 +53,7 @@ type ImageSource = "image" | "dockerfile";
export interface EnvironmentFormValues {
id: string;
provider: EnvironmentProvider;
provider: string;
imageSource: ImageSource;
dockerRef: string;
dockerfile: string;
@ -69,7 +73,7 @@ export interface EnvironmentFormValues {
export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = {
id: "",
provider: EnvironmentProvider.DOCKER,
provider: DOCKER_PROVIDER,
imageSource: "image",
dockerRef: "",
dockerfile: "",

View file

@ -174,7 +174,7 @@ describe("RunSummaryPanelView", () => {
const tree = render({
run: makeRun(),
sandboxState: "running",
sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any,
sandboxResources: { cpu_cores: 4, memory_mb: 8 * 1024 },
});
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB");
});

View file

@ -80,10 +80,10 @@ function SandboxValue({
}) {
const display = SANDBOX_STATE_DISPLAY[state] ?? SANDBOX_STATE_DISPLAY.unknown;
const cpu = resources?.cpu_cores;
const memory = resources?.memory_bytes;
const memoryMb = resources?.memory_mb;
const valueText =
cpu != null && memory != null
? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memory)}`
cpu != null && memoryMb != null
? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memoryMb * 1024 * 1024)}`
: display.label;
return (
@ -221,8 +221,8 @@ export function RunSummaryPanel({ runId }: { runId: string }) {
<RunSummaryPanelView
run={runQuery.data ?? null}
runLoading={runQuery.isLoading && !runQuery.data}
sandboxState={sandboxQuery.data?.state ?? null}
sandboxResources={sandboxQuery.data?.resources ?? null}
sandboxState={sandboxQuery.data?.status.state ?? null}
sandboxResources={sandboxQuery.data?.status.resources ?? null}
sandboxLoading={sandboxReady && sandboxQuery.isLoading && !sandboxQuery.data}
artifactsCount={artifactsQuery.data?.data.length ?? null}
artifactsLoading={artifactsQuery.isLoading && !artifactsQuery.data}

View file

@ -1,17 +1,44 @@
import { EnvironmentProvider, type Environment } from "@qltysh/fabro-api-client";
import type { Environment, ServerSandboxProviderSettings } from "@qltysh/fabro-api-client";
// Providers a managed environment can be created with. `local` is a reserved,
// in-memory environment, never a managed-environment provider, so it is never
// offered. The provider is fixed at creation time and cannot be changed.
export const CREATABLE_PROVIDERS = [
EnvironmentProvider.DOCKER,
EnvironmentProvider.DAYTONA,
] as const;
// The providers linked into the server. Any other provider kind names a
// sandbox-driver plugin the operator configured under
// `server.sandbox.providers.<kind>`.
export const LOCAL_PROVIDER = "local";
export const DOCKER_PROVIDER = "docker";
export const DAYTONA_PROVIDER = "daytona";
export const BUNDLED_PROVIDERS = [LOCAL_PROVIDER, DOCKER_PROVIDER, DAYTONA_PROVIDER] as const;
export type ProviderSettingsMap = { [kind: string]: ServerSandboxProviderSettings };
// `local` runs in the caller's directory and never clones. Every other
// provider owns an isolated workspace that Fabro clones into.
export function isCloneBasedProvider(provider: string): boolean {
return provider !== LOCAL_PROVIDER;
}
// Whether a server-managed environment can back Git-targeted work such as
// automations: only the clone-based (creatable) providers qualify.
// automations: only clone-based providers qualify.
export function isCloneBasedEnvironment(environment: Environment): boolean {
return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider);
return isCloneBasedProvider(environment.provider);
}
// Providers a managed environment can be created with: every enabled
// clone-based provider. `local` is a reserved, in-memory environment, never a
// managed-environment provider, so it is never offered.
export function creatableProviders(providers: ProviderSettingsMap): string[] {
return Object.keys(providers)
.filter((kind) => isCloneBasedProvider(kind) && providers[kind]?.enabled)
.sort(compareProviderKinds);
}
// Bundled kinds first, in their canonical order, then plugins alphabetically.
export function compareProviderKinds(left: string, right: string): number {
const rank = (kind: string) => {
const index = (BUNDLED_PROVIDERS as readonly string[]).indexOf(kind);
return index === -1 ? BUNDLED_PROVIDERS.length : index;
};
return rank(left) - rank(right) || left.localeCompare(right);
}
export function providerLabel(provider: string): string {

View file

@ -11,29 +11,31 @@ export interface SandboxStateDisplay {
text: string;
}
const PENDING = { dot: "bg-amber", text: "text-amber" } as const;
const QUIET = { dot: "bg-fg-muted", text: "text-fg-muted" } as const;
const GONE = { dot: "bg-coral", text: "text-coral" } as const;
/**
* Display metadata for every normalized sandbox lifecycle state. Shared by the
* Display metadata for every sandbox driver lifecycle state. Shared by the
* run overview summary panel and the dedicated sandbox page so the dot color,
* label, and hover copy stay consistent.
* label, and hover copy stay consistent. A state this build does not know
* renders as `unknown`.
*/
export const SANDBOX_STATE_DISPLAY: Record<SandboxState, SandboxStateDisplay> = {
unknown: {
label: "Unknown",
description: "The sandbox state could not be determined.",
dot: "bg-fg-muted",
text: "text-fg-muted",
...QUIET,
},
provisioning: {
label: "Provisioning",
description: "The sandbox is being provisioned.",
dot: "bg-amber",
text: "text-amber",
creating: {
label: "Creating",
description: "The sandbox is being created.",
...PENDING,
},
starting: {
label: "Starting",
description: "The sandbox is starting up.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
running: {
label: "Running",
@ -44,55 +46,71 @@ export const SANDBOX_STATE_DISPLAY: Record<SandboxState, SandboxStateDisplay> =
stopping: {
label: "Stopping",
description: "The sandbox is shutting down.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
stopped: {
label: "Stopped",
description: "The sandbox is stopped.",
dot: "bg-fg-muted",
text: "text-fg-muted",
...QUIET,
},
pausing: {
label: "Pausing",
description: "The sandbox is being paused.",
...PENDING,
},
paused: {
label: "Paused",
description: "The sandbox is paused.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
deleting: {
label: "Deleting",
description: "The sandbox is being deleted.",
dot: "bg-amber",
text: "text-amber",
resuming: {
label: "Resuming",
description: "The sandbox is resuming.",
...PENDING,
},
deleted: {
label: "Deleted",
description: "The sandbox has been deleted.",
dot: "bg-coral",
text: "text-coral",
archiving: {
label: "Archiving",
description: "The sandbox is being archived.",
...PENDING,
},
archived: {
label: "Archived",
description: "The sandbox has been archived.",
dot: "bg-fg-muted",
text: "text-fg-muted",
...QUIET,
},
restoring: {
label: "Restoring",
description: "The sandbox is being restored.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
resizing: {
label: "Resizing",
description: "The sandbox resources are being resized.",
dot: "bg-amber",
text: "text-amber",
...PENDING,
},
forking: {
label: "Forking",
description: "The sandbox is being forked.",
...PENDING,
},
snapshotting: {
label: "Snapshotting",
description: "A snapshot of the sandbox is being taken.",
...PENDING,
},
deleting: {
label: "Deleting",
description: "The sandbox is being deleted.",
...PENDING,
},
deleted: {
label: "Deleted",
description: "The sandbox has been deleted.",
...GONE,
},
error: {
label: "Error",
description: "The sandbox encountered an error.",
dot: "bg-coral",
text: "text-coral",
...GONE,
},
};

View file

@ -103,14 +103,15 @@ mock.restore();
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
function sandboxDetails(
overrides: Partial<SandboxDetails> & {
overrides: {
sandbox?: Partial<SandboxDetails["sandbox"]> & {
runtime?: Partial<NonNullable<SandboxDetails["sandbox"]["runtime"]>>;
};
status?: Partial<SandboxDetails["status"]>;
} = {},
): SandboxDetails {
const sandbox = overrides.sandbox ?? {};
const { sandbox: _sandboxOverride, ...detailOverrides } = overrides;
const status = overrides.status ?? {};
return {
sandbox: {
provider: "docker",
@ -126,34 +127,27 @@ function sandboxDetails(
},
...sandbox,
},
state: "running",
native_state: null,
region: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
network: networkDetails(),
labels: {},
timestamps: { created_at: null, last_activity_at: null },
...detailOverrides,
status: {
id: sandbox.runtime?.id ?? "",
state: "running",
provider_state: "",
error_reason: null,
resources: null,
sandbox_kind: null,
region: null,
labels: {},
image: null,
snapshot: null,
network: null,
workspace_ownership: null,
web_url: null,
created_at: null,
updated_at: null,
...status,
},
};
}
function networkDetails(
overrides: Partial<SandboxDetails["network"]> = {},
): SandboxDetails["network"] {
return {
egress: networkPolicy("unknown"),
ingress: networkPolicy("unknown"),
...overrides,
};
}
function networkPolicy(
mode: SandboxDetails["network"]["egress"]["mode"],
cidrs: string[] = [],
): SandboxDetails["network"]["egress"] {
return { mode, cidrs };
}
function textContent(renderer: TestRenderer.ReactTestRenderer): string {
return renderer.root
.findAll((node) => typeof node.type === "string")
@ -230,22 +224,18 @@ describe("RunSandbox route", () => {
working_directory: "/workspace",
},
},
state: "running",
native_state: "running",
region: undefined,
resources: {
cpu_cores: 2,
memory_bytes: 4 * 1024 * 1024 * 1024,
disk_bytes: undefined,
},
network: networkDetails({
egress: networkPolicy("open"),
ingress: networkPolicy("blocked"),
}),
labels: { run: "abc" },
timestamps: {
created_at: "2026-05-09T12:00:00Z",
last_activity_at: undefined,
status: {
state: "running",
provider_state: "running",
resources: {
cpu_cores: 2,
memory_mb: 4 * 1024,
disk_mb: null,
gpus: null,
},
network: "allow_all",
labels: { run: "abc" },
created_at: "2026-05-09T12:00:00Z",
},
});
const renderer = renderRoute();
@ -256,8 +246,8 @@ describe("RunSandbox route", () => {
.filter((text): text is string => typeof text === "string");
expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]);
const copy = textContent(renderer);
expect(copy).toContain("Open");
expect(copy).toContain("Blocked");
expect(copy).toContain("Allow all");
expect(copy).toContain("4 GiB");
});
test("links to the provider dashboard when a sandbox web URL is present", () => {
@ -269,8 +259,10 @@ describe("RunSandbox route", () => {
working_directory: "/workspace",
},
},
web_url:
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
status: {
web_url:
"https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9",
},
});
const renderer = renderRoute();
@ -296,18 +288,12 @@ describe("RunSandbox route", () => {
working_directory: "/tmp/project",
},
},
state: "unknown",
native_state: undefined,
region: undefined,
resources: {
cpu_cores: undefined,
memory_bytes: undefined,
disk_bytes: undefined,
},
labels: {},
timestamps: {
created_at: undefined,
last_activity_at: undefined,
status: {
state: "unknown",
resources: { cpu_cores: null, memory_mb: null, disk_mb: null, gpus: null },
labels: {},
created_at: null,
updated_at: null,
},
});
const renderer = renderRoute();
@ -328,35 +314,29 @@ describe("RunSandbox route", () => {
expect(noLabelsCopy).toHaveLength(1);
});
test("renders unknown network policies", () => {
currentDetails = sandboxDetails({
network: networkDetails({
egress: networkPolicy("unknown"),
ingress: networkPolicy("unknown"),
}),
});
test("renders an unknown network policy", () => {
currentDetails = sandboxDetails({ status: { network: null } });
const renderer = renderRoute();
const copy = textContent(renderer);
expect(copy).toContain("Network");
expect(copy).toContain("Egress");
expect(copy).toContain("Ingress");
expect(copy).toContain("Policy");
expect(copy).toContain("Unknown");
});
test("renders blocked, essentials, and CIDR network policies", () => {
test("renders blocked and CIDR allow list network policies", () => {
currentDetails = sandboxDetails({
network: networkDetails({
egress: networkPolicy("cidr_allow_list", ["10.0.0.0/8", "192.168.0.0/16"]),
ingress: networkPolicy("essentials_only"),
}),
status: { network: { cidr_allow_list: { cidrs: ["10.0.0.0/8", "192.168.0.0/16"] } } },
});
const renderer = renderRoute();
const copy = textContent(renderer);
expect(copy).toContain("CIDR allow list");
expect(copy).toContain("10.0.0.0/8, 192.168.0.0/16");
expect(copy).toContain("Essentials only");
currentDetails = sandboxDetails({ status: { network: "block" } });
const blocked = renderRoute();
expect(textContent(blocked)).toContain("Blocked");
});
test("shows the empty state when no sandbox is reported", () => {

View file

@ -22,7 +22,7 @@ import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state";
import type {
RunSandbox,
SandboxDetails,
SandboxNetwork,
SandboxNetworkPolicy,
SandboxResources,
} from "@qltysh/fabro-api-client";
import FilesystemPanel from "./run-sandbox/filesystem-panel";
@ -57,27 +57,47 @@ function nullableTimestamp(value: string | null | undefined): string {
return value ? formatAbsoluteTs(value) : EMPTY_VALUE;
}
function nullableMemory(bytes: number | null | undefined): string {
return bytes != null ? formatBytesAsMemory(bytes) : EMPTY_VALUE;
function nullableMegabytes(megabytes: number | null | undefined): string {
return megabytes != null ? formatBytesAsMemory(megabytes * 1024 * 1024) : EMPTY_VALUE;
}
function nullableCpu(cores: number | null | undefined): string {
return cores != null ? formatCpuCores(cores) : EMPTY_VALUE;
}
type SandboxNetworkPolicy = SandboxNetwork["egress"];
type SandboxNetworkPolicyMode = SandboxNetworkPolicy["mode"];
function nullableCount(count: number | null | undefined): string {
return count != null ? String(count) : EMPTY_VALUE;
}
const NETWORK_POLICY_DISPLAY: Record<SandboxNetworkPolicyMode, string> = {
unknown: "Unknown",
open: "Open",
blocked: "Blocked",
cidr_allow_list: "CIDR allow list",
essentials_only: "Essentials only",
const NETWORK_POLICY_DISPLAY: Record<string, string> = {
provider_default: "Provider default",
allow_all: "Allow all",
block: "Blocked",
};
function networkPolicySummary(policy: SandboxNetworkPolicy): string {
return NETWORK_POLICY_DISPLAY[policy.mode] ?? policy.mode;
/** The policy's name, and the entries of an allow list when it carries one. */
function describeNetworkPolicy(
policy: SandboxNetworkPolicy | null | undefined,
): { summary: string; entries: { label: string; values: string[] } | null } {
if (policy == null) {
return { summary: "Unknown", entries: null };
}
if (typeof policy === "string") {
return { summary: NETWORK_POLICY_DISPLAY[policy] ?? policy, entries: null };
}
if ("cidr_allow_list" in policy) {
return {
summary: "CIDR allow list",
entries: { label: "Allowed CIDRs", values: policy.cidr_allow_list.cidrs },
};
}
if ("domain_allow_list" in policy) {
return {
summary: "Domain allow list",
entries: { label: "Allowed domains", values: policy.domain_allow_list.domains },
};
}
return { summary: "Unknown", entries: null };
}
interface RowProps {
@ -142,11 +162,12 @@ function Panel({ title, children }: PanelProps) {
}
function StatusStrip({ details }: { details: SandboxDetails }) {
const display = SANDBOX_STATE_DISPLAY[details.state] ?? SANDBOX_STATE_DISPLAY.unknown;
const status = details.status;
const display = SANDBOX_STATE_DISPLAY[status.state] ?? SANDBOX_STATE_DISPLAY.unknown;
const provider = details.sandbox.provider;
const providerState = status.provider_state ?? "";
const showNative =
details.native_state &&
details.native_state.toLowerCase() !== details.state.toLowerCase();
providerState.length > 0 && providerState.toLowerCase() !== status.state.toLowerCase();
return (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-md border border-line bg-panel/60 px-4 py-3 text-sm">
<span className="font-mono text-xs text-fg-muted uppercase tracking-wide">
@ -158,7 +179,7 @@ function StatusStrip({ details }: { details: SandboxDetails }) {
</span>
{showNative && (
<span className="font-mono text-xs text-fg-muted">
({details.native_state})
({providerState})
</span>
)}
</div>
@ -167,20 +188,25 @@ function StatusStrip({ details }: { details: SandboxDetails }) {
function OverviewPanel({ details }: { details: SandboxDetails }) {
const sandbox = details.sandbox;
const status = details.status;
const runtime = sandbox.runtime;
return (
<Panel title="Overview">
<Row label="ID" value={nullable(runtime?.id)} />
<Row label="ID" value={nullable(status.id || runtime?.id)} />
<Row label="Working directory" value={nullable(runtime?.working_directory)} />
<Row
label="Region"
value={details.region ? details.region : sandbox.provider === "docker" ? "local" : EMPTY_VALUE}
value={status.region ? status.region : sandbox.provider === "docker" ? "local" : EMPTY_VALUE}
/>
<Row label="Image" value={nullable(sandbox.image ?? sandbox.snapshot)} />
{details.web_url && (
<Row
label="Image"
value={nullable(status.image ?? status.snapshot ?? sandbox.image ?? sandbox.snapshot)}
/>
{status.sandbox_kind && <Row label="Kind" value={status.sandbox_kind} />}
{status.web_url && (
<LinkRow
label="Provider"
href={details.web_url}
href={status.web_url}
text={
sandbox.provider === "daytona"
? "Open in Daytona"
@ -192,29 +218,25 @@ function OverviewPanel({ details }: { details: SandboxDetails }) {
);
}
function ResourcesPanel({ resources }: { resources: SandboxResources }) {
function ResourcesPanel({ resources }: { resources: SandboxResources | null | undefined }) {
return (
<Panel title="Resources">
<Row label="CPU" value={nullableCpu(resources.cpu_cores)} />
<Row label="Memory" value={nullableMemory(resources.memory_bytes)} />
<Row label="Disk" value={nullableMemory(resources.disk_bytes)} />
<Row label="CPU" value={nullableCpu(resources?.cpu_cores)} />
<Row label="Memory" value={nullableMegabytes(resources?.memory_mb)} />
<Row label="Disk" value={nullableMegabytes(resources?.disk_mb)} />
{resources?.gpus != null && <Row label="GPUs" value={nullableCount(resources.gpus)} />}
</Panel>
);
}
function NetworkPanel({ network }: { network: SandboxNetwork }) {
const cidrRows: Array<{ label: string; policy: SandboxNetworkPolicy }> = [
{ label: "Egress CIDRs", policy: network.egress },
{ label: "Ingress CIDRs", policy: network.ingress },
].filter(({ policy }) => policy.mode === "cidr_allow_list");
function NetworkPanel({ network }: { network: SandboxNetworkPolicy | null | undefined }) {
const { summary, entries } = describeNetworkPolicy(network);
return (
<Panel title="Network">
<Row label="Egress" value={networkPolicySummary(network.egress)} />
<Row label="Ingress" value={networkPolicySummary(network.ingress)} />
{cidrRows.map(({ label, policy }) => (
<Row key={label} label={label} value={policy.cidrs.join(", ") || EMPTY_VALUE} />
))}
<Row label="Policy" value={summary} />
{entries && (
<Row label={entries.label} value={entries.values.join(", ") || EMPTY_VALUE} />
)}
</Panel>
);
}
@ -237,11 +259,8 @@ function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | un
function TimestampsPanel({ details }: { details: SandboxDetails }) {
return (
<Panel title="Timestamps">
<Row label="Created" value={nullableTimestamp(details.timestamps.created_at)} />
<Row
label="Last activity"
value={nullableTimestamp(details.timestamps.last_activity_at)}
/>
<Row label="Created" value={nullableTimestamp(details.status.created_at)} />
<Row label="Last updated" value={nullableTimestamp(details.status.updated_at)} />
</Panel>
);
}
@ -259,9 +278,9 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) {
<div className="space-y-4">
<StatusStrip details={details} />
<OverviewPanel details={details} />
<ResourcesPanel resources={details.resources} />
<NetworkPanel network={details.network} />
<LabelsPanel labels={details.labels} />
<ResourcesPanel resources={details.status.resources} />
<NetworkPanel network={details.status.network} />
<LabelsPanel labels={details.status.labels} />
<TimestampsPanel details={details} />
</div>
);

View file

@ -26,10 +26,7 @@ function makeIdlePreview(): PreviewMutationShape {
}
function makeServicesData(data: SandboxService[]) {
return {
data,
meta: { source: "ss" as const },
};
return { data };
}
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
@ -116,46 +113,6 @@ describe("ServicesPanelView", () => {
expect(titles).toHaveLength(1);
});
test("shows an iproute2 tip when services were discovered from procfs", () => {
const service: SandboxService = {
port: 3000,
addresses: ["0.0.0.0:3000"],
processes: [],
preview_supported: true,
};
const renderer = renderView({
servicesQuery: {
...makeIdleQuery(),
data: {
data: [service],
meta: { source: "procfs" },
},
},
previewMutation: makeIdlePreview(),
});
const tipLabels = renderer.root.findAll(
(node) =>
node.type === "span" &&
Array.isArray(node.children) &&
node.children.includes("Tip:"),
);
expect(tipLabels).toHaveLength(1);
const commands = renderer.root.findAll(
(node) =>
node.type === "code" &&
Array.isArray(node.children) &&
node.children.includes("apt-get install iproute2"),
);
expect(commands).toHaveLength(1);
const tipText = JSON.stringify(renderer.toJSON());
expect(tipText).toContain("Install ");
expect(tipText).toContain("ss");
expect(tipText).toContain(" in the sandbox for improved services listing:");
});
test("shows API error state with the error message", () => {
const renderer = renderView({
servicesQuery: {

View file

@ -77,7 +77,6 @@ export function ServicesPanelView({
const [previewError, setPreviewError] = useState<string | null>(null);
const services = servicesQuery.data?.data ?? [];
const discoverySource = servicesQuery.data?.meta.source;
const queryErrorMessage = describeQueryError(servicesQuery.error);
const showLoading = servicesQuery.isLoading && !servicesQuery.data;
const showError = queryErrorMessage !== null && !servicesQuery.data;
@ -150,7 +149,6 @@ export function ServicesPanelView({
<EmptyState title="No services" />
) : (
<>
{discoverySource === "procfs" ? <ProcfsDiscoveryTip /> : null}
<ServicesTable
services={services}
pendingPort={pendingPort}
@ -170,17 +168,6 @@ function describeQueryError(error: unknown): string | null {
return "Could not load services.";
}
function ProcfsDiscoveryTip() {
return (
<div className="mb-3 rounded-md border border-line bg-panel/60 px-3 py-2 text-xs leading-5 text-fg-3">
<span className="font-medium text-fg-2">Tip:</span>{" "}
Install <code className="font-mono text-fg-2">ss</code> in the sandbox
for improved services listing:{" "}
<code className="font-mono text-fg-2">apt-get install iproute2</code>
</div>
);
}
function ServicesTable({
services,
pendingPort,

View file

@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, environmentsApi } from "../lib/api-client";
import { useEnvironments, useServerSettings } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { CREATABLE_PROVIDERS, providerLabel } from "../lib/environment-providers";
import { creatableProviders, providerLabel } from "../lib/environment-providers";
import {
Badge,
Muted,
@ -67,9 +67,7 @@ const NEW_BUTTON_CLASS =
// environment's lifetime. `local` is never offered (it's reserved/in-memory).
function NewEnvironmentMenu() {
const { data } = useServerSettings();
const providers = data
? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled)
: [];
const providers = data ? creatableProviders(data.server.sandbox.providers) : [];
if (providers.length === 0) {
return (

View file

@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import { Link } from "react-router";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { ComputerDesktopIcon } from "@heroicons/react/24/outline";
import type { ServerSandboxProvidersSettings } from "@qltysh/fabro-api-client";
import type { ServerSandboxProviderSettings } from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import {
Dot,
@ -12,24 +12,56 @@ import {
SettingsPageIntro,
} from "../components/settings-panel";
import { plural } from "../lib/plural";
import {
DAYTONA_PROVIDER,
DOCKER_PROVIDER,
LOCAL_PROVIDER,
compareProviderKinds,
providerLabel,
type ProviderSettingsMap,
} from "../lib/environment-providers";
export function meta() {
return [{ title: "Sandboxes — Fabro" }];
}
type SandboxProviderId = "local" | "docker" | "daytona";
type SandboxProvider = {
id: SandboxProviderId;
id: string;
name: string;
description: string;
enabled: boolean;
bundled: boolean;
secretName?: string;
};
const DESCRIPTION =
"Runtime environments where workflow stages execute. Configured via settings.toml.";
// Display copy for the providers linked into the server. Any other kind is a
// sandbox-driver plugin configured under `server.sandbox.providers.<kind>`.
const BUNDLED_PROVIDER_COPY: Record<string, Omit<SandboxProvider, "id" | "enabled" | "bundled">> = {
[LOCAL_PROVIDER]: {
name: "Local",
description: "Run stages directly on the Fabro host.",
},
[DOCKER_PROVIDER]: {
name: "Docker",
description: "Run stages in isolated Docker containers on the host daemon.",
},
[DAYTONA_PROVIDER]: {
name: "Daytona",
description: "Run stages in cloud sandboxes managed by Daytona.",
secretName: "DAYTONA_API_KEY",
},
};
function pluginDescription(settings: ServerSandboxProviderSettings): string {
const path = settings.plugin?.path;
return path
? `Sandbox plugin executable at ${path}.`
: "Sandbox plugin executable resolved from PATH.";
}
export default function SettingsSandboxes() {
const query = useServerSettings();
const settings = query.data;
@ -42,29 +74,24 @@ export default function SettingsSandboxes() {
);
}
function ProvidersPanel({ settings }: { settings: ServerSandboxProvidersSettings }) {
function ProvidersPanel({ settings }: { settings: ProviderSettingsMap }) {
const providers: SandboxProvider[] = useMemo(
() => [
{
id: "local",
name: "Local",
description: "Run stages directly on the Fabro host.",
enabled: settings.local.enabled,
},
{
id: "docker",
name: "Docker",
description: "Run stages in isolated Docker containers on the host daemon.",
enabled: settings.docker.enabled,
},
{
id: "daytona",
name: "Daytona",
description: "Run stages in cloud sandboxes managed by Daytona.",
enabled: settings.daytona.enabled,
secretName: "DAYTONA_API_KEY",
},
],
() =>
Object.keys(settings)
.sort(compareProviderKinds)
.map((id) => {
const entry = settings[id];
const copy = BUNDLED_PROVIDER_COPY[id];
return copy
? { id, enabled: entry.enabled, bundled: true, ...copy }
: {
id,
enabled: entry.enabled,
bundled: false,
name: providerLabel(id),
description: pluginDescription(entry),
};
}),
[settings],
);
@ -138,7 +165,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) {
"grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong";
const dim = provider.enabled ? "" : "opacity-60";
if (provider.id === "local") {
if (provider.id === LOCAL_PROVIDER) {
return (
<span className={`${chip} text-page ${dim}`}>
<ComputerDesktopIcon className="size-6" aria-hidden="true" />
@ -146,7 +173,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) {
);
}
if (failed) {
if (failed || !provider.bundled) {
return (
<span className={`${chip} text-base font-medium text-page ${dim}`}>
{provider.name.charAt(0)}

View file

@ -125,7 +125,17 @@ Never build the same `RunEvent` twice if multiple sinks receive it.
### 1. Add the typed event
Add a variant to `Event`, `AgentEvent`, or `SandboxEvent` as appropriate.
Add a variant to `Event`, `AgentEvent`, or `SandboxLifecycle` as appropriate. Sandbox
facts come from two places: the pipeline emits `Initializing`, `Ready`, and
`InitializeFailed` around bringing the sandbox up, and the sandbox driver's own events
(operations and their outcome, progress inside a create such as an image pull, snapshot
builds, state observations, notices) are stored whole as `Event::SandboxDriver` by the
`DriverEventRecorder` in the `fabro-workflow::event` module. Their names derive from the
event (`fabro_types::sandbox_driver_event_name`): `<subject>.<action>.<phase>` such as
`sandbox.stop.completed` or `snapshot.create.started`, `<subject>.state`, and
`<subject>.notice`; their `properties` are the driver's event as the driver serializes
it, so the driver's `Event` is part of fabro's stored format. Fabro-sandbox emits no
events of its own.
### 2. Add tracing

View file

@ -1807,82 +1807,52 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo
| `provider` | string | Sandbox provider name |
| `error` | string | Error message |
### `sandbox.snapshot.pulling`
### Sandbox driver events
Emitted only when the Docker image cache misses and Fabro starts pulling the image.
Everything the sandbox driver reports about a run's sandbox is stored whole. The
event name derives from the driver's event: `<subject>.<action>.<phase>` for an
operation (`sandbox.start.started`, `sandbox.stop.completed`, `sandbox.delete.failed`,
`sandbox.create.progress` for an image pull inside the create, `snapshot.create.started`
and `snapshot.create.completed` for a snapshot build), `<subject>.state` for a state
observation, and `<subject>.notice` for a notice. `properties` is the driver's event as
the driver serializes it.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.snapshot.pulling",
"event": "sandbox.stop.completed",
"properties": {
"name": "my-image:latest"
"id": {"source_id": "9b2f…", "sequence": 4},
"occurred_at": "2026-08-31T20:00:00Z",
"provider": "docker",
"subject": {"type": "sandbox", "id": "container-abc123"},
"operation_id": "58a1…",
"correlation_id": "01JQ…",
"type": "operation_completed",
"action": "stop",
"duration": {"secs": 1, "nanos": 250000000}
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Image/snapshot name |
| `id` | object | The driver's event id: `source_id` and `sequence` within that source |
| `occurred_at` | string | When the driver observed the event (RFC 3339) |
| `provider` | string | The driver's provider kind (`host`, `docker`, `daytona`, a plugin's kind) |
| `subject` | object | `type` (`sandbox`, `snapshot`, `volume`, `provider`) with the resource's `id` and `name` when known |
| `operation_id` | string | Groups the started, progress, and completed or failed events of one operation |
| `correlation_id` | string | The run id fabro attached |
| `type` | string | `operation_started`, `operation_progress`, `operation_completed`, `operation_failed`, `state_observed`, or `notice` |
| `action` | string | The operation (`create`, `start`, `stop`, `delete`, `snapshot`, …) on operation events |
| `progress` | object | `code` (`image.pull`, `snapshot.build`, …), `message`, and optional `completed`, `total`, `unit` on progress events |
| `duration` | object | `secs` and `nanos` on completed and failed events |
| `error` | object | `kind`, `message`, `retryable`, `causes` on failed events |
### `sandbox.snapshot.creating`
Emitted only when a Daytona snapshot cache miss or inactive snapshot requires Fabro to create or wait for the snapshot.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.snapshot.creating",
"properties": {
"name": "my-snapshot"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |
### `sandbox.snapshot.ready`
Emitted when an image or snapshot ensure step succeeds. Cache hits still emit this event with a near-zero `duration_ms`; explicit no-op paths such as Docker `auto_pull = false` and the Daytona default snapshot path do not.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.snapshot.ready",
"properties": {
"name": "my-snapshot",
"duration_ms": 30000
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |
| `duration_ms` | number | Ensure duration |
### `sandbox.snapshot.failed`
Emitted when an image or snapshot ensure step fails.
```json
{
"id": "...", "ts": "...", "run_id": "...",
"event": "sandbox.snapshot.failed",
"properties": {
"name": "my-snapshot",
"error": "disk quota exceeded"
}
}
```
| Property | Type | Description |
|----------|------|-------------|
| `name` | string | Snapshot name |
| `error` | string | Error message |
| `causes` | string[] | Optional error cause chain |
Events stored under `sandbox.start.*`, `sandbox.stop.*`, `sandbox.delete.*`, and
`sandbox.snapshot.*` before the driver's events were kept whole carry fabro's earlier
`provider`, `name`, `duration_ms`, and `error` properties instead; readers treat them as
unknown bodies.
### `sandbox.git.started`

View file

@ -14,7 +14,7 @@ target node on that edge; parallel branches are not subgraph walks.
Every branch:
- receives an independent fork of the parent workflow context;
- receives the same `Arc<dyn Sandbox>` as the parent run;
- receives the same `Arc<RunSandbox>` as the parent run;
- inherits the same sandbox working directory and `internal.work_dir`;
- runs through the normal handler dispatch path, including dry-run behavior;
- retains its branch identity, lifecycle events, and hook scope.

View file

@ -5,12 +5,13 @@ description: "Sandboxing workflow execution"
Sandboxes isolate agent execution from the host machine. When an agent runs a shell command, edits a file, or searches code, it does so inside a sandbox — preventing unintended side effects on the host and providing a reproducible environment for each run.
Fabro supports three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). See [Environments](/execution/environments) for full provider-specific configuration.
Fabro bundles three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). Additional providers run as [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugins configured under `[server.sandbox.providers.<kind>]`; an environment selects one by its kind name. See [Environments](/execution/environments) for full provider-specific configuration and [Server configuration](/administration/server-configuration#serversandboxproviders-section) for plugin settings.
Operators can enable or disable which providers the server may launch with
`[server.sandbox.providers.<provider>]` in `settings.toml`. Missing entries default to
`enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled.
Dry-run Docker/Daytona runs execute locally, so they are governed by the `local` provider policy.
`[server.sandbox.providers.<kind>]` in `settings.toml`. Missing bundled entries default to
`enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled,
and a plugin kind with no entry is disabled. Dry-run runs on any non-local provider execute
locally, so they are governed by the `local` provider policy.
The API can also list Fabro-managed sandboxes directly from configured providers:

View file

@ -181,9 +181,10 @@ The GitHub OAuth client ID still lives under `[server.integrations.github].clien
### `[server.sandbox.providers]` section
Controls which sandbox providers the server may launch. Missing provider entries default to
`enabled = true` for backward compatibility. Disabling a provider rejects new runs whose effective
provider is disabled; dry-run Docker/Daytona runs use the local provider and are governed by
Controls which sandbox providers the server may launch, keyed by provider kind. The bundled
providers `local`, `docker`, and `daytona` run inside the server and default to `enabled = true`
when their entry is missing. Disabling a provider rejects new runs whose effective provider is
disabled; dry-run Docker/Daytona runs use the local provider and are governed by
`server.sandbox.providers.local.enabled`.
```toml title="settings.toml"
@ -197,6 +198,34 @@ enabled = true
enabled = true
```
Any other key names a [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugin:
an executable that speaks the sandbox-driver JSON-RPC protocol on stdin and stdout. The kind must
be lowercase ASCII letters, digits, and interior hyphens. The plugin starts with a scrubbed
environment: only `env` and the ambient variables listed in `inherit_env` reach it. Bundled
providers reject these plugin keys.
```toml title="settings.toml"
[server.sandbox.providers.e2b]
enabled = true
path = "/opt/fabro/plugins/fabro-sandbox-e2b" # default: `fabro-sandbox-<kind>` on PATH
sha256 = "0123…cdef" # pin the executable; `dev = true` skips it
args = []
inherit_env = ["PATH"]
[server.sandbox.providers.e2b.env]
E2B_API_URL = "https://api.e2b.example"
```
| Key | Description | Default |
|---|---|---|
| `enabled` | Whether runs may select this provider | `true` |
| `path` | Plugin executable path | `fabro-sandbox-<kind>` on `PATH` |
| `sha256` | Pinned SHA-256 of the executable, hex | none |
| `dev` | Allow launching without a checksum | `false` |
| `args` | Arguments passed to the executable | `[]` |
| `env` | Complete environment for the plugin, apart from `inherit_env` | `{}` |
| `inherit_env` | Ambient variables forwarded from the server process | `[]` |
### `[server.slatedb]` section
Configure the embedded SlateDB key-value store used for the remaining

View file

@ -3907,7 +3907,7 @@ paths:
operationId: retrieveRunSandbox
tags: [Human-in-the-Loop]
summary: Retrieve Run Sandbox Details
description: Returns provider-neutral details about the sandbox owned by this run, including identity, normalized state, image/snapshot, resources, labels, and timestamps.
description: Returns the sandbox owned by this run as fabro's record of it plus the sandbox driver's status (identity, state, image or snapshot, resources, network policy, labels, and timestamps).
parameters:
- $ref: "#/components/parameters/RunId"
responses:
@ -7244,7 +7244,7 @@ components:
description: Stable revision used with `If-Match` for optimistic concurrency.
example: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
provider:
$ref: "#/components/schemas/EnvironmentProvider"
$ref: "#/components/schemas/SandboxProviderKind"
cwd:
type: ["string", "null"]
description: Local-provider command working directory for this environment. Docker and Daytona ignore this value.
@ -7283,7 +7283,7 @@ components:
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
example: docker
provider:
$ref: "#/components/schemas/EnvironmentProvider"
$ref: "#/components/schemas/SandboxProviderKind"
cwd:
type: ["string", "null"]
description: Local-provider command working directory for this environment. Docker and Daytona ignore this value.
@ -7317,7 +7317,7 @@ components:
- env
properties:
provider:
$ref: "#/components/schemas/EnvironmentProvider"
$ref: "#/components/schemas/SandboxProviderKind"
cwd:
type: ["string", "null"]
description: Local-provider command working directory for this environment. Docker and Daytona ignore this value.
@ -12949,12 +12949,13 @@ components:
example: 180000
SandboxProviderKind:
description: Sandbox provider discriminator.
description: |
Sandbox provider kind. `local`, `docker`, and `daytona` are bundled
with the server; any other value names a sandbox-driver plugin
configured under `server.sandbox.providers.<kind>`.
type: string
enum:
- local
- docker
- daytona
pattern: "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$"
example: docker
RunSandboxKind:
description: Lifecycle state for a run sandbox request.
@ -13874,179 +13875,182 @@ components:
example: docker exec -it fabro-run-01HY0000000000000000000000 sh -lc 'cd /workspace/fabro && exec sh -l'
SandboxState:
description: Normalized sandbox lifecycle state used by the control plane and UI. The original provider-specific state string is preserved in `native_state`.
description: The sandbox driver's lifecycle state for a sandbox. The provider's own state string is preserved in `SandboxStatus.provider_state`. A reader must treat a value it does not know as `unknown`.
type: string
enum:
- unknown
- provisioning
- creating
- starting
- running
- stopping
- stopped
- pausing
- paused
- deleting
- deleted
- resuming
- archiving
- archived
- restoring
- resizing
- forking
- snapshotting
- deleting
- deleted
- error
- unknown
SandboxKind:
description: The kind of isolation a sandbox was provisioned with, as observed by the driver. Not an isolation guarantee.
type: string
enum:
- container
- virtual_machine
- unknown
SandboxWorkspaceOwnership:
description: Who owns a local sandbox's workspace directory. `designated` is a caller-owned directory that deleting the sandbox never touches; `managed` is a directory the driver created and removes.
type: string
enum:
- designated
- managed
SandboxResources:
description: Resource configuration for a sandbox. Fields are nullable when the provider does not surface a value or no limit is configured.
description: Compute resources of a sandbox, in the units the field names give. A field is null when the provider does not report a value or applies its default.
type: object
properties:
cpu_cores:
type: number
format: double
description: Configured CPU cores. Null when unavailable.
memory_bytes:
type: integer
type: ["integer", "null"]
format: int64
minimum: 0
description: Memory limit in bytes. Null when unavailable or unlimited.
disk_bytes:
type: integer
memory_mb:
type: ["integer", "null"]
format: int64
minimum: 0
disk_mb:
type: ["integer", "null"]
format: int64
minimum: 0
gpus:
type: ["integer", "null"]
format: int64
minimum: 0
description: Disk size in bytes. Null when unavailable.
SandboxNetworkPolicyMode:
description: Provider-neutral public-network policy for one direction.
type: string
enum:
- unknown
- open
- blocked
- cidr_allow_list
- essentials_only
SandboxNetworkPolicy:
description: Public-network policy for one direction.
description: The network policy in force for a sandbox. A policy without parameters is its name; an allow list carries its entries.
oneOf:
- type: string
enum:
- provider_default
- allow_all
- block
- type: object
required: [cidr_allow_list]
properties:
cidr_allow_list:
type: object
required: [cidrs]
properties:
cidrs:
type: array
items:
type: string
- type: object
required: [domain_allow_list]
properties:
domain_allow_list:
type: object
required: [domains]
properties:
domains:
type: array
items:
type: string
SandboxStatus:
description: What the sandbox driver reports about a sandbox. Only `id` and `state` are always present; every other field is null or empty when the provider does not report it.
type: object
required:
- mode
- cidrs
- id
- state
properties:
mode:
$ref: "#/components/schemas/SandboxNetworkPolicyMode"
cidrs:
type: array
items:
id:
type: string
description: The provider's stable identifier for the sandbox.
name:
type: ["string", "null"]
description: The provider's display name, which is not the stable identifier.
state:
$ref: "#/components/schemas/SandboxState"
provider_state:
type: string
default: ""
description: The provider's own state string, for display and debugging.
error_reason:
type: ["string", "null"]
resources:
oneOf:
- $ref: "#/components/schemas/SandboxResources"
- type: "null"
sandbox_kind:
oneOf:
- $ref: "#/components/schemas/SandboxKind"
- type: "null"
region:
type: ["string", "null"]
description: The provider region or target the sandbox runs in.
labels:
type: object
additionalProperties:
type: string
description: CIDR entries when `mode` is `cidr_allow_list`; empty for other modes.
SandboxNetwork:
description: Provider-neutral public-network policy for sandbox egress and ingress.
type: object
required:
- egress
- ingress
properties:
egress:
$ref: "#/components/schemas/SandboxNetworkPolicy"
ingress:
$ref: "#/components/schemas/SandboxNetworkPolicy"
SandboxTimestamps:
description: Lifecycle timestamps for a sandbox. Fields are nullable when the provider does not surface a value.
type: object
properties:
description: Provider-stored labels, including fabro's ownership labels.
image:
type: ["string", "null"]
description: The image the sandbox runs, when the provider knows it (a Docker container's image reference).
snapshot:
type: ["string", "null"]
description: The snapshot the sandbox was created from, when the provider knows it (a Daytona snapshot name).
network:
oneOf:
- $ref: "#/components/schemas/SandboxNetworkPolicy"
- type: "null"
description: The network policy in force, when the provider can read it back.
workspace_ownership:
oneOf:
- $ref: "#/components/schemas/SandboxWorkspaceOwnership"
- type: "null"
description: Local sandboxes only.
web_url:
type: ["string", "null"]
description: The provider's console page for the sandbox, when it has one.
created_at:
type: string
type: ["string", "null"]
format: date-time
description: When the sandbox was created.
last_activity_at:
type: string
updated_at:
type: ["string", "null"]
format: date-time
description: Most recent activity timestamp reported by the provider.
description: The provider's most recent activity or update timestamp for the sandbox.
SandboxDetails:
description: Provider-neutral details about the sandbox owned by a run.
description: The sandbox owned by a run, as fabro's record of it and the sandbox driver's status.
type: object
required:
- sandbox
- state
- resources
- network
- labels
- timestamps
- status
properties:
sandbox:
$ref: "#/components/schemas/RunSandboxInstance"
state:
$ref: "#/components/schemas/SandboxState"
native_state:
type: ["string", "null"]
description: Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`.
region:
type: ["string", "null"]
description: Provider region or target. Null for local-style providers.
web_url:
type: ["string", "null"]
description: Provider dashboard URL for this sandbox when available.
resources:
$ref: "#/components/schemas/SandboxResources"
network:
$ref: "#/components/schemas/SandboxNetwork"
labels:
type: object
additionalProperties:
type: string
description: Provider-reported labels.
timestamps:
$ref: "#/components/schemas/SandboxTimestamps"
status:
$ref: "#/components/schemas/SandboxStatus"
SandboxInfo:
description: Provider-backed inventory record for a Fabro-managed sandbox.
description: One sandbox of fabro's provider-backed inventory, as the provider fabro connected it through and the sandbox driver's status.
type: object
required:
- provider
- id
- state
- resources
- network
- labels
- timestamps
- status
properties:
provider:
$ref: "#/components/schemas/SandboxProviderKind"
id:
type: string
description: Provider-native sandbox id.
display_name:
type: ["string", "null"]
description: Provider display name when distinct from the native id.
state:
$ref: "#/components/schemas/SandboxState"
native_state:
type: ["string", "null"]
description: Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`.
image:
type: ["string", "null"]
description: Provider image when surfaced by the sandbox provider.
snapshot:
type: ["string", "null"]
description: Provider snapshot when surfaced by the sandbox provider.
region:
type: ["string", "null"]
description: Provider region or target. Null for local-style providers.
web_url:
type: ["string", "null"]
description: Provider dashboard URL for this sandbox when available.
working_directory:
type: ["string", "null"]
description: Provider-reported or Fabro-default working directory when available.
resources:
$ref: "#/components/schemas/SandboxResources"
network:
$ref: "#/components/schemas/SandboxNetwork"
labels:
type: object
additionalProperties:
type: string
description: Provider-reported labels.
timestamps:
$ref: "#/components/schemas/SandboxTimestamps"
status:
$ref: "#/components/schemas/SandboxStatus"
SandboxProviderLookupError:
description: Provider error captured during fail-soft sandbox inventory lookup.
@ -14115,7 +14119,7 @@ components:
$ref: "#/components/schemas/SandboxFileEntry"
SandboxService:
description: A listening TCP service discovered inside a run sandbox.
description: A TCP port a process inside a run sandbox listens on, as the sandbox driver reports it.
type: object
required:
- port
@ -14131,16 +14135,16 @@ components:
example: 3000
addresses:
type: array
description: Local bind addresses discovered from `ss` or `/proc/net/tcp*`.
description: Local bind addresses the sandbox reports for the port.
items:
type: string
example: ["127.0.0.1:3000", "[::]:3000"]
processes:
type: array
description: Visible process summaries when available. Empty when the sandbox only supports `/proc/net/tcp*` discovery.
description: The listening processes, when the sandbox can name them (`node`, or `pid=1234`). Empty when it cannot.
items:
type: string
example: ['users:(("node",pid=42,fd=23))']
example: ["node"]
preview_supported:
type: boolean
description: Whether the provider supports an external preview URL for this port.
@ -14151,30 +14155,11 @@ components:
type: object
required:
- data
- meta
properties:
data:
type: array
items:
$ref: "#/components/schemas/SandboxService"
meta:
$ref: "#/components/schemas/SandboxServiceListMeta"
SandboxServiceListMeta:
description: Metadata about sandbox service discovery.
type: object
required:
- source
properties:
source:
$ref: "#/components/schemas/SandboxServiceDiscoverySource"
SandboxServiceDiscoverySource:
description: Tool or kernel interface used to discover sandbox services.
type: string
enum:
- ss
- procfs
VncPreviewResponse:
description: Response containing a signed noVNC preview URL for a Daytona sandbox.
@ -14455,15 +14440,13 @@ components:
$ref: "#/components/schemas/ServerSandboxProvidersSettings"
ServerSandboxProvidersSettings:
description: |
Sandbox provider policy keyed by provider kind. The bundled kinds
(`local`, `docker`, `daytona`) are always present; any other key names
a sandbox-driver plugin and carries its launch settings.
type: object
required: [local, docker, daytona]
properties:
local:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
docker:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
daytona:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
additionalProperties:
$ref: "#/components/schemas/ServerSandboxProviderSettings"
ServerSandboxProviderSettings:
type: object
@ -14471,6 +14454,32 @@ components:
properties:
enabled:
type: boolean
plugin:
$ref: "#/components/schemas/SandboxPluginSettings"
SandboxPluginSettings:
description: How the server launches a sandbox-driver plugin executable.
type: object
properties:
path:
type: string
description: Executable path. Absent means `fabro-sandbox-<kind>` on `PATH`.
sha256:
type: string
description: Pinned SHA-256 of the executable, hex.
dev:
type: boolean
description: Allow launching without a checksum.
args:
type: array
items:
type: string
env:
$ref: "#/components/schemas/StringMap"
inherit_env:
type: array
items:
type: string
ServerStorageSettings:
type: object
@ -14952,9 +14961,10 @@ components:
type: boolean
default: false
description: |
When true, Fabro-managed run-branch checkpoint commits bypass
local Git commit hooks. Does not affect Fabro `[[run.hooks]]`
or metadata-branch snapshots. Defaults to false.
Accepted for compatibility. Fabro-managed run-branch checkpoint
commits never run local Git commit hooks: the sandbox driver
disables repository hooks on every git command it runs. Does not
affect Fabro `[[run.hooks]]`. Defaults to false.
RunCloneSettings:
type: object
@ -14994,7 +15004,7 @@ components:
id:
type: string
provider:
$ref: "#/components/schemas/EnvironmentProvider"
$ref: "#/components/schemas/SandboxProviderKind"
cwd:
type: ["string", "null"]
description: Local-provider command working directory for this environment. Docker and Daytona ignore this value.
@ -15019,7 +15029,7 @@ components:
required: [provider, image, resources, network, lifecycle, labels, env]
properties:
provider:
$ref: "#/components/schemas/EnvironmentProvider"
$ref: "#/components/schemas/SandboxProviderKind"
cwd:
type: ["string", "null"]
description: Local-provider command working directory for this environment. Docker and Daytona ignore this value.
@ -15039,11 +15049,6 @@ components:
additionalProperties:
$ref: "#/components/schemas/InterpString"
EnvironmentProvider:
description: Desired environment provider.
type: string
enum: [local, docker, daytona]
EnvironmentImageSettings:
type: object
required: [docker, dockerfile]

View file

@ -270,7 +270,7 @@ memory = "4GB"
mode = "block"
```
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
Every provider except `local` is clone-based, including Docker, Daytona, and sandbox-driver plugins. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. Its Git target may select a branch, an optional bare tag, an optional exact commit SHA, or both tag and SHA. Both providers attach the selected revision to the target's working branch; an exact SHA wins over a tag, and unavailable tags or commits fail without branch fallback. The `none` target is not supported by Local environments, while the Local-only `folder` target is rejected by Docker and Daytona. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.

View file

@ -428,8 +428,8 @@ commit_timeout = "30s"
| Field | Description |
|---|---|
| `exclude_globs` | Glob patterns for files to exclude from checkpoint commits. Uses git pathspec `:(glob,exclude)` syntax. |
| `skip_git_hooks` | When `true`, Fabro-managed run-branch checkpoint commits bypass local Git commit hooks (e.g. `pre-commit`, `commit-msg`). Defaults to `false`. Does not affect Fabro workflow `[[run.hooks]]` or metadata-branch snapshots. |
| `commit_timeout` | Max duration for the per-node run-branch checkpoint commit (e.g. `"30s"`, `"10m"`). This commit runs repository commit hooks unless `skip_git_hooks` is `true`. Defaults to `"30s"`. |
| `skip_git_hooks` | Accepted for compatibility. Fabro-managed run-branch checkpoint commits never run local Git commit hooks (e.g. `pre-commit`, `commit-msg`); the sandbox driver disables repository hooks on every git command it runs. Does not affect Fabro workflow `[[run.hooks]]`. |
| `commit_timeout` | Accepted for compatibility. The per-node run-branch checkpoint commit runs under the sandbox driver's git command budget; no repository hook can prolong it. |
`exclude_globs` replaces across layers — the higher-precedence layer wins wholesale. `skip_git_hooks` and `commit_timeout` use normal override semantics: the highest layer that sets the field wins.

View file

@ -126,9 +126,9 @@ Set either `image.docker` or `image.dockerfile`. `image.docker` can name any ima
dockerfile = "FROM node:20-slim\nRUN apt-get update && apt-get install -y git"
```
Fabro computes an internal snapshot name and looks up that snapshot in Daytona. If it does not exist, Fabro creates it automatically and polls until it reaches `Active` state for up to 30 minutes. A Dockerfile can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests. If the snapshot already exists, Fabro reuses it immediately.
The sandbox driver builds the image or Dockerfile into a Daytona snapshot named by its inputs (the image reference or Dockerfile text, the resources, and the Daytona API key) and creates the sandbox from it. If that snapshot already exists, it is reused immediately; otherwise the driver builds it and waits for it to reach `Active` state. A Dockerfile can be inline content or `{ path = "..." }`; paths are resolved relative to the TOML file that declares them and are bundled into run manifests.
The exact `image.docker` value is part of the snapshot identity. Prefer a digest such as `registry.example.com/team/image@sha256:...` when the image must be reproducible. If a mutable tag moves without its text changing, Fabro continues to reuse the existing snapshot.
The exact `image.docker` value is part of the snapshot identity. Prefer a digest such as `registry.example.com/team/image@sha256:...` when the image must be reproducible. If a mutable tag moves without its text changing, the existing snapshot continues to be reused.
<Note>
If neither image source is configured, sandboxes are created from the `daytona-medium` snapshot, which includes standard dev tools such as Git. To force a new Dockerfile snapshot, change the Dockerfile text, for example by adding a comment.
@ -237,11 +237,11 @@ If doctor reports missing scopes, regenerate the Daytona key with `write:snapsho
### Custom snapshot did not roll
Custom Daytona snapshot names are computed from the image reference or Dockerfile, resource hints, tenant scope, and Daytona API key. For `image.docker`, use an immutable digest and update it when the image changes. For `image.dockerfile`, change the Dockerfile text under the selected `[environments.<slug>.image]`.
Custom Daytona snapshot names (`sandbox-driver-<hex>`) are computed by the sandbox driver from the image reference or Dockerfile, the resources, and the Daytona API key. For `image.docker`, use an immutable digest and update it when the image changes. For `image.dockerfile`, change the Dockerfile text under the selected `[environments.<slug>.image]`.
### "Timed out waiting for snapshot to become active"
Snapshot creation took longer than 30 minutes. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active.
Snapshot creation took longer than the sandbox driver's build budget. This can happen with large Dockerfiles. Check the snapshot status in the Daytona dashboard — it may still be building. Subsequent runs will reuse the snapshot once it's active.
### Git clone fails for private repositories

View file

@ -303,7 +303,7 @@ Behavior notes:
Workflow authors may name any repository reachable by the server's GitHub App installation; Fabro applies no second server-side repository intersection. The token is scoped server-side to exactly the declared set — a request to an undeclared repository fails at GitHub, and Fabro never mints an unscoped installation-wide token. With `contents = "write"`, **any stage can push to any declared repository**. Declare the smallest repository set and the weakest permissions that work.
Installation Access Tokens are short-lived. Fabro refreshes its own credentials before checkpoint pushes. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro also re-mints the token and rewrites the sandbox's `origin` URL before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage.
Installation Access Tokens are short-lived. Fabro's own pushes present a fresh token on each call. Git commands the agent runs inside the sandbox read the token through a credential store the sandbox driver configures for the checkout; the token never appears in the repository's remote URL or configuration. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro re-mints the token and rewrites that store before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage.
`FABRO_PUSH_CRED_REFRESH_AHEAD` defaults to enabled; set it to `0`, `false`, `off`, `no`, or an empty value to disable both turn-entry and background refresh. `FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS` overrides the background interval, and `0` disables only the background loop. This refresh loop is ACP-specific; command and native/API agent stages do not run it. Reconnected sandboxes for resumed or parked runs currently lack the App credentials needed for ACP refresh, so the refresh is skipped there.

View file

@ -29,7 +29,7 @@ tokio = { version = "1", features = ["full"] }
use std::path::PathBuf;
use std::sync::Arc;
use fabro_agent::{AgentProfile, AgentProfileBuilder, LocalSandbox, Session, SessionOptions};
use fabro_agent::{AgentProfile, AgentProfileBuilder, Session, SessionOptions, local_sandbox};
use fabro_auth::VaultCredentialSource;
use fabro_llm::ClientOptions;
use fabro_types::AgentProfileKind;
@ -45,7 +45,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)
.await?
.client;
let sandbox = Arc::new(LocalSandbox::new(PathBuf::from(".")));
let sandbox = Arc::new(local_sandbox(PathBuf::from(".")).await?);
let profile: Arc<dyn AgentProfile> = Arc::from(
AgentProfileBuilder::new(
AgentProfileKind::Anthropic,
@ -86,7 +86,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
pub fn new(
llm_client: Client,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
config: SessionOptions,
) -> Self
```
@ -135,46 +135,57 @@ All fields are public. Key settings with their defaults:
### Sandbox
The `Sandbox` trait abstracts where tools execute — local filesystem, Docker container, SSH remote, or a cloud sandbox. All tool operations go through this interface.
`RunSandbox` is where tools execute: the local filesystem, a Docker container,
or a cloud sandbox. It is one concrete type over a
[sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) sandbox,
and every tool operation goes through it. Paths resolve against the run's
working directory, commands run as Bash with fabro's timeout and stop policy,
and output is drained even when the retained copy is capped.
```rust
#[async_trait]
pub trait Sandbox: Send + Sync {
async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>, String>;
async fn read_file_text(&self, path: &str) -> Result<String, String>;
async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String, String>;
async fn write_file(&self, path: &str, content: &str) -> Result<(), String>;
async fn delete_file(&self, path: &str) -> Result<(), String>;
async fn file_exists(&self, path: &str) -> Result<bool, String>;
async fn list_directory(&self, path: &str, depth: Option<usize>) -> Result<Vec<DirEntry>, String>;
async fn exec_command(
impl RunSandbox {
pub async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>>;
pub async fn read_file_text(&self, path: &str) -> Result<String>;
pub async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String>;
pub async fn write_file(&self, path: &str, content: &str) -> Result<()>;
pub async fn delete_file(&self, path: &str) -> Result<()>;
pub async fn file_exists(&self, path: &str) -> Result<bool>;
pub async fn list_directory(&self, path: &str, depth: Option<usize>) -> Result<Vec<DirEntry>>;
pub async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String>;
async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result<Vec<String>, String>;
async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result<Vec<SandboxFile>, String>;
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String>;
async fn initialize(&self) -> Result<(), String>;
async fn cleanup(&self) -> Result<(), String>;
fn working_directory(&self) -> &str;
fn platform(&self) -> &str;
fn os_version(&self) -> String;
// ... optional methods with defaults: setup_git(), git_push_ref(), etc.
) -> Result<ExecResult>;
pub async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result<Vec<GrepMatch>>;
pub async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result<Vec<SandboxFile>>;
pub async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>>;
pub async fn initialize(&self) -> Result<()>;
pub async fn cleanup(&self) -> Result<()>;
pub fn working_directory(&self) -> &str;
pub fn platform(&self) -> &str;
pub fn os_version(&self) -> String;
// ... plus git setup and push, credentials refresh, preview URLs, and access commands.
}
```
**Built-in implementations:**
`DirEntry`, `GrepMatch`, `GrepOptions`, and `WalkOptions` are the driver's own
types, re-exported from `fabro_sandbox`.
| Type | Description |
**Constructors:**
| Function | Description |
|---|---|
| `LocalSandbox` | Executes directly on the local filesystem. |
| `DockerSandbox` | Runs inside a Docker container (feature-gated: `docker`). |
| `local_sandbox(directory)` | Executes directly on the local filesystem through the sandbox driver Host provider. |
| `provider_sandbox(kind, ...)` | Runs on any sandbox driver provider by kind: the bundled `docker` and `daytona` providers in process, or a configured plugin. |
The `DaytonaSandbox` implementation (feature-gated: `daytona`) runs inside a Daytona cloud sandbox.
**Testing:** `fabro_sandbox::test_support::MockSandbox` (behind the
`test-support` feature) describes a scripted sandbox by its fields — seeded
files, the result every command returns, the platform — and hands out the
`RunSandbox` with `.sandbox()`. Afterwards it reads back what the code did:
`captured_commands()`, `written_files()`, `deleted_files()`, and so on.
### Provider profiles
@ -186,7 +197,7 @@ pub trait AgentProfile: Send + Sync {
fn model(&self) -> &str;
fn tool_registry(&self) -> &ToolRegistry;
fn tool_registry_mut(&mut self) -> &mut ToolRegistry;
fn build_system_prompt(&self, env: &dyn Sandbox, ...) -> String;
fn build_system_prompt(&self, env: &RunSandbox, ...) -> String;
fn capabilities(&self) -> ProfileCapabilities;
fn tools(&self) -> Vec<ToolDefinition>;
// ...

View file

@ -25,6 +25,7 @@ fabro-llm = { path = "../../components/fabro-llm" }
fabro-oauth = { path = "../../foundation/fabro-oauth" }
fabro-github = { path = "../../components/fabro-github" }
fabro-agent = { path = "../../components/fabro-agent" }
sandbox-driver.workspace = true
fabro-dump = { path = "../../components/fabro-dump" }
fabro-hooks = { path = "../../components/fabro-hooks" }
fabro-install = { path = "../../components/fabro-install" }
@ -33,7 +34,7 @@ fabro-mcp = { path = "../../components/fabro-mcp" }
fabro-mcp-server = { path = "../fabro-mcp-server" }
fabro-manifest = { path = "../../components/fabro-manifest" }
fabro-proc = { path = "../../foundation/fabro-proc" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona"] }
fabro-sandbox = { path = "../../components/fabro-sandbox" }
fabro-checkpoint = { path = "../../components/fabro-checkpoint" }
fabro-graphviz = { path = "../../components/fabro-graphviz" }
fabro-validate = { path = "../../components/fabro-validate" }
@ -57,7 +58,6 @@ clap_complete.workspace = true
cli-table.workspace = true
console.workspace = true
indicatif.workspace = true
daytona-sdk.workspace = true
anyhow.workspace = true
miette.workspace = true
dotenvy.workspace = true
@ -104,7 +104,7 @@ nix = { version = "0.30", features = ["fs"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = { version = "0.9", optional = true }
# Vendor openssl only for musl targets. daytona-sdk transitively pulls
# Vendor openssl only for musl targets. Transitive dependencies pull
# native-tls via reqwest, which needs libssl. On glibc runners the system
# libssl is used; on musl runners we compile openssl from source.
[target.'cfg(target_env = "musl")'.dependencies]
@ -118,6 +118,7 @@ chrono = { workspace = true }
assert_cmd = "2"
fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] }
fabro-build-support = { path = "../../foundation/build-support" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] }
fabro-server = { path = "../fabro-server", features = ["test-support"] }
fabro-workflow = { path = "../../components/fabro-workflow", features = ["test-support"] }
fabro-types = { path = "../../foundation/fabro-types", features = ["clap", "test-support"] }

View file

@ -3,8 +3,7 @@ use std::path::Path;
use anyhow::{Context as _, anyhow, bail};
use fabro_config::project;
use fabro_environment::{DEFAULT_ENVIRONMENT_ID, Environment};
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{DirtyStatus, RunId, RunIntent, RunTarget};
use fabro_types::{DirtyStatus, RunId, RunIntent, RunTarget, SandboxProviderKind};
use fabro_util::terminal::Styles;
use super::overrides::prepare_intent_overrides;
@ -73,7 +72,7 @@ pub(crate) async fn create_run(
resolve_run_environment(client.as_ref(), args.environment.as_deref()),
)?;
let (target, dirty_worktree) =
run_target_for_environment(environment.settings.provider, &canonical_cwd)?;
run_target_for_environment(&environment.settings.provider, &canonical_cwd)?;
if dirty_worktree {
fabro_util::printerr!(
ctx.printer(),
@ -169,10 +168,10 @@ fn warn_untransmitted_settings(
/// provider. Returns the target plus whether a clone-based observation found a
/// dirty Git worktree, so the caller can warn about it.
fn run_target_for_environment(
provider: EnvironmentProvider,
provider: &SandboxProviderKind,
canonical_cwd: &Path,
) -> anyhow::Result<(RunTarget, bool)> {
if !provider.is_clone_based() {
if !provider.clones_workspace() {
let path = canonical_cwd.to_str().ok_or_else(|| {
anyhow!(
"caller working directory is not valid UTF-8: {}",

View file

@ -654,25 +654,35 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O
styles.dim.apply_to(&duration),
))
}
"sandbox.snapshot.pulling" => {
let name = prop_str_field(envelope, "name").unwrap_or("?");
"sandbox.create.progress" => {
let code = envelope
.pointer("/properties/progress/code")
.and_then(serde_json::Value::as_str)?;
if code != "image.pull" {
return None;
}
let message = envelope
.pointer("/properties/progress/message")
.and_then(serde_json::Value::as_str)
.unwrap_or("image");
let name = message.strip_prefix("pulling image ").unwrap_or(message);
Some(format!(
"{} Sandbox: pulling {}",
styles.dim.apply_to(&ts),
name,
))
}
"sandbox.snapshot.creating" => {
let name = prop_str_field(envelope, "name").unwrap_or("?");
"snapshot.create.started" => {
let name = driver_subject_name(envelope);
Some(format!(
"{} Sandbox: building {}",
styles.dim.apply_to(&ts),
name,
))
}
"sandbox.snapshot.ready" => {
let name = prop_str_field(envelope, "name").unwrap_or("?");
let duration = format_duration_ms(prop_field(envelope, "duration_ms"));
"snapshot.create.completed" => {
let name = driver_subject_name(envelope);
let duration = format_duration_ms(driver_duration_ms(envelope).as_ref());
Some(format!(
"{} Sandbox snapshot: {} {}",
styles.dim.apply_to(&ts),
@ -680,9 +690,12 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O
styles.dim.apply_to(&duration),
))
}
"sandbox.snapshot.failed" => {
let name = prop_str_field(envelope, "name").unwrap_or("?");
let error = prop_str_field(envelope, "error").unwrap_or("unknown error");
"snapshot.create.failed" => {
let name = driver_subject_name(envelope);
let error = envelope
.pointer("/properties/error/message")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown error");
Some(format!(
"{} {} Sandbox snapshot {} failed: {}",
styles.dim.apply_to(&ts),
@ -809,6 +822,30 @@ fn str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
value.get(key)?.as_str()
}
/// The name of the resource a sandbox driver event is about, falling back
/// to its id.
fn driver_subject_name(envelope: &serde_json::Value) -> &str {
envelope
.pointer("/properties/subject/name")
.or_else(|| envelope.pointer("/properties/subject/id"))
.and_then(serde_json::Value::as_str)
.unwrap_or("?")
}
/// A sandbox driver operation's duration, in milliseconds, as the number
/// [`format_duration_ms`] reads.
fn driver_duration_ms(envelope: &serde_json::Value) -> Option<serde_json::Value> {
let duration = envelope.pointer("/properties/duration")?;
let secs = duration.get("secs").and_then(serde_json::Value::as_u64)?;
let nanos = duration
.get("nanos")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
Some(serde_json::Value::from(
secs.saturating_mul(1000).saturating_add(nanos / 1_000_000),
))
}
fn prop_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
value.get("properties")?.get(key)
}
@ -1261,7 +1298,7 @@ mod tests {
#[test]
fn pretty_sandbox_snapshot_pulling() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.pulling","properties":{"name":"buildpack-deps:noble"}}"#;
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.create.progress","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"docker","subject":{"type":"sandbox"},"type":"operation_progress","action":"create","progress":{"code":"image.pull","message":"pulling image buildpack-deps:noble"}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("Sandbox: pulling"), "got: {result}");
assert!(result.contains("buildpack-deps:noble"), "got: {result}");
@ -1270,7 +1307,7 @@ mod tests {
#[test]
fn pretty_sandbox_snapshot_creating() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.creating","properties":{"name":"fabro-v9-test"}}"#;
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.started","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"daytona","subject":{"type":"snapshot","name":"fabro-v9-test"},"type":"operation_started","action":"create"}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("Sandbox: building"), "got: {result}");
assert!(result.contains("fabro-v9-test"), "got: {result}");
@ -1279,7 +1316,7 @@ mod tests {
#[test]
fn pretty_sandbox_snapshot_ready() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.ready","properties":{"name":"buildpack-deps:noble","duration_ms":8200}}"#;
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.completed","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"daytona","subject":{"type":"snapshot","name":"buildpack-deps:noble"},"type":"operation_completed","action":"create","duration":{"secs":8,"nanos":200000000}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("Sandbox snapshot:"), "got: {result}");
assert!(result.contains("buildpack-deps:noble"), "got: {result}");
@ -1289,7 +1326,7 @@ mod tests {
#[test]
fn pretty_sandbox_snapshot_failed() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"sandbox.snapshot.failed","properties":{"name":"buildpack-deps:noble","error":"pull failed"}}"#;
let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"snapshot.create.failed","properties":{"id":{"source_id":"t","sequence":1},"occurred_at":"2026-01-01T14:25:00Z","provider":"docker","subject":{"type":"snapshot","name":"buildpack-deps:noble"},"type":"operation_failed","action":"create","duration":{"secs":1,"nanos":0},"error":{"kind":"provider","message":"pull failed","retryable":false,"causes":[]}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(
result.contains("Sandbox snapshot buildpack-deps:noble failed: pull failed"),

View file

@ -51,8 +51,6 @@ pub(super) enum ProgressEvent {
provider: String,
duration_ms: u64,
name: Option<String>,
cpu: Option<f64>,
memory: Option<f64>,
url: Option<String>,
},
SandboxFailed {
@ -253,28 +251,13 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
provider: props.provider.clone(),
duration_ms: props.duration_ms,
name: props.name.clone(),
cpu: props.cpu,
memory: props.memory,
url: props.url.clone(),
}),
EventBody::SandboxFailed(props) => Some(ProgressEvent::SandboxFailed {
provider: props.provider.clone(),
error: props.error.clone(),
}),
EventBody::SnapshotPulling(props) => Some(ProgressEvent::SnapshotPulling {
name: props.name.clone(),
}),
EventBody::SnapshotCreating(props) => Some(ProgressEvent::SnapshotCreating {
name: props.name.clone(),
}),
EventBody::SnapshotReady(props) => Some(ProgressEvent::SnapshotReady {
name: props.name.clone(),
duration_ms: props.duration_ms,
}),
EventBody::SnapshotFailed(props) => Some(ProgressEvent::SnapshotFailed {
name: props.name.clone(),
error: props.error.clone(),
}),
EventBody::SandboxDriver { event, .. } => driver_progress_event(event),
EventBody::SshAccessReady(props) => Some(ProgressEvent::SshAccessReady {
ssh_command: props.ssh_command.clone(),
}),
@ -529,11 +512,70 @@ fn display_value(value: &Value) -> Option<String> {
}
}
/// The setup progress a sandbox driver event stands for: the image pull
/// inside the sandbox's create, or a snapshot build. Every other driver
/// event is stored on the run but renders nothing here.
fn driver_progress_event(event: &sandbox_driver::Event) -> Option<ProgressEvent> {
use sandbox_driver::{Action, EventBody as Body, EventSubject, ProgressCode};
match (&event.subject, &event.body) {
(
EventSubject::Sandbox { .. },
Body::OperationProgress {
action: Action::Create,
progress,
},
) if progress.code.as_str() == ProgressCode::IMAGE_PULL => {
Some(ProgressEvent::SnapshotPulling {
name: pulled_image_name(progress.message.as_deref()),
})
}
(EventSubject::Snapshot { id, name }, body) => {
let name = name
.clone()
.or_else(|| id.as_ref().map(ToString::to_string))
.unwrap_or_default();
match body {
Body::OperationStarted {
action: Action::Create,
} => Some(ProgressEvent::SnapshotCreating { name }),
Body::OperationCompleted {
action: Action::Create,
duration,
} => Some(ProgressEvent::SnapshotReady {
name,
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
}),
Body::OperationFailed {
action: Action::Create,
error,
..
} => Some(ProgressEvent::SnapshotFailed {
name,
error: error.message.clone(),
}),
_ => None,
}
}
_ => None,
}
}
/// The image an image pull progress report names. The Docker provider
/// says `pulling image <reference>`; the reference alone reads better.
fn pulled_image_name(message: Option<&str>) -> String {
let message = message.unwrap_or("image");
message
.strip_prefix("pulling image ")
.unwrap_or(message)
.to_owned()
}
#[cfg(test)]
mod tests {
use fabro_agent::AgentEvent;
use fabro_types::{MetadataSnapshotFailureKind, MetadataSnapshotPhase, fixtures};
use fabro_workflow::event::{Event, RunNoticeCode, to_run_event};
use fabro_workflow::event::{Event, RunNoticeCode, SandboxLifecycle, to_run_event};
use super::*;
@ -743,12 +785,10 @@ mod tests {
#[test]
fn round_trip_sandbox_ready() {
let event = Event::Sandbox {
event: fabro_agent::SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "daytona".into(),
duration_ms: 2500,
name: Some("sandbox-1".into()),
cpu: Some(4.0),
memory: Some(8.0),
url: Some("https://example.test".into()),
},
};
@ -769,7 +809,7 @@ mod tests {
#[test]
fn round_trip_sandbox_failed() {
let event = Event::Sandbox {
event: fabro_agent::SandboxEvent::InitializeFailed {
event: SandboxLifecycle::InitializeFailed {
provider: "docker".into(),
error: "pull failed".into(),
causes: Vec::new(),
@ -786,32 +826,76 @@ mod tests {
));
}
#[test]
fn round_trip_snapshot_lifecycle_events() {
let pulling = to_run_event(&fixtures::RUN_1, &Event::Sandbox {
event: fabro_agent::SandboxEvent::SnapshotPulling {
name: "buildpack-deps:noble".into(),
},
});
let creating = to_run_event(&fixtures::RUN_1, &Event::Sandbox {
event: fabro_agent::SandboxEvent::SnapshotCreating {
name: "fabro-v9".into(),
},
});
let ready = to_run_event(&fixtures::RUN_1, &Event::Sandbox {
event: fabro_agent::SandboxEvent::SnapshotReady {
name: "buildpack-deps:noble".into(),
duration_ms: 1200,
},
});
let failed = to_run_event(&fixtures::RUN_1, &Event::Sandbox {
event: fabro_agent::SandboxEvent::SnapshotFailed {
name: "fabro-v9".into(),
error: "build failed".into(),
causes: Vec::new(),
},
});
fn driver_event(value: serde_json::Value) -> Event {
Event::SandboxDriver {
event: serde_json::from_value(value).expect("a driver event"),
}
}
#[test]
fn round_trip_driver_events_that_render_setup_progress() {
let pulling = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 1},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "docker",
"subject": {"type": "sandbox"},
"type": "operation_progress",
"action": "create",
"progress": {"code": "image.pull", "message": "pulling image buildpack-deps:noble"}
})),
);
let creating = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 2},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "daytona",
"subject": {"type": "snapshot", "name": "fabro-v9"},
"type": "operation_started",
"action": "create"
})),
);
let ready = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 3},
"occurred_at": "2026-01-01T00:00:01Z",
"provider": "daytona",
"subject": {"type": "snapshot", "name": "fabro-v9"},
"type": "operation_completed",
"action": "create",
"duration": {"secs": 1, "nanos": 200_000_000}
})),
);
let failed = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 4},
"occurred_at": "2026-01-01T00:00:02Z",
"provider": "daytona",
"subject": {"type": "snapshot", "name": "fabro-v9"},
"type": "operation_failed",
"action": "create",
"duration": {"secs": 2, "nanos": 0},
"error": {"kind": "provider", "message": "build failed", "retryable": false, "causes": []}
})),
);
let stopped = to_run_event(
&fixtures::RUN_1,
&driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 5},
"occurred_at": "2026-01-01T00:00:03Z",
"provider": "docker",
"subject": {"type": "sandbox", "id": "c1"},
"type": "operation_completed",
"action": "stop",
"duration": {"secs": 0, "nanos": 0}
})),
);
assert_eq!(pulling.event_name(), "sandbox.create.progress");
assert!(matches!(
from_run_event(&pulling).unwrap(),
ProgressEvent::SnapshotPulling { name } if name == "buildpack-deps:noble"
@ -823,13 +907,18 @@ mod tests {
assert!(matches!(
from_run_event(&ready).unwrap(),
ProgressEvent::SnapshotReady { name, duration_ms }
if name == "buildpack-deps:noble" && duration_ms == 1200
if name == "fabro-v9" && duration_ms == 1200
));
assert!(matches!(
from_run_event(&failed).unwrap(),
ProgressEvent::SnapshotFailed { name, error }
if name == "fabro-v9" && error == "build failed"
));
assert_eq!(stopped.event_name(), "sandbox.stop.completed");
assert!(
from_run_event(&stopped).is_none(),
"a stop is stored on the run but renders no setup progress"
);
}
#[test]

View file

@ -140,8 +140,6 @@ impl ProgressUI {
provider,
duration_ms,
name,
cpu,
memory,
url,
} => {
self.setup.on_sandbox_ready(
@ -149,8 +147,6 @@ impl ProgressUI {
&provider,
duration_ms,
name.as_deref(),
cpu,
memory,
url.as_deref(),
);
}
@ -457,13 +453,15 @@ mod tests {
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use fabro_agent::{AgentEvent, SandboxEvent};
use fabro_agent::AgentEvent;
use fabro_types::run_event::CliEnsureCompletedProps;
use fabro_types::{
MetadataSnapshotFailureKind, MetadataSnapshotPhase, ModelRef, ParallelBranchId,
SandboxProviderKind, StageId, fixtures,
};
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
use fabro_workflow::event::{
Event, RunNoticeLevel, SandboxLifecycle, to_run_event, to_run_event_at,
};
use fabro_workflow::outcome::billed_model_usage_from_llm;
use lithos_llm::catalog::{ModelId, builtin};
use lithos_llm::types::TokenCounts;
@ -506,6 +504,55 @@ mod tests {
.expect("valid utf-8")
}
fn driver_event(value: serde_json::Value) -> Event {
Event::SandboxDriver {
event: serde_json::from_value(value).expect("a driver event"),
}
}
/// A snapshot build reported by the driver: started, or completed after
/// `secs`.
fn snapshot_build_event(name: &str, kind: &str, secs: Option<u64>) -> Event {
let mut value = serde_json::json!({
"id": {"source_id": "test", "sequence": 1},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "daytona",
"subject": {"type": "snapshot", "name": name},
"type": kind,
"action": "create"
});
if let Some(secs) = secs {
value["duration"] = serde_json::json!({"secs": secs, "nanos": 0});
}
driver_event(value)
}
fn snapshot_build_failed_event(name: &str, error: &str) -> Event {
driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 1},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "docker",
"subject": {"type": "snapshot", "name": name},
"type": "operation_failed",
"action": "create",
"duration": {"secs": 1, "nanos": 0},
"error": {"kind": "provider", "message": error, "retryable": false, "causes": []}
}))
}
/// The Docker provider pulling the sandbox's image inside its create.
fn image_pull_event(image: &str) -> Event {
driver_event(serde_json::json!({
"id": {"source_id": "test", "sequence": 1},
"occurred_at": "2026-01-01T00:00:00Z",
"provider": "docker",
"subject": {"type": "sandbox"},
"type": "operation_progress",
"action": "create",
"progress": {"code": "image.pull", "message": format!("pulling image {image}")}
}))
}
fn emit(ui: &mut ProgressUI, event: Event) {
let stored = to_run_event(&fixtures::RUN_1, &event);
ui.handle_event(&stored);
@ -907,7 +954,7 @@ mod tests {
stage_started("code", "Code"),
Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
provider: SandboxProviderKind::Daytona,
provider: SandboxProviderKind::DAYTONA,
id: "daytona:sandbox-id".into(),
repo_cloned: None,
clone_origin_url: None,
@ -1035,17 +1082,15 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "daytona".into(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "daytona".into(),
duration_ms: 2500,
name: Some("sandbox-1".into()),
cpu: Some(4.0),
memory: Some(8.0),
url: None,
},
});
@ -1064,12 +1109,12 @@ mod tests {
duration_ms: 600,
}),
);
insta::assert_snapshot!(rendered(&buffer), @r"
Sandbox: daytona (ready in 2s)
sandbox-1 (4 cpu, 8 GB)
ssh daytona@example
Setup: 2 commands (8s)
CLI: gh (installed, 600ms)
insta::assert_snapshot!(rendered(&buffer), @"
Sandbox: daytona (ready in 2s)
sandbox-1
ssh daytona@example
Setup: 2 commands (8s)
CLI: gh (installed, 600ms)
");
}
@ -1078,36 +1123,31 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "daytona".into(),
},
});
emit(
&mut ui,
snapshot_build_event("fabro-v9-test", "operation_started", None),
);
emit(
&mut ui,
snapshot_build_event("fabro-v9-test", "operation_completed", Some(210)),
);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotCreating {
name: "fabro-v9-test".into(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotReady {
name: "fabro-v9-test".into(),
duration_ms: 210_000,
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "daytona".into(),
duration_ms: 212_000,
name: Some("sandbox-1".into()),
cpu: Some(4.0),
memory: Some(8.0),
url: None,
},
});
insta::assert_snapshot!(rendered(&buffer), @r"
Sandbox: building fabro-v9-test...
Sandbox: daytona (ready in 3m32s)
sandbox-1 (4 cpu, 8 GB)
insta::assert_snapshot!(rendered(&buffer), @"
Sandbox: building fabro-v9-test...
Sandbox: daytona (ready in 3m32s)
sandbox-1
");
}
@ -1116,28 +1156,16 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
emit(&mut ui, image_pull_event("buildpack-deps:noble"));
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotPulling {
name: "buildpack-deps:noble".into(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotReady {
name: "buildpack-deps:noble".into(),
duration_ms: 8_200,
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "docker".into(),
duration_ms: 9_000,
name: None,
cpu: None,
memory: None,
url: None,
},
});
@ -1153,17 +1181,15 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "docker".into(),
duration_ms: 20,
name: None,
cpu: None,
memory: None,
url: None,
},
});
@ -1176,19 +1202,16 @@ mod tests {
let (mut ui, buffer) = capture_ui(false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
emit(
&mut ui,
snapshot_build_failed_event("buildpack-deps:noble", "pull failed"),
);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotFailed {
name: "buildpack-deps:noble".into(),
error: "pull failed".into(),
causes: Vec::new(),
},
});
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::InitializeFailed {
event: SandboxLifecycle::InitializeFailed {
provider: "docker".into(),
error: "pull failed".into(),
causes: Vec::new(),
@ -1207,27 +1230,23 @@ mod tests {
let mut ui = ProgressUI::new(true, false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
assert!(ui.setup.sandbox_bar.is_some());
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::SnapshotReady {
name: "buildpack-deps:noble".into(),
duration_ms: 10,
},
});
emit(
&mut ui,
snapshot_build_event("buildpack-deps:noble", "operation_completed", Some(0)),
);
assert!(ui.setup.sandbox_bar.is_some());
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Ready {
event: SandboxLifecycle::Ready {
provider: "docker".into(),
duration_ms: 20,
name: None,
cpu: None,
memory: None,
url: None,
},
});
@ -1239,14 +1258,14 @@ mod tests {
let mut ui = ProgressUI::new(true, false);
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::Initializing {
event: SandboxLifecycle::Initializing {
provider: "docker".into(),
},
});
assert!(ui.setup.sandbox_bar.is_some());
emit(&mut ui, Event::Sandbox {
event: SandboxEvent::InitializeFailed {
event: SandboxLifecycle::InitializeFailed {
provider: "docker".into(),
error: "pull failed".into(),
causes: Vec::new(),
@ -1263,7 +1282,7 @@ mod tests {
emit(&mut ui, stage_started("code", "Code"));
emit(&mut ui, Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
provider: SandboxProviderKind::Daytona,
provider: SandboxProviderKind::DAYTONA,
id: "daytona:sandbox-id".into(),
repo_cloned: None,
clone_origin_url: None,

View file

@ -56,20 +56,10 @@ impl SetupDisplay {
provider: &str,
duration_ms: u64,
name: Option<&str>,
cpu: Option<f64>,
memory: Option<f64>,
url: Option<&str>,
) {
let dur = format_duration_ms(duration_ms);
let detail = match (name, cpu, memory) {
(Some(name), Some(cpu), Some(memory)) => Some(format!(
"{name} ({} cpu, {} GB)",
styles::format_number(cpu),
styles::format_number(memory)
)),
(Some(name), _, _) => Some(name.to_string()),
_ => None,
};
let detail = name.map(str::to_string);
if renderer.is_tty() {
let display_provider = match url {

View file

@ -68,19 +68,6 @@ pub(super) fn terminal_hyperlink(url: &str, text: &str) -> String {
format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
}
pub(super) fn format_number(n: f64) -> String {
if (n - n.round()).abs() < f64::EPSILON {
#[allow(
clippy::cast_possible_truncation,
reason = "Whole-number display intentionally narrows to i64 for formatting."
)]
let i = n as i64;
format!("{i}")
} else {
format!("{n:.1}")
}
}
pub(super) fn truncate(s: &str, max: usize) -> String {
let single_line = s.split_whitespace().collect::<Vec<_>>().join(" ");
if single_line.len() > max {

View file

@ -19,10 +19,7 @@ use fabro_server::run_tool_manifest;
use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer};
use fabro_tool::fabro_client::ClientBackend;
use fabro_types::settings::run::{RunMode, RunNamespace};
use fabro_types::{
ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId,
WorkflowSettings,
};
use fabro_types::{ArtifactUpload, BlobHash, EventBody, FailureReason, Principal, RunEvent, RunId};
use fabro_vault::{SecretStore, Vault};
use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader};
use fabro_workflow::event::{Emitter, RunEventSink};
@ -135,8 +132,11 @@ pub(crate) async fn execute(
let vault = load_worker_vault(&storage_dir).await?;
let github_app = {
let vault_guard = vault.read().await;
maybe_build_github_credentials(&run_spec.settings, &vault_guard)?
maybe_build_github_credentials(run_spec, &vault_guard)?
};
let sandbox_providers = ServerSettingsBuilder::load_default()
.map(|settings| settings.server.sandbox.providers)
.unwrap_or_default();
let services = StartServices {
run_id,
cancel_token: cancel_token.clone(),
@ -165,6 +165,7 @@ pub(crate) async fn execute(
.resolve_integration()
.context("failed to resolve github integration")?,
vault,
sandbox_providers,
catalog,
on_node: None,
registry_override: None,
@ -1001,7 +1002,9 @@ impl RunStoreBackend for HttpRunStore {
async move { client.append_run_event(&run_id, &event).await }
}))
.await?;
self.apply_acknowledged_event(seq, event).await
// Both the sandbox lifecycle and the lithos event shapes grew this
// future past clippy's stack budget; box it once at the call.
Box::pin(self.apply_acknowledged_event(seq, event)).await
}
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
@ -1096,10 +1099,13 @@ fn stamp_system_worker(mut event: RunEvent) -> RunEvent {
}
fn maybe_build_github_credentials(
settings: &WorkflowSettings,
run_spec: &fabro_types::RunSpec,
vault: &fabro_vault::Vault,
) -> Result<Option<fabro_github::GitHubCredentials>> {
let resolved_run = &settings.run;
let resolved_run = &run_spec.settings.run;
let has_repo_origin = run_spec
.repo_origin_url()
.is_some_and(|origin| !origin.trim().is_empty());
let resolved_server = ServerSettingsBuilder::load_default().ok();
let server_ns = resolved_server.as_ref().map(|s| &s.server);
let strategy = server_ns
@ -1108,7 +1114,7 @@ fn maybe_build_github_credentials(
let app_id = server_ns.and_then(|server| server.integrations.github.app_id.clone());
let app_slug = server_ns.and_then(|server| server.integrations.github.slug.clone());
if requires_github_credentials(resolved_run) {
if requires_github_credentials(resolved_run, has_repo_origin) {
return build_github_credentials(strategy, app_id.as_deref(), app_slug.as_deref(), vault);
}
@ -1129,14 +1135,17 @@ fn maybe_build_github_credentials(
}
/// Hard-gate for the CLI worker path: a run-level token is requested, or
/// a clone-based sandbox in non-dry-run mode will need credentials to
/// pull the repository. Pull-request-driven credential acquisition is
/// handled separately by the caller as a soft fallback.
fn requires_github_credentials(run: &RunNamespace) -> bool {
/// a clone-based sandbox in non-dry-run mode will clone a repository and
/// needs credentials to pull it. A run without a repository origin creates
/// an empty workspace and needs none. Pull-request-driven credential
/// acquisition is handled separately by the caller as a soft fallback.
fn requires_github_credentials(run: &RunNamespace, has_repo_origin: bool) -> bool {
if run.integrations.github.is_token_requested() {
return true;
}
run.execution.mode != RunMode::DryRun && run.environment.provider.is_clone_based()
run.execution.mode != RunMode::DryRun
&& run.environment.provider.clones_workspace()
&& has_repo_origin
}
fn install_signal_handlers(
@ -1226,10 +1235,10 @@ mod tests {
#[test]
fn clone_sandbox_credentials_are_required_for_clone_based_providers() {
use fabro_types::settings::run::EnvironmentProvider;
assert!(EnvironmentProvider::Docker.is_clone_based());
assert!(EnvironmentProvider::Daytona.is_clone_based());
assert!(!EnvironmentProvider::Local.is_clone_based());
use fabro_types::SandboxProviderKind;
assert!(SandboxProviderKind::DOCKER.clones_workspace());
assert!(SandboxProviderKind::DAYTONA.clones_workspace());
assert!(!SandboxProviderKind::LOCAL.clones_workspace());
}
#[test]
@ -1739,10 +1748,10 @@ mod tests {
use std::collections::HashMap;
use fabro_types::SandboxProviderKind;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
EnvironmentProvider, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode,
RunNamespace,
RunIntegrationsGithubSettings, RunIntegrationsSettings, RunMode, RunNamespace,
};
use super::super::requires_github_credentials;
@ -1755,7 +1764,7 @@ mod tests {
let mut run = RunNamespace::default();
run.execution.mode = mode;
run.environment.provider = provider
.parse::<EnvironmentProvider>()
.parse::<SandboxProviderKind>()
.expect("test provider should parse");
run.integrations = RunIntegrationsSettings {
github: RunIntegrationsGithubSettings {
@ -1772,28 +1781,38 @@ mod tests {
// Even with local sandbox + dry-run, non-empty permissions
// force credential acquisition.
let run = run_with(permissions, "local", RunMode::DryRun);
assert!(requires_github_credentials(&run));
assert!(requires_github_credentials(&run, false));
}
#[test]
fn requires_github_credentials_for_clone_based_provider() {
fn requires_github_credentials_for_clone_based_provider_with_an_origin() {
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
assert!(requires_github_credentials(&run));
assert!(requires_github_credentials(&run, true));
let daytona = run_with(HashMap::new(), "daytona", RunMode::Normal);
assert!(requires_github_credentials(&daytona));
assert!(requires_github_credentials(&daytona, true));
let plugin = run_with(HashMap::new(), "host", RunMode::Normal);
assert!(requires_github_credentials(&plugin, true));
}
#[test]
fn does_not_require_github_credentials_without_a_repository_origin() {
// A `none` target creates an empty workspace; nothing is cloned.
let run = run_with(HashMap::new(), "docker", RunMode::Normal);
assert!(!requires_github_credentials(&run, false));
}
#[test]
fn does_not_require_github_credentials_for_local_clean_run() {
let run = run_with(HashMap::new(), "local", RunMode::Normal);
assert!(!requires_github_credentials(&run));
assert!(!requires_github_credentials(&run, true));
}
#[test]
fn does_not_require_github_credentials_for_clone_provider_in_dry_run() {
let run = run_with(HashMap::new(), "docker", RunMode::DryRun);
assert!(!requires_github_credentials(&run));
assert!(!requires_github_credentials(&run, true));
}
}
}

View file

@ -1,5 +1,29 @@
use anyhow::{Result, bail};
use fabro_sandbox::daytona::detect_repo_info;
use std::path::Path;
use anyhow::{Context as _, Result, bail};
/// Detect the git remote URL and current branch from a local repository.
///
/// Uses `git2` to discover the repo at `path`, reads the `origin` remote URL
/// and the HEAD branch name.
pub(crate) fn detect_repo_info(path: &Path) -> Result<(String, Option<String>)> {
let repo = git2::Repository::discover(path)
.with_context(|| format!("Failed to discover git repo at {}", path.display()))?;
let url = repo
.find_remote("origin")
.context("Failed to find 'origin' remote")?
.url()
.context("origin remote URL is not valid UTF-8")?
.to_string();
let branch = repo
.head()
.ok()
.and_then(|head| head.shorthand().map(String::from));
Ok((url, branch))
}
pub(crate) fn ensure_matching_repo_origin(
expected_origin_url: Option<&str>,
@ -28,10 +52,41 @@ pub(crate) fn ensure_matching_repo_origin(
#[cfg(test)]
mod tests {
use super::ensure_matching_repo_origin;
use super::{detect_repo_info, ensure_matching_repo_origin};
#[test]
fn missing_expected_origin_skips_guard() {
ensure_matching_repo_origin(None, "fork").unwrap();
}
#[test]
fn detect_git_remote_from_repo() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let (url, _branch) = detect_repo_info(dir.path()).unwrap();
assert_eq!(url, "https://github.com/org/repo.git");
}
#[test]
fn detect_repo_info_returns_worktree_branch() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit = repo
.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
repo.remote("origin", "https://github.com/org/repo.git")
.unwrap();
let commit_obj = repo.find_commit(commit).unwrap();
repo.branch("fabro/run/ABC", &commit_obj, false).unwrap();
repo.set_head("refs/heads/fabro/run/ABC").unwrap();
let (_, branch) = detect_repo_info(dir.path()).unwrap();
assert_eq!(branch, Some("fabro/run/ABC".into()));
}
}

View file

@ -1097,6 +1097,91 @@ fn attach_json_errors_without_prompting_for_human_input() {
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.create.started",
"id": "[EVENT_ID]",
"properties": {
"action": "create",
"correlation_id": "[ULID]",
"id": {
"sequence": 1,
"source_id": "[HEX]"
},
"occurred_at": "[TIMESTAMP]",
"operation_id": "[HEX]",
"provider": "host",
"subject": {
"id": "host-dir-[HEX]",
"type": "sandbox"
},
"type": "operation_started"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.create.progress",
"id": "[EVENT_ID]",
"properties": {
"action": "create",
"correlation_id": "[ULID]",
"id": {
"sequence": 2,
"source_id": "[HEX]"
},
"occurred_at": "[TIMESTAMP]",
"operation_id": "[HEX]",
"progress": {
"code": "sandbox.provision"
},
"provider": "host",
"subject": {
"id": "host-dir-[HEX]",
"type": "sandbox"
},
"type": "operation_progress"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
"run_id": "[ULID]"
},
"event": "sandbox.create.completed",
"id": "[EVENT_ID]",
"properties": {
"action": "create",
"correlation_id": "[ULID]",
"duration": {
"nanos": "[NANOS]",
"secs": 0
},
"id": {
"sequence": 3,
"source_id": "[HEX]"
},
"occurred_at": "[TIMESTAMP]",
"operation_id": "[HEX]",
"provider": "host",
"subject": {
"id": "host-dir-[HEX]",
"type": "sandbox"
},
"type": "operation_completed"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"kind": "worker",
@ -1119,9 +1204,12 @@ fn attach_json_errors_without_prompting_for_human_input() {
"event": "sandbox.initialized",
"id": "[EVENT_ID]",
"properties": {
"id": "local:[ULID]",
"id": "host-dir-[HEX]",
"provider": "local",
"working_directory": "[TEMP_DIR]"
"repo_cloned": false,
"repos_root": "[TEMP_DIR]/.repos",
"working_directory": "[TEMP_DIR]",
"workspace_root": "[TEMP_DIR]"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"

View file

@ -261,9 +261,9 @@ fn dump_exports_completed_run_snapshot() {
");
assert_snapshot!(dump_file_summary(&output_dir), @"
checkpoints/0014.json
checkpoints/0018.json
checkpoints/0022.json
checkpoints/0017.json
checkpoints/0021.json
checkpoints/0025.json
events.jsonl
graph.fabro
run.json

View file

@ -993,8 +993,24 @@ fn dry_run_persists_event_history_in_store() {
"event": "sandbox.stop.completed",
"id": "[EVENT_ID]",
"properties": {
"duration_ms": "[DURATION_MS]",
"provider": "local"
"action": "stop",
"correlation_id": "[ULID]",
"duration": {
"nanos": "[NANOS]",
"secs": 0
},
"id": {
"sequence": 5,
"source_id": "[HEX]"
},
"occurred_at": "[TIMESTAMP]",
"operation_id": "[HEX]",
"provider": "host",
"subject": {
"id": "host-dir-[HEX]",
"type": "sandbox"
},
"type": "operation_completed"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"

View file

@ -50,19 +50,22 @@ fn help() {
");
}
/// Preview URLs come from whichever provider facet the run's sandbox
/// exposes. The local provider runs on the server host, so its preview is
/// the loopback address for the port.
#[test]
fn sandbox_preview_rejects_non_daytona_run() {
fn sandbox_preview_uses_the_local_provider_loopback_url() {
let context = test_context!();
let setup = setup_local_sandbox_run(&context);
let mut cmd = context.preview();
cmd.args([&setup.run.run_id, "3000"]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
success: true
exit_code: 0
----- stdout -----
http://127.0.0.1:3000
----- stderr -----
× Sandbox provider does not support this capability.
");
}

View file

@ -1107,7 +1107,7 @@ async fn append_seeded_simple_completion_events(
serde_json::json!({
"working_directory": context.temp_dir.display().to_string(),
"provider": "local",
"id": format!("local:{}", run.run_id),
"id": fabro_sandbox::test_support::local_sandbox_id(&context.temp_dir).await,
"repo_cloned": false,
"clone_origin_url": null,
"clone_branch": null,
@ -1276,7 +1276,7 @@ async fn append_seeded_git_completion_events(
serde_json::json!({
"working_directory": context.temp_dir.display().to_string(),
"provider": "local",
"id": format!("local:{}", run.run_id),
"id": fabro_sandbox::test_support::local_sandbox_id(&context.temp_dir).await,
"repo_cloned": false,
"clone_origin_url": null,
"clone_branch": null,

View file

@ -1,4 +1,4 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, find_run_dir, fixture, has_event, read_conclusion, sandbox_tests, timeout_for,
@ -6,9 +6,7 @@ use super::{
sandbox_tests!(agent_linear, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_agent_linear(sandbox: &str) {
let context = test_context!();
fn scenario_agent_linear(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -23,7 +21,7 @@ fn scenario_agent_linear(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

View file

@ -3,7 +3,7 @@
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for,
@ -12,9 +12,7 @@ use super::{
sandbox_tests!(command_agent_mixed, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_command_agent_mixed(sandbox: &str) {
let context = test_context!();
fn scenario_command_agent_mixed(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -29,7 +27,7 @@ fn scenario_command_agent_mixed(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
@ -47,7 +45,7 @@ fn scenario_command_agent_mixed(sandbox: &str) {
"verify should be completed"
);
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let export_dir = dump_export(context, &run_id_for(&run_dir));
let stdout =
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("output.log"))
.expect("verify output.log should exist");

View file

@ -3,7 +3,7 @@
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for,
@ -12,9 +12,7 @@ use super::{
sandbox_tests!(command_pipeline);
fn scenario_command_pipeline(sandbox: &str) {
let context = test_context!();
fn scenario_command_pipeline(context: &TestContext, sandbox: &str) {
context
.validate()
.arg(fixture("command_pipeline.fabro"))
@ -29,7 +27,7 @@ fn scenario_command_pipeline(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(
conclusion["status"].as_str(),
@ -47,7 +45,7 @@ fn scenario_command_pipeline(sandbox: &str) {
"step2 should be completed"
);
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let export_dir = dump_export(context, &run_id_for(&run_dir));
let stdout1 =
std::fs::read_to_string(stage_dump_dir(&export_dir, "step1@1").join("output.log"))
.expect("step1 output.log should exist");

View file

@ -1,11 +1,10 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
sandbox_tests!(command_routing);
fn scenario_command_routing(sandbox: &str) {
let context = test_context!();
fn scenario_command_routing(context: &TestContext, sandbox: &str) {
let workflow = fixture("command_routing.fabro");
context.validate().arg(&workflow).assert().success();
@ -18,7 +17,7 @@ fn scenario_command_routing(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

View file

@ -1,12 +1,10 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
sandbox_tests!(conditional_branching);
fn scenario_conditional_branching(sandbox: &str) {
let context = test_context!();
fn scenario_conditional_branching(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args(["--auto-approve", "--environment", sandbox])
@ -15,7 +13,7 @@ fn scenario_conditional_branching(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

View file

@ -3,7 +3,7 @@
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{
completed_nodes, dump_export, find_run_dir, fixture, has_event, read_conclusion, read_run_spec,
@ -12,9 +12,7 @@ use super::{
sandbox_tests!(full_stack, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_full_stack(sandbox: &str) {
let context = test_context!();
fn scenario_full_stack(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -29,7 +27,7 @@ fn scenario_full_stack(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(
conclusion["status"].as_str(),
@ -72,7 +70,7 @@ fn scenario_full_stack(sandbox: &str) {
}
// Verify node stdout should contain PASS
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let export_dir = dump_export(context, &run_id_for(&run_dir));
let stdout =
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("output.log"))
.expect("verify output.log should exist");

View file

@ -66,29 +66,24 @@ fn twin_server_storage_dir(context: &fabro_test::TestContext) -> std::path::Path
context.temp_dir.join("hook-server-storage")
}
fn settings_with_hook(context: &fabro_test::TestContext, hook: &str) -> String {
if TestMode::from_env().is_twin() {
/// The twin-mode server settings: a private storage root and dev-token auth.
/// Live mode runs against the developer's own settings.
fn write_server_settings(context: &fabro_test::TestContext) {
if !TestMode::from_env().is_twin() {
return;
}
context.write_home(
".fabro/settings.toml",
format!(
r#"[server.storage]
root = "{}"
[server.auth]
methods = ["dev-token"]
{hook}"#,
"#,
toml_path(&twin_server_storage_dir(context)),
)
} else {
hook.to_string()
}
}
fn write_hook_settings(context: &fabro_test::TestContext, hook: &str) {
let settings = settings_with_hook(context, hook);
if settings.trim().is_empty() {
return;
}
context.write_home(".fabro/settings.toml", settings);
),
);
}
fn seed_openai_vault(storage_dir: &std::path::Path, api_key: &str) {
@ -113,6 +108,31 @@ fn write_workflow(context: &fabro_test::TestContext, name: &str, dot: &str) -> s
context.temp_dir.join(name)
}
/// A workflow config that bundles the graph `<name>.fabro` with `hooks`,
/// which is where run hooks live: `fabro run` does not transmit `run`
/// settings from the user's settings file. Returns the config path to run.
fn write_hooked_workflow(
context: &fabro_test::TestContext,
name: &str,
dot: &str,
hooks: &str,
) -> std::path::PathBuf {
let graph = format!("{name}.fabro");
context.write_temp(&graph, dot);
let config = format!("{name}.toml");
context.write_temp(
&config,
format!(
r#"_version = 1
[workflow]
graph = "{graph}"
{hooks}"#
),
);
context.temp_dir.join(config)
}
fn configure_hook_env(cmd: &mut assert_cmd::Command, hook_model: &str) {
cmd.env_remove("CHATGPT_ACCOUNT_ID");
cmd.env_remove("OPENAI_ORG_ID");
@ -141,8 +161,15 @@ async fn conclusion_status(context: &fabro_test::TestContext) -> String {
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn hook_prompt_proceed_allows_run() {
let mut context = test_context!();
write_hook_settings(
write_server_settings(&context);
let workflow = write_hooked_workflow(
&context,
"hook_prompt_proceed",
r"digraph HookTest {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}",
&format!(
r#"
[[run.hooks]]
@ -154,15 +181,6 @@ model = "{model}"
model = hook_model()
),
);
let workflow = write_workflow(
&context,
"hook_prompt_proceed.fabro",
r"digraph HookTest {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}",
);
if TestMode::from_env().is_twin() {
let twin = twin_openai().await;
@ -190,8 +208,15 @@ model = "{model}"
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn hook_prompt_block_prevents_run() {
let mut context = test_context!();
write_hook_settings(
write_server_settings(&context);
let workflow = write_hooked_workflow(
&context,
"hook_prompt_block",
r"digraph HookTest {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}",
&format!(
r#"
[[run.hooks]]
@ -203,15 +228,6 @@ model = "{model}"
model = hook_model()
),
);
let workflow = write_workflow(
&context,
"hook_prompt_block.fabro",
r"digraph HookTest {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}",
);
let output = if TestMode::from_env().is_twin() {
let twin = twin_openai().await;
@ -246,8 +262,15 @@ model = "{model}"
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn hook_agent_proceed_allows_run() {
let mut context = test_context!();
write_hook_settings(
write_server_settings(&context);
let workflow = write_hooked_workflow(
&context,
"hook_agent_proceed",
r"digraph HookTest {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}",
&format!(
r#"
[[run.hooks]]
@ -261,15 +284,6 @@ agent = "enabled"
model = hook_model()
),
);
let workflow = write_workflow(
&context,
"hook_agent_proceed.fabro",
r"digraph HookTest {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}",
);
if TestMode::from_env().is_twin() {
let twin = twin_openai().await;
@ -299,8 +313,15 @@ async fn hook_agent_with_tool_use() {
let mut context = test_context!();
let marker = context.temp_dir.join("hook_check.txt");
std::fs::write(&marker, "READY").unwrap();
write_hook_settings(
write_server_settings(&context);
let workflow = write_hooked_workflow(
&context,
"hook_agent_tools",
r"digraph HookTest {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}",
&format!(
r#"
[[run.hooks]]
@ -315,15 +336,6 @@ agent = "enabled"
model = hook_model()
),
);
let workflow = write_workflow(
&context,
"hook_agent_tools.fabro",
r"digraph HookTest {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}",
);
if TestMode::from_env().is_twin() {
let twin = twin_openai().await;
@ -355,7 +367,7 @@ agent = "enabled"
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn arc_e2e_with_real_llm() {
let mut context = test_context!();
write_hook_settings(&context, "");
write_server_settings(&context);
let hello = context.temp_dir.join("hello.txt");
let workflow = write_workflow(
&context,

View file

@ -1,12 +1,10 @@
use fabro_test::test_context;
use fabro_test::TestContext;
use super::{completed_nodes, find_run_dir, fixture, read_conclusion, sandbox_tests, timeout_for};
sandbox_tests!(human_gate, keys = ["ANTHROPIC_API_KEY"]);
fn scenario_human_gate(sandbox: &str) {
let context = test_context!();
fn scenario_human_gate(context: &TestContext, sandbox: &str) {
context
.run_cmd()
.args([
@ -21,7 +19,7 @@ fn scenario_human_gate(sandbox: &str) {
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_dir = find_run_dir(context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));

View file

@ -14,6 +14,7 @@ mod dry_run_examples;
mod full_stack;
mod hooks;
mod human_gate;
pub(super) mod plugin;
use std::path::{Path, PathBuf};
use std::time::Duration;
@ -167,6 +168,18 @@ fn run_events(run_dir: &Path) -> Vec<EventEnvelope> {
crate::support::parse_event_envelopes(&response)
}
/// Runs a scenario against every sandbox provider fabro supports:
///
/// - `local`: the bundled Host provider in-process.
/// - `daytona`: the bundled Daytona provider, live credentials required.
/// - `host-plugin`: the driver's Host executable over stdio under the
/// non-bundled `host` kind, a clone-based managed workspace.
/// - `docker-plugin`: the driver's Docker executable over stdio under the
/// non-bundled `docker-plugin` kind.
///
/// The plugin variants need the executables `cargo` builds for
/// `fabro-sandbox`; without them (or without a Docker daemon) they skip,
/// unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set, as CI sets it.
macro_rules! sandbox_tests {
($name:ident) => {
sandbox_tests!($name, keys = []);
@ -175,12 +188,36 @@ macro_rules! sandbox_tests {
paste::paste! {
#[fabro_macros::e2e_test($(live($key)),*)]
fn [<local_ $name>]() {
[<scenario_ $name>]("local");
[<scenario_ $name>](&fabro_test::test_context!(), "local");
}
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY") $(, live($key))*)]
fn [<daytona_ $name>]() {
[<scenario_ $name>]("daytona");
[<scenario_ $name>](&fabro_test::test_context!(), "daytona");
}
#[fabro_macros::e2e_test($(live($key)),*)]
fn [<host_plugin_ $name>]() {
let mut context = fabro_test::test_context!();
if let Some(environment) =
$crate::workflow::plugin::configure(&mut context, $crate::workflow::plugin::Plugin::Host)
{
$crate::workflow::plugin::run_with_server_log(&context, || {
[<scenario_ $name>](&context, environment);
});
}
}
#[fabro_macros::e2e_test($(live($key)),*)]
fn [<docker_plugin_ $name>]() {
let mut context = fabro_test::test_context!();
if let Some(environment) =
$crate::workflow::plugin::configure(&mut context, $crate::workflow::plugin::Plugin::Docker)
{
$crate::workflow::plugin::run_with_server_log(&context, || {
[<scenario_ $name>](&context, environment);
});
}
}
}
};
@ -190,6 +227,7 @@ pub(super) use sandbox_tests;
pub(super) fn timeout_for(sandbox: &str) -> Duration {
match sandbox {
"daytona" => Duration::from_mins(10),
"docker-plugin" => Duration::from_mins(5),
_ => Duration::from_mins(3),
}
}

View file

@ -0,0 +1,229 @@
//! Sandbox providers served by sandbox-driver plugin executables, for the
//! workflow scenarios.
//!
//! The executables are the driver's own `sandbox-driver-host` and
//! `sandbox-driver-docker`, found on `PATH`; CI installs them at the rev the
//! workspace pins, and a developer installs them with
//! `cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev <rev> sandbox-driver-host sandbox-driver-docker`.
//! Each runs under a kind of the scenario's choosing (`host`,
//! `docker-plugin`): the configured kind names the plugin, whatever the
//! executable declares. A scenario configured here runs against its own
//! server so the plugin settings and the environment it creates never leak
//! into the shared session server.
#![expect(
clippy::disallowed_methods,
reason = "test setup reads the process environment for its opt-in gate and probes Docker synchronously"
)]
#![expect(
clippy::print_stderr,
reason = "a skipped scenario says why on the test's stderr"
)]
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use fabro_test::{TestContext, expect_reqwest_status};
use serde_json::json;
use crate::cmd::support::server_endpoint;
/// Set in CI so a missing executable or daemon fails the test instead of
/// skipping it.
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
const DOCKER_IMAGE: &str = "buildpack-deps:noble";
#[derive(Clone, Copy, Debug)]
pub(crate) enum Plugin {
/// The driver's Host executable under the non-bundled `host` kind.
Host,
/// The driver's Docker executable under the non-bundled `docker-plugin`
/// kind: the same containers, reached over stdio.
Docker,
}
impl Plugin {
fn kind(self) -> &'static str {
match self {
Self::Host => "host",
Self::Docker => "docker-plugin",
}
}
fn executable(self) -> &'static str {
match self {
Self::Host => "sandbox-driver-host",
Self::Docker => "sandbox-driver-docker",
}
}
/// The environment id the scenario selects with `--environment`.
fn environment(self) -> &'static str {
match self {
Self::Host => "host-plugin",
Self::Docker => "docker-plugin",
}
}
}
/// Point `context` at an isolated server that serves `plugin` and has an
/// environment for it. Returns the environment id, or `None` when the
/// prerequisites are missing and the test should skip.
pub(crate) fn configure(context: &mut TestContext, plugin: Plugin) -> Option<&'static str> {
let required = std::env::var_os(REQUIRE_ENV).is_some();
let Some(executable) = plugin_executable(plugin) else {
assert!(
!required,
"{REQUIRE_ENV} is set but the {} executable is not built",
plugin.executable()
);
eprintln!(
"skipping: {} is not on PATH; install the sandbox-driver executables at the rev \
Cargo.toml pins",
plugin.executable()
);
return None;
};
if matches!(plugin, Plugin::Docker) && !docker_image_available() {
assert!(
!required,
"{REQUIRE_ENV} is set but no Docker daemon with {DOCKER_IMAGE} is available"
);
eprintln!("skipping: no Docker daemon with {DOCKER_IMAGE}");
return None;
}
let storage_dir = context.temp_dir.join("plugin-server-storage");
let registry = context.temp_dir.join("host-registry");
std::fs::create_dir_all(&registry).expect("registry dir should be created");
let settings = match plugin {
Plugin::Host => format!(
r#"[server.storage]
root = "{storage}"
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.host]
path = "{path}"
dev = true
inherit_env = ["PATH", "HOME"]
[server.sandbox.providers.host.env]
SANDBOX_DRIVER_HOST_REGISTRY = "{registry}"
"#,
storage = toml_path(&storage_dir),
path = toml_path(&executable),
registry = toml_path(&registry),
),
Plugin::Docker => format!(
r#"[server.storage]
root = "{storage}"
[server.auth]
methods = ["dev-token"]
[server.sandbox.providers.docker-plugin]
path = "{path}"
dev = true
inherit_env = ["PATH", "HOME", "DOCKER_HOST", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY"]
"#,
storage = toml_path(&storage_dir),
path = toml_path(&executable),
),
};
context.write_home(".fabro/settings.toml", settings);
context.isolated_server();
create_environment(&context.storage_dir, plugin);
Some(plugin.environment())
}
/// The driver executable on `PATH`, when installed.
fn plugin_executable(plugin: Plugin) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(plugin.executable()))
.find(|candidate| candidate.is_file())
}
fn docker_image_available() -> bool {
Command::new("docker")
.args(["image", "inspect", DOCKER_IMAGE])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn toml_path(path: &Path) -> String {
path.display().to_string().replace('\\', "/")
}
fn create_environment(storage_dir: &Path, plugin: Plugin) {
let body = json!({
"id": plugin.environment(),
"provider": plugin.kind(),
"image": {
"docker": match plugin {
Plugin::Host => serde_json::Value::Null,
Plugin::Docker => json!(DOCKER_IMAGE),
},
"dockerfile": null
},
"resources": { "cpu": null, "memory": null, "disk": null },
"network": { "mode": "allow_all", "allow": [] },
"lifecycle": { "preserve": false, "stop_on_terminal": true, "auto_stop": null },
"labels": {},
"env": {}
});
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build")
.block_on(async {
let (client, base_url) =
server_endpoint(storage_dir).expect("isolated server endpoint should exist");
let response = client
.post(format!("{base_url}/api/v1/environments"))
.json(&body)
.send()
.await
.expect("environment create request should send");
if response.status() != fabro_http::StatusCode::CREATED {
eprintln!("server log tail:\n{}", server_log_tail(storage_dir));
}
expect_reqwest_status(
response,
fabro_http::StatusCode::CREATED,
"POST /api/v1/environments",
)
.await;
});
}
/// Run a scenario; when it fails, print the isolated server's log first, since
/// the worker's stderr (and so a plugin's launch failure) lands only there
/// and the server root is removed when the context drops.
pub(crate) fn run_with_server_log(context: &TestContext, scenario: impl FnOnce()) {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(scenario));
if let Err(panic) = outcome {
eprintln!(
"server log tail:\n{}",
server_log_tail(&context.storage_dir)
);
std::panic::resume_unwind(panic);
}
}
/// The last lines of the isolated server's log, for a failure message.
pub(crate) fn server_log_tail(storage_dir: &Path) -> String {
let path = fabro_config::Storage::new(storage_dir)
.runtime_directory()
.log_path();
let Ok(contents) = std::fs::read_to_string(&path) else {
return format!("(no server log at {})", path.display());
};
let lines: Vec<&str> = contents.lines().collect();
let start = lines.len().saturating_sub(60);
lines[start..].join("\n")
}

View file

@ -34,7 +34,8 @@ fabro-slack = { path = "../../components/fabro-slack" }
fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
fabro-validate = { path = "../../components/fabro-validate" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["daytona", "docker"] }
fabro-sandbox = { path = "../../components/fabro-sandbox" }
sandbox-driver.workspace = true
fabro-github = { path = "../../components/fabro-github" }
fabro-agent = { path = "../../components/fabro-agent" }
fabro-llm = { path = "../../components/fabro-llm" }

View file

@ -23,7 +23,6 @@ use fabro_api::types::{
RunFilesMeta, RunFilesMetaScope, RunFilesMetaSource, SandboxService,
SandboxServiceListResponse,
};
use fabro_types::{SandboxServiceDiscoverySource, SandboxServiceListMeta};
use serde_json::json;
use crate::error::ApiError;
@ -405,19 +404,16 @@ pub(crate) async fn list_sandbox_services_stub(
SandboxService {
port: 3000,
addresses: vec!["0.0.0.0:3000".to_string()],
processes: vec![r#"users:(("node",pid=42,fd=23))"#.to_string()],
processes: vec!["node".to_string()],
preview_supported: true,
},
SandboxService {
port: 2500,
addresses: vec!["127.0.0.1:2500".to_string()],
processes: vec![r#"users:(("debug",pid=84,fd=19))"#.to_string()],
processes: vec!["debug".to_string()],
preview_supported: false,
},
],
meta: SandboxServiceListMeta {
source: SandboxServiceDiscoverySource::Ss,
},
}),
)
.into_response()
@ -1087,9 +1083,9 @@ mod runs {
use fabro_api::types::*;
use fabro_types::settings::run::{
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentProvider,
EnvironmentResourcesSettings, EnvironmentSettings, PreparedStep, PreparedStepRun,
RunEnvironmentSettings, RunGoal, RunModelSettings, RunNamespace, RunPrepareSettings,
EnvironmentImageSettings, EnvironmentLifecycleSettings, EnvironmentResourcesSettings,
EnvironmentSettings, PreparedStep, PreparedStepRun, RunEnvironmentSettings, RunGoal,
RunModelSettings, RunNamespace, RunPrepareSettings,
};
use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace};
use fabro_types::{
@ -1794,7 +1790,7 @@ mod runs {
pub(super) fn settings() -> serde_json::Value {
let environment = EnvironmentSettings {
provider: EnvironmentProvider::Daytona,
provider: SandboxProviderKind::DAYTONA,
image: EnvironmentImageSettings {
docker: Some("api-server-dev".into()),
dockerfile: None,

View file

@ -9,8 +9,9 @@ use fabro_llm::Client;
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::probe::{self, ModelTestStatus};
use fabro_redact::redact_string;
use fabro_sandbox::{DockerSandboxProvider, daytona};
use fabro_sandbox::daytona;
use fabro_static::EnvVars;
use fabro_types::SandboxProviderKind;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::server::GithubIntegrationStrategy;
use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus};
@ -583,10 +584,9 @@ async fn check_docker_sandbox(state: &AppState) -> CheckResult {
.server
.sandbox
.providers
.docker
.enabled,
.is_enabled(&SandboxProviderKind::DOCKER),
|| async {
DockerSandboxProvider::check_daemon()
fabro_sandbox::check_docker_daemon()
.await
.map_err(|err| err.display_with_causes())
},
@ -676,7 +676,7 @@ fn cloud_sandbox_probe_check(probe: anyhow::Result<daytona::DaytonaKeyCheck>) ->
Ok(check) if check.ok() => CheckResult {
name: "Cloud Sandbox".to_string(),
status: CheckStatus::Pass,
summary: format!("Daytona configured ({})", check.key_name),
summary: "Daytona configured".to_string(),
details: Vec::new(),
remediation: None,
},
@ -691,7 +691,7 @@ fn cloud_sandbox_probe_check(probe: anyhow::Result<daytona::DaytonaKeyCheck>) ->
remediation: Some(format!(
"Regenerate the Daytona API key with scopes: {}, then \
`fabro secret set DAYTONA_API_KEY`.",
daytona::required_perms_display()
check.required_display()
)),
},
Err(err) => {

View file

@ -27,12 +27,12 @@ use fabro_install::{
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus};
use fabro_sandbox::daytona;
use fabro_sandbox::driver::DaytonaCredentials;
use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
use fabro_types::ServerSettings;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
use fabro_types::{SandboxProviderKind, ServerSettings};
use fabro_util::version::FABRO_VERSION;
use fabro_util::{Home, session_secret};
use fabro_vault::SecretType as VaultSecretType;
@ -464,10 +464,10 @@ impl InstallSandboxState {
}
}
fn to_environment_provider(&self) -> EnvironmentProvider {
fn to_environment_provider(&self) -> SandboxProviderKind {
match &self.provider {
InstallSandboxProviderState::Docker => EnvironmentProvider::Docker,
InstallSandboxProviderState::Daytona { .. } => EnvironmentProvider::Daytona,
InstallSandboxProviderState::Docker => SandboxProviderKind::DOCKER,
InstallSandboxProviderState::Daytona { .. } => SandboxProviderKind::DAYTONA,
}
}
}
@ -1005,14 +1005,13 @@ async fn check_install_daytona_api_key(
state: &InstallAppState,
api_key: String,
) -> anyhow::Result<daytona::DaytonaKeyCheck> {
let base_url = state
.upstreams
.daytona_api_base_url
.as_deref()
.unwrap_or(daytona::DEFAULT_DAYTONA_API_URL);
let organization_id = state.upstreams.daytona_organization_id.as_deref();
let http_client = fabro_http::http_client().context("failed to build HTTP client")?;
daytona::check_daytona_api_key_with(base_url, organization_id, api_key, http_client).await
let credentials = DaytonaCredentials::new(api_key)
.with_api_url(state.upstreams.daytona_api_base_url.clone())
.with_organization_id(state.upstreams.daytona_organization_id.clone())
.with_http_client(Some(
fabro_http::http_client().context("failed to build HTTP client")?,
));
daytona::check_daytona_api_key(&credentials, daytona::DAYTONA_CREDENTIAL_PROBE_TIMEOUT).await
}
async fn put_install_sandbox(

View file

@ -20,13 +20,13 @@ use std::future::Future;
use std::num::NonZeroU64;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_api::types::{
DiffFile, DiffStats, FileDiff, FileDiffChangeKind, FileDiffTruncationReason, ListRunFilesScope,
PaginatedRunCommitList, PaginatedRunFileList, RunCommit, RunCommitParent, RunCommitParentSha,
@ -35,15 +35,18 @@ use fabro_api::types::{
RunFilesMeta, RunFilesMetaDegradedReason, RunFilesMetaScope, RunFilesMetaSource,
RunFilesMetaToSha,
};
use fabro_sandbox::Termination;
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_sandbox::shell_quote;
use fabro_static::EnvVars;
use fabro_types::RunId;
use fabro_util::shell;
use fabro_workflow::sandbox_git::{
DiffError, DiffNumstat, RawDiffEntry, SubmoduleChange, SymlinkChange, list_changed_files_raw,
list_diff_numstat, stream_blob_metadata, stream_blobs,
};
use futures_util::FutureExt;
use sandbox_driver::{
Git as _, GitCommit, GitDiffOptions, GitFacet, GitLogOptions, GitRevisionRange,
};
use serde::Deserialize;
use tokio::sync::{Mutex, watch};
@ -61,6 +64,7 @@ pub(crate) const AGGREGATE_BYTES_CAP: u64 = 5 * 1024 * 1024;
pub(crate) const FILE_COUNT_CAP: usize = 200;
/// Sandbox git timeout. Matches Unit 3 helpers (10 s).
const SANDBOX_GIT_TIMEOUT_MS: u64 = 10_000;
const SANDBOX_GIT_TIMEOUT: Duration = Duration::from_millis(SANDBOX_GIT_TIMEOUT_MS);
/// Below this SHA count the phase-1 `cat-file --batch-check` pre-filter is
/// skipped — its ~100 ms round-trip dominates for small diffs, and phase-2
@ -307,10 +311,9 @@ async fn materialize_sandbox_range_path(
let start = Instant::now();
let projection = load_projection(state, run_id).await?;
let sandbox = reconnect_run_sandbox(state, run_id, &projection).await?;
let (resolved_to_sha, to_sha_committed_at) =
resolve_ref_sha_and_time(sandbox.as_ref(), to_sha).await?;
let (resolved_to_sha, to_sha_committed_at) = resolve_ref_sha_and_time(&sandbox, to_sha).await?;
materialize_committed_range_sandbox_path(
sandbox.as_ref(),
&sandbox,
None,
from_sha,
&resolved_to_sha,
@ -334,9 +337,8 @@ async fn materialize_run_commits(
.and_then(|s| s.base_sha.clone())
.ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "Run has no base SHA."))?;
let sandbox = reconnect_run_sandbox(state, run_id, &projection).await?;
let (head_sha, _) = resolve_ref_sha_and_time(sandbox.as_ref(), "HEAD").await?;
let output = git_log_commits(sandbox.as_ref(), &base_sha, &head_sha, limit + 1).await?;
let mut commits = parse_git_log_commits(&output)?;
let (head_sha, _) = resolve_ref_sha_and_time(&sandbox, "HEAD").await?;
let mut commits = git_log_commits(&sandbox, &base_sha, &head_sha, limit + 1).await?;
let truncated = commits.len() > usize::try_from(limit).unwrap_or(usize::MAX);
commits.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
let total_returned = u64::try_from(commits.len()).unwrap_or(u64::MAX);
@ -355,61 +357,30 @@ async fn materialize_run_commits(
}
async fn git_log_commits(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
base_sha: &str,
head_sha: &str,
limit: u64,
) -> std::result::Result<String, ApiError> {
let base_q = shell_quote(base_sha);
let head_q = shell_quote(head_sha);
let format_q =
shell_quote("%H%x1f%T%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%B%x1e");
sandbox_git_stdout(
sandbox,
&format!(
"git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false log --first-parent --reverse --max-count={limit} --format={format_q} {base_q}..{head_q}"
),
"git log",
)
.await
) -> std::result::Result<Vec<RunCommit>, ApiError> {
let git = sandbox_git(sandbox)?;
let options = GitLogOptions::new(GitRevisionRange::new(base_sha).to(head_sha))
.first_parent()
.reverse()
.max_count(limit)
.timeout(SANDBOX_GIT_TIMEOUT);
let commits = git
.log(sandbox.working_directory(), &options)
.await
.map_err(|error| sandbox_git_error("git log", &error))?;
commits.iter().map(run_commit).collect()
}
fn parse_git_log_commits(stdout: &str) -> std::result::Result<Vec<RunCommit>, ApiError> {
stdout
.split('\x1e')
.filter_map(|record| {
let record = record.trim_matches('\n');
(!record.is_empty()).then_some(record)
})
.map(parse_git_log_commit)
.collect()
}
fn parse_git_log_commit(record: &str) -> std::result::Result<RunCommit, ApiError> {
let mut fields = record.splitn(10, '\x1f');
let sha = fields.next().unwrap_or_default();
let tree_sha = fields.next().unwrap_or_default();
let parents = fields.next().unwrap_or_default();
let author_name = fields.next().unwrap_or_default();
let author_email = fields.next().unwrap_or_default();
let author_date = fields.next().unwrap_or_default();
let committer_name = fields.next().unwrap_or_default();
let committer_email = fields.next().unwrap_or_default();
let committer_date = fields.next().unwrap_or_default();
let message = fields
.next()
.unwrap_or_default()
.trim_end_matches('\n')
.to_string();
if sha.is_empty() {
return Err(ApiError::bad_request(
"Malformed git log output: missing commit SHA.",
));
}
fn run_commit(commit: &GitCommit) -> std::result::Result<RunCommit, ApiError> {
let message = commit.message.trim_end_matches('\n').to_string();
let (subject, body) = split_commit_message(&message);
let parents = parents
.split_whitespace()
let parents = commit
.parents
.iter()
.map(|parent| {
Ok(RunCommitParent {
sha: sha_newtype::<RunCommitParentSha>(parent)?,
@ -419,27 +390,27 @@ fn parse_git_log_commit(record: &str) -> std::result::Result<RunCommit, ApiError
.collect::<std::result::Result<Vec<_>, ApiError>>()?;
Ok(RunCommit {
sha: sha_newtype::<RunCommitSha>(sha)?,
short_sha: short_sha_newtype::<RunCommitShortSha>(sha)?,
sha: sha_newtype::<RunCommitSha>(&commit.sha)?,
short_sha: short_sha_newtype::<RunCommitShortSha>(&commit.sha)?,
parents,
author: RunCommitPerson {
name: author_name.to_string(),
email: author_email.to_string(),
date: parse_git_date(author_date),
name: commit.author.name.clone(),
email: commit.author.email.clone(),
date: parse_git_date(&commit.author.date),
},
committer: RunCommitPerson {
name: committer_name.to_string(),
email: committer_email.to_string(),
date: parse_git_date(committer_date),
name: commit.committer.name.clone(),
email: commit.committer.email.clone(),
date: parse_git_date(&commit.committer.date),
},
subject,
body,
message: message.clone(),
trailers: parse_commit_trailers(&message),
tree_sha: if tree_sha.is_empty() {
tree_sha: if commit.tree.is_empty() {
None
} else {
Some(sha_newtype::<RunCommitTreeSha>(tree_sha)?)
Some(sha_newtype::<RunCommitTreeSha>(&commit.tree)?)
},
})
}
@ -539,18 +510,12 @@ async fn materialize_sandbox_path(
let materialized = match scope {
ListRunFilesScope::Committed => {
materialize_committed_sandbox_path(
sandbox.as_ref(),
&projection,
&base_sha,
run_id,
start,
)
.await
materialize_committed_sandbox_path(&sandbox, &projection, &base_sha, run_id, start)
.await
}
ListRunFilesScope::Uncommitted => {
materialize_working_tree_sandbox_path(
sandbox.as_ref(),
&sandbox,
"HEAD",
RunFilesMetaScope::Uncommitted,
run_id,
@ -560,7 +525,7 @@ async fn materialize_sandbox_path(
}
ListRunFilesScope::All => {
materialize_working_tree_sandbox_path(
sandbox.as_ref(),
&sandbox,
&base_sha,
RunFilesMetaScope::All,
run_id,
@ -590,7 +555,7 @@ fn sandbox_read_error_should_fallback(err: &ApiError) -> bool {
}
async fn materialize_committed_sandbox_path(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
projection: &fabro_store::RunProjection,
base_sha: &str,
run_id: &RunId,
@ -612,7 +577,7 @@ async fn materialize_committed_sandbox_path(
}
async fn materialize_committed_range_sandbox_path(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
fallback_projection: Option<&fabro_store::RunProjection>,
base_sha: &str,
to_sha: &str,
@ -734,22 +699,22 @@ async fn materialize_committed_range_sandbox_path(
}
async fn materialize_working_tree_sandbox_path(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
base_ref: &str,
scope: RunFilesMetaScope,
run_id: &RunId,
start: Instant,
) -> ListRunFilesResult {
let (to_sha, to_sha_committed_at) = resolve_head_sha_and_time(sandbox).await?;
let base_q = shell_quote(base_ref);
let patch = sandbox_git_stdout(
sandbox,
&format!(
"git -c maintenance.auto=0 -c gc.auto=0 -c core.hooksPath=/dev/null -c core.fsmonitor=false -c core.quotePath=false diff --patch --find-renames=50% {base_q}"
),
"git diff --patch",
)
.await?;
let git = sandbox_git(sandbox)?;
// No head: the driver diffs `base_ref` against the working tree.
let options = GitDiffOptions::new(GitRevisionRange::new(base_ref))
.find_renames(50)
.timeout(SANDBOX_GIT_TIMEOUT);
let patch = git
.diff_patch(sandbox.working_directory(), &options)
.await
.map_err(|error| sandbox_git_error("git diff --patch", &error))?;
let entries: Vec<String> = split_patch_sections(&patch)
.into_iter()
@ -771,22 +736,25 @@ async fn materialize_working_tree_sandbox_path(
))
}
async fn sandbox_git_stdout(
sandbox: &dyn Sandbox,
command: &str,
op: &str,
) -> std::result::Result<String, ApiError> {
let res = sandbox
.exec_command(command, SANDBOX_GIT_TIMEOUT_MS, None, None, None)
.await
.map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))?;
if res.is_timed_out() {
return Err(transient_503(op, "command timed out"));
/// The sandbox's git facet; a provider without git cannot serve files.
fn sandbox_git(sandbox: &RunSandbox) -> std::result::Result<GitFacet<'_>, ApiError> {
sandbox
.git()
.map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))
}
/// A driver git failure as the endpoint's transient 503, so the client
/// retries; a command that timed out says so.
fn sandbox_git_error(op: &str, error: &sandbox_driver::Error) -> ApiError {
let timed_out = matches!(
error,
sandbox_driver::Error::Git(failure)
if failure.output().is_some_and(|output| output.termination() == Termination::TimedOut)
);
if timed_out {
return transient_503(op, "command timed out");
}
if !res.is_success() {
return Err(transient_503(op, res.stderr.trim()));
}
Ok(res.stdout)
transient_503(op, &fabro_sandbox::display_for_log(error))
}
/// Build the degraded response from the stored terminal diff patch.
@ -1202,18 +1170,18 @@ async fn reconnect_run_sandbox(
state: &Arc<AppState>,
run_id: &RunId,
projection: &fabro_store::RunProjection,
) -> std::result::Result<Box<dyn Sandbox>, ApiError> {
) -> std::result::Result<RunSandbox, ApiError> {
let record = projection
.sandbox
.as_ref()
.and_then(fabro_types::RunSandbox::instance)
.cloned()
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Run sandbox was not created."))?;
let daytona_api_key = state
.vault_secret(EnvVars::DAYTONA_API_KEY)
let access = state
.provider_access()
.await
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
let sandbox = reconnect_for_run(&record, daytona_api_key, Some(*run_id))
let sandbox = reconnect_for_run(&record, &access, Some(*run_id), None)
.await
.map_err(|err| ApiError::new(StatusCode::CONFLICT, err.to_string()))?;
sandbox
@ -1228,16 +1196,16 @@ async fn reconnect_run_sandbox(
/// a space. The commit time is best-effort — if parsing fails the handler
/// still succeeds without the freshness timestamp.
async fn resolve_head_sha_and_time(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
) -> std::result::Result<(String, Option<chrono::DateTime<chrono::Utc>>), ApiError> {
resolve_ref_sha_and_time(sandbox, "HEAD").await
}
async fn resolve_ref_sha_and_time(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
git_ref: &str,
) -> std::result::Result<(String, Option<chrono::DateTime<chrono::Utc>>), ApiError> {
let ref_q = shell_quote(git_ref);
let ref_q = shell::shell_quote(git_ref);
let res = sandbox
.exec_command(
&format!("git -c core.hooksPath=/dev/null show -s --format=%H\\ %cI {ref_q}"),
@ -1248,13 +1216,13 @@ async fn resolve_ref_sha_and_time(
)
.await
.map_err(|err| ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.display_with_causes()))?;
if !res.is_success() {
if !res.success() {
return Err(ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Failed to resolve sandbox git ref.",
));
}
parse_head_show_output(&res.stdout).ok_or_else(|| {
parse_head_show_output(&res.stdout_lossy()).ok_or_else(|| {
ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Sandbox HEAD resolved to an empty value.",
@ -1616,7 +1584,7 @@ fn collect_blob_shas(classified: &[ClassifiedEntry]) -> Vec<String> {
/// but with a semantically-accurate cause.
/// - Phase 2 transient error: 503 to the client.
async fn fetch_blob_table(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
shas: &[String],
) -> std::result::Result<HashMap<String, Option<String>>, ApiError> {
if shas.is_empty() {
@ -1712,7 +1680,9 @@ fn count_flags(data: &[FileDiff]) -> (u64, u64, u64, u64) {
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use fabro_types::{CommandTermination, RunId, test_support};
use fabro_sandbox::Termination;
use fabro_sandbox::test_support::exec_result;
use fabro_types::{RunId, test_support};
use tokio::time::{Duration, sleep};
use super::*;
@ -1748,28 +1718,17 @@ mod tests {
}
}
struct ScriptedWorkingTreeSandbox {
commands: StdMutex<Vec<String>>,
}
#[async_trait::async_trait]
impl fabro_agent::Sandbox for ScriptedWorkingTreeSandbox {
async fn exec_command(
&self,
command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> fabro_sandbox::Result<fabro_sandbox::ExecResult> {
self.commands
.lock()
.expect("commands lock poisoned")
.push(command.to_string());
#[tokio::test]
async fn working_tree_scope_uses_one_git_diff_and_excludes_untracked_files() {
// The commit header, then the one diff; anything else is unexpected.
let sandbox = MockSandbox {
exec_error: Some("unexpected command".into()),
..MockSandbox::default()
};
sandbox.respond_with(|command| {
let stdout = if command.contains(" show -s --format=") {
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 2026-05-09T17:12:40Z\n".to_string()
} else if command.contains(" diff --patch --find-renames=50% ") {
} else if command.contains("'diff'") && command.contains("'--find-renames=50%'") {
"\
diff --git a/src/live.rs b/src/live.rs
--- a/src/live.rs
@ -1780,93 +1739,13 @@ diff --git a/src/live.rs b/src/live.rs
"
.to_string()
} else {
return Err(fabro_sandbox::Error::message(format!(
"unexpected command: {command}"
)));
return None;
};
Ok(fabro_sandbox::ExecResult {
stdout,
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 0,
})
}
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
unimplemented!()
}
async fn write_file(&self, _: &str, _: &str) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn delete_file(&self, _: &str) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn file_exists(&self, _: &str) -> fabro_sandbox::Result<bool> {
unimplemented!()
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> fabro_sandbox::Result<Vec<fabro_sandbox::DirEntry>> {
unimplemented!()
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &fabro_sandbox::GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn download_file_to_local(
&self,
_remote: &str,
_local: &std::path::Path,
) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn upload_file_from_local(
&self,
_local: &std::path::Path,
_remote: &str,
) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &'static str {
"/tmp"
}
fn platform(&self) -> &'static str {
"linux"
}
fn os_version(&self) -> String {
"test".to_string()
}
}
#[tokio::test]
async fn working_tree_scope_uses_one_git_diff_and_excludes_untracked_files() {
let sandbox = ScriptedWorkingTreeSandbox {
commands: StdMutex::new(Vec::new()),
};
Some(exec_result(&stdout, "", Some(0), Termination::Exited, 0))
});
let body = materialize_working_tree_sandbox_path(
&sandbox,
&sandbox.sandbox(),
"HEAD",
RunFilesMetaScope::Uncommitted,
&RunId::new(),
@ -1878,15 +1757,21 @@ diff --git a/src/live.rs b/src/live.rs
assert_eq!(body.meta.source, RunFilesMetaSource::Sandbox);
assert_eq!(body.meta.scope, RunFilesMetaScope::Uncommitted);
assert_eq!(body.data.len(), 1);
let commands = sandbox.commands.lock().expect("commands lock poisoned");
let commands = sandbox.driver().scripted_exec().commands();
assert_eq!(commands.len(), 2);
assert!(commands[0].contains(" show -s --format="));
assert!(commands[1].contains(" diff --patch --find-renames=50% HEAD"));
assert!(
commands[1].contains("'diff'")
&& commands[1].contains("'--find-renames=50%'")
&& commands[1].contains("'HEAD'"),
"{}",
commands[1]
);
assert!(!commands.iter().any(|command| command.contains("ls-files")));
}
#[test]
fn parse_git_log_commits_keeps_external_and_fabro_metadata() {
#[tokio::test]
async fn git_log_commits_keeps_external_and_fabro_metadata() {
let stdout = concat!(
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x1f",
"cccccccccccccccccccccccccccccccccccccccc\x1f",
@ -1901,8 +1786,26 @@ diff --git a/src/live.rs b/src/live.rs
"Alice\x1falice@example.com\x1f2026-05-09T18:00:00Z\x1f",
"external tool update\n\nLonger body.\n\x1e",
);
let sandbox = fabro_sandbox::test_support::MockSandbox::default();
sandbox
.driver()
.scripted_exec()
.push_result(fabro_sandbox::test_support::exec_result(
stdout,
"",
Some(0),
Termination::Exited,
1,
));
let commits = parse_git_log_commits(stdout).expect("git log should parse");
let commits = git_log_commits(
&sandbox.sandbox(),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"dddddddddddddddddddddddddddddddddddddddd",
50,
)
.await
.expect("git log should parse");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].subject, "fabro(run_1): implement (succeeded)");
@ -1915,6 +1818,11 @@ diff --git a/src/live.rs b/src/live.rs
assert_eq!(commits[1].subject, "external tool update");
assert_eq!(commits[1].body.as_deref(), Some("Longer body."));
assert!(commits[1].trailers.is_empty());
let command = &sandbox.driver().scripted_exec().commands()[0];
assert!(
command.contains("'--first-parent'") && command.contains("'--max-count=50'"),
"{command}"
);
}
#[tokio::test]
@ -2943,120 +2851,35 @@ rename to .env.production
// ── fetch_blob_table two-phase error isolation ─────────────────────
use async_trait::async_trait;
use fabro_sandbox::{Error as SandboxError, ExecResult, Result as SandboxResult};
use fabro_sandbox::ExecResult;
use fabro_sandbox::test_support::MockSandbox;
/// Scripted sandbox for the two-phase tests — serves different
/// `exec_command` responses for `cat-file --batch-check` vs
/// `cat-file --batch`. Every other `Sandbox` method panics because
/// `fetch_blob_table` only uses `exec_command`.
struct ScriptedBlobSandbox {
batch_check_result: ExecResult,
batch_result: ExecResult,
}
#[async_trait]
impl fabro_agent::Sandbox for ScriptedBlobSandbox {
async fn exec_command(
&self,
command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> SandboxResult<ExecResult> {
/// A sandbox for the two-phase tests: it answers `cat-file --batch-check`
/// and `cat-file --batch` differently and fails any other command, since
/// `fetch_blob_table` runs nothing else.
fn blob_sandbox(batch_check_result: ExecResult, batch_result: ExecResult) -> MockSandbox {
let sandbox = MockSandbox {
exec_error: Some("unexpected command".into()),
..MockSandbox::default()
};
sandbox.respond_with(move |command| {
if command.contains("cat-file --batch-check") {
Ok(self.batch_check_result.clone())
Some(batch_check_result.clone())
} else if command.contains("cat-file --batch") {
Ok(self.batch_result.clone())
Some(batch_result.clone())
} else {
Err(SandboxError::message(format!(
"unexpected command in ScriptedBlobSandbox: {command}"
)))
None
}
}
// Unused by fetch_blob_table — panic loudly if anything tries to
// use this sandbox beyond cat-file.
async fn read_file_bytes(&self, _path: &str) -> SandboxResult<Vec<u8>> {
unimplemented!()
}
async fn write_file(&self, _: &str, _: &str) -> SandboxResult<()> {
unimplemented!()
}
async fn delete_file(&self, _: &str) -> SandboxResult<()> {
unimplemented!()
}
async fn file_exists(&self, _: &str) -> SandboxResult<bool> {
unimplemented!()
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> SandboxResult<Vec<fabro_sandbox::DirEntry>> {
unimplemented!()
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &fabro_sandbox::GrepOptions,
) -> SandboxResult<Vec<String>> {
unimplemented!()
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> SandboxResult<Vec<String>> {
unimplemented!()
}
async fn download_file_to_local(
&self,
_remote: &str,
_local: &std::path::Path,
) -> SandboxResult<()> {
unimplemented!()
}
async fn upload_file_from_local(
&self,
_local: &std::path::Path,
_remote: &str,
) -> SandboxResult<()> {
unimplemented!()
}
async fn initialize(&self) -> SandboxResult<()> {
Ok(())
}
async fn cleanup(&self) -> SandboxResult<()> {
Ok(())
}
fn working_directory(&self) -> &'static str {
"/tmp"
}
fn platform(&self) -> &'static str {
"linux"
}
fn os_version(&self) -> String {
"test".to_string()
}
});
sandbox
}
fn ok_exec(stdout: &str) -> ExecResult {
ExecResult {
stdout: stdout.to_string(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 0,
}
exec_result(stdout, "", Some(0), Termination::Exited, 0)
}
fn fail_exec(stderr: &str) -> ExecResult {
ExecResult {
stdout: String::new(),
stderr: stderr.to_string(),
exit_code: Some(1),
termination: CommandTermination::Exited,
duration_ms: 0,
}
exec_result("", stderr, Some(1), Termination::Exited, 0)
}
#[tokio::test]
@ -3089,12 +2912,9 @@ rename to .env.production
// Permanent error.
let batch_stdout = format!("{} blob 999999\n<no content>\n", shas[1]);
let sandbox = ScriptedBlobSandbox {
batch_check_result: ok_exec(&batch_check_stdout),
batch_result: ok_exec(&batch_stdout),
};
let sandbox = blob_sandbox(ok_exec(&batch_check_stdout), ok_exec(&batch_stdout));
let table = fetch_blob_table(&sandbox, &shas)
let table = fetch_blob_table(&sandbox.sandbox(), &shas)
.await
.expect("transient-only errors should never bubble up for permanent parse fail");
@ -3118,7 +2938,7 @@ rename to .env.production
#[tokio::test]
async fn fetch_blob_table_small_sha_list_skips_phase1() {
// With ≤ METADATA_PHASE_SHA_THRESHOLD SHAs, phase 1 is skipped. If
// phase-1 were to run, ScriptedBlobSandbox's batch_check_result
// phase-1 were to run, the batch-check result
// would need to be valid; we make it an error that would fail the
// whole request to prove phase 1 wasn't invoked.
let sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
@ -3126,14 +2946,14 @@ rename to .env.production
let batch_stdout = format!("{sha} blob 5\nhello\n");
let sandbox = ScriptedBlobSandbox {
// If phase 1 ran this would surface as a transient 503 and
// break the test.
batch_check_result: fail_exec("phase 1 should not have been called"),
batch_result: ok_exec(&batch_stdout),
};
// If phase 1 ran, its failure would surface as a transient 503 and
// break the test.
let sandbox = blob_sandbox(
fail_exec("phase 1 should not have been called"),
ok_exec(&batch_stdout),
);
let table = fetch_blob_table(&sandbox, &shas)
let table = fetch_blob_table(&sandbox.sandbox(), &shas)
.await
.expect("small SHA lists skip phase 1 entirely; phase-2 success is the full story");
assert_eq!(table.get(&sha), Some(&Some("hello".to_string())));

View file

@ -17,20 +17,17 @@ use fabro_graphviz::render::apply_direction;
use fabro_llm::FabroClient;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::probe::{self, ModelTestStatus};
use fabro_sandbox::daytona::DaytonaConfig;
use fabro_sandbox::from_environment::{
daytona_config_from_environment, docker_config_from_environment,
local_working_directory_from_environment,
use fabro_sandbox::{
CloneRequest, ProviderAccess, RunSandbox, SandboxSpec, sandbox_spec_for_environment,
};
use fabro_sandbox::redact::redact_auth_url;
use fabro_sandbox::{DockerSandboxOptions, Sandbox, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::settings::ModelRef;
use fabro_types::settings::cli::OutputVerbosity;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{EnvironmentProvider, McpServerSettings, RunGoal, RunNamespace};
use fabro_types::settings::run::{McpServerSettings, RunGoal, RunNamespace};
use fabro_types::{
ManifestPath, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings, WorkflowSettings,
BundledProvider, ManifestPath, RunId, RunNoticeLevel, SandboxProviderKind, ServerSettings,
WorkflowSettings,
};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
@ -439,7 +436,7 @@ async fn build_preflight_report(
let server_settings = state.server_settings();
let github_integration = &server_settings.server.integrations.github;
let sandbox_provider = effective_sandbox_provider(&resolved_run);
if let Some(error) = sandbox_provider_policy_error(&server_settings, sandbox_provider) {
if let Some(error) = sandbox_provider_policy_error(&server_settings, &sandbox_provider) {
checks.push(CheckResult {
name: "Sandbox Provider Policy".into(),
status: CheckStatus::Error,
@ -465,8 +462,8 @@ async fn build_preflight_report(
&ready_providers,
&resolved_run.model.fallbacks,
);
let needs_github_credentials =
sandbox_provider.is_clone_based() || resolved_run.integrations.github.is_token_requested();
let needs_github_credentials = sandbox_provider.clones_workspace()
|| resolved_run.integrations.github.is_token_requested();
let github_app = if needs_github_credentials {
match state.github_credentials(github_integration).await {
Ok(credentials) => credentials,
@ -483,19 +480,19 @@ async fn build_preflight_report(
None
};
let daytona_api_key = state.vault_secret(EnvVars::DAYTONA_API_KEY).await?;
let access = state.provider_access().await?;
let sandbox_ok = run_sandbox_check(
&mut checks,
sandbox_provider,
&sandbox_provider,
prepared,
&resolved_run,
github_app.clone(),
daytona_api_key,
&access,
)
.await;
let repository_access_ok = run_repository_access_check(
&mut checks,
sandbox_provider,
&sandbox_provider,
prepared,
&resolved_run,
github_app.clone(),
@ -650,48 +647,39 @@ fn base_preflight_checks(prepared: &PreparedManifest, graph: &Graph) -> Vec<Chec
pub(crate) fn sandbox_provider_policy_error(
server_settings: &ServerSettings,
provider: SandboxProviderKind,
provider: &SandboxProviderKind,
) -> Option<String> {
let enabled = server_settings
.server
.sandbox
.providers
.for_provider(provider)
.enabled;
(!enabled).then(|| {
format!(
let providers = &server_settings.server.sandbox.providers;
match providers.get(provider) {
Some(entry) if entry.enabled => None,
Some(_) => Some(format!(
"sandbox provider \"{provider}\" is disabled by server.sandbox.providers.{provider}.enabled"
)
})
)),
None => Some(format!(
"sandbox provider \"{provider}\" is not configured; add [server.sandbox.providers.{provider}] to settings.toml"
)),
}
}
pub(crate) fn configured_sandbox_provider(settings: &RunNamespace) -> SandboxProviderKind {
SandboxProviderKind::from(settings.environment.provider)
settings.environment.provider.clone()
}
pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProviderKind {
configured_sandbox_provider(settings).effective_for(settings.execution.mode)
}
fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig {
daytona_config_from_environment(&settings.environment, &settings.clone)
}
fn resolve_docker_config(settings: &RunNamespace) -> DockerSandboxOptions {
docker_config_from_environment(&settings.environment, &settings.clone)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct GitRemoteRefCheck {
origin_url: String,
branch: Option<String>,
}
fn clone_disabled_for_provider(provider: SandboxProviderKind, resolved_run: &RunNamespace) -> bool {
match provider {
SandboxProviderKind::Docker | SandboxProviderKind::Daytona => !resolved_run.clone.enabled,
SandboxProviderKind::Local => false,
}
fn clone_disabled_for_provider(
provider: &SandboxProviderKind,
resolved_run: &RunNamespace,
) -> bool {
provider.clones_workspace() && !resolved_run.clone.enabled
}
fn run_environment_capability_check(checks: &mut Vec<CheckResult>, resolved_run: &RunNamespace) {
@ -714,8 +702,8 @@ fn run_environment_capability_check(checks: &mut Vec<CheckResult>, resolved_run:
fn environment_capability_warnings(resolved_run: &RunNamespace) -> Vec<String> {
let environment = &resolved_run.environment;
let mut warnings = Vec::new();
match environment.provider {
EnvironmentProvider::Local => {
match environment.provider.bundled() {
Some(BundledProvider::Local) => {
if environment.resources.cpu.is_some()
|| environment.resources.memory.is_some()
|| environment.resources.disk.is_some()
@ -729,7 +717,7 @@ fn environment_capability_warnings(resolved_run: &RunNamespace) -> Vec<String> {
warnings.push("local provider ignores lifecycle.auto_stop".to_string());
}
}
EnvironmentProvider::Docker => {
Some(BundledProvider::Docker) => {
if environment.cwd.is_some() {
warnings.push("docker provider ignores cwd".to_string());
}
@ -746,11 +734,16 @@ fn environment_capability_warnings(resolved_run: &RunNamespace) -> Vec<String> {
warnings.push("docker provider ignores image.dockerfile".to_string());
}
}
EnvironmentProvider::Daytona => {
Some(BundledProvider::Daytona) => {
if environment.cwd.is_some() {
warnings.push("daytona provider ignores cwd".to_string());
}
}
None => {
if environment.cwd.is_some() {
warnings.push(format!("{} provider ignores cwd", environment.provider));
}
}
}
warnings
}
@ -765,7 +758,7 @@ fn repository_access_details(request: &GitRemoteRefCheck) -> Vec<CheckDetail> {
async fn run_repository_access_check(
checks: &mut Vec<CheckResult>,
sandbox_provider: SandboxProviderKind,
sandbox_provider: &SandboxProviderKind,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
@ -783,7 +776,7 @@ async fn run_repository_access_check(
async fn run_repository_access_check_with<F, Fut>(
checks: &mut Vec<CheckResult>,
sandbox_provider: SandboxProviderKind,
sandbox_provider: &SandboxProviderKind,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
@ -793,7 +786,7 @@ where
F: FnOnce(GitRemoteRefCheck, Option<fabro_github::GitHubCredentials>) -> Fut,
Fut: Future<Output = Result<(), String>>,
{
if !sandbox_provider.is_clone_based()
if !sandbox_provider.clones_workspace()
|| clone_disabled_for_provider(sandbox_provider, resolved_run)
{
return true;
@ -879,7 +872,10 @@ async fn check_git_remote_ref(
run_ls_remote(command)
.await
.map_err(|message| redact_auth_url(&message, auth_url.as_ref()))
.map_err(|message| match &auth_url {
Some(auth_url) => auth_url.redact_in(&message),
None => message,
})
}
/// Run a prepared `git ls-remote` invocation with a 10s timeout, reducing a
@ -910,11 +906,11 @@ async fn run_ls_remote(mut command: Command) -> std::result::Result<(), String>
}
fn preflight_sandbox_spec(
sandbox_provider: SandboxProviderKind,
sandbox_provider: &SandboxProviderKind,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
daytona_api_key: Option<String>,
access: &ProviderAccess,
) -> std::result::Result<SandboxSpec, fabro_sandbox::Error> {
let clone_origin_url = prepared
.git
@ -922,58 +918,53 @@ fn preflight_sandbox_spec(
.map(|git| fabro_github::normalize_repo_origin_url(&git.origin_url));
let clone_branch = prepared.git.as_ref().map(|git| git.branch.clone());
Ok(match sandbox_provider {
SandboxProviderKind::Local => {
let working_directory = local_working_directory_from_environment(
&resolved_run.environment,
Some(&prepared.source_directory),
)?;
SandboxSpec::Local { working_directory }
}
SandboxProviderKind::Docker => {
let mut config = resolve_docker_config(resolved_run);
config.skip_clone = true;
SandboxSpec::Docker {
config,
github_app,
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
}
}
SandboxProviderKind::Daytona => {
let mut config = resolve_daytona_config(resolved_run);
config.skip_clone = true;
SandboxSpec::Daytona {
config: Box::new(config),
github_app,
run_id: None,
clone_origin_url,
clone_branch,
clone_tag: None,
clone_commit_sha: None,
api_key: daytona_api_key,
}
}
if sandbox_provider.bundled() == Some(BundledProvider::Local) {
let working_directory = resolved_run
.environment
.local_working_directory(Some(&prepared.source_directory))
.map_err(|err| {
fabro_sandbox::Error::context(
"Failed to resolve local environment working directory",
err,
)
})?;
return Ok(SandboxSpec::local(working_directory, access.clone()));
}
// No vault is available on this path, so a `{{ secrets.* }}` value keeps
// its source form. Preflight never clones.
let spec = sandbox_spec_for_environment(
&resolved_run.environment,
resolved_run.environment.unresolved_env(),
)?;
let clone = CloneRequest {
origin_url: clone_origin_url,
branch: clone_branch,
..CloneRequest::none()
};
Ok(SandboxSpec {
kind: sandbox_provider.clone(),
access: access.clone(),
spec,
clone,
github_app,
run_id: None,
})
}
async fn run_sandbox_check(
checks: &mut Vec<CheckResult>,
sandbox_provider: SandboxProviderKind,
sandbox_provider: &SandboxProviderKind,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
daytona_api_key: Option<String>,
access: &ProviderAccess,
) -> bool {
let spec = match preflight_sandbox_spec(
sandbox_provider,
prepared,
resolved_run,
github_app.clone(),
daytona_api_key,
access,
) {
Ok(spec) => spec,
Err(err) => {
@ -987,8 +978,8 @@ async fn run_sandbox_check(
return false;
}
};
let sandbox_result: Result<Arc<dyn Sandbox>, String> = spec.build(None).await.map_err(|err| {
if matches!(sandbox_provider, SandboxProviderKind::Daytona) {
let sandbox_result: Result<Arc<RunSandbox>, String> = spec.build(None).await.map_err(|err| {
if *sandbox_provider == SandboxProviderKind::DAYTONA {
format!("Daytona sandbox creation failed: {err}")
} else {
err.to_string()
@ -999,7 +990,7 @@ async fn run_sandbox_check(
Ok(sandbox) => match sandbox.initialize().await {
Ok(()) => {
let mut details = vec![CheckDetail::new(format!("Provider: {sandbox_provider}"))];
if sandbox_provider.is_clone_based()
if sandbox_provider.clones_workspace()
&& prepared.git.is_none()
&& !clone_disabled_for_provider(sandbox_provider, resolved_run)
{
@ -1008,7 +999,7 @@ async fn run_sandbox_check(
warn: true,
});
}
if let Err(err) = sandbox.cleanup().await {
if let Err(err) = sandbox.delete().await {
checks.push(CheckResult {
name: "Sandbox".into(),
status: CheckStatus::Error,
@ -1028,7 +1019,7 @@ async fn run_sandbox_check(
true
}
Err(err) => {
let cleanup_error = sandbox.cleanup().await.err();
let cleanup_error = sandbox.delete().await.err();
checks.push(CheckResult {
name: "Sandbox".into(),
status: CheckStatus::Error,
@ -1509,23 +1500,21 @@ async fn probe_github_repository(
/// Retry auth-shaped failures with the SAME token: replication of a given
/// token only makes progress, while re-minting would restart the replication
/// clock. The sandbox git retry executor owns attempt limits,
/// classification, and pacing.
/// clock. The driver's git retry owns the decision and the pacing; fabro's
/// probe policy owns the attempt count.
async fn probe_with_replication_retry<F, Fut>(
snapshot: TokenSnapshot,
mut run: F,
run: F,
) -> std::result::Result<(), String>
where
F: FnMut() -> Fut,
Fut: Future<Output = std::result::Result<(), String>>,
{
let credential_context = fabro_sandbox::CredentialContext::from_snapshot(Some(&snapshot));
fabro_sandbox::retry_git_operation(
SandboxProviderKind::Local,
fabro_sandbox::retry_git_messages(
&fabro_sandbox::repository_probe_policy(),
Some(&snapshot),
"repository probe",
&fabro_sandbox::RetryPlan::repository_probe(),
|_attempt| run(),
|message| fabro_sandbox::classify_failure(message, credential_context),
run,
)
.await
}
@ -1978,7 +1967,7 @@ digraph Demo {{
}
fn prepared_and_resolved_for_sandbox(
provider: SandboxProviderKind,
provider: &SandboxProviderKind,
clone_enabled: bool,
git: Option<types::GitContext>,
) -> (PreparedManifest, RunNamespace) {
@ -2028,7 +2017,7 @@ enabled = {clone_enabled}
#[test]
fn docker_environment_cwd_is_reported_as_ignored() {
let mut resolved = RunNamespace::default();
resolved.environment.provider = EnvironmentProvider::Docker;
resolved.environment.provider = SandboxProviderKind::DOCKER;
resolved.environment.cwd = Some("/workspace/custom".to_string());
assert_eq!(environment_capability_warnings(&resolved), vec![
@ -2039,7 +2028,7 @@ enabled = {clone_enabled}
#[test]
fn daytona_environment_cwd_is_reported_as_ignored() {
let mut resolved = RunNamespace::default();
resolved.environment.provider = EnvironmentProvider::Daytona;
resolved.environment.provider = SandboxProviderKind::DAYTONA;
resolved.environment.cwd = Some("/home/daytona/workspace/custom".to_string());
assert_eq!(environment_capability_warnings(&resolved), vec![
@ -2074,14 +2063,14 @@ provider = "local"
assert_eq!(
prepared.settings.run.environment.provider,
EnvironmentProvider::Local
SandboxProviderKind::LOCAL
);
}
#[tokio::test]
async fn repository_access_check_skips_when_clone_is_disabled() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
false,
Some(git_context("https://github.com/acme/widgets", "main")),
);
@ -2091,7 +2080,7 @@ provider = "local"
let ok = run_repository_access_check_with(
&mut checks,
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
&prepared,
&resolved,
None,
@ -2110,7 +2099,7 @@ provider = "local"
#[tokio::test]
async fn repository_access_check_rejects_non_github_origins_before_remote_probe() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
true,
Some(git_context("https://gitlab.com/acme/widgets", "main")),
);
@ -2120,7 +2109,7 @@ provider = "local"
let ok = run_repository_access_check_with(
&mut checks,
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
&prepared,
&resolved,
None,
@ -2148,7 +2137,7 @@ provider = "local"
#[tokio::test]
async fn repository_access_check_probes_normalized_github_branch() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
true,
Some(git_context(
"git@github.com:acme/widgets.git",
@ -2161,7 +2150,7 @@ provider = "local"
let ok = run_repository_access_check_with(
&mut checks,
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
&prepared,
&resolved,
None,
@ -2185,7 +2174,7 @@ provider = "local"
#[tokio::test]
async fn repository_access_check_surfaces_remote_probe_failure() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
true,
Some(git_context("https://github.com/acme/widgets", "missing")),
);
@ -2193,7 +2182,7 @@ provider = "local"
let ok = run_repository_access_check_with(
&mut checks,
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
&prepared,
&resolved,
None,
@ -2217,35 +2206,27 @@ provider = "local"
#[test]
fn preflight_sandbox_spec_disables_docker_clone_but_preserves_clone_metadata() {
let (prepared, resolved) = prepared_and_resolved_for_sandbox(
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
true,
Some(git_context("https://github.com/acme/widgets", "main")),
);
let spec = preflight_sandbox_spec(
SandboxProviderKind::Docker,
&SandboxProviderKind::DOCKER,
&prepared,
&resolved,
None,
None,
&ProviderAccess::default(),
);
match spec {
Ok(SandboxSpec::Docker {
config,
clone_origin_url,
clone_branch,
..
}) => {
assert!(config.skip_clone);
assert_eq!(
clone_origin_url.as_deref(),
Some("https://github.com/acme/widgets")
);
assert_eq!(clone_branch.as_deref(), Some("main"));
}
_ => panic!("expected Docker preflight sandbox spec"),
}
let spec = spec.expect("Docker preflight sandbox spec");
assert_eq!(spec.kind, SandboxProviderKind::DOCKER);
assert!(spec.clone.skip);
assert_eq!(
spec.clone.origin_url.as_deref(),
Some("https://github.com/acme/widgets")
);
assert_eq!(spec.clone.branch.as_deref(), Some("main"));
}
#[test]
@ -3281,7 +3262,7 @@ dockerfile = { path = "Dockerfile" }
fn declared(origin: &str, additional: &[&str]) -> (PreparedManifest, RunNamespace) {
let (prepared, mut resolved) = prepared_and_resolved_for_sandbox(
SandboxProviderKind::Local,
&SandboxProviderKind::LOCAL,
true,
Some(git_context(origin, "main")),
);

View file

@ -799,7 +799,7 @@ where
github_api_base_url: None,
active_config_path,
http_client: None,
sandbox_provider_registry: None,
sandbox_inventory: None,
shutdown: shutdown.clone(),
#[cfg(test)]
worker_control_bus: None,

View file

@ -62,13 +62,10 @@ use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{ClientOptions, FabroClient};
use fabro_mcp_store::McpServerStore;
use fabro_redact::redact_jsonl_line;
use fabro_sandbox::daytona::{self, DaytonaSandbox};
use fabro_sandbox::details::sandbox_details;
use fabro_sandbox::driver::{DaytonaCredentials, ProviderAccess, ProviderConnectOptions};
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_sandbox::{
DaytonaSandboxProvider, DockerSandboxProvider, LocalSandboxProvider, Sandbox, SandboxProvider,
SandboxProviderRegistry,
};
use fabro_sandbox::{SandboxInventory, daytona};
use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient};
use fabro_slack::config::{
SlackCredentialResolution,
@ -1137,7 +1134,7 @@ pub struct AppState {
pub(crate) github_api_base_url: String,
active_config_path: PathBuf,
http_client: Option<fabro_http::HttpClient>,
sandbox_provider_registry: SandboxProviderRegistry,
sandbox_inventory: SandboxInventory,
shutdown: CancellationToken,
shutting_down: AtomicBool,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
@ -1280,7 +1277,7 @@ pub(crate) struct AppStateConfig {
pub(crate) github_api_base_url: Option<String>,
pub(crate) active_config_path: PathBuf,
pub(crate) http_client: Option<fabro_http::HttpClient>,
pub(crate) sandbox_provider_registry: Option<SandboxProviderRegistry>,
pub(crate) sandbox_inventory: Option<SandboxInventory>,
pub(crate) shutdown: CancellationToken,
#[cfg(test)]
pub(crate) worker_control_bus: Option<Arc<dyn WorkerControlBus>>,
@ -1468,6 +1465,28 @@ impl AppState {
(self.env_lookup)(name)
}
/// Daytona credentials for `api_key`: the key from the vault, the
/// control-plane URL and organization from server configuration, and
/// the server's HTTP client. The process environment is consulted only
/// through the configured lookup.
pub(crate) fn daytona_credentials(&self, api_key: String) -> DaytonaCredentials {
DaytonaCredentials::from_api_key(api_key, |name| self.config_env_lookup(name))
.with_http_client(self.http_client().ok())
}
/// Everything a reconnect needs to reach a run's provider: the server's
/// provider settings and the Daytona credentials from the vault (`None`
/// when no key is stored).
pub(crate) async fn provider_access(&self) -> Result<ProviderAccess, SecretStoreError> {
Ok(ProviderAccess {
providers: self.server_settings().server.sandbox.providers.clone(),
daytona: self
.vault_secret(EnvVars::DAYTONA_API_KEY)
.await?
.map(|api_key| self.daytona_credentials(api_key)),
})
}
pub(crate) async fn check_daytona_api_key(
&self,
api_key: String,
@ -1481,21 +1500,7 @@ impl AppState {
api_key: String,
probe_timeout: Duration,
) -> anyhow::Result<daytona::DaytonaKeyCheck> {
let base_url = self
.config_env_lookup(EnvVars::DAYTONA_API_URL)
.or_else(|| self.config_env_lookup(EnvVars::DAYTONA_SERVER_URL))
.unwrap_or_else(|| daytona::DEFAULT_DAYTONA_API_URL.to_string());
let org_id = self.config_env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID);
let http_client = fabro_http::http_client().context("failed to build HTTP client")?;
daytona::check_daytona_api_key_with_timeout(
&base_url,
org_id.as_deref(),
api_key,
http_client,
probe_timeout,
)
.await
daytona::check_daytona_api_key(&self.daytona_credentials(api_key), probe_timeout).await
}
/// Borrow the persistent store so sibling modules can open run readers
@ -1523,8 +1528,8 @@ impl AppState {
&self.session_runtimes
}
pub(crate) fn sandbox_provider_registry(&self) -> &SandboxProviderRegistry {
&self.sandbox_provider_registry
pub(crate) fn sandbox_inventory(&self) -> &SandboxInventory {
&self.sandbox_inventory
}
pub(crate) fn server_secret(&self, name: &str) -> Option<String> {
@ -2321,36 +2326,45 @@ fn worker_token_keys_from_server_secrets(
.map_err(|err| jwt_auth::session_secret_key_error(&err))
}
fn build_sandbox_provider_registry(
fn build_sandbox_inventory(
server_settings: &ServerSettings,
daytona_api_key: Option<String>,
env_lookup: &EnvLookup,
http_client: Option<fabro_http::HttpClient>,
) -> SandboxProviderRegistry {
) -> SandboxInventory {
let provider_settings = &server_settings.server.sandbox.providers;
let mut providers: Vec<Arc<dyn SandboxProvider>> = Vec::new();
let mut inventory = SandboxInventory::empty();
if provider_settings.local.enabled {
providers.push(Arc::new(LocalSandboxProvider));
if provider_settings.is_enabled(&SandboxProviderKind::LOCAL) {
inventory = inventory.with_host_directories(SandboxProviderKind::LOCAL);
}
if provider_settings.docker.enabled {
providers.push(Arc::new(DockerSandboxProvider::new()));
if let Some(docker) = provider_settings.get(&SandboxProviderKind::DOCKER) {
if docker.enabled {
inventory = inventory.with_lazy(
SandboxProviderKind::DOCKER,
docker.clone(),
ProviderConnectOptions::default(),
);
}
}
if provider_settings.daytona.enabled && daytona_api_key.is_some() {
let api_url = env_lookup(EnvVars::DAYTONA_API_URL)
.or_else(|| env_lookup(EnvVars::DAYTONA_SERVER_URL));
let organization_id = env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID);
providers.push(Arc::new(DaytonaSandboxProvider::new(
daytona_api_key,
api_url,
organization_id,
http_client,
)));
if let Some(daytona) = provider_settings.get(&SandboxProviderKind::DAYTONA) {
if let Some(api_key) = daytona_api_key.filter(|_| daytona.enabled) {
let credentials = DaytonaCredentials::from_api_key(api_key, |name| env_lookup(name))
.with_http_client(http_client);
inventory = inventory.with_lazy(
SandboxProviderKind::DAYTONA,
daytona.clone(),
ProviderConnectOptions {
host_registry_root: None,
daytona: Some(credentials),
},
);
}
}
SandboxProviderRegistry::new(providers)
inventory
}
pub(crate) fn automation_dir_for_active_config(active_config_path: &std::path::Path) -> PathBuf {
@ -2403,7 +2417,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
github_api_base_url,
active_config_path,
http_client,
sandbox_provider_registry,
sandbox_inventory,
shutdown,
#[cfg(test)]
worker_control_bus,
@ -2426,8 +2440,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
.server
.sandbox
.providers
.local
.enabled;
.is_enabled(&SandboxProviderKind::LOCAL);
let environment_pool = db_pool.clone();
let environment_store = Arc::new(
load_store_blocking("environment store", move || async move {
@ -2473,8 +2486,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
fabro_llm::build_catalog(&resolved_settings.llm_overlay, &|name| env_lookup(name))
.context("building LLM model catalog")?,
);
let sandbox_provider_registry = sandbox_provider_registry.unwrap_or_else(|| {
build_sandbox_provider_registry(
let sandbox_inventory = sandbox_inventory.unwrap_or_else(|| {
build_sandbox_inventory(
current_server_settings.as_ref(),
daytona_api_key,
&env_lookup,
@ -2586,7 +2599,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
github_api_base_url,
active_config_path,
http_client,
sandbox_provider_registry,
sandbox_inventory,
shutdown,
shutting_down: AtomicBool::new(false),
registry_factory_override,
@ -2758,11 +2771,11 @@ async fn delete_run_sandbox_resource(
}));
}
let daytona_api_key = state
.vault_secret(EnvVars::DAYTONA_API_KEY)
let access = state
.provider_access()
.await
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
let sandbox = match reconnect_for_run(&record, daytona_api_key, Some(id)).await {
let sandbox = match reconnect_for_run(&record, &access, Some(id), None).await {
Ok(sandbox) => sandbox,
Err(err) if force || delete_started => {
tracing::warn!(
@ -3285,7 +3298,8 @@ async fn reject_run_if_sandbox_provider_disabled(
settings: &RunNamespace,
) -> bool {
let provider = run_manifest::effective_sandbox_provider(settings);
let Some(error) = run_manifest::sandbox_provider_policy_error(server_settings, provider) else {
let Some(error) = run_manifest::sandbox_provider_policy_error(server_settings, &provider)
else {
return false;
};
tracing::warn!(run_id = %run_id, error = %error, "Sandbox provider disabled by server policy");
@ -4075,7 +4089,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
let run_spec = persisted.run_spec();
let settings = &run_spec.settings.run;
let clone_can_use_github_credentials = settings.execution.mode != RunMode::DryRun
&& settings.environment.provider.is_clone_based()
&& settings.environment.provider.clones_workspace()
&& run_spec
.repo_origin_url()
.is_some_and(|origin| !origin.trim().is_empty());
@ -4169,6 +4183,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
github_app,
github_integration,
vault: Arc::new(AsyncRwLock::new(vault.into_vault())),
sandbox_providers: state.server_settings().server.sandbox.providers.clone(),
catalog: state.catalog(),
on_node: None,
registry_override,

View file

@ -7,7 +7,7 @@ use fabro_automation::{
};
use fabro_environment::EnvironmentId;
use fabro_store::{RunSummaryListQuery, RunSummaryVisibility};
use fabro_types::{AutomationRef, RunId, SandboxProviderKind};
use fabro_types::{AutomationRef, RunId};
use fabro_util::error as error_util;
use serde::Serialize;
@ -282,7 +282,7 @@ pub(in crate::server) fn resolve_automation_environment(
"automation_environment_not_found",
));
};
if !environment.settings.provider.is_clone_based() {
if !environment.settings.provider.clones_workspace() {
return Err(ApiError::with_code(
status,
format!(
@ -291,9 +291,9 @@ pub(in crate::server) fn resolve_automation_environment(
"automation_environment_incompatible",
));
}
let provider = SandboxProviderKind::from(environment.settings.provider);
let provider = environment.settings.provider.clone();
if let Some(message) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
run_manifest::sandbox_provider_policy_error(&state.server_settings(), &provider)
{
return Err(ApiError::with_code(
status,
@ -302,10 +302,9 @@ pub(in crate::server) fn resolve_automation_environment(
));
}
if !state
.sandbox_provider_registry()
.providers()
.iter()
.any(|sandbox_provider| sandbox_provider.kind() == provider)
.sandbox_inventory()
.kinds()
.any(|kind| *kind == provider)
{
return Err(ApiError::with_code(
status,

View file

@ -3,12 +3,13 @@ use std::sync::Arc;
use axum::http::HeaderMap;
use fabro_environment::{Environment, EnvironmentDraft, EnvironmentId, EnvironmentStoreError};
use fabro_types::SandboxProviderKind;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
EnvironmentSettings,
EnvironmentNetworkSettings, EnvironmentResourcesSettings, EnvironmentSettings,
};
use fabro_util::error::{collect_chain, render_with_causes};
use serde::de::IgnoredAny;
use serde::{Deserialize, Serialize};
@ -33,7 +34,7 @@ struct EnvironmentListMeta {
#[serde(deny_unknown_fields)]
struct CreateEnvironmentRequest {
id: EnvironmentId,
provider: EnvironmentProvider,
provider: SandboxProviderKind,
cwd: Option<String>,
image: ApiEnvironmentImageSettings,
resources: EnvironmentResourcesSettings,
@ -46,7 +47,7 @@ struct CreateEnvironmentRequest {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ReplaceEnvironmentRequest {
provider: EnvironmentProvider,
provider: SandboxProviderKind,
cwd: Option<String>,
image: ApiEnvironmentImageSettings,
resources: EnvironmentResourcesSettings,
@ -267,10 +268,17 @@ impl From<EnvironmentStoreError> for ApiError {
| EnvironmentStoreError::JsonDecode { .. }
| EnvironmentStoreError::Db { .. }
| EnvironmentStoreError::RowCountOverflow { .. }
| EnvironmentStoreError::Io { .. } => Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"environment store operation failed",
),
| EnvironmentStoreError::Io { .. } => {
// The response hides the cause; the log keeps it.
tracing::error!(
error = %render_with_causes(&err.to_string(), &collect_chain(&err)),
"environment store operation failed"
);
Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"environment store operation failed",
)
}
}
}
}

View file

@ -1104,21 +1104,20 @@ async fn validate_intent_environment(
let configured_provider = run_manifest::configured_sandbox_provider(&settings.run);
let effective_provider = run_manifest::effective_sandbox_provider(&settings.run);
let image = &settings.run.environment.image;
let image_incompatible = match effective_provider {
SandboxProviderKind::Docker => image.docker.is_none() && image.dockerfile.is_some(),
SandboxProviderKind::Local | SandboxProviderKind::Daytona => false,
};
let image_incompatible = effective_provider == SandboxProviderKind::DOCKER
&& image.docker.is_none()
&& image.dockerfile.is_some();
let (target_incompatible, detail) = match target {
RunTarget::Git(_) => (
configured_provider == SandboxProviderKind::Local || !settings.run.clone.enabled,
configured_provider == SandboxProviderKind::LOCAL || !settings.run.clone.enabled,
"Git targets require a compatible clone-enabled Docker or Daytona environment",
),
RunTarget::None {} => (
configured_provider == SandboxProviderKind::Local,
configured_provider == SandboxProviderKind::LOCAL,
"none targets require a compatible Docker or Daytona environment",
),
RunTarget::Folder { .. } => (
configured_provider != SandboxProviderKind::Local,
configured_provider != SandboxProviderKind::LOCAL,
"folder targets require a Local environment",
),
};
@ -1127,18 +1126,18 @@ async fn validate_intent_environment(
}
// Settings resolution drops `run.pull_request` unless it is enabled, so
// `Some` means automatic pull requests were requested.
if !configured_provider.is_clone_based() && settings.run.pull_request.is_some() {
if !configured_provider.clones_workspace() && settings.run.pull_request.is_some() {
return Err(EnvironmentSelectionError::AutomaticPullRequestUnsupported);
}
if let Some(detail) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), effective_provider)
run_manifest::sandbox_provider_policy_error(&state.server_settings(), &effective_provider)
{
return Err(EnvironmentSelectionError::ProviderDisabled {
provider: effective_provider,
detail,
});
}
if effective_provider == SandboxProviderKind::Daytona {
if effective_provider == SandboxProviderKind::DAYTONA {
match state.vault_secret(EnvVars::DAYTONA_API_KEY).await {
Ok(Some(key)) if !key.trim().is_empty() => {}
Ok(_) => {
@ -1382,7 +1381,7 @@ pub(crate) async fn create_run_from_manifest(
let prepared = prepared.with_web_url(state.run_web_url(&run_id));
let provider = run_manifest::effective_sandbox_provider(&prepared.settings().run);
if let Some(error) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
run_manifest::sandbox_provider_policy_error(&state.server_settings(), &provider)
{
return ApiError::bad_request(error).into_response();
}

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ async fn list_sandboxes(
State(state): State<Arc<AppState>>,
_auth: RequiredRunManagementActor,
) -> Json<SandboxListResponse> {
Json(state.sandbox_provider_registry().list_managed().await)
Json(state.sandbox_inventory().list_managed().await)
}
async fn retrieve_sandbox(
@ -30,7 +30,7 @@ async fn retrieve_sandbox(
_auth: RequiredRunManagementActor,
) -> Result<Json<SandboxInfo>, ApiError> {
state
.sandbox_provider_registry()
.sandbox_inventory()
.get_managed_by_native_id(&id)
.await
.map(Json)
@ -79,23 +79,49 @@ fn provider_list(providers: &[SandboxProviderKind]) -> String {
mod tests {
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use fabro_sandbox::SandboxProviderRegistry;
use fabro_sandbox::test_support::{
FakeGet, FakeList, FakeSandboxProvider, fake_registry, fake_sandbox_info,
};
use fabro_sandbox::SandboxInventory;
use fabro_sandbox::driver::{ConnectedProvider, ProviderConnectOptions};
use fabro_sandbox::test_support::{managed_scripted_sandbox, scripted_inventory_provider};
use fabro_types::SandboxProviderKind;
use fabro_types::settings::server::{SandboxPluginSettings, ServerSandboxProviderSettings};
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::test_support::{TestAppStateBuilder, build_test_router};
fn app_with_registry(registry: SandboxProviderRegistry) -> axum::Router {
fn app_with_inventory(inventory: SandboxInventory) -> axum::Router {
let state = TestAppStateBuilder::new()
.sandbox_provider_registry(registry)
.sandbox_inventory(inventory)
.build();
build_test_router(state)
}
/// A connected provider of `kind` holding fabro-managed sandboxes `ids`.
fn provider(kind: SandboxProviderKind, ids: &[&str]) -> ConnectedProvider {
scripted_inventory_provider(
kind,
ids.iter().map(|id| managed_scripted_sandbox(id)).collect(),
)
}
/// A plugin kind whose executable does not exist, so every lookup fails
/// to connect.
fn with_unreachable_plugin(inventory: SandboxInventory, name: &str) -> SandboxInventory {
let settings = ServerSandboxProviderSettings {
enabled: true,
plugin: Some(SandboxPluginSettings {
path: Some(format!("/nonexistent/fabro-sandbox-{name}")),
dev: true,
..SandboxPluginSettings::default()
}),
};
inventory.with_lazy(
SandboxProviderKind::try_new(name).expect("valid kind"),
settings,
ProviderConnectOptions::default(),
)
}
fn req_get(uri: &str) -> Request<Body> {
Request::builder()
.method("GET")
@ -113,37 +139,28 @@ mod tests {
#[tokio::test]
async fn list_returns_provider_backed_data_without_run_projection_state() {
let docker = fake_sandbox_info(SandboxProviderKind::Docker, "docker-native-id");
let app = app_with_registry(fake_registry(vec![FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(vec![docker]),
FakeGet::Missing,
)]));
let app = app_with_inventory(
SandboxInventory::empty()
.with_connected(provider(SandboxProviderKind::DOCKER, &["docker-native-id"])),
);
let response = app.oneshot(req_get("/api/v1/sandboxes")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["data"][0]["id"], "docker-native-id");
assert_eq!(body["data"][0]["status"]["id"], "docker-native-id");
assert_eq!(body["data"][0]["provider"], "docker");
assert_eq!(body["data"][0]["status"]["state"], "running");
assert_eq!(body["meta"]["provider_errors"], json!([]));
}
#[tokio::test]
async fn retrieve_searches_all_configured_providers() {
let daytona = fake_sandbox_info(SandboxProviderKind::Daytona, "native-id");
let app = app_with_registry(fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(daytona)),
),
]));
let app = app_with_inventory(
SandboxInventory::empty()
.with_connected(provider(SandboxProviderKind::DOCKER, &[]))
.with_connected(provider(SandboxProviderKind::DAYTONA, &["native-id"])),
);
let response = app
.oneshot(req_get("/api/v1/sandboxes/native-id"))
@ -152,24 +169,17 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["id"], "native-id");
assert_eq!(body["status"]["id"], "native-id");
assert_eq!(body["provider"], "daytona");
}
#[tokio::test]
async fn no_matching_sandbox_returns_404() {
let app = app_with_registry(fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
]));
let app = app_with_inventory(
SandboxInventory::empty()
.with_connected(provider(SandboxProviderKind::DOCKER, &[]))
.with_connected(provider(SandboxProviderKind::DAYTONA, &[])),
);
let response = app
.oneshot(req_get("/api/v1/sandboxes/missing"))
@ -181,24 +191,11 @@ mod tests {
#[tokio::test]
async fn duplicate_native_ids_return_409() {
let app = app_with_registry(fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(fake_sandbox_info(
SandboxProviderKind::Docker,
"same-id",
))),
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Found(Box::new(fake_sandbox_info(
SandboxProviderKind::Daytona,
"same-id",
))),
),
]));
let app = app_with_inventory(
SandboxInventory::empty()
.with_connected(provider(SandboxProviderKind::DOCKER, &["same-id"]))
.with_connected(provider(SandboxProviderKind::DAYTONA, &["same-id"])),
);
let response = app
.oneshot(req_get("/api/v1/sandboxes/same-id"))
@ -217,18 +214,10 @@ mod tests {
#[tokio::test]
async fn provider_lookup_uncertainty_returns_502() {
let app = app_with_registry(fake_registry(vec![
FakeSandboxProvider::new(
SandboxProviderKind::Docker,
FakeList::Ok(Vec::new()),
FakeGet::Missing,
),
FakeSandboxProvider::new(
SandboxProviderKind::Daytona,
FakeList::Ok(Vec::new()),
FakeGet::Err("daytona unavailable"),
),
]));
let app = app_with_inventory(with_unreachable_plugin(
SandboxInventory::empty().with_connected(provider(SandboxProviderKind::DOCKER, &[])),
"e2b",
));
let response = app
.oneshot(req_get("/api/v1/sandboxes/maybe-missing"))
@ -241,7 +230,7 @@ mod tests {
body["errors"][0]["detail"]
.as_str()
.unwrap_or_default()
.contains("daytona unavailable")
.contains("e2b: Failed to connect to the e2b provider")
);
}
}

View file

@ -23,7 +23,6 @@ use fabro_api::types::{
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{FabroClient, ModelSelectionError, catalog, selection};
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_static::EnvVars;
use fabro_store::{
EventPayload, ProjectedRunSession, RunDatabase, project_run_session, project_run_sessions,
};
@ -718,18 +717,18 @@ async fn build_agent_session(
let sandbox_instance = sandbox_record.instance().ok_or_else(|| {
AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!("run sandbox was not created"))
})?;
let daytona_api_key = state
.vault_secret(EnvVars::DAYTONA_API_KEY)
let access = state
.provider_access()
.await
.map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))?;
let sandbox = reconnect_for_run(sandbox_instance, daytona_api_key, Some(run_id))
let sandbox = reconnect_for_run(sandbox_instance, &access, Some(run_id), None)
.await
.map_err(AskFabroBuildError::SandboxUnavailable)?;
sandbox
.activate()
.await
.map_err(|err| AskFabroBuildError::SandboxUnavailable(anyhow::Error::new(err)))?;
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::from(sandbox);
let sandbox = Arc::new(sandbox);
// No optional web-tool dependencies: `AskFabroToolAccessPolicy` denies
// `web_search` and `web_fetch`, and both `tools()` and the prompt are
// filtered through that policy.
@ -976,7 +975,7 @@ fn render_ask_fabro_tool_guidance(
}
fn build_ask_fabro_system_prompt(
env: &dyn fabro_agent::Sandbox,
env: &fabro_agent::RunSandbox,
env_context: &fabro_agent::EnvContext,
_memory: &[String],
user_instructions: Option<&str>,
@ -1143,7 +1142,7 @@ impl AgentProfile for AskFabroProfile {
fn build_system_prompt(
&self,
env: &dyn fabro_agent::Sandbox,
env: &fabro_agent::RunSandbox,
env_context: &fabro_agent::EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -1820,13 +1819,15 @@ enabled = true
]);
}
#[test]
fn ask_fabro_prompt_lists_effective_tools_without_denied_tools() {
#[tokio::test]
async fn ask_fabro_prompt_lists_effective_tools_without_denied_tools() {
let registry = ask_fabro_test_registry();
let policy = build_ask_fabro_tool_access_policy();
let prompt = build_ask_fabro_system_prompt(
&fabro_agent::LocalSandbox::new(std::env::current_dir().unwrap()),
&fabro_agent::local_sandbox(std::env::current_dir().unwrap())
.await
.unwrap(),
&fabro_agent::EnvContext::default(),
&[],
None,
@ -1870,8 +1871,8 @@ enabled = true
assert!(prompt.contains("Use workspace file tools only when the question asks"));
}
#[test]
fn ask_fabro_prompt_keeps_tool_descriptions_inert() {
#[tokio::test]
async fn ask_fabro_prompt_keeps_tool_descriptions_inert() {
let mut registry = ToolRegistry::new();
let mut tool = stub_tool("read_file");
tool.definition.description = "{{ inputs.env_block }}".to_string();
@ -1879,7 +1880,9 @@ enabled = true
let policy = build_ask_fabro_tool_access_policy();
let prompt = build_ask_fabro_system_prompt(
&fabro_agent::LocalSandbox::new(std::env::current_dir().unwrap()),
&fabro_agent::local_sandbox(std::env::current_dir().unwrap())
.await
.unwrap(),
&fabro_agent::EnvContext::default(),
&[],
None,
@ -2026,9 +2029,11 @@ enabled = true
tool_exposure_mode: ToolExposureMode::AutoApprovedOnly,
..SessionOptions::default()
};
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(fabro_agent::LocalSandbox::new(
std::env::current_dir().unwrap(),
));
let sandbox = Arc::new(
fabro_agent::local_sandbox(std::env::current_dir().unwrap())
.await
.unwrap(),
);
for tool_name in denied_tools {
let result = fabro_agent::tool_execution::execute_and_emit_one_tool(

View file

@ -22,7 +22,7 @@ use fabro_interview::{
};
use fabro_llm::lithos_catalog::Catalog;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::{ApprovalMode, EnvironmentProvider};
use fabro_types::settings::run::ApprovalMode;
use fabro_types::{
AgentBackend, AttrValue, AuthMethod, BlobHash, CommandTermination, FailureCategory,
FailureDetail, GitRunTarget, Graph, InterviewQuestionRecord, ModelRef, Node, Outcome,
@ -90,7 +90,7 @@ fn manifest_run_defaults_from_toml(source: &str) -> fabro_config::RunLayer {
}
fn test_environment_store(
default_provider: Option<EnvironmentProvider>,
default_provider: Option<SandboxProviderKind>,
local_enabled: bool,
) -> (tempfile::TempDir, EnvironmentStore) {
let temp = tempfile::tempdir().expect("environment store tempdir should be created");
@ -1349,7 +1349,7 @@ id = "missing"
#[test]
fn system_sandbox_provider_uses_manifest_defaults() {
let (_environment_temp, environment_store) =
test_environment_store(Some(EnvironmentProvider::Daytona), true);
test_environment_store(Some(SandboxProviderKind::DAYTONA), true);
let (_mcp_temp, mcp_server_store) = test_mcp_server_store();
let source = r#"
_version = 1
@ -1403,8 +1403,11 @@ enabled = false
);
assert_eq!(
crate::run_manifest::sandbox_provider_policy_error(&settings, SandboxProviderKind::Daytona)
.as_deref(),
crate::run_manifest::sandbox_provider_policy_error(
&settings,
&SandboxProviderKind::DAYTONA
)
.as_deref(),
Some(
"sandbox provider \"daytona\" is disabled by server.sandbox.providers.daytona.enabled"
)
@ -1413,10 +1416,10 @@ enabled = false
#[test]
fn clone_sandbox_credentials_are_available_for_clone_based_providers() {
use fabro_types::settings::run::EnvironmentProvider;
assert!(EnvironmentProvider::Docker.is_clone_based());
assert!(EnvironmentProvider::Daytona.is_clone_based());
assert!(!EnvironmentProvider::Local.is_clone_based());
use fabro_types::SandboxProviderKind;
assert!(SandboxProviderKind::DOCKER.clones_workspace());
assert!(SandboxProviderKind::DAYTONA.clones_workspace());
assert!(!SandboxProviderKind::LOCAL.clones_workspace());
}
#[tokio::test]
@ -1677,9 +1680,8 @@ async fn create_secret_rejects_under_scoped_daytona_api_key_and_leaves_vault_unc
assert_eq!(
body["errors"][0]["detail"],
"API key 'delete-only' is missing required Daytona scopes: \
write:snapshots, write:sandboxes. Regenerate the key with all \
snapshot and sandbox scopes."
"Daytona API key is missing required scopes: write:snapshots, write:sandboxes. \
Regenerate the key with all snapshot and sandbox scopes."
);
assert_eq!(
state
@ -2105,7 +2107,7 @@ fn slack_app_state_with_settings_and_secret_sources(
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
sandbox_inventory: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
worker_runtime: None,
@ -2266,7 +2268,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() {
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
sandbox_inventory: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
worker_runtime: None,
@ -2620,7 +2622,7 @@ methods = ["dev-token"]
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
sandbox_inventory: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
worker_runtime: None,
@ -3542,7 +3544,7 @@ async fn post_run_intent_response(app: &Router, intent: serde_json::Value) -> Re
/// the only placement folder targets admit.
fn local_test_app_state() -> Arc<AppState> {
TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Local))
.default_environment_provider(Some(SandboxProviderKind::LOCAL))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build()
}
@ -3741,7 +3743,7 @@ docker = "workflow-owned:latest"
);
assert_eq!(
projection.spec.settings.run.environment.provider,
EnvironmentProvider::Docker
SandboxProviderKind::DOCKER
);
assert_eq!(
projection
@ -3827,7 +3829,7 @@ async fn post_runs_run_intent_args_true_override_resolved_settings_without_start
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Local))
.default_environment_provider(Some(SandboxProviderKind::LOCAL))
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
@ -3887,7 +3889,7 @@ async fn post_runs_run_intent_dry_run_uses_configured_target_provider() {
),
(
TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.default_environment_provider(Some(SandboxProviderKind::DAYTONA))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build(),
Some("_version = 1\n[run.execution]\nmode = \"dry_run\"\n"),
@ -3896,7 +3898,7 @@ async fn post_runs_run_intent_dry_run_uses_configured_target_provider() {
),
(
TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.default_environment_provider(Some(SandboxProviderKind::DAYTONA))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build(),
Some("_version = 1\n[run.execution]\nmode = \"dry_run\"\n"),
@ -3913,7 +3915,7 @@ async fn post_runs_run_intent_dry_run_uses_configured_target_provider() {
default_test_server_settings(),
manifest_run_defaults_from_toml("[run.execution]\nmode = \"dry_run\"\n"),
)
.default_environment_provider(Some(EnvironmentProvider::Local))
.default_environment_provider(Some(SandboxProviderKind::LOCAL))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build(),
None,
@ -3971,7 +3973,7 @@ async fn post_runs_run_intent_dry_run_rejects_configured_target_mismatches() {
),
(
TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.default_environment_provider(Some(SandboxProviderKind::DAYTONA))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build(),
json!({ "kind": "folder", "path": "/path-that-must-not-be-read" }),
@ -4106,7 +4108,7 @@ preserve = true
"#,
),
)
.default_environment_provider(Some(EnvironmentProvider::Local))
.default_environment_provider(Some(SandboxProviderKind::LOCAL))
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
@ -4244,7 +4246,7 @@ async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target()
);
assert_eq!(
projection.spec.settings.run.environment.provider,
EnvironmentProvider::Local
SandboxProviderKind::LOCAL
);
assert_eq!(projection.spec.manifest_blob, None);
assert!(projection.spec.definition_blob.is_some());
@ -4339,7 +4341,7 @@ async fn post_runs_run_intent_accepts_automatic_pull_requests_for_configured_doc
assert_eq!(
projection.spec.settings.run.environment.provider,
EnvironmentProvider::Docker
SandboxProviderKind::DOCKER
);
assert_eq!(projection.spec.settings.run.execution.mode, RunMode::DryRun);
assert!(projection.spec.settings.run.pull_request.is_some());
@ -4436,7 +4438,7 @@ async fn post_runs_run_intent_applies_the_folder_target_environment_matrix() {
for state in [
test_app_state(),
TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.default_environment_provider(Some(SandboxProviderKind::DAYTONA))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build(),
] {
@ -4472,7 +4474,7 @@ enabled = false
),
RunLayer::default(),
)
.default_environment_provider(Some(EnvironmentProvider::Local))
.default_environment_provider(Some(SandboxProviderKind::LOCAL))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let app = crate::test_support::build_test_router(Arc::clone(&disabled_state));
@ -4495,7 +4497,7 @@ enabled = false
#[tokio::test]
async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment() {
let state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.default_environment_provider(Some(SandboxProviderKind::DAYTONA))
.vault_entries([
(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key"),
(
@ -4532,7 +4534,7 @@ async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment
);
assert_eq!(
projection.spec.settings.run.environment.provider,
EnvironmentProvider::Daytona
SandboxProviderKind::DAYTONA
);
assert_eq!(projection.spec.source_directory, None);
assert_eq!(projection.spec.git, None);
@ -4799,7 +4801,7 @@ enabled = false
assert_run_intent_targets_unavailable(&disabled_state).await;
let daytona_state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.default_environment_provider(Some(SandboxProviderKind::DAYTONA))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
assert_run_intent_targets_unavailable(&daytona_state).await;
@ -8438,7 +8440,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
github_api_base_url,
active_config_path,
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_provider_registry: None,
sandbox_inventory: None,
shutdown: tokio_util::sync::CancellationToken::new(),
worker_control_bus: None,
worker_runtime: None,
@ -15860,7 +15862,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
definition_blob: None,
},
workflow_event::Event::SandboxInitialized {
provider: SandboxProviderKind::Local,
provider: SandboxProviderKind::LOCAL,
id: "sandbox-preserve-1".to_string(),
working_directory: "/tmp/fabro-preserved-sandbox".to_string(),
image: None,
@ -16614,7 +16616,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::SandboxInitialized {
provider: SandboxProviderKind::Docker,
provider: SandboxProviderKind::DOCKER,
id: "missing-sandbox".to_string(),
working_directory: "/tmp/fabro-missing-sandbox".to_string(),
image: None,
@ -19336,7 +19338,7 @@ async fn list_runs_includes_live_metadata_from_run_state() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::SandboxInitialized {
provider: SandboxProviderKind::Local,
provider: SandboxProviderKind::LOCAL,
id: "sb-test".to_string(),
working_directory: "/sandbox/workdir".to_string(),
image: None,
@ -19423,7 +19425,7 @@ async fn list_runs_page_limit_preserves_metadata_for_paged_items() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::SandboxInitialized {
provider: SandboxProviderKind::Local,
provider: SandboxProviderKind::LOCAL,
id: sandbox_id.to_string(),
working_directory: "/sandbox/workdir".to_string(),
image: None,

View file

@ -19,12 +19,11 @@ use fabro_config::{LlmLayer, RunLayer, ServerSettingsBuilder, Storage, envfile};
use fabro_db::DbPool;
use fabro_interview::Interviewer;
use fabro_llm::lithos_catalog::Catalog;
use fabro_sandbox::SandboxProviderRegistry;
use fabro_sandbox::SandboxInventory;
use fabro_static::EnvVars;
use fabro_store::{ArtifactStore, Database, test_support as store_test_support};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{AuthMethod, IdpIdentity, ServerSettings};
use fabro_types::{AuthMethod, IdpIdentity, SandboxProviderKind, ServerSettings};
use fabro_vault::{SecretType, Vault};
use fabro_workflow::handler::HandlerRegistry;
use lithos_llm::catalog::ProviderId;
@ -91,14 +90,14 @@ pub struct TestAppStateBuilder {
manifest_run_defaults: RunLayer,
max_concurrent_runs: usize,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
sandbox_provider_registry: Option<SandboxProviderRegistry>,
sandbox_inventory: Option<SandboxInventory>,
store_bundle: Option<(Arc<Database>, ArtifactStore)>,
vault_path: Option<PathBuf>,
vault_entries: Vec<(String, String)>,
server_env_path: Option<PathBuf>,
active_config_path: Option<PathBuf>,
server_secret_env: HashMap<String, String>,
default_environment_provider: Option<EnvironmentProvider>,
default_environment_provider: Option<SandboxProviderKind>,
env_lookup: EnvLookup,
llm_overlay: LlmLayer,
automation_materializer: Option<TestAutomationRunMaterializer>,
@ -113,14 +112,14 @@ impl Default for TestAppStateBuilder {
manifest_run_defaults: RunLayer::default(),
max_concurrent_runs: 5,
registry_factory_override: None,
sandbox_provider_registry: None,
sandbox_inventory: None,
store_bundle: None,
vault_path: None,
vault_entries: Vec::new(),
server_env_path: None,
active_config_path: None,
server_secret_env: HashMap::new(),
default_environment_provider: Some(EnvironmentProvider::Docker),
default_environment_provider: Some(SandboxProviderKind::DOCKER),
env_lookup: default_env_lookup(),
llm_overlay: LlmLayer::default(),
automation_materializer: None,
@ -161,11 +160,8 @@ impl TestAppStateBuilder {
self
}
pub fn sandbox_provider_registry(
mut self,
sandbox_provider_registry: SandboxProviderRegistry,
) -> Self {
self.sandbox_provider_registry = Some(sandbox_provider_registry);
pub fn sandbox_inventory(mut self, sandbox_inventory: SandboxInventory) -> Self {
self.sandbox_inventory = Some(sandbox_inventory);
self
}
@ -220,7 +216,7 @@ impl TestAppStateBuilder {
self
}
pub fn default_environment_provider(mut self, provider: Option<EnvironmentProvider>) -> Self {
pub fn default_environment_provider(mut self, provider: Option<SandboxProviderKind>) -> Self {
self.default_environment_provider = provider;
self
}
@ -313,7 +309,7 @@ impl TestAppStateBuilder {
http_client: Some(
fabro_http::test_http_client().expect("test HTTP client should build"),
),
sandbox_provider_registry: self.sandbox_provider_registry,
sandbox_inventory: self.sandbox_inventory,
shutdown: CancellationToken::new(),
#[cfg(test)]
worker_control_bus: None,
@ -592,13 +588,13 @@ pub fn test_store_bundle() -> (Arc<Database>, ArtifactStore) {
pub(crate) fn test_db_pool_for_vault_path(vault_path: &Path) -> anyhow::Result<DbPool> {
test_db_pool_for_vault_path_with_default_environment(
vault_path,
Some(EnvironmentProvider::Docker),
Some(SandboxProviderKind::DOCKER),
)
}
pub(crate) fn test_db_pool_for_vault_path_with_default_environment(
vault_path: &Path,
default_environment_provider: Option<EnvironmentProvider>,
default_environment_provider: Option<SandboxProviderKind>,
) -> anyhow::Result<DbPool> {
test_db_pool(
sqlite_path_for_vault_path(vault_path),
@ -632,7 +628,7 @@ pub async fn test_environment_from_storage_dir(
fn test_db_pool(
path: PathBuf,
vault_path: PathBuf,
default_environment_provider: Option<EnvironmentProvider>,
default_environment_provider: Option<SandboxProviderKind>,
) -> anyhow::Result<DbPool> {
std::thread::spawn(move || {
let runtime = TokioRuntimeBuilder::new_current_thread()

View file

@ -17,6 +17,7 @@ use fabro_server::install::{
InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router,
};
use fabro_server::test_support::test_environment_from_storage_dir;
use fabro_types::SandboxProviderKind;
use fabro_util::Home;
use fabro_vault::Vault;
use httpmock::Method::GET;
@ -58,9 +59,18 @@ fn assert_sandbox_provider_policy(
.server
.sandbox
.providers;
assert_eq!(resolved.local.enabled, local_enabled);
assert_eq!(resolved.docker.enabled, docker_enabled);
assert_eq!(resolved.daytona.enabled, daytona_enabled);
assert_eq!(
resolved.is_enabled(&SandboxProviderKind::LOCAL),
local_enabled
);
assert_eq!(
resolved.is_enabled(&SandboxProviderKind::DOCKER),
docker_enabled
);
assert_eq!(
resolved.is_enabled(&SandboxProviderKind::DAYTONA),
daytona_enabled
);
}
async fn seeded_default_environment(
@ -2783,9 +2793,8 @@ async fn sandbox_daytona_test_endpoint_rejects_under_scoped_api_key() {
assert_eq!(
body["errors"][0]["detail"],
"API key 'delete-only' is missing required Daytona scopes: \
write:snapshots, write:sandboxes. Regenerate the key with all \
snapshot and sandbox scopes."
"Daytona API key is missing required scopes: write:snapshots, write:sandboxes. \
Regenerate the key with all snapshot and sandbox scopes."
);
auth.assert_async().await;
current_key.assert_async().await;

View file

@ -136,7 +136,7 @@ async fn append_local_sandbox_initialized(store: &Database, run_id: &RunId) {
.expect("test should run inside a source checkout")
.display()
.to_string(),
provider: SandboxProviderKind::Local,
provider: SandboxProviderKind::LOCAL,
id: "local:test-sandbox".to_string(),
image: None,
snapshot: None,

View file

@ -1,7 +1,7 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_llm::lithos_catalog::CatalogProvider;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::SandboxProviderKind;
use tower::ServiceExt;
use crate::helpers::{
@ -78,7 +78,7 @@ fn daytona_disabled_app() -> (axum::Router, tempfile::TempDir) {
let state = fabro_server::test_support::TestAppStateBuilder::new()
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
.active_config_path(active_config_path)
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.default_environment_provider(Some(SandboxProviderKind::DAYTONA))
.build();
(
fabro_server::test_support::build_test_router(state),

View file

@ -9,7 +9,7 @@ use agent_client_protocol::schema::{
};
use agent_client_protocol::util::MatchDispatch;
use agent_client_protocol::{ActiveSession, Agent, Client, Error as ProtocolError, SessionMessage};
use fabro_sandbox::Sandbox;
use fabro_sandbox::RunSandbox;
use fabro_types::{Principal, SteeringMessage};
use fabro_util::time::elapsed_ms;
use tokio::sync::Notify;
@ -164,7 +164,7 @@ pub struct AcpRunRequest {
pub cwd: String,
pub timeout_ms: Option<u64>,
pub env: HashMap<String, String>,
pub sandbox: Arc<dyn Sandbox>,
pub sandbox: Arc<RunSandbox>,
pub cancel_token: CancellationToken,
pub on_activity: Option<Arc<dyn Fn() + Send + Sync>>,
pub live_control: Option<AcpLiveControl>,

View file

@ -9,10 +9,10 @@ use agent_client_protocol::{
Agent, Client, ConnectTo, Error as ProtocolError, Lines, Result as AcpProtocolResult,
};
use fabro_sandbox::{
DEFAULT_EXEC_OUTPUT_TAIL_BYTES, Error as SandboxError, Result as SandboxResult, Sandbox,
StderrCollector, StdioProcessHandle, StdioProcessTermination,
DEFAULT_EXEC_OUTPUT_TAIL_BYTES, Error as SandboxError, Result as SandboxResult, RunSandbox,
StderrTail, StdioProcessHandle, Termination, command_termination, program_exit_code,
};
use fabro_types::{CommandTermination, ExecOutputTail};
use fabro_types::ExecOutputTail;
use futures::io::BufReader;
use futures::sink::unfold;
use futures::{AsyncBufReadExt, AsyncWriteExt, Stream};
@ -27,8 +27,8 @@ const CLEAN_EXIT_PROTOCOL_GRACE: Duration = Duration::from_millis(500);
#[derive(Clone)]
pub(crate) struct TransportState {
handle: Arc<TokioMutex<Option<StdioProcessHandle>>>,
stderr: Arc<TokioMutex<Option<StderrCollector>>>,
handle: Arc<TokioMutex<Option<Arc<dyn StdioProcessHandle>>>>,
stderr: Arc<TokioMutex<Option<StderrTail>>>,
startup_error: Arc<TokioMutex<Option<SandboxError>>>,
process_exit: Arc<TokioMutex<Option<AcpProcessExit>>>,
}
@ -43,7 +43,7 @@ impl TransportState {
}
}
async fn set_process(&self, handle: StdioProcessHandle, stderr: StderrCollector) {
async fn set_process(&self, handle: Arc<dyn StdioProcessHandle>, stderr: StderrTail) {
*self.handle.lock().await = Some(handle);
*self.stderr.lock().await = Some(stderr);
}
@ -52,10 +52,15 @@ impl TransportState {
*self.startup_error.lock().await = Some(error);
}
async fn set_process_exit(&self, termination: StdioProcessTermination, stderr: &str) {
async fn set_process_exit(
&self,
termination: Termination,
exit_code: Option<i32>,
stderr: &str,
) {
*self.process_exit.lock().await = Some(AcpProcessExit {
termination: termination.termination,
exit_code: termination.exit_code,
termination: command_termination(termination),
exit_code: program_exit_code(termination, exit_code),
exec_output_tail: redacted_stderr_tail(stderr),
});
}
@ -70,14 +75,14 @@ impl TransportState {
pub(crate) async fn terminate(&self) -> SandboxResult<()> {
if let Some(handle) = self.handle.lock().await.as_ref().cloned() {
handle.terminate().await?;
handle.terminate().await;
}
Ok(())
}
pub(crate) async fn stderr_tail(&self) -> String {
if let Some(stderr) = self.stderr.lock().await.as_ref().cloned() {
return stderr.tail_string().await;
return stderr.to_string_lossy();
}
String::new()
}
@ -92,7 +97,7 @@ pub(crate) struct SandboxAcpTransport {
command: AcpProcessSpec,
cwd: String,
env: HashMap<String, String>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
state: TransportState,
}
@ -101,7 +106,7 @@ impl SandboxAcpTransport {
command: AcpProcessSpec,
cwd: String,
env: HashMap<String, String>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
state: TransportState,
) -> Self {
Self {
@ -125,7 +130,6 @@ impl ConnectTo<Client> for SandboxAcpTransport {
&self.command.to_shell_command(),
Some(&self.cwd),
Some(&env),
None,
)
.await
{
@ -136,9 +140,11 @@ impl ConnectTo<Client> for SandboxAcpTransport {
}
};
let handle = process.handle.clone();
let stderr = process.stderr.clone();
self.state.set_process(handle.clone(), stderr.clone()).await;
let handle: Arc<dyn StdioProcessHandle> = Arc::from(process.handle);
let stderr = process.stderr_tail.clone();
self.state
.set_process(Arc::clone(&handle), stderr.clone())
.await;
let incoming_lines = Box::pin(BufReader::new(process.stdout.compat()).lines())
as Pin<Box<dyn Stream<Item = IoResult<String>> + Send>>;
@ -158,25 +164,20 @@ impl ConnectTo<Client> for SandboxAcpTransport {
));
tokio::select! {
result = &mut protocol => {
if let Err(err) = handle.terminate().await {
tracing::warn!(error = %err, "Failed to terminate ACP process after protocol completion");
}
handle.terminate().await;
let _ = timeout(Duration::from_millis(500), handle.wait()).await;
result
}
termination = handle.wait() => {
let termination = termination.map_err(ProtocolError::into_internal_error)?;
let stderr = stderr.tail_string().await;
if termination.termination == CommandTermination::Exited
&& termination.exit_code == Some(0)
{
(termination, exit_code) = handle.wait() => {
let stderr = stderr.to_string_lossy();
if termination == Termination::Exited && exit_code == Some(0) {
// Stdio agents commonly exit immediately after writing their final response.
// Process wait can observe that exit before the line reader drains stdout.
if let Ok(result) = timeout(CLEAN_EXIT_PROTOCOL_GRACE, &mut protocol).await {
return result;
}
}
self.state.set_process_exit(termination, &stderr).await;
self.state.set_process_exit(termination, exit_code, &stderr).await;
Err(process_exited_before_protocol_completed())
}
}

View file

@ -10,9 +10,10 @@ use fabro_acp::{
run_acp_turn,
};
use fabro_sandbox::test_support::{MockSandbox, MockStdioProcess};
use fabro_sandbox::{LocalSandbox, Sandbox, shell_quote};
use fabro_sandbox::{RunSandbox, local_sandbox};
use fabro_types::SteeringMessage;
use fabro_util::error::collect_chain;
use fabro_util::shell;
use tokio::fs::{read_to_string, write};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
use tokio::process::Command;
@ -39,7 +40,7 @@ async fn stdio_spawn_failure_returns_sandbox_error() {
let command = AcpProcessSpec::from_command_attr("fake-acp-agent").expect("parse ACP command");
let mut sandbox = MockSandbox::linux();
sandbox.stdio_process_error = Some(SANDBOX_FAILURE.to_string());
let sandbox: Arc<dyn Sandbox> = Arc::new(sandbox);
let sandbox = sandbox.sandbox();
let result = run_acp_turn(AcpRunRequest {
command,
@ -70,9 +71,11 @@ async fn stdio_spawn_failure_returns_sandbox_error() {
#[tokio::test]
async fn clean_stdio_exit_after_final_response_completes_turn() {
let sandbox = MockSandbox::linux();
sandbox.set_stdio_process(mock_acp_stdio_process("end_turn"));
let sandbox: Arc<dyn Sandbox> = Arc::new(sandbox);
let sandbox = MockSandbox {
stdio_process: Some(mock_acp_stdio_process("end_turn")),
..MockSandbox::linux()
}
.sandbox();
let command = AcpProcessSpec::from_command_attr("mock-acp-agent").expect("parse ACP command");
let result = run_acp_turn(AcpRunRequest {
@ -102,9 +105,16 @@ async fn session_lifecycle_initializes_sends_prompt_and_aggregates_text() {
.await
.expect("write fake ACP agent");
let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy()));
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
);
let result = run_acp_turn(AcpRunRequest {
command,
@ -143,9 +153,16 @@ async fn steering_sends_followup_session_prompt_over_acp() {
.await
.expect("write fake ACP agent");
let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy()));
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
);
let control_handle = AcpControlHandle::new();
let handle_for_activity = control_handle.clone();
let queued = Arc::new(AtomicBool::new(false));
@ -206,9 +223,16 @@ async fn interrupt_then_steer_sends_cancel_then_followup_session_prompt_over_acp
.await
.expect("write fake ACP agent");
let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy()));
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
);
let control_handle = AcpControlHandle::new();
let handle_for_activity = control_handle.clone();
let queued = Arc::new(AtomicBool::new(false));
@ -281,9 +305,16 @@ async fn inline_interrupt_terminates_agent_that_ignores_cancel() {
.await
.expect("write fake ACP agent");
let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy()));
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
);
let control_handle = AcpControlHandle::new();
let handle_for_activity = control_handle.clone();
let interrupted = Arc::new(AtomicBool::new(false));
@ -668,9 +699,16 @@ async fn run_fake_agent_with_activity(
write(&script_path, fake_acp_agent_script())
.await
.expect("write fake ACP agent");
let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy()));
let raw_command = format!(
"python3 {}",
shell::shell_quote(&script_path.to_string_lossy())
);
let command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command");
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.to_path_buf()));
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.to_path_buf())
.await
.expect("local sandbox should be created"),
);
env.entry("LC_ALL".to_string())
.or_insert_with(|| "C".to_string());

View file

@ -11,8 +11,6 @@ keywords = ["llm", "ai", "agent", "coding"]
categories = ["api-bindings"]
[features]
default = ["docker"]
docker = ["fabro-sandbox/docker"]
quarantine = []
[lib]
@ -31,6 +29,7 @@ lithos-llm = { workspace = true, features = ["runtime"] }
fabro-llm = { path = "../fabro-llm" }
fabro-mcp = { path = "../fabro-mcp" }
fabro-sandbox = { path = "../fabro-sandbox" }
sandbox-driver.workspace = true
fabro-static.workspace = true
fabro-template = { path = "../../foundation/fabro-template" }
fabro-util = { path = "../../foundation/fabro-util" }
@ -67,6 +66,7 @@ tempfile = "3"
paste = "1"
shlex = "1"
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
sandbox-driver-testing.workspace = true
fabro-macros = { path = "../../foundation/fabro-macros" }
httpmock = "0.8"
fabro-test = { workspace = true }

View file

@ -9,7 +9,7 @@ The crate is organized around a central `Session` that drives an agentic loop:
1. **User input** is appended to a conversation `History`
2. The session builds a `Request` with system prompt, history, and tools
3. An LLM generates a response (text and/or tool calls) via `unified-llm`
4. Tool calls are executed through a `ToolRegistry` against a `Sandbox`
4. Tool calls are executed through a `ToolRegistry` against a `RunSandbox`
5. Results are recorded and the loop continues until the LLM responds with text only (natural completion), a turn limit is reached, or the session is interrupted
```
@ -41,7 +41,7 @@ User Input
- **`Session`** -- Manages the full agentic loop: LLM calls, tool execution, steering, follow-ups, interrupt handling, and event emission.
- **`AgentProfile`** (trait) -- Defines how to build system prompts, which tools to register, and what capabilities a provider supports. Ships with `AnthropicProfile`, `OpenAiProfile`, and `GeminiProfile`.
- **`Sandbox`** (trait) -- Abstracts filesystem, shell, grep, and glob operations. `LocalSandbox` provides a real implementation; the trait enables sandboxing and testing.
- **`RunSandbox`** -- Filesystem, shell, grep, and glob operations over a sandbox-driver sandbox: the local filesystem through `local_sandbox`, or a Docker or Daytona provider through `provider_sandbox`. Tests script one with `fabro_sandbox::test_support::MockSandbox`.
- **`ToolRegistry`** -- Maps tool names to definitions and async executor functions. Tools are registered per-profile.
- **`History`** -- Ordered list of `Turn` variants (`User`, `Assistant`, `ToolResults`, `System`, `Steering`) that converts to LLM messages.
- **`Emitter`** -- Broadcasts `SessionEvent`s (tool calls, text, errors, warnings) over a `tokio::sync::broadcast` channel for UI or logging.
@ -63,7 +63,7 @@ pub trait AgentProfile: Send + Sync {
fn tool_registry(&self) -> &ToolRegistry;
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
project_docs: &[String],
user_instructions: Option<&str>,
@ -81,23 +81,23 @@ All profiles include the common file, shell, search, and `web_fetch` tools.
`web_search` is included only when a Brave Search API key is supplied while
building the profile.
### `Sandbox`
### `RunSandbox`
```rust
pub trait Sandbox: Send + Sync {
async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>, String>;
async fn read_file_text(&self, path: &str) -> Result<String, String>;
async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String, String>; // line-numbered display
async fn write_file(&self, path: &str, content: &str) -> Result<(), String>;
async fn exec_command(&self, command: &str, timeout_ms: u64, ...) -> Result<ExecResult, String>;
async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result<Vec<String>, String>;
async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result<Vec<SandboxFile>, String>;
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String>;
impl RunSandbox {
pub async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>>;
pub async fn read_file_text(&self, path: &str) -> Result<String>;
pub async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String>; // line-numbered display
pub async fn write_file(&self, path: &str, content: &str) -> Result<()>;
pub async fn exec_command(&self, command: &str, timeout_ms: u64, ...) -> Result<ExecResult>;
pub async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result<Vec<GrepMatch>>;
pub async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result<Vec<SandboxFile>>;
pub async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>>;
// ... plus delete_file, file_exists, list_directory, initialize, cleanup, platform info
}
```
`LocalSandbox` is the real implementation with env-var filtering (strips secrets), process group management, and ripgrep/grep fallback.
`RunSandbox` is one concrete type over a [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) sandbox. Paths resolve against the run's working directory; commands run as Bash under fabro's timeout and stop policy, with credential-shaped variables filtered when the sandbox is the worker host itself.
### `SessionConfig`
@ -118,7 +118,7 @@ pub struct SessionConfig {
```rust
use agent::{
AnthropicProfile, LocalSandbox, Session, SessionConfig,
AnthropicProfile, Session, SessionConfig, local_sandbox,
};
use std::path::PathBuf;
use std::sync::Arc;
@ -131,9 +131,7 @@ let client: Client = /* configure unified-llm client */;
let profile = Arc::new(AnthropicProfile::new("claude-sonnet-4-20250514"));
// 3. Create a sandbox
let env = Arc::new(LocalSandbox::new(
PathBuf::from("/path/to/project"),
));
let env = Arc::new(local_sandbox(PathBuf::from("/path/to/project")).await?);
// 4. Configure the session
let config = SessionConfig {
@ -234,6 +232,6 @@ profile.register_subagent_tools(manager, factory, 0);
- **Context window monitoring** -- Emits `Warning` events (kind `"context_window"`) when estimated usage exceeds 80%
- **Tool argument validation** -- Validates arguments against JSON Schema before execution
- **Tool output truncation** -- Per-tool character and line limits with head/tail or tail-only truncation modes
- **Environment variable filtering** -- `LocalSandbox` strips secrets (`*_API_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_CREDENTIAL`) from subprocess environments
- **Environment variable filtering** -- the local sandbox strips secrets (`*_API_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_CREDENTIAL`) from subprocess environments
- **Command timeouts** -- Configurable per-command with process group cleanup (SIGTERM then SIGKILL)
- **Project doc discovery** -- Automatically discovers `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, or `.codex/instructions.md` based on provider, with a 32KB budget

View file

@ -7,7 +7,7 @@ use lithos_llm::catalog::ProviderId;
use lithos_llm::types::ToolDefinition;
use crate::profiles::EnvContext;
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::Skill;
use crate::subagent::{
SessionFactory, SubAgentSupervisor, make_close_agent_tool, make_send_input_tool,
@ -29,7 +29,7 @@ pub trait AgentProfile: Send + Sync {
fn tool_registry_mut(&mut self) -> &mut ToolRegistry;
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -116,7 +116,7 @@ mod tests {
#[test]
fn profile_build_system_prompt() {
let profile = TestProfile::new();
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let ctx = EnvContext::default();
let docs = vec!["README.md contents".into()];
let prompt = profile.build_system_prompt(&env, &ctx, &docs, None, &[]);
@ -126,7 +126,7 @@ mod tests {
#[test]
fn profile_build_system_prompt_with_user_instructions() {
let profile = TestProfile::new();
let env = MockSandbox::default();
let env = MockSandbox::default().sandbox();
let ctx = EnvContext::default();
let prompt = profile.build_system_prompt(&env, &ctx, &[], Some("Always use TDD"), &[]);
assert!(prompt.contains("Always use TDD"));

View file

@ -7,7 +7,7 @@ use std::sync::Arc;
use lithos_llm::types::ToolDefinition;
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::tool_registry::{RegisteredTool, ToolSource};
const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("apply_patch.lark");
@ -234,7 +234,7 @@ fn check_patch_boundaries_strict(lines: &[&str]) -> Result<(), String> {
/// Returns an error if any file operation fails.
pub async fn apply_patch_operations(
ops: &[PatchOperation],
env: &dyn Sandbox,
env: &RunSandbox,
) -> Result<String, String> {
if ops.is_empty() {
return Err("No files were modified.".to_string());
@ -508,8 +508,8 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
use crate::LocalSandbox;
use crate::test_support::MutableMockSandbox;
use crate::local_sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::{ToolContext, ToolDefinitionExt};
#[test]
@ -655,7 +655,11 @@ mod tests {
"src/game.py".to_string(),
"from src.cards import Suit\nfrom src.piles import Pile\n\nclass GameState:\n stock: list = field(default_factory=list)\n waste: list = field(default_factory=list)".to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let ops = vec![PatchOperation::Update {
path: "src/game.py".into(),
@ -786,7 +790,11 @@ mod tests {
"src/lib.rs".to_string(),
"fn unchanged() {\n old_line();\n}".to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let ops = vec![PatchOperation::Update {
path: "src/lib.rs".into(),
@ -817,7 +825,11 @@ mod tests {
"src/lib.rs".to_string(),
"import foo\nimport bar\n\ndef setup():\n old_setup()\n\ndef teardown():\n old_teardown()\n".to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let ops = vec![PatchOperation::Update {
path: "src/lib.rs".into(),
@ -854,7 +866,11 @@ mod tests {
#[tokio::test]
async fn apply_patch_add_file() {
let env = MutableMockSandbox::new(HashMap::new());
let env = MockSandbox {
files: HashMap::new(),
..Default::default()
}
.sandbox();
let ops = vec![PatchOperation::Add {
path: "src/new.rs".into(),
content: "fn new() {}".into(),
@ -874,7 +890,11 @@ mod tests {
"src/lib.rs".to_string(),
"fn hello() {\n println!(\"old\");\n}".to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let ops = vec![PatchOperation::Update {
path: "src/lib.rs".into(),
@ -905,7 +925,7 @@ mod tests {
fs::write(&path, "fn hello() {\n println!(\"old\");\n}\n")
.await
.unwrap();
let env = LocalSandbox::new(dir.path().to_path_buf());
let env = local_sandbox(dir.path().to_path_buf()).await.unwrap();
let patch = "\
*** Begin Patch
*** Update File: src/lib.rs
@ -949,7 +969,11 @@ mod tests {
#[tokio::test]
async fn apply_patch_tool_executor_accepts_raw_patch_string() {
let env = Arc::new(MutableMockSandbox::new(HashMap::new()));
let env = MockSandbox {
files: HashMap::new(),
..Default::default()
}
.sandbox();
let tool = make_apply_patch_tool();
let patch = "\
*** Begin Patch
@ -981,7 +1005,11 @@ mod tests {
async fn apply_patch_add_overwrites_existing_file_with_codex_summary() {
let mut files = HashMap::new();
files.insert("duplicate.txt".to_string(), "old content\n".to_string());
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
*** Add File: duplicate.txt
@ -1017,7 +1045,11 @@ mod tests {
async fn pure_addition_update_hunk_appends_before_final_newline() {
let mut files = HashMap::new();
files.insert("insert_only.txt".to_string(), "alpha\nomega\n".to_string());
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
*** Update File: insert_only.txt
@ -1043,7 +1075,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("insert_only.txt");
fs::write(&path, "alpha\nomega\n").await.unwrap();
let env = LocalSandbox::new(dir.path().to_path_buf());
let env = local_sandbox(dir.path().to_path_buf()).await.unwrap();
let patch = "\
*** Begin Patch
*** Update File: insert_only.txt
@ -1071,7 +1103,11 @@ mod tests {
"no_newline.txt".to_string(),
"no newline at end".to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
*** Update File: no_newline.txt
@ -1110,7 +1146,11 @@ please apply this
"src/game.py".to_string(),
"def real_fn():\n pass".to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let ops = vec![PatchOperation::Update {
path: "src/game.py".into(),
@ -1134,7 +1174,11 @@ please apply this
#[tokio::test]
async fn update_missing_target_file_rejected() {
let env = MutableMockSandbox::new(HashMap::new());
let env = MockSandbox {
files: HashMap::new(),
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
*** Update File: missing.txt
@ -1151,7 +1195,11 @@ please apply this
#[tokio::test]
async fn delete_missing_target_file_rejected() {
let env = MutableMockSandbox::new(HashMap::new());
let env = MockSandbox {
files: HashMap::new(),
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
*** Delete File: missing.txt
@ -1317,7 +1365,11 @@ please apply this
"src/old.py".to_string(),
"def hello():\n pass".to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let ops = vec![PatchOperation::Update {
path: "src/old.py".into(),
@ -1457,7 +1509,11 @@ class GameState:
"
.to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
@ -1515,7 +1571,11 @@ def main():
"
.to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
@ -1570,7 +1630,11 @@ class User:
"
.to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
// Heredoc-wrapped patch with stacked @@, End of File, and Move to
let patch = "\
@ -1632,7 +1696,11 @@ def gamma():
"
.to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
@ -1666,7 +1734,11 @@ def gamma():
"src/lib.rs".to_string(),
"fn main() { \n println!(\"hello\"); \n}\n".to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
@ -1710,7 +1782,11 @@ def farewell(name):
"src/obsolete.py".to_string(),
"def old():\n pass\n".to_string(),
);
let env = Arc::new(MutableMockSandbox::new(files));
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
// Register apply_patch tool
let mut registry = ToolRegistry::new();
@ -1780,7 +1856,11 @@ def farewell(name):
"src/app.py".to_string(),
"def present():\n return 1\n".to_string(),
);
let env = Arc::new(MutableMockSandbox::new(files));
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let mut registry = ToolRegistry::new();
registry.register(make_apply_patch_tool());

View file

@ -31,8 +31,8 @@ use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::tool_permissions::{is_auto_approved, tool_category};
use crate::tools::WebFetchSummarizer;
use crate::{
AgentEvent, AgentProfile, AgentProfileBuilder, LocalSandbox, Message, Sandbox, Session,
SessionOptions, SessionShutdownReason,
AgentEvent, AgentProfile, AgentProfileBuilder, Message, RunSandbox, Session, SessionOptions,
SessionShutdownReason, local_sandbox,
};
#[expect(
@ -576,7 +576,11 @@ async fn run_with_args_and_client_and_catalog_styled(
// Build sandbox
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let cwd_str = cwd.to_string_lossy().to_string();
let env: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(cwd));
let env: Arc<RunSandbox> = Arc::new(
local_sandbox(cwd)
.await
.context("failed to create the local sandbox")?,
);
// Build tool approval callback
let permissions = args.permissions.unwrap_or(PermissionLevel::ReadWrite);

View file

@ -1,2 +0,0 @@
// Re-export from fabro-sandbox
pub use fabro_sandbox::docker::{DockerSandbox, DockerSandboxOptions};

View file

@ -3,8 +3,8 @@ use std::time::SystemTime;
use tokio::sync::broadcast;
use crate::sandbox::OutputCaptureStats;
use crate::tool_registry::AgentEventEmitter;
use crate::truncation::OutputCaptureStats;
use crate::types::{AgentEvent, SessionEvent};
#[derive(Clone)]

View file

@ -1,6 +1,3 @@
#[cfg(feature = "docker")]
pub mod docker_sandbox;
pub mod agent_profile;
pub mod apply_patch;
pub mod cli;
@ -38,14 +35,13 @@ pub use config::{
NativeToolOptions, SessionOptions, ToolAccess, ToolAccessPolicy, ToolApprovalAdapter,
ToolExposureMode, ToolHookCallback, ToolHookDecision, ToolSecrets,
};
#[cfg(feature = "docker")]
pub use docker_sandbox::{DockerSandbox, DockerSandboxOptions};
pub use error::{CompactionError, Error, InterruptReason, Result};
pub use event::Emitter;
pub use fabro_mcp::config::McpServerSettings;
pub use fabro_sandbox::{CloneRequest, ProviderAccess, SandboxProviderKind, provider_sandbox};
pub use fabro_types::SteeringMessage;
pub use history::History;
pub use local_sandbox::LocalSandbox;
pub use local_sandbox::local_sandbox;
pub use loop_detection::detect_loop;
pub use memory::{MemoryDocument, discover_memory};
pub use native_tool::{NativeTool, ToolVocabulary};
@ -59,10 +55,10 @@ pub use question_tools::{
OPENAI_REQUEST_USER_INPUT_TOOL, register_question_tools,
};
pub use sandbox::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult,
GrepOptions, OutputCaptureStats, RefreshOutcome, RemoteCredentialAction, Sandbox, SandboxEvent,
SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle, TokenProvenance,
TokenSnapshot, format_lines_numbered, shell_quote,
CaptureStats, DirEntry, DriverSpec, ExecControls, ExecResult, ExecResultExt, ExecSpec,
ExecStreamingResult, FileKind, GrepMatch, GrepOptions, OutputSink, OutputStream, RunSandbox,
SandboxFile, SandboxSource, StderrTail, StdioProcess, StdioProcessHandle, Termination,
TokenProvenance, TokenSnapshot, WalkOptions, command_termination, program_exit_code,
};
pub use session::{
CompletionCoordinator, Session, SessionControlHandle, SessionInputTiming,
@ -81,7 +77,9 @@ pub use tools::{
WebFetchSummarizer, make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool,
make_shell_tool, make_shell_tool_with_options, make_write_file_tool, register_core_tools,
};
pub use truncation::{TruncationMode, truncate_lines, truncate_output, truncate_tool_output};
pub use truncation::{
OutputCaptureStats, TruncationMode, truncate_lines, truncate_output, truncate_tool_output,
};
pub use types::{
AgentEvent, McpToolSummary, MemoryFileSummary, Message, SessionEvent, SessionState,
SkillActivationSource, SkillSummary,

View file

@ -1,2 +1,3 @@
// Re-export from fabro-sandbox
pub use fabro_sandbox::local::LocalSandbox;
//! The host-backed sandbox fabro calls `local`, re-exported from
//! fabro-sandbox so agent consumers construct it without a second import.
pub use fabro_sandbox::local_sandbox;

View file

@ -51,7 +51,6 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolContext;
@ -94,7 +93,7 @@ mod tests {
let tools = make_mcp_tools(&Arc::new(mgr));
let tool = &tools[0];
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let result = (tool.executor)(
serde_json::json!({"message": "test message"}),
ToolContext {

View file

@ -5,7 +5,7 @@ use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::error::{Error, InterruptReason};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
pub const BUDGET_BYTES: usize = 32768;
@ -22,7 +22,7 @@ pub struct MemoryDocument {
}
pub async fn discover_memory(
env: &dyn Sandbox,
env: &RunSandbox,
git_root: &str,
working_dir: &str,
profile_kind: AgentProfileKind,
@ -151,22 +151,21 @@ fn truncate_to_budget(content: &str, budget: usize) -> String {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
#[tokio::test]
async fn discovers_agents_md() {
let mut files = HashMap::new();
files.insert("/repo/AGENTS.md".into(), "Agent instructions".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let docs = discover_memory(
env.as_ref(),
"/repo",
@ -192,10 +191,11 @@ mod tests {
files.insert("/repo/.codex/instructions.md".into(), "copilot".into());
files.insert("/repo/GEMINI.md".into(), "gemini".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files: files.clone(),
..Default::default()
});
}
.sandbox();
let anthropic_docs = discover_memory(
env.as_ref(),
"/repo",
@ -209,10 +209,11 @@ mod tests {
assert_eq!(anthropic_docs[0].content, "agents");
assert_eq!(anthropic_docs[1].content, "claude");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files: files.clone(),
..Default::default()
});
}
.sandbox();
let claude5_docs = discover_memory(
env.as_ref(),
"/repo",
@ -226,10 +227,11 @@ mod tests {
assert_eq!(claude5_docs[0].content, "agents");
assert_eq!(claude5_docs[1].content, "claude");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files: files.clone(),
..Default::default()
});
}
.sandbox();
let openai_docs = discover_memory(
env.as_ref(),
"/repo",
@ -243,10 +245,11 @@ mod tests {
assert_eq!(openai_docs[0].content, "agents");
assert_eq!(openai_docs[1].content, "copilot");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files: files.clone(),
..Default::default()
});
}
.sandbox();
let gpt56_docs = discover_memory(
env.as_ref(),
"/repo",
@ -260,10 +263,11 @@ mod tests {
assert_eq!(gpt56_docs[0].content, "agents");
assert_eq!(gpt56_docs[1].content, "copilot");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files: files.clone(),
..Default::default()
});
}
.sandbox();
let gemini_docs = discover_memory(
env.as_ref(),
"/repo",
@ -277,10 +281,11 @@ mod tests {
assert_eq!(gemini_docs[0].content, "agents");
assert_eq!(gemini_docs[1].content, "gemini");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let kimi_docs = discover_memory(
env.as_ref(),
"/repo",
@ -303,10 +308,11 @@ mod tests {
files.insert("/repo/AGENTS.md".into(), large_content.clone());
files.insert("/repo/CLAUDE.md".into(), second_content);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let docs = discover_memory(
env.as_ref(),
"/repo",
@ -336,10 +342,11 @@ mod tests {
let mut files = HashMap::new();
files.insert("/repo/AGENTS.md".into(), "shared instructions".into());
files.insert("/repo/CLAUDE.md".into(), "shared instructions".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let docs = discover_memory(
env.as_ref(),
"/repo",
@ -358,10 +365,11 @@ mod tests {
let mut files = HashMap::new();
files.insert("/repo/AGENTS.md".into(), "shared instructions".into());
files.insert("/repo/src/AGENTS.md".into(), "shared instructions".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let docs = discover_memory(
env.as_ref(),
"/repo",
@ -383,10 +391,11 @@ mod tests {
let large_content = "x".repeat(BUDGET_BYTES + 1024);
files.insert("/repo/AGENTS.md".into(), large_content.clone());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let docs = discover_memory(
env.as_ref(),
"/repo",
@ -410,10 +419,11 @@ mod tests {
files.insert("/repo/src/AGENTS.md".into(), "src agents".into());
files.insert("/repo/src/app/AGENTS.md".into(), "app agents".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let docs = discover_memory(
env.as_ref(),
"/repo",

View file

@ -10,7 +10,7 @@ use crate::config::NativeToolOptions;
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::Skill;
use crate::todo_tools::{
make_task_create_tool, make_task_get_tool, make_task_list_tool, make_task_update_tool,
@ -76,7 +76,7 @@ impl AgentProfile for AnthropicProfile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -139,7 +139,7 @@ mod tests {
#[test]
fn anthropic_system_prompt_contains_env_context() {
let profile = AnthropicProfile::new("claude-sonnet-4-20250514");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("You are Claude, an AI coding assistant made by Anthropic"));
assert!(prompt.contains("<environment>"));
@ -175,7 +175,7 @@ mod tests {
#[test]
fn anthropic_system_prompt_uses_claude_code_style_sections() {
let profile = AnthropicProfile::new("claude-sonnet-4-20250514");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("# System"));
@ -196,7 +196,7 @@ mod tests {
#[test]
fn anthropic_system_prompt_contains_communication_and_safety_guidance() {
let profile = AnthropicProfile::new("claude-sonnet-4-20250514");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(
@ -211,7 +211,7 @@ mod tests {
#[test]
fn anthropic_system_prompt_includes_subagent_guidance_only_when_registered() {
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let profile = AnthropicProfile::new("claude-sonnet-4-20250514");
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(!prompt.contains("Subagents are valuable for independent work"));
@ -232,7 +232,7 @@ mod tests {
#[test]
fn anthropic_system_prompt_includes_memory() {
let profile = AnthropicProfile::new("claude-sonnet-4-20250514");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let docs = vec!["# Project README".into(), "# CONTRIBUTING guide".into()];
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None, &[]);
assert!(prompt.contains("# Project README"));
@ -242,7 +242,7 @@ mod tests {
#[test]
fn anthropic_system_prompt_includes_env_context() {
let profile = AnthropicProfile::new("claude-opus-4-6");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let ctx = EnvContext {
git_branch: Some("feature-branch".into()),
is_git_repo: true,
@ -263,7 +263,7 @@ mod tests {
#[test]
fn anthropic_system_prompt_includes_user_instructions() {
let profile = AnthropicProfile::new("claude-opus-4-6");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let ctx = EnvContext::default();
let prompt =
profile.build_system_prompt(&env, &ctx, &[], Some("Always write tests first"), &[]);

View file

@ -13,7 +13,7 @@ use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, claude5_tools, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::Skill;
use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::todo_tools::{
@ -94,7 +94,7 @@ impl AgentProfile for Claude5Profile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -212,7 +212,7 @@ mod tests {
#[test]
fn prompt_conditionals_follow_registered_tools() {
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let profile = Claude5Profile::new("claude-fable-5");
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(!prompt.contains("# Background agents"));

View file

@ -460,7 +460,6 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::{MockSandbox, make_session, text_response};
use crate::todo_runtime::TodoRuntime;
use crate::todo_tools::{
@ -501,7 +500,7 @@ mod tests {
fn context() -> ToolContext {
ToolContext {
env: Arc::new(MockSandbox::default()) as Arc<dyn Sandbox>,
env: MockSandbox::default().sandbox(),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some("root".to_string()),

View file

@ -10,7 +10,7 @@ use crate::config::NativeToolOptions;
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::Skill;
use crate::tool_registry::ToolRegistry;
use crate::tools::{
@ -70,7 +70,7 @@ impl AgentProfile for GeminiProfile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -122,7 +122,7 @@ mod tests {
#[test]
fn gemini_system_prompt_contains_identity() {
let profile = GeminiProfile::new("gemini-2.0-flash");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("You are Gemini CLI"));
assert!(prompt.contains("solving bugs"));
@ -134,7 +134,7 @@ mod tests {
#[test]
fn gemini_system_prompt_contains_tool_guidance() {
let profile = GeminiProfile::new("gemini-2.0-flash");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("read_file"));
assert!(prompt.contains("read_many_files"));
@ -152,7 +152,7 @@ mod tests {
#[test]
fn gemini_system_prompt_contains_memory_convention() {
let profile = GeminiProfile::new("gemini-2.0-flash");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("GEMINI.md"));
assert!(prompt.contains("AGENTS.md"));
@ -161,7 +161,7 @@ mod tests {
#[test]
fn gemini_system_prompt_contains_coding_best_practices() {
let profile = GeminiProfile::new("gemini-2.0-flash");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("clean, maintainable code"));
assert!(prompt.contains("Handle errors appropriately"));
@ -171,7 +171,7 @@ mod tests {
#[test]
fn gemini_system_prompt_contains_env_context() {
let profile = GeminiProfile::new("gemini-2.0-flash");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("<environment>"));
assert!(prompt.contains("linux"));

View file

@ -29,7 +29,7 @@ use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, FileEditToolKind, ProfileDeps, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::Skill;
use crate::todo_runtime::TodoRuntime;
use crate::todo_tools::make_update_plan_tool;
@ -187,7 +187,7 @@ impl AgentProfile for Gpt56Profile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -246,7 +246,7 @@ enabled = true
}
fn prompt(profile: &Gpt56Profile) -> String {
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[])
}
@ -409,7 +409,7 @@ enabled = true
#[test]
fn prompt_contains_env_context_and_memory_and_user_instructions() {
let profile = Gpt56Profile::new("gpt-5.6-sol");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let docs = vec!["# Project README".to_string()];
let rendered = profile.build_system_prompt(
&env,

View file

@ -11,7 +11,7 @@ use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors, kimi_tools,
};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::Skill;
use crate::todo_runtime::TodoRuntime;
use crate::todo_tools::make_todo_list_tool;
@ -132,7 +132,7 @@ impl AgentProfile for KimiProfile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -375,7 +375,7 @@ enabled = true
assert_eq!(profile.profile_kind(), AgentProfileKind::Kimi);
assert_eq!(profile.provider_id(), ProviderId::new("openrouter"));
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("You are Kimi"));
assert!(prompt.contains("# Tracking Multi-Step Work"));

View file

@ -27,11 +27,12 @@ use serde_json::Value;
use strum::EnumString;
use crate::native_tool::NativeTool;
use crate::sandbox::{GrepOptions, format_lines_numbered};
use crate::sandbox::{ExecResultExt, GrepOptions, Termination};
use crate::tool_registry::{RegisteredTool, ToolSource};
use crate::tools::{
DEFAULT_READ_LINES, emit_shell_process_completed, execute_grep, execute_shell_command,
grep_result_path, make_edit_file_tool, optional_usize_arg, required_str, retain_shell_output,
format_lines_numbered, grep_result_path, make_edit_file_tool, optional_usize_arg, required_str,
retain_shell_output,
};
const DEFAULT_GREP_RESULTS: usize = 250;
@ -120,25 +121,27 @@ explicitly asked. Never run commands requiring superuser privileges unless expli
let result = &streaming.result;
let mut out = String::new();
if result.is_timed_out() {
out.push_str("Command timed out.\n");
} else if result.is_cancelled() {
out.push_str("Command cancelled.\n");
match result.termination {
Termination::TimedOut => out.push_str("Command timed out.\n"),
Termination::Cancelled | Termination::Killed => {
out.push_str("Command cancelled.\n");
}
_ => {}
}
out.push_str(&result.stdout);
out.push_str(&result.stdout_lossy());
if !result.stderr.is_empty() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&result.stderr);
out.push_str(&result.stderr_lossy());
}
if let Some(code) = result.exit_code.filter(|c| *c != 0) {
if let Some(code) = result.program_exit_code().filter(|c| *c != 0) {
if !out.is_empty() {
out.push('\n');
}
let _ = write!(out, "Command failed with exit code: {code}");
}
let is_success = result.is_success();
let is_success = result.success();
let out = retain_shell_output(&ctx, &streaming, out);
emit_shell_process_completed(&ctx, streaming).await;
if is_success { Ok(out) } else { Err(out) }
@ -228,9 +231,16 @@ depends on an exact file, API, or output shape, inspect the final result before
Some(offset) => {
let start = usize::try_from(offset)
.map_err(|_| "line_offset must fit in usize".to_string())?;
ctx.env.read_file(path, Some(start), Some(n_lines)).await
ctx.env
.read_file_text(path)
.await
.map(|text| format_lines_numbered(&text, Some(start), Some(n_lines)))
}
None => ctx.env.read_file(path, None, Some(n_lines)).await,
None => ctx
.env
.read_file_text(path)
.await
.map(|text| format_lines_numbered(&text, None, Some(n_lines))),
}
.map_err(|e| e.display_with_causes())?;
@ -367,15 +377,17 @@ pub fn make_kimi_edit_tool(description: &str) -> RegisteredTool {
mod tests {
use std::collections::HashMap;
use fabro_sandbox::Termination;
use fabro_sandbox::test_support::exec_result;
use serde_json::json;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::{ExecResult, Sandbox};
use crate::test_support::{MockSandbox, MutableMockSandbox};
use crate::sandbox::RunSandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::{ToolContext, ToolDefinitionExt};
fn ctx(env: Arc<dyn Sandbox>) -> ToolContext {
fn ctx(env: Arc<RunSandbox>) -> ToolContext {
ToolContext {
env,
cancel: CancellationToken::new(),
@ -387,10 +399,14 @@ mod tests {
}
}
fn sandbox_with(path: &str, content: &str) -> Arc<MutableMockSandbox> {
fn sandbox_with(path: &str, content: &str) -> Arc<RunSandbox> {
let mut files = HashMap::new();
files.insert(path.to_string(), content.to_string());
Arc::new(MutableMockSandbox::new(files))
MockSandbox {
files,
..Default::default()
}
.sandbox()
}
/// The reason Read is a separate tool: a negative `line_offset` means
@ -492,7 +508,11 @@ mod tests {
#[tokio::test]
async fn write_append_propagates_a_missing_file_error() {
let env = Arc::new(MutableMockSandbox::new(HashMap::new()));
let env = MockSandbox {
files: HashMap::new(),
..Default::default()
}
.sandbox();
let tool = make_kimi_write_tool();
let err = (tool.executor)(
@ -552,10 +572,11 @@ mod tests {
}
async fn grep_with(args: serde_json::Value, lines: Vec<String>) -> Result<String, String> {
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
grep_results: lines,
..MockSandbox::default()
});
}
.sandbox();
let tool = make_kimi_grep_tool();
(tool.executor)(args, ctx(env)).await
}
@ -675,20 +696,12 @@ mod tests {
#[tokio::test]
async fn bash_reuses_session_env_cwd_and_timeout_rendering() {
use fabro_types::CommandTermination;
let tool = make_kimi_bash_tool(60_000, 600_000);
let env = Arc::new(MockSandbox {
exec_result: ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
termination: CommandTermination::TimedOut,
duration_ms: 7_000,
},
let env = MockSandbox {
exec_result: exec_result("", "", None, Termination::TimedOut, 7_000),
..MockSandbox::default()
});
let mut tool_ctx = ctx(env.clone());
};
let mut tool_ctx = ctx(env.sandbox());
let tool_env = HashMap::from([("TOKEN".to_string(), "value".to_string())]);
tool_ctx.tool_env_provider = Some(Arc::new(crate::StaticEnvProvider(tool_env.clone())));
@ -700,15 +713,18 @@ mod tests {
.expect_err("a timeout is a failed tool result");
assert!(output.starts_with("Command timed out.\n"), "{output}");
assert_eq!(*env.captured_timeout.lock().unwrap(), Some(7_000));
assert_eq!(env.captured_working_dirs.lock().unwrap().as_slice(), &[
Some("/repo".to_string())
]);
assert_eq!(*env.captured_env_vars.lock().unwrap(), Some(tool_env));
assert_eq!(env.captured_timeout(), Some(7_000));
assert_eq!(
env.captured_command.lock().unwrap().as_deref(),
Some("echo $TOKEN")
env.driver()
.scripted_exec()
.recorded()
.iter()
.map(|spec| spec.working_dir.clone())
.collect::<Vec<_>>(),
vec![Some("/repo".to_string())]
);
assert_eq!(env.captured_env_vars(), Some(tool_env));
assert_eq!(env.captured_command().as_deref(), Some("echo $TOKEN"));
}
}
@ -806,19 +822,18 @@ page through a large result set.
));
}
let options = GrepOptions {
glob_filter: args.get("glob").and_then(Value::as_str).map(str::to_string),
case_insensitive: args.get("-i").and_then(Value::as_bool).unwrap_or(false),
max_results: match mode {
GrepOutputMode::Content => Some(
head_limit
.saturating_add(offset)
.min(MAX_GREP_MATCHES_SCANNED),
),
GrepOutputMode::FilesWithMatches | GrepOutputMode::CountMatches => {
Some(MAX_GREP_MATCHES_SCANNED)
}
},
let mut options = GrepOptions::default();
options.include = args.get("glob").and_then(Value::as_str).map(str::to_string);
options.case_insensitive = args.get("-i").and_then(Value::as_bool).unwrap_or(false);
options.max_matches = match mode {
GrepOutputMode::Content => Some(
head_limit
.saturating_add(offset)
.min(MAX_GREP_MATCHES_SCANNED),
),
GrepOutputMode::FilesWithMatches | GrepOutputMode::CountMatches => {
Some(MAX_GREP_MATCHES_SCANNED)
}
};
let lines = execute_grep(&ctx, pattern, path, &options).await?;

View file

@ -27,7 +27,7 @@ use crate::agent_profile::AgentProfile;
use crate::apply_patch;
use crate::config::{NativeToolOptions, ToolSecrets};
use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::{Skill, format_skills_prompt_section};
use crate::todo_runtime::TodoRuntime;
use crate::tool_registry::ToolRegistry;
@ -381,7 +381,7 @@ impl EmbeddedPrompt {
#[must_use]
pub fn assemble_system_prompt(
template: EmbeddedPrompt,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -414,12 +414,12 @@ pub fn assemble_system_prompt(
#[cfg(test)]
#[must_use]
pub fn build_env_context_block(env: &dyn Sandbox) -> String {
pub fn build_env_context_block(env: &RunSandbox) -> String {
build_env_context_block_with(env, &EnvContext::default())
}
#[must_use]
pub fn build_env_context_block_with(env: &dyn Sandbox, ctx: &EnvContext) -> String {
pub fn build_env_context_block_with(env: &RunSandbox, ctx: &EnvContext) -> String {
let mut lines = vec![
"<environment>".to_string(),
format!("Working directory: {}", env.working_directory()),
@ -480,7 +480,7 @@ mod tests {
}
fn system_prompt(profile: &dyn AgentProfile) -> String {
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let context = EnvContext::default();
profile.build_system_prompt(&env, &context, &[], None, &[])
}
@ -668,7 +668,7 @@ mod tests {
#[test]
fn env_context_block_contains_platform() {
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let block = build_env_context_block(&env);
assert!(block.contains("<environment>"));
assert!(block.contains("</environment>"));
@ -679,7 +679,7 @@ mod tests {
#[test]
fn env_context_block_with_extra_context() {
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let ctx = EnvContext {
git_branch: Some("main".into()),
is_git_repo: true,
@ -700,7 +700,7 @@ mod tests {
#[test]
fn profile_builder_keeps_tool_availability_and_prompt_guidance_in_sync() {
let catalog = Arc::new(test_catalog());
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let cases = [
(AgentProfileKind::OpenAi, builtin::openai(), "gpt-5.4-mini"),
(
@ -794,7 +794,7 @@ mod tests {
let child_create = executor(child.as_ref(), "TaskCreate");
let child_list = executor(child.as_ref(), "TaskList");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let context = |session_id: &str| ToolContext {
env: Arc::clone(&env),
cancel: CancellationToken::new(),

View file

@ -11,7 +11,7 @@ use crate::config::NativeToolOptions;
use crate::profiles::{
self, BaseProfile, EmbeddedPrompt, ProfileDeps, impl_base_profile_accessors,
};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::Skill;
use crate::todo_runtime::TodoRuntime;
use crate::todo_tools::make_update_plan_tool;
@ -67,7 +67,7 @@ impl AgentProfile for OpenAiProfile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -125,7 +125,7 @@ mod tests {
#[test]
fn openai_system_prompt_contains_env_context() {
let profile = OpenAiProfile::new("o3-mini");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("You are a coding agent powered by openai"));
assert!(prompt.contains("<environment>"));
@ -137,7 +137,7 @@ mod tests {
#[test]
fn openai_system_prompt_contains_tool_guidance() {
let profile = OpenAiProfile::new("o3-mini");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("read_file"));
assert!(prompt.contains("apply_patch"));
@ -152,7 +152,7 @@ mod tests {
#[test]
fn openai_system_prompt_contains_coding_best_practices() {
let profile = OpenAiProfile::new("o3-mini");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("clean, maintainable code"));
assert!(prompt.contains("existing code conventions"));
@ -161,7 +161,7 @@ mod tests {
#[test]
fn openai_system_prompt_matches_codex_incremental_plan_guidance() {
let profile = OpenAiProfile::new("gpt-5.5");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains(
"update item statuses incrementally as each item is completed rather than \
@ -172,7 +172,7 @@ mod tests {
#[test]
fn openai_system_prompt_includes_memory() {
let profile = OpenAiProfile::new("o3-mini");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let docs = vec!["# Project README".into(), "# CONTRIBUTING guide".into()];
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &docs, None, &[]);
assert!(prompt.contains("# Project README"));
@ -182,7 +182,7 @@ mod tests {
#[test]
fn openai_system_prompt_includes_user_instructions() {
let profile = OpenAiProfile::new("o3-mini");
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(
&env,
&EnvContext::default(),
@ -237,7 +237,7 @@ mod tests {
fn moonshot_provider_prompt_uses_catalog_display_name() {
let profile =
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("moonshot"), test_catalog());
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("powered by Moonshot AI"));
assert!(!prompt.contains("powered by OpenAI"));
@ -264,7 +264,7 @@ mod tests {
);
}
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("## edit_file"));
assert!(!prompt.contains("## apply_patch"));
@ -275,7 +275,7 @@ mod tests {
fn zai_provider_prompt_uses_catalog_display_name() {
let profile =
OpenAiProfile::new("glm-4.7").with_route(ProviderId::new("zai"), test_catalog());
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("powered by Z.ai"));
}
@ -284,7 +284,7 @@ mod tests {
fn minimax_provider_prompt_uses_catalog_display_name() {
let profile = OpenAiProfile::new("minimax-m2.5")
.with_route(ProviderId::new("minimax"), test_catalog());
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("powered by MiniMax"));
}
@ -293,7 +293,7 @@ mod tests {
fn inception_provider_prompt_uses_catalog_display_name() {
let profile = OpenAiProfile::new("mercury-2")
.with_route(ProviderId::new("inception"), test_catalog());
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("powered by Inception"));
}

View file

@ -959,7 +959,7 @@ mod tests {
}]
}),
ToolContext {
env: Arc::new(MockSandbox::default()),
env: MockSandbox::default().sandbox(),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some("child".to_string()),

View file

@ -1,10 +1,7 @@
// Re-export all sandbox types from fabro-sandbox.
// Re-export the delegate_sandbox! macro at crate root so existing
// `crate::delegate_sandbox!` invocations continue to work.
// Re-export the sandbox types the agent works with from fabro-sandbox.
pub use fabro_sandbox::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult,
GrepOptions, OutputCaptureStats, RefreshOutcome, RemoteCredentialAction, Sandbox, SandboxEvent,
SandboxEventCallback, SandboxFile, StderrCollector, StdioProcess, StdioProcessHandle,
StdioProcessTermination, TokenProvenance, TokenSnapshot, WalkOptions, delegate_sandbox,
format_lines_numbered, shell_quote,
CaptureStats, DirEntry, DriverSpec, ExecControls, ExecResult, ExecResultExt, ExecSpec,
ExecStreamingResult, FileKind, GrepMatch, GrepOptions, OutputSink, OutputStream, RunSandbox,
SandboxFile, SandboxSource, StderrTail, StdioProcess, StdioProcessHandle, Termination,
TokenProvenance, TokenSnapshot, WalkOptions, command_termination, program_exit_code,
};

View file

@ -22,6 +22,7 @@ use lithos_llm::types::{
ContentPart, Message as LlmMessage, ReasoningEffort, Role, Speed, TokenCounts, ToolCall,
ToolChoice,
};
use sandbox_driver::{ServiceId, ServiceSpec, Services as _, ServicesFacet};
use tokio::sync::{Notify, broadcast};
use tokio::time;
use tokio_util::sync::CancellationToken;
@ -42,7 +43,7 @@ use crate::memory::{BUDGET_BYTES, MemoryDocument, discover_memory};
use crate::native_tool::NativeTool;
use crate::profiles::EnvContext;
use crate::question_tools::AgentToolRuntime;
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::skills::{
ExpandedInput, Skill, default_skill_dirs, discover_skills, expand_skill,
make_use_skill_tool_for_vocabulary,
@ -446,7 +447,7 @@ pub struct Session {
ended: bool,
llm_client: Client,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
control_state: Arc<Mutex<ControlState>>,
control_notify: Arc<Notify>,
followup_queue: Arc<Mutex<VecDeque<String>>>,
@ -472,7 +473,7 @@ impl Session {
pub fn new(
llm_client: Client,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
config: SessionOptions,
subagent_supervisor: Option<SubAgentSupervisor>,
) -> Self {
@ -514,7 +515,7 @@ impl Session {
runtime_context: &[SessionMessage],
llm_client: Client,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
config: SessionOptions,
subagent_supervisor: Option<SubAgentSupervisor>,
) -> Result<Self, Error> {
@ -852,14 +853,14 @@ impl Session {
Ok(resolved)
}
/// Start an MCP server inside the sandbox and return (url, headers) for
/// HTTP connection.
/// Start an MCP server inside the sandbox as a driver service and return
/// (url, headers) for HTTP connection.
///
/// The outer `Result` surfaces fatal cancellation as
/// `Error::Interrupted(InterruptReason::Cancelled)` (the running MCP
/// process group is terminated before returning). The inner `Result`
/// captures non-fatal startup failures that the caller logs and turns
/// into an `McpServerFailed` event.
/// `Error::Interrupted(InterruptReason::Cancelled)` (a service already
/// started is stopped before returning). The inner `Result` captures
/// non-fatal startup failures that the caller logs and turns into an
/// `McpServerFailed` event.
async fn start_sandbox_mcp_server(
&self,
command: &[String],
@ -868,82 +869,56 @@ impl Session {
cancel_token: &CancellationToken,
) -> Result<Result<(String, std::collections::HashMap<String, String>), String>, Error> {
let sandbox = self.sandbox.as_ref();
let launch_script = sandbox_mcp_launch_script(command);
let env_ref = if env.is_empty() { None } else { Some(env) };
let services = match sandbox.services() {
Ok(services) => services,
Err(error) => {
return Ok(Err(format!(
"Failed to launch MCP server: {}",
error.display_with_causes()
)));
}
};
let mut spec = ServiceSpec::new(mcp_service_command(command));
for (key, value) in env {
spec = spec.env_var(key.clone(), value.clone());
}
if cancel_token.is_cancelled() {
return Err(Error::Interrupted(InterruptReason::Cancelled));
}
let launch_result = match sandbox
.exec_command(
&launch_script,
30_000,
None,
env_ref,
Some(cancel_token.child_token()),
)
.await
{
Ok(result) => result,
Err(e) => {
let service = match services.spawn(&spec).await {
Ok(service) => service,
Err(error) => {
if cancel_token.is_cancelled() {
return Err(Error::Interrupted(InterruptReason::Cancelled));
}
return Ok(Err(format!(
"Failed to launch MCP server: {}",
e.display_with_causes()
)));
return Ok(Err(format!("Failed to launch MCP server: {error}")));
}
};
let pid = launch_result.stdout.trim().to_string();
info!(pid = %pid, port, "MCP server process launched in sandbox");
// Wait for the server to start listening on the port
let poll_cmd = format!(
"for i in $(seq 1 30); do ss -tln | grep -q ':{port} ' && echo ready && exit 0; sleep 1; done; echo timeout"
info!(
service = service.as_str(),
port, "MCP server started as a sandbox service"
);
let poll_result = sandbox
.exec_command(
&poll_cmd,
60_000,
None,
None,
Some(cancel_token.child_token()),
)
.await;
if cancel_token.is_cancelled() {
kill_mcp_pid(sandbox, &pid).await;
return Err(Error::Interrupted(InterruptReason::Cancelled));
}
let poll_result = match poll_result {
Ok(result) => result,
Err(e) => {
return Ok(Err(format!(
"Failed to poll MCP server readiness: {}",
e.display_with_causes()
)));
// Wait for the server to listen; a cancellation stops it.
let ready = tokio::select! {
() = cancel_token.cancelled() => {
stop_mcp_service(&services, &service).await;
return Err(Error::Interrupted(InterruptReason::Cancelled));
}
ready = services.wait_for_port(port, MCP_SERVER_READY_TIMEOUT) => ready,
};
if poll_result.stdout.trim() != "ready" {
// Grab stderr for debugging
let stderr = sandbox
.exec_command(
"cat /tmp/mcp_server_stderr.log 2>/dev/null | tail -20",
10_000,
None,
None,
Some(cancel_token.child_token()),
)
if let Err(error) = ready {
let logs = services
.logs(&service, MCP_SERVER_LOG_TAIL_BYTES)
.await
.map(|r| r.stdout)
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
.unwrap_or_default();
stop_mcp_service(&services, &service).await;
return Ok(Err(format!(
"MCP server did not start listening on port {port} within 30s. stderr:\n{stderr}"
"MCP server did not start listening on port {port} within {}s ({error}). \
logs:\n{logs}",
MCP_SERVER_READY_TIMEOUT.as_secs()
)));
}
@ -955,7 +930,7 @@ impl Session {
};
if cancel_token.is_cancelled() {
kill_mcp_pid(sandbox, &pid).await;
stop_mcp_service(&services, &service).await;
return Err(Error::Interrupted(InterruptReason::Cancelled));
}
@ -993,8 +968,8 @@ impl Session {
)
.await
.ok()
.filter(fabro_sandbox::ExecResult::is_success)
.map(|r| r.stdout.trim().to_string());
.filter(fabro_sandbox::ExecResult::success)
.map(|r| r.stdout_lossy().trim().to_string());
if cancel_token.is_cancelled() {
return Err(Error::Interrupted(InterruptReason::Cancelled));
@ -1013,8 +988,8 @@ impl Session {
)
.await
.ok()
.filter(fabro_sandbox::ExecResult::is_success)
.map(|r| r.stdout.trim().to_string())
.filter(fabro_sandbox::ExecResult::success)
.map(|r| r.stdout_lossy().trim().to_string())
.filter(|s| !s.is_empty())
} else {
None
@ -1035,8 +1010,8 @@ impl Session {
)
.await
.ok()
.filter(fabro_sandbox::ExecResult::is_success)
.map(|r| r.stdout.trim().to_string())
.filter(fabro_sandbox::ExecResult::success)
.map(|r| r.stdout_lossy().trim().to_string())
.filter(|s| !s.is_empty())
} else {
None
@ -2192,49 +2167,31 @@ impl Session {
}
}
/// Build the script that launches a sandbox MCP server detached and echoes its
/// PID.
/// How long a sandbox MCP server gets to start listening on its port.
const MCP_SERVER_READY_TIMEOUT: Duration = Duration::from_secs(30);
/// How much of a failed MCP server's output the failure message carries.
const MCP_SERVER_LOG_TAIL_BYTES: usize = 4096;
/// The Bash source a sandbox MCP server runs as a service.
///
/// `setsid` fully detaches the server so Daytona's exec doesn't block on it.
/// The inner command is shell-quoted for the wrapper so a single quote or
/// metacharacter in any argv element can't break out, and the wrapper itself is
/// the current `$BASH` because the sandbox evaluates this string as non-login
/// Bash and may resolve that executable outside `/bin` (for example on NixOS).
fn sandbox_mcp_launch_script(command: &[String]) -> String {
let command_source = match command {
// Sandbox MCP `script` entries resolve to this exact argv shape. The
// surrounding launcher is already the provider-selected Bash, so
// evaluate the source in that process instead of PATH-resolving a
// second interpreter. Grouping keeps the log redirections scoped to
// the whole script, including multi-command and trailing-comment
// forms.
[interpreter, flag, source] if interpreter == "bash" && flag == "-c" => {
format!("{{\n{source}\n}}")
}
/// Sandbox MCP `script` entries resolve to the argv shape `bash -c <source>`.
/// The service already runs in the provider-selected Bash, so the source
/// runs there as it is instead of PATH-resolving a second interpreter (which
/// may live outside `/bin`, for example on NixOS). Any other argv is quoted
/// into one command line, so a quote or metacharacter in an element stays
/// inert.
fn mcp_service_command(command: &[String]) -> String {
match command {
[interpreter, flag, source] if interpreter == "bash" && flag == "-c" => source.clone(),
_ => shell::shell_join(command),
};
let inner =
format!("{command_source} > /tmp/mcp_server_stdout.log 2>/tmp/mcp_server_stderr.log");
format!(
"setsid \"$BASH\" -c {quoted} </dev/null >/dev/null 2>&1 &\necho $!",
quoted = shell::shell_quote(&inner)
)
}
}
/// Best-effort kill of a sandbox MCP server process group. Used when
/// `start_sandbox_mcp_server` is cancelled after spawning a detached
/// `setsid` child but before reporting readiness. Errors from the sandbox
/// are logged and swallowed; the caller is already returning a Cancelled
/// error.
async fn kill_mcp_pid(sandbox: &dyn Sandbox, pid: &str) {
let pid = pid.trim();
if pid.is_empty() {
return;
}
let script =
format!("kill -TERM -{pid} 2>/dev/null; sleep 1; kill -KILL -{pid} 2>/dev/null; true");
if let Err(err) = sandbox.exec_command(&script, 5_000, None, None, None).await {
warn!(pid, error = %err.display_with_causes(), "Failed to kill MCP server process group during cancellation");
/// Best-effort stop of a sandbox MCP service that will not be used: the
/// caller is already returning a cancellation or a startup failure.
async fn stop_mcp_service(services: &ServicesFacet<'_>, service: &ServiceId) {
if let Err(error) = services.stop(service).await {
warn!(service = service.as_str(), error = %error, "Failed to stop the MCP server service");
}
}
@ -2267,82 +2224,33 @@ mod tests {
use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
#[test]
fn sandbox_mcp_launch_wrapper_uses_bash() {
// The sandbox evaluates this string as non-login Bash, so the detached
// wrapper reuses the executable selected by the provider.
let script = sandbox_mcp_launch_script(&[
"npx".to_string(),
"@playwright/mcp@latest".to_string(),
"--port".to_string(),
"3100".to_string(),
]);
assert!(
script.starts_with("setsid \"$BASH\" -c "),
"launch wrapper should detach through the provider-selected Bash: {script}"
);
assert!(
script.ends_with(" </dev/null >/dev/null 2>&1 &\necho $!"),
"launch wrapper should stay detached and report its PID: {script}"
);
assert!(
script.contains("/tmp/mcp_server_stdout.log")
&& script.contains("2>/tmp/mcp_server_stderr.log"),
"launch wrapper should keep its log redirection: {script}"
);
}
#[test]
fn sandbox_mcp_launch_wrapper_evaluates_scripts_in_the_selected_bash() {
fn mcp_service_command_runs_script_entries_in_the_service_bash() {
let source =
"PATH=/mcp-only\nprintf 'starting server\\n'\nexec my-server --port 3100 # ready";
let script =
sandbox_mcp_launch_script(&["bash".to_string(), "-c".to_string(), source.to_string()]);
let wrapper_argument = script
.strip_prefix("setsid \"$BASH\" -c ")
.and_then(|rest| rest.strip_suffix(" </dev/null >/dev/null 2>&1 &\necho $!"))
.expect("launch wrapper should have the canonical shape");
let unwrapped = shlex::split(wrapper_argument).expect("wrapper argument should parse");
assert_eq!(unwrapped, vec![format!(
"{{\n{source}\n}} > /tmp/mcp_server_stdout.log 2>/tmp/mcp_server_stderr.log"
)]);
assert!(
!unwrapped[0].contains("bash -c"),
"script entries must not PATH-resolve a nested Bash: {}",
unwrapped[0]
let command =
mcp_service_command(&["bash".to_string(), "-c".to_string(), source.to_string()]);
assert_eq!(
command, source,
"script entries must not PATH-resolve a nested Bash"
);
}
#[test]
fn sandbox_mcp_launch_wrapper_quotes_arbitrary_argv() {
// A quote or metacharacter in any argv element must not break out of
// the wrapper; it has to arrive as one argument.
let script = sandbox_mcp_launch_script(&[
fn mcp_service_command_quotes_arbitrary_argv() {
// A quote or metacharacter in any argv element must not break out
// of the command line; it has to arrive as one argument.
let command = mcp_service_command(&[
"my-server".to_string(),
"--flag=it's a value".to_string(),
"$(touch /tmp/pwned)".to_string(),
]);
let wrapper_argument = script
.strip_prefix("setsid \"$BASH\" -c ")
.and_then(|rest| rest.strip_suffix(" </dev/null >/dev/null 2>&1 &\necho $!"))
.expect("launch wrapper should have the canonical shape");
// Unwrap the wrapper's own quoting: the whole inner script must arrive
// as one argument to `bash -c`, with each argv element still quoted so
// the substitution stays inert.
let unwrapped = shlex::split(wrapper_argument).expect("wrapper argument should parse");
assert_eq!(
unwrapped.len(),
1,
"the command must stay a single argument"
command,
"my-server \"--flag=it's a value\" '$(touch /tmp/pwned)'"
);
assert_eq!(
unwrapped[0],
"my-server \"--flag=it's a value\" '$(touch /tmp/pwned)' > \
/tmp/mcp_server_stdout.log 2>/tmp/mcp_server_stderr.log"
mcp_service_command(&["npx".to_string(), "@playwright/mcp@latest".to_string()]),
"npx @playwright/mcp@latest"
);
}
@ -2624,7 +2532,7 @@ mod tests {
) -> Session {
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
Session::new(
client,
profile,
@ -2735,7 +2643,7 @@ mod tests {
));
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
let result = session
@ -3628,7 +3536,7 @@ mod tests {
let provider = Arc::new(MockLlmProvider::new(responses));
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
enable_loop_detection: false,
..Default::default()
@ -3662,7 +3570,7 @@ mod tests {
}));
let client = make_client(error_provider).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
let result = session.process_input("Hello").await;
@ -3759,7 +3667,7 @@ mod tests {
let provider = Arc::new(MockLlmProvider::new(responses));
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
let mut rx = session.subscribe();
@ -3810,7 +3718,7 @@ mod tests {
let client = make_client(provider).await;
let registry = ToolRegistry::new();
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
let mut rx = session.subscribe();
@ -3832,7 +3740,7 @@ mod tests {
let provider_ref = provider.clone();
let client = make_client(provider as Arc<dyn ProviderAdapter>).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
// Default reasoning_effort is None
@ -3855,7 +3763,7 @@ mod tests {
let registry = ToolRegistry::new();
// Large context window so short input stays well under 80%
let profile = Arc::new(TestProfile::with_context_window(registry, 200_000));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
let mut rx = session.subscribe();
@ -3987,7 +3895,7 @@ mod tests {
let provider_ref = provider.clone();
let client = make_client(provider as Arc<dyn ProviderAdapter>).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
user_instructions: Some("Always use TDD".into()),
..Default::default()
@ -4015,7 +3923,7 @@ mod tests {
let provider_ref = provider.clone();
let client = make_client(provider as Arc<dyn ProviderAdapter>).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
// Intentionally skip initialize(): system prompt remains empty.
@ -4047,7 +3955,7 @@ mod tests {
registry.register(make_named_noop_tool("read_file"));
registry.register(make_named_noop_tool("write_file"));
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
session.process_input("test").await.unwrap();
@ -4103,7 +4011,7 @@ mod tests {
registry.register(make_named_noop_tool("read_file"));
registry.register(make_named_noop_tool("write_file"));
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
tool_access_policy: Some(Arc::new(NamedToolAccessPolicy::new(vec![
("read_file", ToolAccess::Allowed),
@ -4135,7 +4043,7 @@ mod tests {
registry.register(make_named_noop_tool("apply_patch"));
registry.register(make_named_noop_tool("shell"));
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
tool_access_policy: Some(Arc::new(NamedToolAccessPolicy::new(vec![
("read_file", ToolAccess::Allowed),
@ -4167,7 +4075,7 @@ mod tests {
registry.register(make_named_noop_tool("read_file"));
registry.register(make_named_noop_tool("shell"));
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
tool_access_policy: Some(Arc::new(NamedToolAccessPolicy::new(vec![
("read_file", ToolAccess::Allowed),
@ -4980,7 +4888,7 @@ mod tests {
registry.register(counting_tool("echo", Arc::clone(&executions)));
let client = make_client_without_retries(provider.clone() as Arc<dyn ProviderAdapter>);
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
let mut rx = session.subscribe();
@ -5014,7 +4922,7 @@ mod tests {
// `make_client` installs a three-attempt policy with no delay.
let client = make_client(provider.clone() as Arc<dyn ProviderAdapter>).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, SessionOptions::default(), None);
let mut rx = session.subscribe();
@ -5050,7 +4958,7 @@ mod tests {
]));
let client = make_client(provider.clone() as Arc<dyn ProviderAdapter>).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
replay_retry_policy: test_retry_policy(),
..SessionOptions::default()
@ -5092,7 +5000,7 @@ mod tests {
]));
let client = make_client(provider.clone() as Arc<dyn ProviderAdapter>).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
replay_retry_policy: RetryPolicy::exponential()
.max_attempts(3)
@ -5160,7 +5068,7 @@ mod tests {
let client = make_client(provider).await;
let registry = ToolRegistry::new();
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
enable_context_compaction: true,
compaction_preserve_turns: 1,
@ -5206,7 +5114,7 @@ mod tests {
let client = make_client(provider).await;
let registry = ToolRegistry::new();
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
enable_context_compaction: true,
compaction_preserve_turns: 1,
@ -5246,7 +5154,7 @@ mod tests {
let client = make_client(provider).await;
let registry = ToolRegistry::new();
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
enable_context_compaction: true,
compaction_preserve_turns: 10,
@ -5287,7 +5195,7 @@ mod tests {
let client = make_client(provider).await;
let registry = ToolRegistry::new();
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
enable_context_compaction: false,
..Default::default()
@ -5317,7 +5225,7 @@ mod tests {
let client = make_client(provider).await;
let registry = ToolRegistry::new();
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
enable_context_compaction: false,
compaction_preserve_turns: 1,
@ -5406,7 +5314,7 @@ mod tests {
let client = make_client_without_retries(provider.clone() as Arc<dyn ProviderAdapter>);
let registry = ToolRegistry::new();
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
enable_context_compaction: true,
compaction_preserve_turns: 1,
@ -5519,7 +5427,7 @@ mod tests {
let client = make_client(provider.clone() as Arc<dyn ProviderAdapter>).await;
// Tiny context window to force compaction
let profile = Arc::new(TestProfile::with_context_window(registry, 100));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let config = SessionOptions {
enable_context_compaction: true,
compaction_preserve_turns: 1,
@ -5622,7 +5530,7 @@ mod tests {
let provider = Arc::new(MockLlmProvider::new(responses));
let client = make_client(provider).await;
let profile: Arc<dyn AgentProfile> = Arc::new(TestProfile::new());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let mut session = Session::new(client, profile, env, config, None);
// Subscribe to events before initialize
@ -5820,7 +5728,7 @@ mod tests {
]));
let client = make_client(parent_provider).await;
let profile = Arc::new(TestProfile::with_tools(parent_registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let session = Session::new(
client,
profile,
@ -6025,7 +5933,7 @@ mod tests {
}
async fn build_initialized_session(
sandbox: Arc<MockSandbox>,
sandbox: Arc<RunSandbox>,
config: SessionOptions,
) -> Session {
let provider = Arc::new(MockLlmProvider::new(vec![text_response("ok")]));
@ -6038,10 +5946,11 @@ mod tests {
async fn initialize_emits_memory_loaded_with_file_metadata() {
let mut files = std::collections::HashMap::new();
files.insert("/home/test/AGENTS.md".into(), "Hello world".into());
let sandbox = Arc::new(MockSandbox {
let sandbox = MockSandbox {
files,
..MockSandbox::linux()
});
}
.sandbox();
let config = SessionOptions {
git_root: Some("/home/test".into()),
skill_dirs: Some(Vec::new()),
@ -6077,7 +5986,7 @@ mod tests {
#[tokio::test]
async fn initialize_emits_memory_loaded_event_with_empty_files_when_no_memory() {
let sandbox = Arc::new(MockSandbox::linux());
let sandbox = MockSandbox::linux().sandbox();
let config = SessionOptions {
git_root: Some("/home/test".into()),
skill_dirs: Some(Vec::new()),
@ -6108,11 +6017,11 @@ mod tests {
"/skills/commit/SKILL.md".into(),
"---\nname: commit\ndescription: Make a commit\n---\nDo commit".into(),
);
let sandbox = Arc::new(MockSandbox {
let sandbox = MockSandbox {
files,
glob_results: vec!["/skills/commit/SKILL.md".into()],
..MockSandbox::linux()
});
}
.sandbox();
let config = SessionOptions {
git_root: Some("/home/test".into()),
skill_dirs: Some(vec!["/skills".into()]),
@ -6145,7 +6054,7 @@ mod tests {
#[tokio::test]
async fn initialize_emits_skills_discovered_event_when_no_skills() {
let sandbox = Arc::new(MockSandbox::linux());
let sandbox = MockSandbox::linux().sandbox();
let config = SessionOptions {
git_root: Some("/home/test".into()),
skill_dirs: Some(Vec::new()),
@ -6176,11 +6085,11 @@ mod tests {
"/skills/commit/SKILL.md".into(),
"---\nname: commit\ndescription: Make a commit\n---\nRun commit. {{user_input}}".into(),
);
let sandbox = Arc::new(MockSandbox {
let sandbox = MockSandbox {
files,
glob_results: vec!["/skills/commit/SKILL.md".into()],
..MockSandbox::linux()
});
}
.sandbox();
let config = SessionOptions {
git_root: Some("/home/test".into()),
skill_dirs: Some(vec!["/skills".into()]),
@ -6216,11 +6125,11 @@ mod tests {
"/skills/commit/SKILL.md".into(),
"---\nname: commit\ndescription: Make a commit\n---\nRun commit.".into(),
);
let sandbox = Arc::new(MockSandbox {
let sandbox = MockSandbox {
files,
glob_results: vec!["/skills/commit/SKILL.md".into()],
..MockSandbox::linux()
});
}
.sandbox();
let config = SessionOptions {
git_root: Some("/home/test".into()),
skill_dirs: Some(vec!["/skills".into()]),
@ -6260,7 +6169,7 @@ mod tests {
#[tokio::test]
async fn use_skill_tool_failed_lookup_does_not_emit_activation() {
let sandbox = Arc::new(MockSandbox::linux());
let sandbox = MockSandbox::linux().sandbox();
let config = SessionOptions {
git_root: Some("/home/test".into()),
skill_dirs: Some(Vec::new()),
@ -6277,7 +6186,7 @@ mod tests {
let skills_arc = Arc::new(Vec::<Skill>::new());
let tool = make_use_skill_tool(skills_arc);
let mut rx = session.subscribe();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let ctx = ToolContext {
env,
cancel: CancellationToken::new(),

View file

@ -5,7 +5,7 @@ use tokio_util::sync::CancellationToken;
use crate::error::{Error, InterruptReason};
use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::sandbox::Sandbox;
use crate::sandbox::RunSandbox;
use crate::tool_registry::{RegisteredTool, ToolSource};
use crate::tools::required_str;
use crate::types::{AgentEvent, SkillActivationSource};
@ -299,7 +299,7 @@ pub fn default_skill_dirs(fabro_skills_dir: Option<&str>, git_root: Option<&str>
}
pub async fn discover_skills(
env: &dyn Sandbox,
env: &RunSandbox,
dirs: &[String],
cancel_token: &CancellationToken,
) -> Result<Vec<Skill>, Error> {
@ -348,7 +348,6 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::{ToolContext, ToolDefinitionExt};
@ -542,9 +541,9 @@ name: trimmed
);
let env = MockSandbox {
files,
glob_results: vec!["/skills/commit/SKILL.md".into()],
..Default::default()
};
}
.sandbox();
let skills = discover_skills(&env, &["/skills".into()], &CancellationToken::new())
.await
@ -564,12 +563,9 @@ name: trimmed
files.insert("/skills/bad/SKILL.md".into(), "no frontmatter here".into());
let env = MockSandbox {
files,
glob_results: vec![
"/skills/good/SKILL.md".into(),
"/skills/bad/SKILL.md".into(),
],
..Default::default()
};
}
.sandbox();
let skills = discover_skills(&env, &["/skills".into()], &CancellationToken::new())
.await
@ -580,7 +576,7 @@ name: trimmed
#[tokio::test]
async fn discover_empty_dirs() {
let env = MockSandbox::default();
let env = MockSandbox::default().sandbox();
let skills = discover_skills(&env, &[], &CancellationToken::new())
.await
.unwrap();
@ -604,12 +600,9 @@ name: trimmed
// and glob returns both — the later dir overrides the earlier.
let env = MockSandbox {
files,
glob_results: vec![
"/global/commit/SKILL.md".into(),
"/project/commit/SKILL.md".into(),
],
..Default::default()
};
}
.sandbox();
// discover_skills iterates dirs in order; later dirs override earlier names
let skills = discover_skills(
@ -648,7 +641,7 @@ name: trimmed
let skills = Arc::new(test_skills());
let tool = make_use_skill_tool(skills);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let args = serde_json::json!({"skill_name": "commit"});
let ctx = ToolContext {
env,
@ -671,7 +664,7 @@ name: trimmed
let skills = Arc::new(test_skills());
let tool = make_use_skill_tool(skills);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let args = serde_json::json!({"skill_name": "nonexistent"});
let ctx = ToolContext {
env,
@ -692,7 +685,7 @@ name: trimmed
let skills = Arc::new(test_skills());
let tool = make_use_skill_tool(skills);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let args = serde_json::json!({});
let ctx = ToolContext {
env,
@ -712,7 +705,7 @@ name: trimmed
async fn kimi_skill_schema_and_args_match_kimi_code() {
let skills = Arc::new(test_skills());
let tool = make_use_skill_tool_for_vocabulary(skills, ToolVocabulary::KimiCode);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let ctx = ToolContext {
env,
cancel: CancellationToken::new(),
@ -755,7 +748,7 @@ name: trimmed
let result = (tool.executor)(
serde_json::json!({"skill": "commit", "args": "only staged files"}),
ToolContext {
env: Arc::new(MockSandbox::default()),
env: MockSandbox::default().sandbox(),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: None,

View file

@ -1647,7 +1647,7 @@ mod tests {
let provider_ref = provider.clone();
let client = make_client(provider as Arc<dyn ProviderAdapter>).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let session = Session::new(client, profile, env, SessionOptions::default(), None);
let agent_id = manager.spawn(session, "Do something".into(), 0).unwrap();
@ -1753,7 +1753,7 @@ mod tests {
let tool = make_wait_tool(manager.clone());
let tool_cancel = CancellationToken::new();
let ctx = ToolContext {
env: Arc::new(MockSandbox::default()),
env: MockSandbox::default().sandbox(),
cancel: tool_cancel.clone(),
tool_env_provider: None,
session_id: None,
@ -2058,7 +2058,7 @@ mod tests {
let provider_ref = provider.clone();
let client = make_client(provider as Arc<dyn ProviderAdapter>).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let session = Session::new(client, profile, env, SessionOptions::default(), None);
let agent_id = manager.spawn(session, "Do something".into(), 0).unwrap();
let first = manager.wait(&agent_id).await.unwrap();

View file

@ -9,7 +9,7 @@ pub use fabro_llm::test_support::{response_to_stream, test_retry_policy};
use fabro_llm::{
Client, ClientOptions, Error as LlmError, FinishReason, Request, Response, ResponseStream,
};
pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox};
pub use fabro_sandbox::test_support::MockSandbox;
use fabro_types::AgentProfileKind;
use lithos_llm::catalog::{ModelId, ProviderId, builtin};
use lithos_llm::types::{ContentPart, TokenCounts, ToolCall};
@ -18,7 +18,7 @@ use crate::agent_profile::AgentProfile;
use crate::config::SessionOptions;
use crate::native_tool::ToolVocabulary;
use crate::profiles::EnvContext;
use crate::sandbox::*;
use crate::sandbox::RunSandbox;
use crate::session::Session;
use crate::skills::{Skill, format_skills_prompt_section};
use crate::tool_registry::{RegisteredTool, ToolRegistry, ToolSource};
@ -82,7 +82,7 @@ impl AgentProfile for TestProfile {
fn build_system_prompt(
&self,
_env: &dyn Sandbox,
_env: &RunSandbox,
_env_context: &EnvContext,
_memory: &[String],
user_instructions: Option<&str>,
@ -231,7 +231,7 @@ pub async fn make_session(responses: Vec<Response>) -> Session {
let provider = Arc::new(MockLlmProvider::new(responses));
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
Session::new(client, profile, env, SessionOptions::default(), None)
}
@ -246,7 +246,7 @@ pub async fn make_session_with_provider_and_tools(
) -> Session {
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
Session::new(client, profile, env, SessionOptions::default(), None)
}
@ -254,7 +254,7 @@ pub async fn make_session_with_config(responses: Vec<Response>, config: SessionO
let provider = Arc::new(MockLlmProvider::new(responses));
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::new());
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
Session::new(client, profile, env, config, None)
}
@ -266,7 +266,7 @@ pub async fn make_session_with_tools_and_config(
let provider = Arc::new(MockLlmProvider::new(responses));
let client = make_client(provider).await;
let profile = Arc::new(TestProfile::with_tools(registry));
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
Session::new(client, profile, env, config, None)
}

View file

@ -147,7 +147,6 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::{AgentEventEmitter, ToolContext};
@ -166,7 +165,7 @@ mod tests {
}
fn ctx_with(emitter: Arc<CollectingEmitter>) -> ToolContext {
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
ToolContext {
env,
cancel: CancellationToken::new(),

View file

@ -663,11 +663,10 @@ mod kimi_todo_tests {
use super::tests::SilentEmitter;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
fn ctx() -> ToolContext {
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
ToolContext {
env,
cancel: CancellationToken::new(),
@ -775,7 +774,6 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::{AgentEventEmitter, ToolContext};
use crate::types::AgentEvent;
@ -787,7 +785,7 @@ mod tests {
}
fn ctx_for(session: &str, root: &str) -> ToolContext {
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
ToolContext {
env,
cancel: CancellationToken::new(),

Some files were not shown because too many files have changed in this diff Show more