Retire the fabro Sandbox trait for one concrete RunSandbox

Fabro carried its own Sandbox trait long after every implementation
became a thin layer over the sandbox driver: one production type
implemented it, a delegation macro forwarded it, and each consumer crate
kept hand-written fakes of its thirty methods for tests. The trait
existed to be mocked, and the mocks pinned behavior that no provider
had — canned walk listings that ignored the traversal root, opaque
provider paths, activation failures with no lifecycle behind them.

There is now one sandbox type. RunSandbox keeps fabro's semantics — path
resolution against the run's working directory, the Bash exec policy,
git setup and push, credential refresh — as inherent methods over the
driver's exec, filesystem, search, and git facets, and every consumer
takes Arc<RunSandbox>. The directory, grep, and walk types are the
driver's own, re-exported from fabro-sandbox. The exec policy reports
the provider's measured duration rather than its own clock.

Tests script a sandbox through fabro-sandbox's MockSandbox: a struct of
fields (seeded files, the result every command returns, the platform,
a runtime directory) that hands out a RunSandbox over the driver's
scripted doubles and reads back what the code did — commands, timeouts,
environment, term stops, writes, deletes, existence probes. The
hand-written fakes in fabro-agent, fabro-acp, fabro-hooks,
fabro-workflow, and fabro-server are gone; the one wrapper a git
integration test still needs sits at the driver level, hiding a path
from a real Host sandbox. The refresh-ahead loop takes the refresh as a
closure so its schedule is tested without a sandbox at all.

The driver pin moves to the testing-crate stack head, which gained the
double behavior these ports needed: retention caps on scripted output,
canned walks narrowed to the requested base, upload and download on the
memory filesystem, and recorders for deletes, existence probes, and
term stops. One test that modelled a provider handing back opaque object
paths from a walk is removed: the driver contract has no such thing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-10 00:14:42 -06:00
parent 331935904a
commit 6e874aff5b
No known key found for this signature in database
93 changed files with 1956 additions and 3588 deletions

View file

@ -122,7 +122,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 +139,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

27
Cargo.lock generated
View file

@ -2305,6 +2305,7 @@ dependencies = [
"jsonschema",
"libc",
"paste",
"sandbox-driver-testing",
"serde",
"serde_json",
"sha2 0.10.9",
@ -3002,6 +3003,7 @@ dependencies = [
"sandbox-driver-docker-config",
"sandbox-driver-host",
"sandbox-driver-protocol",
"sandbox-driver-testing",
"serde",
"serde_json",
"sha2 0.10.9",
@ -3423,6 +3425,7 @@ dependencies = [
"predicates",
"rand 0.9.4",
"regex",
"sandbox-driver",
"scopeguard",
"serde",
"serde_json",
@ -7007,7 +7010,7 @@ dependencies = [
[[package]]
name = "sandbox-driver"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=1c30e72062d20df802d491f07b428318391498f1#1c30e72062d20df802d491f07b428318391498f1"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=f66eb17c92ed4e70565473a256e7cf34fa3d02c6#f66eb17c92ed4e70565473a256e7cf34fa3d02c6"
dependencies = [
"async-trait",
"globset",
@ -7023,7 +7026,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-daytona"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=1c30e72062d20df802d491f07b428318391498f1#1c30e72062d20df802d491f07b428318391498f1"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=f66eb17c92ed4e70565473a256e7cf34fa3d02c6#f66eb17c92ed4e70565473a256e7cf34fa3d02c6"
dependencies = [
"anyhow",
"async-trait",
@ -7048,7 +7051,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-daytona-config"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=1c30e72062d20df802d491f07b428318391498f1#1c30e72062d20df802d491f07b428318391498f1"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=f66eb17c92ed4e70565473a256e7cf34fa3d02c6#f66eb17c92ed4e70565473a256e7cf34fa3d02c6"
dependencies = [
"sandbox-driver-docker-config",
"serde",
@ -7058,7 +7061,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-docker"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=1c30e72062d20df802d491f07b428318391498f1#1c30e72062d20df802d491f07b428318391498f1"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=f66eb17c92ed4e70565473a256e7cf34fa3d02c6#f66eb17c92ed4e70565473a256e7cf34fa3d02c6"
dependencies = [
"anyhow",
"async-trait",
@ -7079,7 +7082,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-docker-config"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=1c30e72062d20df802d491f07b428318391498f1#1c30e72062d20df802d491f07b428318391498f1"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=f66eb17c92ed4e70565473a256e7cf34fa3d02c6#f66eb17c92ed4e70565473a256e7cf34fa3d02c6"
dependencies = [
"serde",
"serde_json",
@ -7088,7 +7091,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-host"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=1c30e72062d20df802d491f07b428318391498f1#1c30e72062d20df802d491f07b428318391498f1"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=f66eb17c92ed4e70565473a256e7cf34fa3d02c6#f66eb17c92ed4e70565473a256e7cf34fa3d02c6"
dependencies = [
"anyhow",
"async-trait",
@ -7106,7 +7109,7 @@ dependencies = [
[[package]]
name = "sandbox-driver-protocol"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=1c30e72062d20df802d491f07b428318391498f1#1c30e72062d20df802d491f07b428318391498f1"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=f66eb17c92ed4e70565473a256e7cf34fa3d02c6#f66eb17c92ed4e70565473a256e7cf34fa3d02c6"
dependencies = [
"async-trait",
"base64",
@ -7120,6 +7123,16 @@ dependencies = [
"tracing",
]
[[package]]
name = "sandbox-driver-testing"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=f66eb17c92ed4e70565473a256e7cf34fa3d02c6#f66eb17c92ed4e70565473a256e7cf34fa3d02c6"
dependencies = [
"async-trait",
"sandbox-driver",
"tokio",
]
[[package]]
name = "schannel"
version = "0.1.28"

View file

@ -102,14 +102,14 @@ futures-util = "0.3"
# git failures, stop grace, snapshot ensure, ownership scope, testing doubles), 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 = "1c30e72062d20df802d491f07b428318391498f1" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "1c30e72062d20df802d491f07b428318391498f1" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "1c30e72062d20df802d491f07b428318391498f1" }
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "1c30e72062d20df802d491f07b428318391498f1" }
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "1c30e72062d20df802d491f07b428318391498f1" }
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "1c30e72062d20df802d491f07b428318391498f1" }
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "1c30e72062d20df802d491f07b428318391498f1" }
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "1c30e72062d20df802d491f07b428318391498f1" }
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "f66eb17c92ed4e70565473a256e7cf34fa3d02c6" }
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "f66eb17c92ed4e70565473a256e7cf34fa3d02c6" }
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "f66eb17c92ed4e70565473a256e7cf34fa3d02c6" }
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "f66eb17c92ed4e70565473a256e7cf34fa3d02c6" }
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "f66eb17c92ed4e70565473a256e7cf34fa3d02c6" }
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "f66eb17c92ed4e70565473a256e7cf34fa3d02c6" }
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "f66eb17c92ed4e70565473a256e7cf34fa3d02c6" }
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "f66eb17c92ed4e70565473a256e7cf34fa3d02c6" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

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

@ -27,7 +27,7 @@ tokio = { version = "1", features = ["full"] }
```rust
use fabro_agent::{
AnthropicProfile, LocalSandbox, Session, SessionOptions,
AnthropicProfile, Session, SessionOptions, local_sandbox,
};
use fabro_auth::EnvCredentialSource;
use fabro_llm::client::Client;
@ -41,7 +41,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let source = EnvCredentialSource::new();
let catalog = Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default())?);
let client = Client::from_source(&source, Arc::clone(&catalog)).await?;
let sandbox = Arc::new(LocalSandbox::new(PathBuf::from(".")));
let sandbox = Arc::new(local_sandbox(PathBuf::from(".")).await?);
let profile = Arc::new(AnthropicProfile::new("claude-sonnet-4-5"));
let config = SessionOptions::default();
@ -74,7 +74,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
```
@ -123,45 +123,58 @@ 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 |
|---|---|
| `local_sandbox(...)` | Executes directly on the local filesystem through the sandbox driver Host provider. |
| `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. |
**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
The `AgentProfile` trait encapsulates LLM-specific system prompts, tool definitions, and capability metadata. It controls how the agent presents itself to the model.
@ -172,7 +185,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

@ -26,7 +26,7 @@ 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,
@ -306,10 +306,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,
@ -333,8 +332,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 (head_sha, _) = resolve_ref_sha_and_time(&sandbox, "HEAD").await?;
let output = git_log_commits(&sandbox, &base_sha, &head_sha, limit + 1).await?;
let mut commits = parse_git_log_commits(&output)?;
let truncated = commits.len() > usize::try_from(limit).unwrap_or(usize::MAX);
commits.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
@ -354,7 +353,7 @@ async fn materialize_run_commits(
}
async fn git_log_commits(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
base_sha: &str,
head_sha: &str,
limit: u64,
@ -538,18 +537,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,
@ -559,7 +552,7 @@ async fn materialize_sandbox_path(
}
ListRunFilesScope::All => {
materialize_working_tree_sandbox_path(
sandbox.as_ref(),
&sandbox,
&base_sha,
RunFilesMetaScope::All,
run_id,
@ -589,7 +582,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,
@ -611,7 +604,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,
@ -733,7 +726,7 @@ 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,
@ -771,7 +764,7 @@ async fn materialize_working_tree_sandbox_path(
}
async fn sandbox_git_stdout(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
command: &str,
op: &str,
) -> std::result::Result<String, ApiError> {
@ -1201,7 +1194,7 @@ 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()
@ -1227,13 +1220,13 @@ 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);
@ -1615,7 +1608,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() {
@ -1747,25 +1740,14 @@ 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% ") {
@ -1779,93 +1761,19 @@ 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 {
Some(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()),
};
});
let body = materialize_working_tree_sandbox_path(
&sandbox,
&sandbox.sandbox(),
"HEAD",
RunFilesMetaScope::Uncommitted,
&RunId::new(),
@ -1877,7 +1785,7 @@ 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.captured_commands();
assert_eq!(commands.len(), 2);
assert!(commands[0].contains(" show -s --format="));
assert!(commands[1].contains(" diff --patch --find-renames=50% HEAD"));
@ -2942,100 +2850,27 @@ 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 {
@ -3088,12 +2923,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");
@ -3117,7 +2949,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();
@ -3125,14 +2957,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

@ -19,7 +19,7 @@ use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::redact::redact_auth_url;
use fabro_sandbox::{
ProviderAccess, ProviderSandboxSpec, Sandbox, SandboxSpec,
ProviderAccess, ProviderSandboxSpec, RunSandbox, SandboxSpec,
local_working_directory_from_environment, options_from_environment, unresolved_env,
};
use fabro_static::EnvVars;
@ -972,7 +972,7 @@ async fn run_sandbox_check(
return false;
}
};
let sandbox_result: Result<Arc<dyn Sandbox>, String> = spec.build(None).await.map_err(|err| {
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 {

View file

@ -71,8 +71,8 @@ use fabro_sandbox::details::sandbox_details;
use fabro_sandbox::driver::{DaytonaCredentials, ProviderAccess, ProviderConnectOptions};
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_sandbox::{
DriverInventoryProvider, LocalSandboxProvider, Sandbox, SandboxProvider,
SandboxProviderRegistry, daytona,
DriverInventoryProvider, LocalSandboxProvider, SandboxProvider, SandboxProviderRegistry,
daytona,
};
use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient};
use fabro_slack::config::{

View file

@ -6,7 +6,8 @@ use std::time::Duration;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use fabro_sandbox::{
DriverSandbox, ProviderAccess, TerminalSize, open_terminal_for_run, reconnect_driver_for_run,
FileKind, ProviderAccess, RunSandbox, TerminalSize, open_terminal_for_run,
reconnect_driver_for_run,
};
use fabro_types::{
RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta,
@ -16,7 +17,7 @@ use futures_util::future::BoxFuture;
use super::super::{
ApiError, AppState, Bytes, HeaderMap, IntoResponse, Json, NamedTempFile, Path,
PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId, Sandbox,
PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId,
SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State, StatusCode,
VncPreviewResponse, collect_causes, fs, get, octet_stream_response, parse_run_id_path, post,
@ -57,7 +58,7 @@ trait VncSandbox {
fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result<String>>;
}
impl VncSandbox for DriverSandbox {
impl VncSandbox for RunSandbox {
fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result<String>> {
async move {
let vnc = self.handle()?.vnc().ok_or_else(|| {
@ -353,7 +354,7 @@ async fn generate_preview_url(
Ok(record) => record,
Err(response) => return response,
};
let sandbox = match reconnect_driver_sandbox_instance(&state, &id, &record).await {
let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {
Ok(sandbox) => sandbox,
Err(response) => return response,
};
@ -421,7 +422,7 @@ async fn create_ssh_access(
)
.into_response();
}
let sandbox = match reconnect_driver_sandbox_instance(&state, &id, &record).await {
let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {
Ok(sandbox) => sandbox,
Err(response) => return response,
};
@ -474,7 +475,7 @@ async fn create_sandbox_vnc_preview(
)
.into_response();
}
let sandbox = match reconnect_driver_sandbox_instance(&state, &id, &record).await {
let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {
Ok(sandbox) => sandbox,
Err(response) => return response,
};
@ -557,8 +558,8 @@ async fn list_sandbox_files(
data: entries
.into_iter()
.map(|entry| SandboxFileEntry {
is_dir: entry.is_dir,
name: entry.name,
is_dir: entry.kind == FileKind::Directory,
name: entry.path,
size: entry.size.map(u64::cast_signed),
})
.collect(),
@ -872,28 +873,17 @@ async fn put_sandbox_file(
async fn reconnect_run_sandbox(
state: &Arc<AppState>,
run_id: &RunId,
) -> Result<Box<dyn Sandbox>, Response> {
) -> Result<RunSandbox, Response> {
let record = load_run_sandbox_instance(state, run_id).await?;
reconnect_run_sandbox_instance(state, run_id, &record).await
}
/// Reconnects a run's sandbox and brings it to running.
async fn reconnect_run_sandbox_instance(
state: &Arc<AppState>,
run_id: &RunId,
record: &RunSandboxInstance,
) -> Result<Box<dyn Sandbox>, Response> {
let sandbox = reconnect_driver_sandbox_instance(state, run_id, record).await?;
Ok(Box::new(sandbox))
}
/// Reconnects a run's sandbox as the driver-backed type, for endpoints that
/// reach a driver facet fabro's `Sandbox` trait does not carry (VNC, signed
/// previews, leased SSH).
async fn reconnect_driver_sandbox_instance(
state: &Arc<AppState>,
run_id: &RunId,
record: &RunSandboxInstance,
) -> Result<DriverSandbox, Response> {
) -> Result<RunSandbox, Response> {
let access = load_provider_access(state).await?;
let sandbox = reconnect_driver_for_run(record, &access, Some(*run_id), None)
.await

View file

@ -725,7 +725,7 @@ async fn build_agent_session(
.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.
@ -946,7 +946,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>,
@ -1113,7 +1113,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>,
@ -2033,7 +2033,7 @@ reasoning = false
tool_exposure_mode: ToolExposureMode::AutoApprovedOnly,
..SessionOptions::default()
};
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let sandbox = Arc::new(
fabro_agent::local_sandbox(std::env::current_dir().unwrap())
.await
.unwrap(),

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,7 +9,7 @@ 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,
DEFAULT_EXEC_OUTPUT_TAIL_BYTES, Error as SandboxError, Result as SandboxResult, RunSandbox,
StderrCollector, StdioProcessHandle, StdioProcessTermination,
};
use fabro_types::{CommandTermination, ExecOutputTail};
@ -92,7 +92,7 @@ pub(crate) struct SandboxAcpTransport {
command: AcpProcessSpec,
cwd: String,
env: HashMap<String, String>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
state: TransportState,
}
@ -101,7 +101,7 @@ impl SandboxAcpTransport {
command: AcpProcessSpec,
cwd: String,
env: HashMap<String, String>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
state: TransportState,
) -> Self {
Self {

View file

@ -10,7 +10,7 @@ use fabro_acp::{
run_acp_turn,
};
use fabro_sandbox::test_support::{MockSandbox, MockStdioProcess};
use fabro_sandbox::{Sandbox, local_sandbox, shell_quote};
use fabro_sandbox::{RunSandbox, local_sandbox, shell_quote};
use fabro_types::SteeringMessage;
use fabro_util::error::collect_chain;
use tokio::fs::{read_to_string, write};
@ -39,7 +39,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 +70,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 {
@ -104,7 +106,7 @@ async fn session_lifecycle_initializes_sends_prompt_and_aggregates_text() {
let raw_command = format!("python3 {}", 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(
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
@ -149,7 +151,7 @@ async fn steering_sends_followup_session_prompt_over_acp() {
let raw_command = format!("python3 {}", 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(
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
@ -216,7 +218,7 @@ async fn interrupt_then_steer_sends_cancel_then_followup_session_prompt_over_acp
let raw_command = format!("python3 {}", 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(
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
@ -295,7 +297,7 @@ async fn inline_interrupt_terminates_agent_that_ignores_cancel() {
let raw_command = format!("python3 {}", 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(
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
@ -686,7 +688,7 @@ async fn run_fake_agent_with_activity(
.expect("write fake ACP agent");
let raw_command = format!("python3 {}", 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(
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempdir.to_path_buf())
.await
.expect("local sandbox should be created"),

View file

@ -64,6 +64,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

@ -2,7 +2,7 @@ use fabro_llm::types::ToolDefinition;
use fabro_model::{AgentProfileKind, Catalog, Model, ProviderId};
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,
@ -21,7 +21,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>,
@ -105,7 +105,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, &[]);
@ -115,7 +115,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 fabro_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());
@ -510,7 +510,7 @@ mod tests {
use super::*;
use crate::local_sandbox;
use crate::test_support::MutableMockSandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolContext;
#[test]
@ -656,7 +656,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(),
@ -787,7 +791,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(),
@ -818,7 +826,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(),
@ -855,7 +867,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(),
@ -875,7 +891,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(),
@ -950,7 +970,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
@ -982,7 +1006,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
@ -1018,7 +1046,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
@ -1072,7 +1104,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
@ -1111,7 +1147,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(),
@ -1135,7 +1175,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
@ -1152,7 +1196,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
@ -1318,7 +1366,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(),
@ -1458,7 +1510,11 @@ class GameState:
"
.to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
@ -1516,7 +1572,11 @@ def main():
"
.to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
@ -1571,7 +1631,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 = "\
@ -1633,7 +1697,11 @@ def gamma():
"
.to_string(),
);
let env = MutableMockSandbox::new(files);
let env = MockSandbox {
files,
..Default::default()
}
.sandbox();
let patch = "\
*** Begin Patch
@ -1667,7 +1735,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
@ -1709,7 +1781,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();
@ -1796,7 +1872,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

@ -33,7 +33,7 @@ use crate::subagent::{SessionFactory, SubAgentSupervisor};
use crate::tool_permissions::{is_auto_approved, tool_category};
use crate::tools::WebFetchSummarizer;
use crate::{
AgentEvent, AgentProfile, AgentProfileBuilder, Message, Sandbox, Session, SessionOptions,
AgentEvent, AgentProfile, AgentProfileBuilder, Message, RunSandbox, Session, SessionOptions,
SessionShutdownReason, local_sandbox,
};
@ -557,7 +557,7 @@ pub async fn run_with_args_and_client_and_catalog(
// 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(
let env: Arc<RunSandbox> = Arc::new(
local_sandbox(cwd)
.await
.context("failed to create the local sandbox")?,

View file

@ -41,7 +41,7 @@ pub use fabro_mcp::config::McpServerSettings;
pub use fabro_sandbox::{ProviderAccess, SandboxOptions, SandboxProviderKind, provider_sandbox};
pub use fabro_types::SteeringMessage;
pub use history::History;
pub use local_sandbox::{DriverSandbox, local_sandbox};
pub use local_sandbox::local_sandbox;
pub use loop_detection::detect_loop;
pub use memory::{MemoryDocument, discover_memory};
pub use native_tool::{NativeTool, ToolVocabulary};
@ -56,9 +56,10 @@ pub use 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,
FileKind, GrepMatch, GrepOptions, OutputCaptureStats, RefreshOutcome, RemoteCredentialAction,
RunSandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StderrCollector, StdioProcess,
StdioProcessHandle, TokenProvenance, TokenSnapshot, WalkOptions, format_lines_numbered,
shell_quote,
};
pub use session::{
CompletionCoordinator, Session, SessionControlHandle, SessionInputTiming,

View file

@ -1,3 +1,3 @@
//! 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::{DriverSandbox, local_sandbox};
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

@ -8,7 +8,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,
@ -74,7 +74,7 @@ impl AgentProfile for AnthropicProfile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -135,7 +135,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>"));
@ -171,7 +171,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"));
@ -192,7 +192,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!(
@ -207,7 +207,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"));
@ -228,7 +228,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"));
@ -238,7 +238,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,
@ -259,7 +259,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

@ -11,7 +11,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::{
@ -92,7 +92,7 @@ impl AgentProfile for Claude5Profile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -210,7 +210,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

@ -463,7 +463,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::{
@ -503,7 +502,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

@ -8,7 +8,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::{
@ -68,7 +68,7 @@ impl AgentProfile for GeminiProfile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -118,7 +118,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"));
@ -130,7 +130,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"));
@ -148,7 +148,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"));
@ -157,7 +157,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"));
@ -167,7 +167,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

@ -27,7 +27,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;
@ -185,7 +185,7 @@ impl AgentProfile for Gpt56Profile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -240,7 +240,7 @@ mod tests {
}
fn prompt(profile: &Gpt56Profile) -> String {
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[])
}
@ -405,7 +405,7 @@ mod tests {
#[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

@ -9,7 +9,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;
@ -130,7 +130,7 @@ impl AgentProfile for KimiProfile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -370,7 +370,7 @@ mod tests {
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

@ -375,11 +375,11 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::{ExecResult, Sandbox};
use crate::test_support::{MockSandbox, MutableMockSandbox};
use crate::sandbox::{ExecResult, RunSandbox};
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolContext;
fn ctx(env: Arc<dyn Sandbox>) -> ToolContext {
fn ctx(env: Arc<RunSandbox>) -> ToolContext {
ToolContext {
env,
cancel: CancellationToken::new(),
@ -391,10 +391,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
@ -496,7 +500,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)(
@ -556,10 +564,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
}
@ -681,7 +690,7 @@ mod tests {
use fabro_types::CommandTermination;
let tool = make_kimi_bash_tool(60_000, 600_000);
let env = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: String::new(),
stderr: String::new(),
@ -690,8 +699,8 @@ mod tests {
duration_ms: 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())));
@ -703,15 +712,10 @@ 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_command.lock().unwrap().as_deref(),
Some("echo $TOKEN")
);
assert_eq!(env.captured_timeout(), Some(7_000));
assert_eq!(env.captured_working_dirs(), vec![Some("/repo".to_string())]);
assert_eq!(env.captured_env_vars(), Some(tool_env));
assert_eq!(env.captured_command().as_deref(), Some("echo $TOKEN"));
}
}
@ -809,19 +813,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

@ -23,7 +23,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;
@ -376,7 +376,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>,
@ -409,12 +409,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()),
@ -471,7 +471,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, &[])
}
@ -663,7 +663,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>"));
@ -674,7 +674,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,
@ -695,7 +695,7 @@ mod tests {
#[test]
fn profile_builder_keeps_tool_availability_and_prompt_guidance_in_sync() {
let catalog = Arc::new(Catalog::from_builtin().unwrap());
let env = MockSandbox::linux();
let env = MockSandbox::linux().sandbox();
let cases = [
(
AgentProfileKind::OpenAi,
@ -793,7 +793,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

@ -9,7 +9,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;
@ -65,7 +65,7 @@ impl AgentProfile for OpenAiProfile {
fn build_system_prompt(
&self,
env: &dyn Sandbox,
env: &RunSandbox,
env_context: &EnvContext,
memory: &[String],
user_instructions: Option<&str>,
@ -120,7 +120,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>"));
@ -132,7 +132,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"));
@ -147,7 +147,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"));
@ -156,7 +156,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 \
@ -167,7 +167,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"));
@ -177,7 +177,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(),
@ -232,7 +232,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"));
@ -258,7 +258,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"));
@ -269,7 +269,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"));
}
@ -278,7 +278,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"));
}
@ -287,7 +287,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

@ -961,7 +961,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,8 @@
// 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,
FileKind, GrepMatch, GrepOptions, OutputCaptureStats, RefreshOutcome, RemoteCredentialAction,
RunSandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StderrCollector, StdioProcess,
StdioProcessHandle, StdioProcessTermination, TokenProvenance, TokenSnapshot, WalkOptions,
format_lines_numbered, shell_quote,
};

View file

@ -42,7 +42,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,
@ -394,7 +394,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>>>,
@ -420,7 +420,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 {
@ -471,7 +471,7 @@ impl Session {
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
config: SessionOptions,
subagent_supervisor: Option<SubAgentSupervisor>,
) -> Result<Self, LlmError> {
@ -490,7 +490,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> {
@ -2243,7 +2243,7 @@ fn sandbox_mcp_launch_script(command: &[String]) -> String {
/// `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) {
async fn kill_mcp_pid(sandbox: &RunSandbox, pid: &str) {
let pid = pid.trim();
if pid.is_empty() {
return;
@ -2570,7 +2570,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,
@ -2681,7 +2681,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
@ -3566,7 +3566,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()
@ -3603,7 +3603,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;
@ -3700,7 +3700,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();
@ -3751,7 +3751,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();
@ -3773,7 +3773,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
@ -3796,7 +3796,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();
@ -3928,7 +3928,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()
@ -3956,7 +3956,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.
@ -3988,7 +3988,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();
@ -4042,7 +4042,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),
@ -4073,7 +4073,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),
@ -4105,7 +4105,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),
@ -4971,7 +4971,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,
@ -5017,7 +5017,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,
@ -5057,7 +5057,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,
@ -5098,7 +5098,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()
@ -5128,7 +5128,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,
@ -5235,7 +5235,7 @@ mod tests {
let client = make_client(provider.clone() as Arc<dyn ProviderAdapter>).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,
@ -5346,7 +5346,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,
@ -5449,7 +5449,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
@ -5647,7 +5647,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,
@ -5852,7 +5852,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")]));
@ -5865,10 +5865,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()),
@ -5904,7 +5905,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()),
@ -5935,11 +5936,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()]),
@ -5972,7 +5973,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()),
@ -6003,11 +6004,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()]),
@ -6043,11 +6044,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()]),
@ -6087,7 +6088,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()),
@ -6104,7 +6105,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};
@ -300,7 +300,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> {
@ -349,7 +349,6 @@ mod tests {
use tokio_util::sync::CancellationToken;
use super::*;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolContext;
@ -543,9 +542,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
@ -565,12 +564,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
@ -581,7 +577,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();
@ -605,12 +601,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(
@ -649,7 +642,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,
@ -672,7 +665,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,
@ -693,7 +686,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,
@ -713,7 +706,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(),
@ -756,7 +749,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

@ -1646,7 +1646,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();
@ -1752,7 +1752,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,
@ -2057,7 +2057,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

@ -10,14 +10,14 @@ use fabro_llm::types::{
ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts,
};
use fabro_model::{AgentProfileKind, ProviderId};
pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox};
pub use fabro_sandbox::test_support::MockSandbox;
use futures::stream;
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};
@ -75,7 +75,7 @@ impl AgentProfile for TestProfile {
fn build_system_prompt(
&self,
_env: &dyn Sandbox,
_env: &RunSandbox,
_env_context: &EnvContext,
_memory: &[String],
user_instructions: Option<&str>,
@ -206,7 +206,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)
}
@ -221,7 +221,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)
}
@ -229,7 +229,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)
}
@ -241,7 +241,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

@ -665,11 +665,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(),
@ -777,7 +776,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;
@ -789,7 +787,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(),

View file

@ -9,7 +9,7 @@ use tracing::debug;
use crate::config::{SessionOptions, ToolHookCallback, ToolHookDecision};
use crate::event::{Emitter, SessionBoundEmitter};
use crate::question_tools::{self, AgentToolRuntime, is_question_tool};
use crate::sandbox::{OutputCaptureStats, Sandbox};
use crate::sandbox::{OutputCaptureStats, RunSandbox};
use crate::session::ToolEnvProvider;
use crate::tool_registry::{AgentEventEmitter, RegisteredTool, ToolContext, ToolRegistry};
use crate::truncation::{
@ -28,7 +28,7 @@ pub async fn execute_tool_calls(
tool_calls: &[ToolCall],
parallel: bool,
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
env: Arc<RunSandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
cancel_token: &CancellationToken,
config: &SessionOptions,
@ -95,7 +95,7 @@ pub async fn execute_tool_calls(
async fn execute_tool_calls_sequential(
tool_calls: &[ToolCall],
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
env: Arc<RunSandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
cancel_token: &CancellationToken,
config: &SessionOptions,
@ -138,7 +138,7 @@ async fn execute_tool_calls_sequential(
async fn execute_tool_calls_parallel(
tool_calls: &[ToolCall],
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
env: Arc<RunSandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
cancel_token: &CancellationToken,
config: &SessionOptions,
@ -200,7 +200,7 @@ async fn execute_tool_calls_parallel(
async fn execute_question_tool_round(
tool_calls: &[ToolCall],
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
env: Arc<RunSandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
cancel_token: &CancellationToken,
config: &SessionOptions,
@ -329,7 +329,7 @@ fn emit_tool_call_result(
pub async fn execute_and_emit_one_tool(
tc: &ToolCall,
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
env: Arc<RunSandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
cancel_token: CancellationToken,
config: &SessionOptions,
@ -361,7 +361,7 @@ pub async fn execute_and_emit_one_tool(
async fn execute_and_emit_one_tool_with_runtime(
tc: &ToolCall,
registry: &ToolRegistry,
env: Arc<dyn Sandbox>,
env: Arc<RunSandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
cancel_token: CancellationToken,
config: &SessionOptions,
@ -404,7 +404,7 @@ async fn execute_and_emit_one_tool_with_lookup(
tc: &ToolCall,
registered_tool: Option<&RegisteredTool>,
access_denial: Option<String>,
env: Arc<dyn Sandbox>,
env: Arc<RunSandbox>,
tool_hooks: Option<&Arc<dyn ToolHookCallback>>,
cancel_token: CancellationToken,
config: &SessionOptions,
@ -518,7 +518,7 @@ struct ExecutedToolResult {
async fn execute_one_tool(
tc: &ToolCall,
registered_tool: Option<&RegisteredTool>,
env: Arc<dyn Sandbox>,
env: Arc<RunSandbox>,
cancel_token: CancellationToken,
emitter: &Emitter,
session_id: &str,
@ -897,7 +897,7 @@ mod tests {
}
}
async fn make_sandbox() -> Arc<dyn Sandbox> {
async fn make_sandbox() -> Arc<RunSandbox> {
Arc::new(
local_sandbox(std::env::current_dir().unwrap())
.await
@ -1336,11 +1336,12 @@ mod tests {
assert_eq!(*executions.lock().unwrap(), 0);
}
fn shell_sandbox(result: fabro_sandbox::ExecResult) -> Arc<dyn Sandbox> {
Arc::new(MockSandbox {
fn shell_sandbox(result: fabro_sandbox::ExecResult) -> Arc<RunSandbox> {
MockSandbox {
exec_result: result,
..Default::default()
})
}
.sandbox()
}
fn exited(exit_code: i32) -> fabro_sandbox::ExecResult {

View file

@ -9,7 +9,7 @@ use tokio_util::sync::CancellationToken;
use crate::config::{ToolAccessPolicy, ToolExposureMode};
use crate::native_tool::{NativeTool, ToolVocabulary};
use crate::sandbox::{OutputCaptureStats, Sandbox};
use crate::sandbox::{OutputCaptureStats, RunSandbox};
use crate::session::ToolEnvProvider;
use crate::tool_permissions;
use crate::types::AgentEvent;
@ -27,7 +27,7 @@ pub trait AgentEventEmitter: Send + Sync {
}
pub struct ToolContext {
pub env: Arc<dyn Sandbox>,
pub env: Arc<RunSandbox>,
pub cancel: CancellationToken,
pub tool_env_provider: Option<Arc<dyn ToolEnvProvider>>,
/// Emitting session's ID. `None` when a tool is invoked outside of a
@ -277,7 +277,6 @@ impl Default for ToolRegistry {
mod tests {
use super::*;
use crate::config::{ToolAccess, ToolAccessPolicy, ToolExposureMode};
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
struct NamedPolicy {
@ -507,7 +506,7 @@ mod tests {
let tool = registry.get("echo").unwrap();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let ctx = ToolContext {
env,
cancel: CancellationToken::new(),

View file

@ -11,7 +11,7 @@ use futures::{StreamExt, stream};
use tokio::task;
use crate::config::NativeToolOptions;
use crate::sandbox::{ExecStreamingResult, GrepOptions};
use crate::sandbox::{ExecStreamingResult, FileKind, GrepOptions};
use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
use crate::truncation::{MAX_RETAINED_TOOL_OUTPUT_BYTES, retain_tool_output};
use crate::types::AgentEvent;
@ -228,7 +228,7 @@ pub fn make_edit_file_tool() -> RegisteredTool {
};
ctx.env
.write_existing_file(file_path, &new_content)
.write_file(file_path, &new_content)
.await
.map_err(|e| e.display_with_causes())?;
Ok(format!("Successfully edited {file_path}"))
@ -442,17 +442,16 @@ pub fn make_grep_tool() -> RegisteredTool {
.map_err(|_| format!("Parameter max_results is too large: {value}"))
})
.transpose()?;
let options = GrepOptions {
glob_filter: args
.get("glob_filter")
.and_then(serde_json::Value::as_str)
.map(String::from),
case_insensitive: args
.get("case_insensitive")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
max_results,
};
let mut options = GrepOptions::default();
options.include = args
.get("glob_filter")
.and_then(serde_json::Value::as_str)
.map(String::from);
options.case_insensitive = args
.get("case_insensitive")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
options.max_matches = max_results;
let results = execute_grep(&ctx, pattern, path, &options).await?;
Ok(results.join("\n"))
@ -472,10 +471,15 @@ pub(crate) async fn execute_grep(
path: &str,
options: &GrepOptions,
) -> Result<Vec<String>, String> {
ctx.env
let matches = ctx
.env
.grep(pattern, path, options)
.await
.map_err(|e| e.display_with_causes())
.map_err(|e| e.display_with_causes())?;
Ok(matches
.into_iter()
.map(|found| format!("{}:{}:{}", found.path, found.line_number, found.line))
.collect())
}
/// Extract the file path from `<path>:<line>:<content>` grep output.
@ -619,10 +623,10 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool {
let lines: Vec<String> = entries
.iter()
.map(|e| {
if e.is_dir {
format!("{}/", e.name)
if e.kind == FileKind::Directory {
format!("{}/", e.path)
} else {
e.name.clone()
e.path.clone()
}
})
.collect();
@ -840,10 +844,11 @@ mod tests {
let tool = make_read_file_tool();
let mut files = HashMap::new();
files.insert("/test.txt".into(), "hello\nworld".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext {
env,
cancel: CancellationToken::new(),
@ -864,10 +869,11 @@ mod tests {
.map(|line| format!("line{line}"))
.collect::<Vec<_>>()
.join("\n");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files: HashMap::from([("/test.txt".to_string(), content)]),
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext {
env,
@ -890,10 +896,11 @@ mod tests {
let tool = make_read_file_tool();
let mut files = HashMap::new();
files.insert("/test.txt".into(), "line1\nline2\nline3\nline4".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(
serde_json::json!({"file_path": "/test.txt", "offset": 2, "limit": 2}),
ToolContext {
@ -913,8 +920,8 @@ mod tests {
#[tokio::test]
async fn write_file_calls_env() {
let tool = make_write_file_tool();
let env = Arc::new(MockSandbox::default());
let env_clone: Arc<dyn Sandbox> = env.clone();
let env = MockSandbox::default();
let env_clone = env.sandbox();
let result = (tool.executor)(
serde_json::json!({"file_path": "/out.txt", "content": "hello"}),
ToolContext {
@ -929,8 +936,7 @@ mod tests {
)
.await;
assert_eq!(result.unwrap(), "Successfully wrote to /out.txt");
assert_eq!(env.existing_file_write_count(), 0);
let written = env.written_files.lock().unwrap();
let written = env.written_files();
assert_eq!(written.len(), 1);
assert_eq!(written[0].0, "/out.txt");
assert_eq!(written[0].1, "hello");
@ -941,11 +947,11 @@ mod tests {
let tool = make_edit_file_tool();
let mut files = HashMap::new();
files.insert("/f.txt".into(), "hello world".into());
let env = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
let env_clone: Arc<dyn Sandbox> = env.clone();
};
let env_clone = env.sandbox();
let result = (tool.executor)(
serde_json::json!({
"file_path": "/f.txt",
@ -964,8 +970,7 @@ mod tests {
)
.await;
assert_eq!(result.unwrap(), "Successfully edited /f.txt");
assert_eq!(env.existing_file_write_count(), 1);
let written = env.written_files.lock().unwrap();
let written = env.written_files();
assert_eq!(written.len(), 1);
assert_eq!(written[0].1, "goodbye world");
}
@ -975,10 +980,11 @@ mod tests {
let tool = make_edit_file_tool();
let mut files = HashMap::new();
files.insert("/f.txt".into(), "hello world".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(
serde_json::json!({
"file_path": "/f.txt",
@ -1004,10 +1010,11 @@ mod tests {
let tool = make_edit_file_tool();
let mut files = HashMap::new();
files.insert("/f.txt".into(), "aa bb aa".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(
serde_json::json!({
"file_path": "/f.txt",
@ -1035,11 +1042,11 @@ mod tests {
let tool = make_edit_file_tool();
let mut files = HashMap::new();
files.insert("/f.txt".into(), "aa bb aa".into());
let env = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
let env_clone: Arc<dyn Sandbox> = env.clone();
};
let env_clone = env.sandbox();
let result = (tool.executor)(
serde_json::json!({
"file_path": "/f.txt",
@ -1059,7 +1066,7 @@ mod tests {
)
.await;
assert_eq!(result.unwrap(), "Successfully edited /f.txt");
let written = env.written_files.lock().unwrap();
let written = env.written_files();
assert_eq!(written.len(), 1);
assert_eq!(written[0].1, "cc bb cc");
}
@ -1069,11 +1076,11 @@ mod tests {
let tool = make_edit_file_tool();
let mut files = HashMap::new();
files.insert("/f.txt".into(), "1 | keep this literal\nhello".into());
let env = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
let env_clone: Arc<dyn Sandbox> = env.clone();
};
let env_clone = env.sandbox();
let result = (tool.executor)(
serde_json::json!({
"file_path": "/f.txt",
@ -1092,12 +1099,12 @@ mod tests {
)
.await;
assert_eq!(result.unwrap(), "Successfully edited /f.txt");
let written = env.written_files.lock().unwrap();
let written = env.written_files();
assert_eq!(written.len(), 1);
assert_eq!(written[0].1, "1 | keep this literal\ngoodbye");
}
fn shell_context(env: Arc<dyn Sandbox>) -> ToolContext {
fn shell_context(env: Arc<RunSandbox>) -> ToolContext {
ToolContext {
env,
cancel: CancellationToken::new(),
@ -1109,7 +1116,7 @@ mod tests {
}
}
fn shell_context_with_emitter(env: Arc<dyn Sandbox>, emitter: &Emitter) -> ToolContext {
fn shell_context_with_emitter(env: Arc<RunSandbox>, emitter: &Emitter) -> ToolContext {
ToolContext {
session_id: Some("test-session".to_string()),
root_session_id: Some("test-session".to_string()),
@ -1134,23 +1141,24 @@ mod tests {
event.event
}
fn mock_sandbox_with(result: ExecResult) -> Arc<MockSandbox> {
Arc::new(MockSandbox {
fn mock_sandbox_with(result: ExecResult) -> MockSandbox {
MockSandbox {
exec_result: result,
..Default::default()
})
}
}
#[tokio::test]
async fn shell_success_returns_ok_with_metadata_and_separate_streams() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = mock_sandbox_with(ExecResult {
let env = mock_sandbox_with(ExecResult {
stdout: "hello".into(),
stderr: "a warning".into(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 10,
});
})
.sandbox();
let output = (tool.executor)(
serde_json::json!({"command": "echo hello"}),
shell_context(env),
@ -1177,23 +1185,19 @@ mod tests {
});
let _ = (tool.executor)(
serde_json::json!({"command": "make test"}),
shell_context(env.clone()),
shell_context(env.sandbox()),
)
.await;
let captured = env
.captured_command
.lock()
.expect("captured_command lock poisoned")
.clone();
let captured = env.captured_command();
assert_eq!(captured.as_deref(), Some("make test"));
}
#[tokio::test]
async fn shell_with_timeout() {
let tool = make_shell_tool();
let env = Arc::new(MockSandbox::default());
let env_clone: Arc<dyn Sandbox> = env.clone();
let env = MockSandbox::default();
let env_clone = env.sandbox();
let _result = (tool.executor)(
serde_json::json!({"command": "sleep 1", "timeout_ms": 5000}),
ToolContext {
@ -1207,13 +1211,13 @@ mod tests {
},
)
.await;
assert_eq!(*env.captured_timeout.lock().unwrap(), Some(5000));
assert_eq!(env.captured_timeout(), Some(5000));
}
#[tokio::test]
async fn shell_nonzero_exit_code() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: "error".into(),
stderr: String::new(),
@ -1222,7 +1226,8 @@ mod tests {
duration_ms: 10,
},
..Default::default()
});
}
.sandbox();
let output = (tool.executor)(serde_json::json!({"command": "false"}), shell_context(env))
.await
.expect_err("a nonzero exit is a failed tool result");
@ -1235,13 +1240,14 @@ mod tests {
#[tokio::test]
async fn shell_timeout_returns_error_with_partial_output() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = mock_sandbox_with(ExecResult {
let env = mock_sandbox_with(ExecResult {
stdout: "partial".into(),
stderr: String::new(),
exit_code: None,
termination: CommandTermination::TimedOut,
duration_ms: 10000,
});
})
.sandbox();
let output = (tool.executor)(
serde_json::json!({"command": "sleep 100"}),
shell_context(env),
@ -1257,13 +1263,14 @@ mod tests {
#[tokio::test]
async fn shell_cancellation_returns_error_with_partial_output() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = mock_sandbox_with(ExecResult {
let env = mock_sandbox_with(ExecResult {
stdout: "partial".into(),
stderr: String::new(),
exit_code: None,
termination: CommandTermination::Cancelled,
duration_ms: 42,
});
})
.sandbox();
let output = (tool.executor)(
serde_json::json!({"command": "sleep 100"}),
shell_context(env),
@ -1279,10 +1286,11 @@ mod tests {
#[tokio::test]
async fn shell_sandbox_failure_returns_error_without_a_process_outcome() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
exec_error: Some("sandbox transport is down".into()),
..Default::default()
});
}
.sandbox();
let emitter = Emitter::new();
let mut receiver = emitter.subscribe();
@ -1311,13 +1319,14 @@ mod tests {
#[tokio::test]
async fn shell_emits_process_event_with_typed_outcome_and_redacted_tails() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = mock_sandbox_with(ExecResult {
let env = mock_sandbox_with(ExecResult {
stdout: "out".into(),
stderr: "boom key=AKIAYRWQG5EJLPZLBYNP".into(),
exit_code: Some(7),
termination: CommandTermination::Exited,
duration_ms: 12,
});
})
.sandbox();
let emitter = Emitter::new();
let mut receiver = emitter.subscribe();
@ -1357,7 +1366,7 @@ mod tests {
#[tokio::test]
async fn shell_renders_combined_output_when_streams_are_not_separated() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: "interleaved".into(),
stderr: String::new(),
@ -1367,7 +1376,8 @@ mod tests {
},
streams_separated: false,
..Default::default()
});
}
.sandbox();
let emitter = Emitter::new();
let mut receiver = emitter.subscribe();
@ -1399,13 +1409,14 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
assert!(stdout.len() > 30_000);
let env: Arc<dyn Sandbox> = mock_sandbox_with(ExecResult {
let env = mock_sandbox_with(ExecResult {
stdout,
stderr: "the build failed".into(),
exit_code: Some(2),
termination: CommandTermination::Exited,
duration_ms: 900,
});
})
.sandbox();
let output = (tool.executor)(
serde_json::json!({"command": "make build"}),
@ -1431,7 +1442,7 @@ mod tests {
#[tokio::test]
async fn shell_reports_real_local_process_outcome() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = Arc::new(
let env: Arc<RunSandbox> = Arc::new(
local_sandbox(std::env::current_dir().expect("current dir"))
.await
.unwrap(),
@ -1473,8 +1484,8 @@ mod tests {
#[tokio::test]
async fn shell_passes_tool_env_to_exec_command() {
let tool = make_shell_tool();
let env = Arc::new(MockSandbox::default());
let env_clone: Arc<dyn Sandbox> = env.clone();
let env = MockSandbox::default();
let env_clone = env.sandbox();
let mut tool_env = HashMap::new();
tool_env.insert("MY_KEY".into(), "my_value".into());
let _result = (tool.executor)(
@ -1490,7 +1501,7 @@ mod tests {
},
)
.await;
let captured = env.captured_env_vars.lock().unwrap().clone();
let captured = env.captured_env_vars();
assert_eq!(captured, Some(tool_env));
}
@ -1517,7 +1528,8 @@ mod tests {
#[tokio::test]
async fn shell_resolves_tool_env_for_each_call() {
let tool = make_shell_tool();
let env = Arc::new(MockSandbox::default());
let env = MockSandbox::default();
let sandbox = env.sandbox();
let provider = Arc::new(SequenceToolEnvProvider {
values: std::sync::Mutex::new(vec![
HashMap::from([("GITHUB_TOKEN".to_string(), "t1".to_string())]),
@ -1528,7 +1540,7 @@ mod tests {
let _result = (tool.executor)(
serde_json::json!({"command": "echo $GITHUB_TOKEN"}),
ToolContext {
env: env.clone(),
env: sandbox.clone(),
cancel: CancellationToken::new(),
tool_env_provider: Some(provider.clone()),
session_id: None,
@ -1539,7 +1551,7 @@ mod tests {
)
.await;
assert_eq!(
env.captured_env_vars.lock().unwrap().clone(),
env.captured_env_vars(),
Some(HashMap::from([(
"GITHUB_TOKEN".to_string(),
"t1".to_string()
@ -1549,7 +1561,7 @@ mod tests {
let _result = (tool.executor)(
serde_json::json!({"command": "echo $GITHUB_TOKEN"}),
ToolContext {
env: env.clone(),
env: sandbox.clone(),
cancel: CancellationToken::new(),
tool_env_provider: Some(provider),
session_id: None,
@ -1560,7 +1572,7 @@ mod tests {
)
.await;
assert_eq!(
env.captured_env_vars.lock().unwrap().clone(),
env.captured_env_vars(),
Some(HashMap::from([(
"GITHUB_TOKEN".to_string(),
"t2".to_string()
@ -1571,7 +1583,7 @@ mod tests {
#[tokio::test]
async fn shell_returns_provider_error_for_env_resolution_failure() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let result = (tool.executor)(
serde_json::json!({"command": "echo $GITHUB_TOKEN"}),
@ -1599,10 +1611,11 @@ mod tests {
let tool = make_read_file_tool();
let mut files = HashMap::new();
files.insert("/test.txt".into(), "hello".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
files,
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext {
env,
@ -1619,10 +1632,10 @@ mod tests {
}
#[tokio::test]
async fn shell_passes_none_env_when_tool_env_is_none() {
async fn shell_passes_no_env_when_tool_env_is_none() {
let tool = make_shell_tool();
let env = Arc::new(MockSandbox::default());
let env_clone: Arc<dyn Sandbox> = env.clone();
let env = MockSandbox::default();
let env_clone = env.sandbox();
let _result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
@ -1633,14 +1646,14 @@ mod tests {
agent_event_emitter: None,
})
.await;
let captured = env.captured_env_vars.lock().unwrap().clone();
assert_eq!(captured, None);
let captured = env.captured_env_vars();
assert_eq!(captured, Some(HashMap::new()));
}
#[tokio::test]
async fn web_fetch_passes_tool_env_to_exec_command() {
let tool = make_web_fetch_tool(None);
let env = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: "fetched content".into(),
stderr: String::new(),
@ -1649,8 +1662,8 @@ mod tests {
duration_ms: 100,
},
..Default::default()
});
let env_clone: Arc<dyn Sandbox> = env.clone();
};
let env_clone = env.sandbox();
let mut tool_env = HashMap::new();
tool_env.insert("API_KEY".into(), "secret".into());
let _result = (tool.executor)(
@ -1666,20 +1679,21 @@ mod tests {
},
)
.await;
let captured = env.captured_env_vars.lock().unwrap().clone();
let captured = env.captured_env_vars();
assert_eq!(captured, Some(tool_env));
}
#[tokio::test]
async fn grep_basic() {
let tool = make_grep_tool();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
grep_results: vec![
"src/main.rs:10:fn main()".into(),
"src/lib.rs:5:pub fn".into(),
],
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(serde_json::json!({"pattern": "fn"}), ToolContext {
env,
cancel: CancellationToken::new(),
@ -1698,10 +1712,14 @@ mod tests {
#[tokio::test]
async fn glob_basic() {
let tool = make_glob_tool();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
glob_results: vec!["src/main.rs".into(), "src/lib.rs".into()],
let env = MockSandbox {
files: HashMap::from([
("src/main.rs".to_string(), String::new()),
("src/lib.rs".to_string(), String::new()),
]),
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(serde_json::json!({"pattern": "src/**/*.rs"}), ToolContext {
env,
cancel: CancellationToken::new(),
@ -1729,7 +1747,7 @@ mod tests {
#[tokio::test]
async fn web_search_missing_query_returns_error() {
let tool = make_web_search_tool_with_api_key("fake-key".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let result = (tool.executor)(serde_json::json!({}), ToolContext {
env,
cancel: CancellationToken::new(),
@ -1763,7 +1781,7 @@ mod tests {
let tool = registry
.get("web_search")
.expect("web_search should be registered");
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let result = (tool.executor)(serde_json::json!({}), ToolContext {
env,
cancel: CancellationToken::new(),
@ -1785,7 +1803,7 @@ mod tests {
#[tokio::test]
async fn web_fetch_builds_curl_command() {
let tool = make_web_fetch_tool(None);
let env = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: "<html><body><h1>hello</h1></body></html>".into(),
stderr: String::new(),
@ -1794,8 +1812,8 @@ mod tests {
duration_ms: 100,
},
..Default::default()
});
let env_clone: Arc<dyn Sandbox> = env.clone();
};
let env_clone = env.sandbox();
let result = (tool.executor)(
serde_json::json!({"url": "https://example.com"}),
ToolContext {
@ -1818,7 +1836,7 @@ mod tests {
!output.contains("<html>"),
"raw HTML tags should be removed, got: {output}"
);
let cmd = env.captured_command.lock().unwrap().clone().unwrap();
let cmd = env.captured_command().unwrap();
assert!(
cmd.starts_with("curl -sL --max-time 30 "),
"command should start with curl flags, got: {cmd}"
@ -1836,7 +1854,7 @@ mod tests {
#[tokio::test]
async fn web_fetch_rejects_non_http_url() {
let tool = make_web_fetch_tool(None);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let result = (tool.executor)(
serde_json::json!({"url": "ftp://example.com/file"}),
ToolContext {
@ -1860,8 +1878,8 @@ mod tests {
#[tokio::test]
async fn web_fetch_timeout_flows_through() {
let tool = make_web_fetch_tool(None);
let env = Arc::new(MockSandbox::default());
let env_clone: Arc<dyn Sandbox> = env.clone();
let env = MockSandbox::default();
let env_clone = env.sandbox();
let _result = (tool.executor)(
serde_json::json!({"url": "https://example.com", "timeout_ms": 15000}),
ToolContext {
@ -1875,8 +1893,8 @@ mod tests {
},
)
.await;
assert_eq!(*env.captured_timeout.lock().unwrap(), Some(15000));
let cmd = env.captured_command.lock().unwrap().clone().unwrap();
assert_eq!(env.captured_timeout(), Some(15000));
let cmd = env.captured_command().unwrap();
assert!(
cmd.contains("--max-time 15"),
"curl timeout should be 15 seconds, got: {cmd}"
@ -1886,8 +1904,8 @@ mod tests {
#[tokio::test]
async fn web_fetch_timeout_capped_at_60s() {
let tool = make_web_fetch_tool(None);
let env = Arc::new(MockSandbox::default());
let env_clone: Arc<dyn Sandbox> = env.clone();
let env = MockSandbox::default();
let env_clone = env.sandbox();
let _result = (tool.executor)(
serde_json::json!({"url": "https://example.com", "timeout_ms": 120_000}),
ToolContext {
@ -1901,8 +1919,8 @@ mod tests {
},
)
.await;
assert_eq!(*env.captured_timeout.lock().unwrap(), Some(60000));
let cmd = env.captured_command.lock().unwrap().clone().unwrap();
assert_eq!(env.captured_timeout(), Some(60000));
let cmd = env.captured_command().unwrap();
assert!(
cmd.contains("--max-time 60"),
"curl timeout should be capped at 60 seconds, got: {cmd}"
@ -1913,7 +1931,7 @@ mod tests {
async fn web_fetch_truncates_large_output() {
let large_content = "x".repeat(150 * 1024);
let tool = make_web_fetch_tool(None);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: large_content,
stderr: String::new(),
@ -1922,7 +1940,8 @@ mod tests {
duration_ms: 100,
},
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(
serde_json::json!({"url": "https://example.com"}),
ToolContext {
@ -1944,7 +1963,7 @@ mod tests {
#[tokio::test]
async fn web_fetch_returns_error_on_nonzero_exit() {
let tool = make_web_fetch_tool(None);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: String::new(),
stderr: "curl: (6) Could not resolve host".into(),
@ -1953,7 +1972,8 @@ mod tests {
duration_ms: 100,
},
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(
serde_json::json!({"url": "https://nonexistent.example.com"}),
ToolContext {
@ -1995,7 +2015,7 @@ mod tests {
};
let tool = make_web_fetch_tool(Some(summarizer));
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: "<html><body><p>Lots of content about Rust...</p></body></html>"
.into(),
@ -2005,7 +2025,8 @@ mod tests {
duration_ms: 100,
},
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(
serde_json::json!({"url": "https://example.com", "prompt": "What is Rust?"}),
ToolContext {
@ -2029,7 +2050,7 @@ mod tests {
#[tokio::test]
async fn web_fetch_prompt_without_summarizer_returns_content_with_note() {
let tool = make_web_fetch_tool(None);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout:
"<html><body><p>Rust is a systems programming language.</p></body></html>"
@ -2040,7 +2061,8 @@ mod tests {
duration_ms: 100,
},
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(
serde_json::json!({"url": "https://example.com", "prompt": "What is Rust?"}),
ToolContext {
@ -2104,7 +2126,7 @@ mod tests {
};
let tool = make_web_fetch_tool(Some(summarizer));
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
let env = MockSandbox {
exec_result: ExecResult {
stdout: "<html><body><p>Page content</p></body></html>".into(),
stderr: String::new(),
@ -2113,7 +2135,8 @@ mod tests {
duration_ms: 100,
},
..Default::default()
});
}
.sandbox();
let result = (tool.executor)(
serde_json::json!({"url": "https://example.com", "prompt": "Summarize this"}),
ToolContext {
@ -2171,7 +2194,7 @@ mod tests {
let api_key = std::env::var(EnvVars::BRAVE_SEARCH_API_KEY)
.expect("BRAVE_SEARCH_API_KEY must be set to run this test");
let tool = make_web_search_tool_with_api_key(api_key);
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
let result = (tool.executor)(
serde_json::json!({"query": "rust programming language"}),
ToolContext {

View file

@ -300,7 +300,6 @@ pub(crate) fn make_web_search_tool_with_api_key(api_key: String) -> RegisteredTo
#[cfg(test)]
mod tests {
use std::sync::Arc;
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
@ -308,7 +307,6 @@ mod tests {
use super::*;
use crate::config::ToolSecrets;
use crate::sandbox::Sandbox;
use crate::test_support::MockSandbox;
use crate::tool_registry::ToolContext;
@ -320,7 +318,7 @@ mod tests {
}
async fn execute(tool: &RegisteredTool, args: serde_json::Value) -> Result<String, String> {
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let env = MockSandbox::default().sandbox();
(tool.executor)(args, ToolContext {
env,
cancel: CancellationToken::new(),

View file

@ -5,7 +5,6 @@
use std::sync::Arc;
use fabro_agent::event::SessionBoundEmitter;
use fabro_agent::sandbox::Sandbox;
use fabro_agent::tool_registry::ToolContext;
use fabro_agent::tools::make_shell_tool;
use fabro_agent::types::AgentEvent;
@ -48,7 +47,7 @@ async fn shell_reports_real_docker_process_outcome() {
let result = (tool.executor)(
serde_json::json!({"command": "printf 'out'; printf 'err' >&2; exit 7"}),
ToolContext {
env: sandbox.clone() as Arc<dyn Sandbox>,
env: sandbox.clone(),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some("test-session".to_string()),

View file

@ -82,7 +82,7 @@ async fn make_session(
let client = make_client(&provider, twin.as_ref()).await;
let profile_builder = profile_builder(&provider, model, &client, tool_secrets);
let mut profile = profile_builder.build();
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let env: Arc<fabro_agent::RunSandbox> = Arc::new(
local_sandbox(cwd.to_path_buf())
.await
.expect("local sandbox should be created"),

View file

@ -1,6 +1,6 @@
use std::sync::Arc;
use fabro_agent::{Sandbox, ToolHookCallback, ToolHookDecision};
use fabro_agent::{RunSandbox, ToolHookCallback, ToolHookDecision};
use fabro_types::RunId;
use crate::runner::HookRunner;
@ -12,7 +12,7 @@ use crate::types::{HookContext, HookDecision, HookEvent, HookExecutionContext};
/// context needed to build `HookContext` for tool-level events.
pub struct WorkflowToolHookCallback {
pub hook_runner: Arc<HookRunner>,
pub sandbox: Arc<dyn Sandbox>,
pub sandbox: Arc<RunSandbox>,
pub run_id: RunId,
pub workflow_name: String,
pub hook_execution_context: HookExecutionContext,
@ -98,7 +98,7 @@ mod tests {
&self,
_definition: &HookDefinition,
context: &HookContext,
_sandbox: Arc<dyn Sandbox>,
_sandbox: Arc<RunSandbox>,
execution_context: &HookExecutionContext,
_llm_source: &dyn fabro_auth::CredentialSource,
_catalog: Arc<Catalog>,
@ -129,7 +129,7 @@ mod tests {
}
}
async fn make_sandbox() -> Arc<dyn Sandbox> {
async fn make_sandbox() -> Arc<RunSandbox> {
Arc::new(
fabro_agent::local_sandbox(std::env::current_dir().unwrap())
.await
@ -139,7 +139,7 @@ mod tests {
fn make_bridge(
hook_runner: Arc<HookRunner>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
hook_execution_context: HookExecutionContext,
) -> WorkflowToolHookCallback {
WorkflowToolHookCallback {

View file

@ -4,7 +4,7 @@ use std::sync::{Arc, LazyLock};
use std::time::Instant;
use async_trait::async_trait;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_agent::tool_registry::ToolContext;
use fabro_auth::CredentialSource;
use fabro_llm::client::Client as LlmClient;
@ -47,7 +47,7 @@ pub trait HookExecutor: Send + Sync {
&self,
definition: &HookDefinition,
context: &HookContext,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
execution_context: &HookExecutionContext,
llm_source: &dyn CredentialSource,
catalog: Arc<Catalog>,
@ -128,7 +128,7 @@ impl HookExecutorImpl {
definition: &HookDefinition,
command: &InterpString,
context: &HookContext,
sandbox: &Arc<dyn Sandbox>,
sandbox: &Arc<RunSandbox>,
execution_context: &HookExecutionContext,
) -> HookDecision {
let command = match resolve_interp(command) {
@ -346,7 +346,7 @@ impl HookExecutorImpl {
model: Option<&InterpString>,
max_tool_rounds: Option<u32>,
context: &HookContext,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
llm_source: &dyn CredentialSource,
catalog: Arc<Catalog>,
) -> HookDecision {
@ -627,7 +627,7 @@ impl HookExecutor for HookExecutorImpl {
&self,
definition: &HookDefinition,
context: &HookContext,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
execution_context: &HookExecutionContext,
llm_source: &dyn CredentialSource,
catalog: Arc<Catalog>,
@ -740,7 +740,7 @@ mod tests {
HookContext::new(HookEvent::StageStart, fixtures::RUN_1, "test-wf".into())
}
async fn make_sandbox() -> Arc<dyn Sandbox> {
async fn make_sandbox() -> Arc<RunSandbox> {
Arc::new(
fabro_agent::local_sandbox(std::env::current_dir().unwrap())
.await

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_auth::CredentialSource;
#[cfg(test)]
use fabro_auth::test_support;
@ -71,7 +71,7 @@ impl HookRunner {
pub async fn run(
&self,
context: &HookContext,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
execution_context: HookExecutionContext,
) -> HookDecision {
let matching = self.filter_hooks(context);
@ -141,7 +141,7 @@ impl HookRunner {
&self,
hooks: &[&HookDefinition],
context: &HookContext,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
execution_context: &HookExecutionContext,
) -> HookDecision {
let mut merged = HookDecision::Proceed;
@ -197,7 +197,7 @@ impl HookRunner {
&self,
hooks: &[&HookDefinition],
context: &HookContext,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
execution_context: &HookExecutionContext,
) -> HookDecision {
for hook in hooks {
@ -254,7 +254,7 @@ mod tests {
&self,
definition: &HookDefinition,
_context: &HookContext,
_sandbox: Arc<dyn Sandbox>,
_sandbox: Arc<RunSandbox>,
_execution_context: &HookExecutionContext,
_llm_source: &dyn CredentialSource,
_catalog: Arc<Catalog>,
@ -267,7 +267,7 @@ mod tests {
}
}
async fn make_sandbox() -> Arc<dyn Sandbox> {
async fn make_sandbox() -> Arc<RunSandbox> {
Arc::new(
fabro_agent::local_sandbox(std::env::current_dir().unwrap())
.await

View file

@ -1,7 +1,7 @@
use std::path::Path;
use std::sync::Arc;
use fabro_agent::{Sandbox, local_sandbox};
use fabro_agent::{RunSandbox, local_sandbox};
use fabro_auth::{CredentialSource, test_support};
use fabro_hooks::{
HookContext, HookDecision, HookDefinition, HookEvent, HookExecutionContext, HookRunner,
@ -19,7 +19,7 @@ fn test_catalog() -> Arc<Catalog> {
Arc::new(Catalog::from_builtin().expect("default catalog should build"))
}
async fn test_sandbox() -> Arc<dyn Sandbox> {
async fn test_sandbox() -> Arc<RunSandbox> {
Arc::new(
local_sandbox(std::env::current_dir().expect("test process should have a cwd"))
.await

View file

@ -4,12 +4,12 @@ edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Sandbox trait and implementations for Fabro agent execution environments"
description = "Fabro run sandboxes over the sandbox driver: local, Docker, and Daytona"
[features]
default = ["local"]
local = []
test-support = []
test-support = ["dep:sandbox-driver-testing"]
[lib]
doctest = false
@ -25,6 +25,7 @@ sandbox-driver-docker.workspace = true
sandbox-driver-docker-config.workspace = true
sandbox-driver-daytona.workspace = true
sandbox-driver-daytona-config.workspace = true
sandbox-driver-testing = { workspace = true, optional = true }
anyhow.workspace = true
async-trait.workspace = true
thiserror.workspace = true
@ -54,6 +55,7 @@ chrono = { workspace = true }
[dev-dependencies]
fabro-github = { path = "../fabro-github", features = ["test-support"] }
sandbox-driver-testing.workspace = true
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"
serde_json.workspace = true

View file

@ -754,8 +754,7 @@ mod wire_gate {
use tokio::io::{duplex, split};
use super::*;
use crate::Sandbox as _;
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace};
use crate::driver_sandbox::{LayoutSource, RepoWorkspace, RunSandbox};
use crate::options::base_spec;
#[expect(
@ -805,7 +804,7 @@ mod wire_gate {
let snapshot = SnapshotId::try_new(DEFAULT_SNAPSHOT).expect("snapshot id");
let options = SandboxOptions::default();
let spec = overlay(base_spec(&options, None), &options, None, &snapshot);
let sandbox = DriverSandbox::pending(
let sandbox = RunSandbox::pending(
SandboxProviderKind::DAYTONA,
remote,
spec,

View file

@ -20,10 +20,12 @@ use async_trait::async_trait;
use fabro_github::GitHubCredentials;
use fabro_github::token_source::InstallationTokenSource;
use fabro_types::SandboxProviderKind;
use fabro_util::workspace_glob::WorkspaceGlob;
use sandbox_driver::{
Action, Event, EventBody, EventContext, EventObserver, FileKind, LifecycleTimers, ProgressCode,
PtyOptions, PtySize, Sandbox as DriverHandle, SandboxProvider as DriverProvider, SandboxSource,
SandboxSpec as DriverSpec, SandboxState, Search as _, WaitOptions,
Action, DirEntry, Event, EventBody, EventContext, EventObserver, FileKind, GrepMatch,
GrepOptions, LifecycleTimers, ProgressCode, PtyOptions, PtySize, Sandbox as DriverHandle,
SandboxProvider as DriverProvider, SandboxSource, SandboxSpec as DriverSpec, SandboxState,
Search as _, WaitOptions, WalkOptions,
};
use sandbox_driver_host::HostProvider;
use tokio::fs;
@ -44,7 +46,7 @@ use crate::{GitRunInfo, GitSetupIntent, RefreshOutcome, RetryPlan};
/// scratch path. The registry lives in a per-process temporary root, so a
/// later process rebuilds the handle by calling this again with the
/// persisted working directory rather than by id.
pub async fn local_sandbox(working_directory: impl Into<PathBuf>) -> crate::Result<DriverSandbox> {
pub async fn local_sandbox(working_directory: impl Into<PathBuf>) -> crate::Result<RunSandbox> {
let working_directory: PathBuf = working_directory.into();
fs::create_dir_all(&working_directory)
.await
@ -56,15 +58,14 @@ pub async fn local_sandbox(working_directory: impl Into<PathBuf>) -> crate::Resu
.create(&spec, None)
.await
.map_err(|error| crate::Error::context("Failed to create local sandbox", error))?;
let sandbox = DriverSandbox::new(SandboxProviderKind::LOCAL, handle);
let sandbox = RunSandbox::new(SandboxProviderKind::LOCAL, handle);
sandbox.learn_platform().await?;
Ok(sandbox)
}
use crate::exec::{ExplicitEnvPolicy, SandboxExec};
use crate::sandbox::{
self, DirEntry, ExecResult, ExecStreamingRequest, ExecStreamingResult, GrepOptions, PushError,
PushReport, Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, SandboxWorkspaceLayout,
StdioProcess, WalkOptions,
self, ExecResult, ExecStreamingRequest, ExecStreamingResult, PushError, PushReport,
SandboxEvent, SandboxEventCallback, SandboxFile, SandboxWorkspaceLayout, StdioProcess,
};
/// Where a clone-based provider puts its files: the run works under
@ -318,7 +319,7 @@ struct PendingCreate {
}
/// A fabro sandbox backed by a sandbox-driver handle.
pub struct DriverSandbox {
pub struct RunSandbox {
kind: SandboxProviderKind,
/// Set at construction for an existing sandbox, at `initialize` for a
/// pending one.
@ -334,7 +335,7 @@ pub struct DriverSandbox {
snapshot: OnceLock<String>,
}
impl DriverSandbox {
impl RunSandbox {
/// Wraps a driver handle. `local` runs on the worker host, so explicit
/// environment variables pass the credential filter; every other kind
/// is isolated and takes the caller's environment as composed.
@ -345,6 +346,20 @@ impl DriverSandbox {
sandbox
}
/// A sandbox over an existing handle whose platform is already known,
/// so tests need no activation round trip before reading it.
#[cfg(any(test, feature = "test-support"))]
pub fn new_with_platform(
kind: SandboxProviderKind,
handle: Arc<dyn DriverHandle>,
platform: impl Into<String>,
os_version: impl Into<String>,
) -> Self {
let sandbox = Self::new(kind, handle);
let _ = sandbox.platform.set((platform.into(), os_version.into()));
sandbox
}
/// A sandbox `initialize` will create from `spec` on `provider`, then
/// prepare per `workspace`.
pub(crate) fn pending(
@ -438,7 +453,9 @@ impl DriverSandbox {
})
}
fn exec(&self) -> crate::Result<SandboxExec<'_>> {
/// Fabro's exec policy over the driver's exec facet, working in the
/// run's directory. Absent until a pending sandbox is initialized.
pub fn exec(&self) -> crate::Result<SandboxExec<'_>> {
let mut exec = SandboxExec::new(self.handle()?.exec(), self.env_policy);
if let Some(workspace) = &self.workspace {
if let Some(dir) = workspace.execution_directory.get() {
@ -764,9 +781,8 @@ fn file_context(action: &str, path: &str) -> String {
format!("Failed to {action} {path}")
}
#[async_trait]
impl Sandbox for DriverSandbox {
async fn read_file_bytes(&self, path: &str) -> crate::Result<Vec<u8>> {
impl RunSandbox {
pub async fn read_file_bytes(&self, path: &str) -> crate::Result<Vec<u8>> {
self.handle()?
.fs()
.read(&self.resolve(path))
@ -774,7 +790,26 @@ impl Sandbox for DriverSandbox {
.map_err(|error| crate::Error::context(file_context("read", path), error))
}
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
pub async fn read_file_text(&self, path: &str) -> crate::Result<String> {
String::from_utf8(self.read_file_bytes(path).await?)
.map_err(|err| crate::Error::context("File is not valid UTF-8", err))
}
/// A file's text with line numbers, from `offset` for `limit` lines.
pub async fn read_file(
&self,
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> crate::Result<String> {
Ok(sandbox::format_lines_numbered(
&self.read_file_text(path).await?,
offset,
limit,
))
}
pub async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {
self.handle()?
.fs()
.write(&self.resolve(path), content.as_bytes())
@ -782,7 +817,7 @@ impl Sandbox for DriverSandbox {
.map_err(|error| crate::Error::context(file_context("write", path), error))
}
async fn delete_file(&self, path: &str) -> crate::Result<()> {
pub async fn delete_file(&self, path: &str) -> crate::Result<()> {
// Fabro's contract fails on a missing file; the driver's delete is
// idempotent, so check first.
if !self.file_exists(path).await? {
@ -798,7 +833,7 @@ impl Sandbox for DriverSandbox {
.map_err(|error| crate::Error::context(file_context("delete", path), error))
}
async fn file_exists(&self, path: &str) -> crate::Result<bool> {
pub async fn file_exists(&self, path: &str) -> crate::Result<bool> {
self.handle()?
.fs()
.exists(&self.resolve(path))
@ -806,32 +841,29 @@ impl Sandbox for DriverSandbox {
.map_err(|error| crate::Error::context(file_context("stat", path), error))
}
async fn list_directory(
/// Lists a directory to `depth` (`None` is the immediate children),
/// sorted by path. Sizes are reported for files only.
pub async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> crate::Result<Vec<DirEntry>> {
let entries = self
let mut entries = self
.handle()?
.fs()
.list_dir(&self.resolve(path), depth.unwrap_or(1))
.await
.map_err(|error| crate::Error::context(file_context("list", path), error))?;
let mut entries: Vec<DirEntry> = entries
.into_iter()
.map(|entry| DirEntry {
name: entry.path,
is_dir: entry.kind == FileKind::Directory,
size: (entry.kind == FileKind::File)
.then_some(entry.size)
.flatten(),
})
.collect();
entries.sort_by(|left, right| left.name.cmp(&right.name));
for entry in &mut entries {
if entry.kind != FileKind::File {
entry.size = None;
}
}
entries.sort_by(|left, right| left.path.cmp(&right.path));
Ok(entries)
}
async fn exec_command(
pub async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
@ -850,14 +882,14 @@ impl Sandbox for DriverSandbox {
.await
}
async fn exec_command_streaming(
pub async fn exec_command_streaming(
&self,
request: ExecStreamingRequest<'_>,
) -> crate::Result<ExecStreamingResult> {
self.exec()?.run_streaming(request).await
}
async fn spawn_stdio_process(
pub async fn spawn_stdio_process(
&self,
command: &str,
working_dir: Option<&str>,
@ -869,44 +901,43 @@ impl Sandbox for DriverSandbox {
.await
}
async fn grep(
/// Searches file contents below `path`, resolved against the run's
/// working directory.
pub async fn grep(
&self,
pattern: &str,
path: &str,
options: &GrepOptions,
) -> crate::Result<Vec<String>> {
let mut driver_options = sandbox_driver::GrepOptions::default();
driver_options.case_insensitive = options.case_insensitive;
driver_options.max_matches = options.max_results;
driver_options.include.clone_from(&options.glob_filter);
let matches = self
.search()?
.grep(pattern, &self.resolve(path), &driver_options)
) -> crate::Result<Vec<GrepMatch>> {
self.search()?
.grep(pattern, &self.resolve(path), options)
.await
.map_err(|error| crate::Error::context("Failed to search file contents", error))?;
Ok(matches
.into_iter()
.map(|m| format!("{}:{}:{}", m.path, m.line_number, m.line))
.collect())
.map_err(|error| crate::Error::context("Failed to search file contents", error))
}
async fn walk_files(
/// Recursively enumerates regular files below `base`, starting at the
/// literal directory `relative_start` inside it. Every returned
/// `relative_path` is relative to `base`; `options.exclude_dirs` names
/// directory basenames pruned at every depth, including on the way to
/// `relative_start`.
pub async fn walk_files(
&self,
base: &str,
relative_start: &str,
options: &WalkOptions,
) -> crate::Result<Vec<SandboxFile>> {
if options.excludes_relative_path(relative_start) {
if relative_start.split('/').any(|segment| {
options
.exclude_dirs
.iter()
.any(|excluded| excluded == segment)
}) {
return Ok(Vec::new());
}
let mut driver_options = sandbox_driver::WalkOptions::default();
driver_options
.exclude_dirs
.clone_from(&options.excluded_directory_names);
let walk_base = self.walk_base(base, relative_start);
let walked = self
.search()?
.walk(&walk_base, &driver_options)
.walk(&walk_base, options)
.await
.map_err(|error| crate::Error::context("Failed to enumerate files", error))?;
let mut files = Vec::with_capacity(walked.len());
@ -935,7 +966,23 @@ impl Sandbox for DriverSandbox {
Ok(files)
}
async fn download_file_to_local(
/// Matches a workspace-relative glob with provider-independent
/// semantics, over [`RunSandbox::walk_files`].
pub async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result<Vec<String>> {
let glob = WorkspaceGlob::try_new(pattern)
.map_err(|error| crate::Error::context("Invalid glob pattern", error))?;
let base = path.unwrap_or_else(|| self.working_directory());
let mut files = self
.walk_files(base, glob.traversal_root(), &WalkOptions::default())
.await?
.into_iter()
.filter(|file| glob.is_match(&file.relative_path))
.collect::<Vec<_>>();
files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
Ok(files.into_iter().map(|file| file.path).collect())
}
pub async fn download_file_to_local(
&self,
remote_path: &str,
local_path: &Path,
@ -947,7 +994,7 @@ impl Sandbox for DriverSandbox {
.map_err(|error| crate::Error::context(file_context("download", remote_path), error))
}
async fn upload_file_from_local(
pub async fn upload_file_from_local(
&self,
local_path: &Path,
remote_path: &str,
@ -961,7 +1008,7 @@ impl Sandbox for DriverSandbox {
/// Create the sandbox when it is pending, bring it to `Running`, and
/// prepare fabro's workspace (empty root or clone) on first use.
async fn initialize(&self) -> crate::Result<()> {
pub async fn initialize(&self) -> crate::Result<()> {
self.emit(SandboxEvent::Initializing {
provider: self.provider_name(),
});
@ -1006,7 +1053,7 @@ impl Sandbox for DriverSandbox {
/// Idempotent access-time check: a running sandbox is left alone; a
/// stopped or paused one is brought back and its Bash verified.
async fn activate(&self) -> crate::Result<()> {
pub async fn activate(&self) -> crate::Result<()> {
let status = self.handle()?.describe().await?;
if status.state == SandboxState::Running {
return Ok(());
@ -1014,7 +1061,7 @@ impl Sandbox for DriverSandbox {
self.make_ready().await
}
async fn start(&self) -> crate::Result<()> {
pub async fn start(&self) -> crate::Result<()> {
self.emit(SandboxEvent::StartStarted {
provider: self.provider_name(),
});
@ -1034,7 +1081,7 @@ impl Sandbox for DriverSandbox {
result
}
async fn stop(&self) -> crate::Result<()> {
pub async fn stop(&self) -> crate::Result<()> {
self.emit(SandboxEvent::StopStarted {
provider: self.provider_name(),
});
@ -1057,7 +1104,7 @@ impl Sandbox for DriverSandbox {
result
}
async fn delete(&self) -> crate::Result<()> {
pub async fn delete(&self) -> crate::Result<()> {
self.emit(SandboxEvent::DeleteStarted {
provider: self.provider_name(),
});
@ -1080,7 +1127,7 @@ impl Sandbox for DriverSandbox {
/// Releases the sandbox. For a designated host directory this frees the
/// handle and leaves the directory in place; for an isolated provider it
/// removes the sandbox.
async fn cleanup(&self) -> crate::Result<()> {
pub async fn cleanup(&self) -> crate::Result<()> {
self.emit(SandboxEvent::CleanupStarted {
provider: self.provider_name(),
});
@ -1102,7 +1149,7 @@ impl Sandbox for DriverSandbox {
/// The directory the run works in: the cloned repository's link for a
/// clone-based workspace, the provider's working directory otherwise.
fn working_directory(&self) -> &str {
pub fn working_directory(&self) -> &str {
if let Some(directory) = self
.workspace
.as_ref()
@ -1115,19 +1162,19 @@ impl Sandbox for DriverSandbox {
.map_or("", |handle| handle.working_directory())
}
fn runtime_directory(&self) -> Option<&str> {
pub fn runtime_directory(&self) -> Option<&str> {
self.handle
.get()
.and_then(|handle| handle.runtime_directory())
}
fn platform(&self) -> &str {
pub fn platform(&self) -> &str {
self.platform
.get()
.map_or("unknown", |(platform, _)| platform.as_str())
}
fn os_version(&self) -> String {
pub fn os_version(&self) -> String {
self.platform.get().map_or_else(
|| self.platform().to_string(),
|(_, version)| version.clone(),
@ -1138,7 +1185,7 @@ impl Sandbox for DriverSandbox {
/// sandbox is its working directory, which the run record already
/// carries, and its Host registry id does not outlive the process.
/// Empty for a pending sandbox that has not been created.
fn sandbox_info(&self) -> String {
pub fn sandbox_info(&self) -> String {
if self.kind.is_local() {
return String::new();
}
@ -1148,15 +1195,15 @@ impl Sandbox for DriverSandbox {
.unwrap_or_default()
}
fn snapshot_info(&self) -> Option<String> {
pub fn snapshot_info(&self) -> Option<String> {
self.snapshot.get().cloned()
}
fn workspace_layout(&self) -> Option<SandboxWorkspaceLayout> {
pub fn workspace_layout(&self) -> Option<SandboxWorkspaceLayout> {
self.workspace.as_ref().and_then(RepoWorkspace::record)
}
async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
pub async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {
let mut timers = LifecycleTimers::default();
timers.auto_stop_after_idle = u64::try_from(minutes)
.ok()
@ -1172,14 +1219,14 @@ impl Sandbox for DriverSandbox {
}
}
async fn setup_git(&self, intent: &GitSetupIntent) -> crate::Result<Option<GitRunInfo>> {
pub async fn setup_git(&self, intent: &GitSetupIntent) -> crate::Result<Option<GitRunInfo>> {
if !self.repo_cloned() {
return Ok(None);
}
sandbox::setup_git_via_exec(self, intent).await.map(Some)
}
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
pub fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
if !self.repo_cloned() {
return Vec::new();
}
@ -1190,7 +1237,11 @@ impl Sandbox for DriverSandbox {
)]
}
async fn git_push_ref(&self, refspec: &str, plan: &RetryPlan) -> Result<PushReport, PushError> {
pub async fn git_push_ref(
&self,
refspec: &str,
plan: &RetryPlan,
) -> Result<PushReport, PushError> {
let Some(workspace) = &self.workspace else {
// A designated directory: push only when the checkout has an
// origin, with whatever credentials its URL already carries.
@ -1222,7 +1273,7 @@ impl Sandbox for DriverSandbox {
sandbox::git_push_via_exec(self, credentials, refspec, plan).await
}
fn origin_url(&self) -> Option<&str> {
pub fn origin_url(&self) -> Option<&str> {
let workspace = self.workspace.as_ref()?;
if !workspace.repo_cloned() {
return None;
@ -1231,7 +1282,7 @@ impl Sandbox for DriverSandbox {
}
#[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))]
async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
pub async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
let Some(workspace) = &self.workspace else {
return Ok(RefreshOutcome::none());
};
@ -1249,7 +1300,7 @@ impl Sandbox for DriverSandbox {
.await
}
fn push_token_source(&self) -> Option<Arc<InstallationTokenSource>> {
pub fn push_token_source(&self) -> Option<Arc<InstallationTokenSource>> {
self.workspace
.as_ref()
.and_then(|workspace| workspace.credentials.source().cloned())
@ -1258,7 +1309,7 @@ impl Sandbox for DriverSandbox {
/// The local command that opens a shell in the sandbox, from the
/// provider's access facet. `None` when the provider has no such
/// command (the local sandbox is the host).
async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
pub async fn ssh_access_command(&self) -> crate::Result<Option<String>> {
let Some(shell) = self.handle()?.shell_command() else {
return Ok(None);
};
@ -1269,7 +1320,7 @@ impl Sandbox for DriverSandbox {
.map_err(|error| crate::Error::context("Failed to build sandbox shell command", error))
}
async fn get_preview_url(
pub async fn get_preview_url(
&self,
port: u16,
) -> crate::Result<Option<(String, HashMap<String, String>)>> {
@ -1287,7 +1338,7 @@ impl Sandbox for DriverSandbox {
}
}
impl DriverSandbox {
impl RunSandbox {
fn repo_cloned(&self) -> bool {
self.workspace
.as_ref()
@ -1321,7 +1372,7 @@ mod tests {
struct Fixture {
dir: tempfile::TempDir,
_provider: HostProvider,
sandbox: DriverSandbox,
sandbox: RunSandbox,
}
async fn fixture() -> Fixture {
@ -1338,7 +1389,7 @@ mod tests {
Fixture {
dir,
_provider: provider,
sandbox: DriverSandbox::new(SandboxProviderKind::LOCAL, handle),
sandbox: RunSandbox::new(SandboxProviderKind::LOCAL, handle),
}
}
@ -1378,15 +1429,15 @@ mod tests {
.unwrap();
let entries = f.sandbox.list_directory(".", None).await.unwrap();
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
let names: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(names, vec!["a.txt", "b.txt", "c_dir"]);
assert_eq!(entries[0].size, Some(2));
assert!(!entries[0].is_dir);
assert!(entries[2].is_dir);
assert_eq!(entries[0].kind, FileKind::File);
assert_eq!(entries[2].kind, FileKind::Directory);
assert_eq!(entries[2].size, None);
let deep = f.sandbox.list_directory(".", Some(2)).await.unwrap();
assert!(deep.iter().any(|e| e.name == "c_dir/inner.txt"));
assert!(deep.iter().any(|e| e.path == "c_dir/inner.txt"));
}
#[tokio::test]
@ -1429,14 +1480,16 @@ mod tests {
.await
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].starts_with("test.rs:2:"), "{results:?}");
assert!(results[0].contains("println"));
assert_eq!(results[0].path, "test.rs", "{results:?}");
assert_eq!(results[0].line_number, 2);
assert!(results[0].line.contains("println"));
let insensitive = f
.sandbox
.grep("PRINTLN", ".", &GrepOptions {
case_insensitive: true,
..GrepOptions::default()
.grep("PRINTLN", ".", &{
let mut options = GrepOptions::default();
options.case_insensitive = true;
options
})
.await
.unwrap();
@ -1464,8 +1517,10 @@ mod tests {
let files = f
.sandbox
.walk_files(f.sandbox.working_directory(), ".ai", &WalkOptions {
excluded_directory_names: vec!["target".to_string()],
.walk_files(f.sandbox.working_directory(), ".ai", &{
let mut options = WalkOptions::default();
options.exclude_dirs = vec!["target".to_string()];
options
})
.await
.unwrap();
@ -1551,7 +1606,7 @@ mod tests {
"local sandboxes are identified by directory"
);
let handle = Arc::clone(f.sandbox.handle().unwrap());
let isolated = DriverSandbox::new(SandboxProviderKind::DOCKER, Arc::clone(&handle));
let isolated = RunSandbox::new(SandboxProviderKind::DOCKER, Arc::clone(&handle));
assert_eq!(isolated.sandbox_info(), handle.id().to_string());
f.sandbox.stop().await.unwrap();

View file

@ -20,7 +20,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
use async_trait::async_trait;
use fabro_static::EnvVars;
@ -169,7 +169,6 @@ impl<'a> SandboxExec<'a> {
output_callback,
stream_output_bytes_cap,
} = request;
let started = Instant::now();
let mut spec = ExecSpec::bash(command)
.no_timeout()
@ -202,7 +201,7 @@ impl<'a> SandboxExec<'a> {
let streaming = self.exec.run_streaming(&spec, controls).await?;
let termination = map_termination(streaming.result.termination);
let duration_ms = elapsed_ms(started);
let duration_ms = duration_ms(streaming.result.duration);
Ok(ExecStreamingResult {
result: ExecResult {
stdout: String::from_utf8_lossy(&streaming.result.stdout).into_owned(),
@ -328,8 +327,9 @@ fn adapt_output_callback(callback: CommandOutputCallback) -> sandbox_driver::Out
})
}
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
/// The provider's measured run time in whole milliseconds.
fn duration_ms(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
struct DriverStdioControl {
@ -356,6 +356,7 @@ impl StdioProcessControl for DriverStdioControl {
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use std::time::Instant;
use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec};
use sandbox_driver_host::HostProvider;

View file

@ -37,7 +37,7 @@ pub mod test_support;
pub use details::sandbox_details;
pub use docker::check_docker_daemon;
pub use driver::{DaytonaCredentials, ProviderAccess};
pub use driver_sandbox::{DriverSandbox, local_sandbox};
pub use driver_sandbox::{RunSandbox, local_sandbox};
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
pub use exec::{ExplicitEnvPolicy, SandboxExec, is_sensitive_env_var};
pub use fabro_github::token_source::{
@ -61,15 +61,16 @@ pub use reconnect::{
reconnect, reconnect_driver_for_run, reconnect_for_run, reconnect_for_run_with_callback,
};
pub use sandbox::{
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
ExecStreamingRequest, ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions,
OutputCaptureStats, PushAttempt, PushError, PushReport, RefreshOutcome, RemoteCredentialAction,
Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, SandboxWorkspaceLayout,
StderrCollector, StdioProcess, StdioProcessHandle, StdioProcessTermination, WalkOptions,
format_lines_numbered, redacted_output_tail, setup_git_via_exec, shell_quote,
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, ExecResult, ExecStreamingRequest,
ExecStreamingResult, GitRunInfo, GitSetupIntent, OutputCaptureStats, PushAttempt, PushError,
PushReport, RefreshOutcome, RemoteCredentialAction, SandboxEvent, SandboxEventCallback,
SandboxFile, SandboxWorkspaceLayout, StderrCollector, StdioProcess, StdioProcessHandle,
StdioProcessTermination, format_lines_numbered, redacted_output_tail, setup_git_via_exec,
shell_quote,
};
/// The network policy a [`SandboxOptions`] asks for, re-exported so consumers
/// building options need no direct driver dependency.
pub use sandbox_driver::NetworkPolicy;
/// Driver types a run sandbox's file and search operations speak, and the
/// network policy a [`SandboxOptions`] asks for, re-exported so consumers
/// need no direct driver dependency.
pub use sandbox_driver::{DirEntry, FileKind, GrepMatch, GrepOptions, NetworkPolicy, WalkOptions};
pub use sandbox_spec::{ProviderSandboxSpec, SandboxSpec};
pub use terminal::{DriverTerminalSession, TerminalSession, TerminalSize, open_terminal_for_run};

View file

@ -16,7 +16,7 @@ use fabro_types::{BundledProvider, RunId, SandboxProviderKind};
use sandbox_driver::{OwnedProvider, SandboxId, SandboxProvider};
use crate::driver::{ProviderAccess, connect_provider};
use crate::driver_sandbox::{DriverSandbox, LayoutSource, RepoWorkspace};
use crate::driver_sandbox::{LayoutSource, RepoWorkspace, RunSandbox};
use crate::options::{self, SandboxOptions};
use crate::{daytona, docker, managed_labels};
@ -38,7 +38,7 @@ pub async fn provider_sandbox(
clone_branch: Option<String>,
clone_tag: Option<String>,
clone_commit_sha: Option<String>,
) -> crate::Result<DriverSandbox> {
) -> crate::Result<RunSandbox> {
let workspace = RepoWorkspace::plan(
layout_source(&kind),
options.skip_clone,
@ -54,7 +54,7 @@ pub async fn provider_sandbox(
Ok(match kind.bundled() {
Some(BundledProvider::Docker) => {
let (spec, image) = docker::overlay(base, &options);
DriverSandbox::pending(kind, provider, spec, Some(image), workspace)
RunSandbox::pending(kind, provider, spec, Some(image), workspace)
}
Some(BundledProvider::Daytona) => {
let credentials = access
@ -68,7 +68,7 @@ pub async fn provider_sandbox(
options,
run_id,
);
DriverSandbox::pending_with_plan(kind, provider, Box::new(plan), workspace)
RunSandbox::pending_with_plan(kind, provider, Box::new(plan), workspace)
}
Some(BundledProvider::Local) => {
return Err(crate::Error::message(
@ -78,7 +78,7 @@ pub async fn provider_sandbox(
None => {
let mut spec = base;
spec.network = options::supported_network(spec.network, provider.capabilities());
DriverSandbox::pending(kind, provider, spec, options.image.clone(), workspace)
RunSandbox::pending(kind, provider, spec, options.image.clone(), workspace)
}
})
}
@ -98,7 +98,7 @@ pub async fn attach_provider_sandbox(
working_directory: String,
clone_origin_url: Option<String>,
run_id: Option<RunId>,
) -> crate::Result<DriverSandbox> {
) -> crate::Result<RunSandbox> {
let provider = connect(&kind, access, run_id.as_ref()).await?;
let id = SandboxId::try_new(sandbox_id)
.map_err(|error| crate::Error::context(format!("Invalid {kind} sandbox id"), error))?;
@ -115,7 +115,7 @@ pub async fn attach_provider_sandbox(
working_directory,
clone_origin_url,
);
let sandbox = DriverSandbox::attached(kind.clone(), handle, workspace);
let sandbox = RunSandbox::attached(kind.clone(), handle, workspace);
if kind.bundled() == Some(BundledProvider::Daytona) {
if let Some(snapshot) = status.source {
sandbox.set_snapshot(snapshot);

View file

@ -256,7 +256,7 @@ impl CredentialLease<'_> {
/// propagated: the push proceeds with the last embedded token.
pub(crate) async fn ensure_embedded(
&mut self,
sandbox: &dyn crate::Sandbox,
sandbox: &crate::RunSandbox,
origin_url: &str,
force: bool,
) -> crate::Result<EnsureOutcome> {
@ -345,7 +345,7 @@ impl CredentialLease<'_> {
/// Rewrite `origin` with the token embedded, through the sandbox's uniform
/// exec surface.
async fn set_url_via_exec(
sandbox: &dyn crate::Sandbox,
sandbox: &crate::RunSandbox,
origin_url: &str,
token: &ResolvedToken,
) -> crate::Result<()> {
@ -360,7 +360,7 @@ async fn set_url_via_exec(
}
pub(crate) async fn set_auth_url_via_exec(
sandbox: &dyn crate::Sandbox,
sandbox: &crate::RunSandbox,
auth_url: DisplaySafeUrl,
) -> crate::Result<()> {
let command = format!(

View file

@ -4,17 +4,14 @@ use anyhow::{Context, Result};
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
use crate::driver::ProviderAccess;
use crate::driver_sandbox::{DriverSandbox, local_sandbox};
use crate::driver_sandbox::{RunSandbox, local_sandbox};
use crate::{SandboxEventCallback, provider_sandbox};
/// Reconnect to a sandbox from a saved record.
///
/// `access` carries the provider settings and vault credentials the record's
/// provider needs; the process environment is never consulted.
pub async fn reconnect(
record: &RunSandboxInstance,
access: &ProviderAccess,
) -> Result<Box<dyn crate::Sandbox>> {
pub async fn reconnect(record: &RunSandboxInstance, access: &ProviderAccess) -> Result<RunSandbox> {
reconnect_for_run(record, access, None).await
}
@ -22,7 +19,7 @@ pub async fn reconnect_for_run(
record: &RunSandboxInstance,
access: &ProviderAccess,
run_id: Option<RunId>,
) -> Result<Box<dyn crate::Sandbox>> {
) -> Result<RunSandbox> {
reconnect_for_run_with_callback(record, access, run_id, None).await
}
@ -31,9 +28,8 @@ pub async fn reconnect_for_run_with_callback(
access: &ProviderAccess,
run_id: Option<RunId>,
event_callback: Option<SandboxEventCallback>,
) -> Result<Box<dyn crate::Sandbox>> {
let sandbox = reconnect_driver_for_run(record, access, run_id, event_callback).await?;
Ok(Box::new(sandbox))
) -> Result<RunSandbox> {
reconnect_driver_for_run(record, access, run_id, event_callback).await
}
/// Reconnects as the driver-backed sandbox type, for callers that need a
@ -44,7 +40,7 @@ pub async fn reconnect_driver_for_run(
access: &ProviderAccess,
run_id: Option<RunId>,
event_callback: Option<SandboxEventCallback>,
) -> Result<DriverSandbox> {
) -> Result<RunSandbox> {
let runtime = &record.runtime;
// A local sandbox is its working directory: rebuilding the handle over
// that directory is the reconnect. The per-process Host registry holds

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,9 @@ use fabro_github::GitHubCredentials;
use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
use crate::driver::ProviderAccess;
use crate::driver_sandbox::{LayoutSource, local_sandbox};
use crate::driver_sandbox::{LayoutSource, RunSandbox, local_sandbox};
use crate::options::SandboxOptions;
use crate::{Sandbox, SandboxEventCallback, clone_source, provider_sandbox};
use crate::{SandboxEventCallback, clone_source, provider_sandbox};
/// Options for sandbox initialization and construction.
#[derive(Clone, Debug)]
@ -52,7 +52,7 @@ impl SandboxSpec {
/// Build initialized sandbox metadata for persistence.
pub fn to_run_sandbox_instance(
&self,
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
run_id: RunId,
) -> RunSandboxInstance {
let working_directory = sandbox.working_directory().to_string();
@ -146,7 +146,7 @@ impl SandboxSpec {
pub async fn build(
&self,
event_callback: Option<SandboxEventCallback>,
) -> Result<Arc<dyn Sandbox>, anyhow::Error> {
) -> Result<Arc<RunSandbox>, anyhow::Error> {
match self {
Self::Local { working_directory } => {
let mut sandbox = local_sandbox(working_directory.clone())
@ -206,9 +206,19 @@ fn runtime_layout_metadata(
#[cfg(test)]
mod tests {
use fabro_types::RunId;
use sandbox_driver_testing::ScriptedSandbox;
use super::*;
use crate::test_support::MockSandbox;
fn sandbox_at(working_dir: &str) -> RunSandbox {
RunSandbox::new(
SandboxProviderKind::DOCKER,
Arc::new(ScriptedSandbox::with_id_and_working_dir(
"scripted-1",
working_dir,
)),
)
}
#[test]
fn docker_run_sandbox_persists_layout_metadata_for_cloned_repo() {
@ -223,8 +233,7 @@ mod tests {
clone_tag: None,
clone_commit_sha: None,
}));
let mut sandbox = MockSandbox::linux();
sandbox.working_dir = "/workspace/rack-test";
let sandbox = sandbox_at("/workspace/rack-test");
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
let record = spec.to_run_sandbox_instance(&sandbox, run_id);
@ -294,8 +303,7 @@ mod tests {
clone_tag: None,
clone_commit_sha: None,
}));
let mut sandbox = MockSandbox::linux();
sandbox.working_dir = "/workspace";
let sandbox = sandbox_at("/workspace");
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
let record = spec.to_run_sandbox_instance(&sandbox, run_id);

View file

@ -2,7 +2,7 @@ use async_trait::async_trait;
use fabro_types::{BundledProvider, RunId, RunSandboxInstance};
use crate::driver::ProviderAccess;
use crate::{Sandbox, reconnect};
use crate::reconnect;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalSize {

File diff suppressed because it is too large Load diff

View file

@ -4,8 +4,8 @@ mod daytona_streaming_live {
use anyhow::{Context, Result, ensure};
use fabro_sandbox::{
CommandOutputCallback, DaytonaCredentials, DriverSandbox, ExecStreamingResult,
ProviderAccess, Sandbox, SandboxOptions, SandboxProviderKind, provider_sandbox,
CommandOutputCallback, DaytonaCredentials, ExecStreamingResult, ProviderAccess, RunSandbox,
SandboxOptions, SandboxProviderKind, provider_sandbox,
};
use fabro_static::EnvVars;
use fabro_types::{CommandOutputStream, CommandTermination};
@ -327,7 +327,7 @@ mod daytona_streaming_live {
Ok(())
}
async fn run_glob_checks(sandbox: &DriverSandbox) -> Result<()> {
async fn run_glob_checks(sandbox: &RunSandbox) -> Result<()> {
// Build a skills tree with a SKILL.md at the search root, one level
// below it, and two levels below it.
let seed = sandbox
@ -372,7 +372,7 @@ mod daytona_streaming_live {
Ok(())
}
async fn run_smoke(sandbox: Arc<DriverSandbox>) -> Result<()> {
async fn run_smoke(sandbox: Arc<RunSandbox>) -> Result<()> {
let chunks = Arc::new(Mutex::new(Vec::new()));
let cancel_token = CancellationToken::new();
let callback = capture_callback(Arc::clone(&chunks));
@ -498,7 +498,7 @@ mod daytona_streaming_live {
}
async fn run_captured(
sandbox: &DriverSandbox,
sandbox: &RunSandbox,
command: &str,
timeout_ms: u64,
cancel_token: Option<CancellationToken>,
@ -507,7 +507,7 @@ mod daytona_streaming_live {
}
async fn run_captured_with_stdin(
sandbox: &DriverSandbox,
sandbox: &RunSandbox,
command: &str,
timeout_ms: u64,
cancel_token: Option<CancellationToken>,

View file

@ -4,7 +4,7 @@ use std::collections::BTreeMap;
use std::sync::Arc;
use fabro_sandbox::{
CommandOutputCallback, ExecStreamingRequest, ProviderAccess, Sandbox, SandboxOptions,
CommandOutputCallback, ExecStreamingRequest, ProviderAccess, SandboxOptions,
SandboxProviderKind, provider_sandbox,
};
use tokio::process::Command;

View file

@ -36,11 +36,11 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use fabro_sandbox::{
ProviderAccess, Sandbox as FabroSandbox, SandboxOptions, SandboxProviderKind, local_sandbox,
ProviderAccess, RunSandbox, SandboxOptions, SandboxProviderKind, local_sandbox,
provider_sandbox,
};
use sandbox_driver::{
ExecSpec, GrepOptions, Sandbox as DriverSandbox, SandboxProvider, SandboxSource, SandboxSpec,
ExecSpec, GrepOptions, Sandbox as DriverHandle, SandboxProvider, SandboxSource, SandboxSpec,
Search,
};
use sandbox_driver_docker::DockerProvider;
@ -177,11 +177,7 @@ fn report(rows: &[Row]) {
/// The two operations an agent issues most: a file read and a content
/// search, expressed against fabro's current trait.
async fn bench_fabro(
label: &'static str,
sandbox: &dyn FabroSandbox,
repo: &Repository,
) -> Vec<Row> {
async fn bench_fabro(label: &'static str, sandbox: &RunSandbox, repo: &Repository) -> Vec<Row> {
let mut reads = Samples::default();
for file in &repo.files {
let started = Instant::now();
@ -193,11 +189,9 @@ async fn bench_fabro(
reads.record(started.elapsed());
}
let mut greps = Samples::default();
let options = fabro_sandbox::GrepOptions {
glob_filter: Some("*.rs".to_owned()),
case_insensitive: false,
max_results: Some(50),
};
let mut options = GrepOptions::default();
options.include = Some("*.rs".to_owned());
options.max_matches = Some(50);
for _ in 0..GREPS {
let started = Instant::now();
let matches = sandbox
@ -226,7 +220,7 @@ async fn bench_fabro(
/// The same two operations against the driver's facets.
async fn bench_driver(
label: &'static str,
sandbox: &dyn DriverSandbox,
sandbox: &dyn DriverHandle,
repo: &Repository,
) -> Vec<Row> {
let mut reads = Samples::default();
@ -270,7 +264,7 @@ async fn bench_driver(
]
}
async fn unpack_fabro(sandbox: &dyn FabroSandbox, repo: &Repository) {
async fn unpack_fabro(sandbox: &RunSandbox, repo: &Repository) {
sandbox
.upload_file_from_local(&repo.tarball, "/tmp/repo.tar")
.await
@ -288,7 +282,7 @@ async fn unpack_fabro(sandbox: &dyn FabroSandbox, repo: &Repository) {
assert!(result.is_success(), "unpack failed: {}", result.stderr);
}
async fn unpack_driver(sandbox: &dyn DriverSandbox, repo: &Repository) {
async fn unpack_driver(sandbox: &dyn DriverHandle, repo: &Repository) {
sandbox
.fs()
.upload(&repo.tarball, "/tmp/repo.tar")

View file

@ -84,6 +84,7 @@ fabro-workflow = { path = ".", features = ["test-support"] }
fabro-api = { path = "../../foundation/fabro-api" }
fabro-environment = { path = "../fabro-environment" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
sandbox-driver.workspace = true
fabro-mcp = { path = "../fabro-mcp" }
tokio = { workspace = true, features = ["test-util", "macros"] }
object_store.workspace = true

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_config::RunScratch;
use fabro_types::{
BlobHash, ParallelBranchResult, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref,
@ -176,7 +176,7 @@ pub async fn demote_large_values_for_prompt(
values: &mut HashMap<String, Value>,
node_outcomes: &mut HashMap<String, Outcome>,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
env: &RunSandbox,
run_dir: &Path,
) {
let mut locality = SandboxLocality::default();
@ -228,7 +228,7 @@ pub async fn demote_large_values_for_prompt(
pub async fn demote_large_items_for_prompt(
items: &mut [Value],
run_store: &RunStoreHandle,
env: &dyn Sandbox,
env: &RunSandbox,
run_dir: &Path,
) {
let mut locality = SandboxLocality::default();
@ -258,7 +258,7 @@ async fn demote_value_for_prompt(
value: &mut Value,
max_inline_bytes: usize,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
env: &RunSandbox,
run_dir: &Path,
locality: &mut SandboxLocality,
) -> Result<bool> {
@ -282,7 +282,7 @@ async fn demote_value_for_prompt(
async fn materialize_value_bytes(
bytes: &[u8],
run_store: &RunStoreHandle,
env: &dyn Sandbox,
env: &RunSandbox,
run_dir: &Path,
locality: &mut SandboxLocality,
) -> Result<String> {
@ -315,14 +315,14 @@ async fn materialize_value_bytes(
/// a later checkpoint can never commit it. The `runtime/blobs` suffix keeps
/// the path recognizable as a managed blob reference, so durable storage
/// still records `blob://sha256/...` instead of this execution-local path.
fn remote_materialized_blob_path(env: &dyn Sandbox, blob_hash: &BlobHash) -> Result<String> {
fn remote_materialized_blob_path(env: &RunSandbox, blob_hash: &BlobHash) -> Result<String> {
let runtime_directory = env.runtime_directory().ok_or_else(|| {
Error::engine("sandbox exposes no runtime directory for blob materialization")
})?;
Ok(format!("{runtime_directory}/blobs/{blob_hash}.json"))
}
async fn write_remote_blob_file(env: &dyn Sandbox, path: &str, bytes: &[u8]) -> Result<()> {
async fn write_remote_blob_file(env: &RunSandbox, path: &str, bytes: &[u8]) -> Result<()> {
let content = std::str::from_utf8(bytes)
.map_err(|e| Error::engine_with_source("artifact blob was not valid UTF-8 JSON", e))?;
env.write_file(path, content)
@ -457,7 +457,7 @@ pub fn normalize_checkpoint_for_resume(checkpoint: &mut Checkpoint) {
pub async fn resolve_context_for_execution(
context: &Context,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
env: &RunSandbox,
run_dir: &Path,
) -> Result<Context> {
let values = resolved_context_snapshot(context, run_store, env, run_dir).await?;
@ -484,7 +484,7 @@ pub async fn resolve_context_for_edge_selection(
pub async fn resolve_outcomes_for_execution(
node_outcomes: &HashMap<String, Outcome>,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
env: &RunSandbox,
run_dir: &Path,
) -> Result<HashMap<String, Outcome>> {
let mut resolved = node_outcomes.clone();
@ -505,7 +505,7 @@ pub async fn resolve_outcomes_for_execution(
pub async fn resolved_context_snapshot(
context: &Context,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
env: &RunSandbox,
run_dir: &Path,
) -> Result<HashMap<String, Value>> {
let mut values = context.snapshot();
@ -613,7 +613,7 @@ pub async fn resolve_text_or_blob_ref_str(
/// fails.
pub async fn sync_artifacts_to_env(
updates: &mut HashMap<String, Value>,
env: &dyn Sandbox,
env: &RunSandbox,
) -> Result<()> {
for value in updates.values_mut() {
let local_path = match artifact_path(value) {
@ -676,7 +676,7 @@ fn normalize_durable_value(value: &mut Value) {
fn resolve_execution_values<'a>(
values: &'a mut HashMap<String, Value>,
run_store: &'a RunStoreHandle,
env: &'a dyn Sandbox,
env: &'a RunSandbox,
run_dir: &'a Path,
locality: &'a mut SandboxLocality,
) -> BoxFuture<'a, Result<()>> {
@ -697,7 +697,7 @@ fn resolve_execution_value<'a>(
key: Option<&'a str>,
value: &'a mut Value,
run_store: &'a RunStoreHandle,
env: &'a dyn Sandbox,
env: &'a RunSandbox,
run_dir: &'a Path,
locality: &'a mut SandboxLocality,
) -> BoxFuture<'a, Result<()>> {
@ -747,7 +747,7 @@ fn resolve_execution_value<'a>(
async fn materialize_blob_ref(
blob_hash: &BlobHash,
run_store: &RunStoreHandle,
env: &dyn Sandbox,
env: &RunSandbox,
run_dir: &Path,
locality: &mut SandboxLocality,
) -> Result<String> {
@ -814,7 +814,7 @@ async fn read_required_blob(
.ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_hash}")))
}
async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result<String> {
async fn resolve_explicit_file_ref(value: &str, env: &RunSandbox) -> Result<String> {
let local_path = value
.strip_prefix(ARTIFACT_POINTER_PREFIX)
.ok_or_else(|| Error::engine(format!("invalid artifact pointer: {value}")))?;
@ -858,7 +858,7 @@ struct SandboxLocality {
}
impl SandboxLocality {
async fn is_local(&mut self, env: &dyn Sandbox, run_dir: &Path) -> Result<bool> {
async fn is_local(&mut self, env: &RunSandbox, run_dir: &Path) -> Result<bool> {
if let Some(local) = self.cached {
return Ok(local);
}
@ -888,11 +888,31 @@ mod tests {
use std::sync::Arc;
use std::time::Duration;
use fabro_sandbox::test_support::MockSandbox;
use object_store::memory::InMemory;
use ulid::Ulid;
use super::*;
/// A remote-style sandbox: the run directory is not visible inside it
/// unless a test seeds it, and `runtime_dir` is its scratch directory.
fn remote_env(runtime_dir: Option<&'static str>) -> MockSandbox {
MockSandbox {
working_dir: "/workspace",
runtime_dir,
..MockSandbox::linux()
}
}
/// A sandbox in which `visible` exists, as the run directory does for a
/// local run.
fn local_env(visible: &Path) -> MockSandbox {
MockSandbox {
files: HashMap::from([(format!("{}/.probe", visible.display()), String::new())]),
..remote_env(None)
}
}
fn test_run_id(label: &str) -> fabro_types::RunId {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
label.hash(&mut hasher);
@ -1158,13 +1178,17 @@ mod tests {
}
}]),
);
let env = TestSyncEnv::new(true, "/workspace");
let run_dir = tempfile::tempdir().unwrap();
let env = local_env(run_dir.path());
let resolved =
resolved_context_snapshot(&context, &run_store.clone().into(), &env, run_dir.path())
.await
.unwrap();
let resolved = resolved_context_snapshot(
&context,
&run_store.clone().into(),
&env.sandbox(),
run_dir.path(),
)
.await
.unwrap();
let updates = &resolved[context::keys::PARALLEL_RESULTS][0]["context_updates"];
assert_eq!(updates["response.branch_a"], serde_json::json!(response));
@ -1202,15 +1226,20 @@ mod tests {
let context = Context::new();
context.set("first", fabro_types::format_blob_ref(&first_blob).into());
context.set("second", fabro_types::format_blob_ref(&second_blob).into());
let env = TestSyncEnv::new(true, "/workspace");
let run_dir = tempfile::tempdir().unwrap();
let env = local_env(run_dir.path());
resolved_context_snapshot(&context, &run_store.clone().into(), &env, run_dir.path())
.await
.unwrap();
resolved_context_snapshot(
&context,
&run_store.clone().into(),
&env.sandbox(),
run_dir.path(),
)
.await
.unwrap();
assert_eq!(
*env.exists_calls.lock().unwrap(),
env.exists_calls(),
1,
"sandbox locality should be probed once per resolution pass"
);
@ -1354,133 +1383,6 @@ mod tests {
// --- sync_artifacts_to_env tests ---
use std::sync::Mutex;
struct TestSyncEnv {
accessible: bool,
written: Mutex<Vec<(String, String)>>,
working_dir: String,
runtime_dir: Option<String>,
exists_calls: Mutex<usize>,
}
impl TestSyncEnv {
fn new(accessible: bool, working_dir: &str) -> Self {
Self {
accessible,
written: Mutex::new(Vec::new()),
working_dir: working_dir.to_string(),
runtime_dir: None,
exists_calls: Mutex::new(0),
}
}
fn with_runtime_directory(mut self, runtime_dir: &str) -> Self {
self.runtime_dir = Some(runtime_dir.to_string());
self
}
}
#[async_trait::async_trait]
impl Sandbox for TestSyncEnv {
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
Err("not implemented".into())
}
async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> {
self.written
.lock()
.unwrap()
.push((path.to_string(), content.to_string()));
Ok(())
}
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
*self.exists_calls.lock().unwrap() += 1;
Ok(self.accessible)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
Err("not implemented".into())
}
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_agent::ExecResult> {
Err("not implemented".into())
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &fabro_agent::GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn download_file_to_local(
&self,
_remote_path: &str,
_local_path: &std::path::Path,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {
&self.working_dir
}
fn runtime_directory(&self) -> Option<&str> {
self.runtime_dir.as_deref()
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Linux 5.15".to_string()
}
}
#[tokio::test]
async fn sync_uploads_artifact_when_not_accessible() {
let dir = tempfile::tempdir().unwrap();
@ -1491,10 +1393,12 @@ mod tests {
let mut updates = HashMap::new();
updates.insert("response.plan".to_string(), Value::String(pointer));
let env = TestSyncEnv::new(false, "/workspace");
sync_artifacts_to_env(&mut updates, &env).await.unwrap();
let env = remote_env(None);
sync_artifacts_to_env(&mut updates, &env.sandbox())
.await
.unwrap();
let written = env.written.lock().unwrap();
let written = env.written_files();
assert_eq!(written.len(), 1);
assert_eq!(
written[0].0,
@ -1519,10 +1423,15 @@ mod tests {
let mut updates = HashMap::new();
updates.insert("key".to_string(), Value::String(pointer.clone()));
let env = TestSyncEnv::new(true, "/workspace");
sync_artifacts_to_env(&mut updates, &env).await.unwrap();
let env = MockSandbox {
files: HashMap::from([(artifact_file.display().to_string(), "{}".to_string())]),
..remote_env(None)
};
sync_artifacts_to_env(&mut updates, &env.sandbox())
.await
.unwrap();
let written = env.written.lock().unwrap();
let written = env.written_files();
assert!(written.is_empty());
assert_eq!(updates["key"].as_str().unwrap(), &pointer);
}
@ -1534,10 +1443,12 @@ mod tests {
updates.insert("count".to_string(), serde_json::json!(42));
updates.insert("nested".to_string(), serde_json::json!({"a": 1}));
let env = TestSyncEnv::new(false, "/workspace");
sync_artifacts_to_env(&mut updates, &env).await.unwrap();
let env = remote_env(None);
sync_artifacts_to_env(&mut updates, &env.sandbox())
.await
.unwrap();
let written = env.written.lock().unwrap();
let written = env.written_files();
assert!(written.is_empty());
assert_eq!(updates["name"], serde_json::json!("Alice"));
assert_eq!(updates["count"], serde_json::json!(42));
@ -1607,8 +1518,7 @@ mod tests {
async fn demote_materializes_remote_values_under_sandbox_runtime_directory() {
let run_store: RunStoreHandle = make_run_store("prompt-demote-remote").await.into();
let run_dir = tempfile::tempdir().unwrap();
let env =
TestSyncEnv::new(false, "/workspace").with_runtime_directory("/tmp/fabro/runtime");
let env = remote_env(Some("/tmp/fabro/runtime"));
let oversized = serde_json::json!("x".repeat(PROMPT_INLINE_VALUE_MAX + 1));
let expected_bytes = serde_json::to_vec(&oversized).unwrap();
@ -1622,7 +1532,7 @@ mod tests {
&mut values,
&mut HashMap::new(),
&run_store,
&env,
&env.sandbox(),
run_dir.path(),
)
.await;
@ -1630,7 +1540,7 @@ mod tests {
let details = prompt_large_value(&values["dataset"])
.expect("oversized remote context value should demote");
assert_eq!(details.path, expected_path);
let written = env.written.lock().unwrap();
let written = env.written_files();
assert_eq!(written.len(), 1);
assert_eq!(written[0].0, expected_path);
assert_eq!(written[0].1.as_bytes(), expected_bytes);
@ -1644,7 +1554,7 @@ mod tests {
async fn demote_keeps_value_inline_when_sandbox_has_no_runtime_directory() {
let run_store: RunStoreHandle = make_run_store("prompt-demote-no-runtime").await.into();
let run_dir = tempfile::tempdir().unwrap();
let env = TestSyncEnv::new(false, "/workspace");
let env = remote_env(None);
let oversized = serde_json::json!("x".repeat(PROMPT_INLINE_VALUE_MAX + 1));
let mut values = HashMap::from([("dataset".to_string(), oversized.clone())]);
@ -1653,13 +1563,13 @@ mod tests {
&mut values,
&mut HashMap::new(),
&run_store,
&env,
&env.sandbox(),
run_dir.path(),
)
.await;
assert_eq!(values["dataset"], oversized);
assert!(env.written.lock().unwrap().is_empty());
assert!(env.written_files().is_empty());
}
#[tokio::test]
@ -1670,21 +1580,24 @@ mod tests {
let blob_hash = run_store.write_blob(&report_bytes).await.unwrap();
let context = Context::new();
context.set("report", fabro_types::format_blob_ref(&blob_hash).into());
let env =
TestSyncEnv::new(false, "/workspace").with_runtime_directory("/tmp/fabro/runtime");
let env = remote_env(Some("/tmp/fabro/runtime"));
let run_dir = tempfile::tempdir().unwrap();
let resolved =
resolved_context_snapshot(&context, &run_store.clone().into(), &env, run_dir.path())
.await
.unwrap();
let resolved = resolved_context_snapshot(
&context,
&run_store.clone().into(),
&env.sandbox(),
run_dir.path(),
)
.await
.unwrap();
let expected_path = format!("/tmp/fabro/runtime/blobs/{blob_hash}.json");
assert_eq!(
resolved["report"],
serde_json::json!(format!("file://{expected_path}"))
);
let written = env.written.lock().unwrap();
let written = env.written_files();
assert_eq!(written.len(), 1);
assert_eq!(written[0].0, expected_path);
assert_eq!(written[0].1.as_bytes(), report_bytes);

View file

@ -1,6 +1,6 @@
use std::path::Path;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_sandbox::{SandboxFile, WalkOptions};
use fabro_types::ArtifactUpload;
use fabro_util::workspace_glob::WorkspaceGlobSet;
@ -110,16 +110,15 @@ async fn compute_artifact_info(
/// Collect artifact files matching the configured workspace globs.
pub async fn collect_artifacts(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
artifact_capture_dir: &Path,
globs: &WorkspaceGlobSet,
) -> Result<ArtifactCollectionSummary, String> {
let walk_options = WalkOptions {
excluded_directory_names: EXCLUDE_DIRS
.iter()
.map(|directory| (*directory).to_string())
.collect(),
};
let mut walk_options = WalkOptions::default();
walk_options.exclude_dirs = EXCLUDE_DIRS
.iter()
.map(|directory| (*directory).to_string())
.collect();
let walk_options = &walk_options;
let traversal_roots = globs
.traversal_roots()
@ -306,7 +305,7 @@ mod tests {
let sandbox = asset_sandbox(contents);
let globs = workspace_globs(&[".ai/reports/*.md", ".ai/plans/????-??-??-*.md"]);
let summary = collect_artifacts(&sandbox, stage_dir.path(), &globs)
let summary = collect_artifacts(&sandbox.sandbox(), stage_dir.path(), &globs)
.await
.unwrap();
@ -331,7 +330,7 @@ mod tests {
)]));
let globs = workspace_globs(&["test-results/**"]);
let summary = collect_artifacts(&sandbox, stage_dir.path(), &globs)
let summary = collect_artifacts(&sandbox.sandbox(), stage_dir.path(), &globs)
.await
.unwrap();
@ -355,29 +354,6 @@ mod tests {
);
}
#[tokio::test]
async fn collect_artifacts_downloads_provider_resolved_paths() {
let stage_dir = tempfile::tempdir().unwrap();
let file = SandboxFile {
path: "provider-object:report-1".to_string(),
relative_path: "test-results/r.xml".to_string(),
size: 7,
};
let sandbox = MockSandbox {
files: HashMap::from([(file.path.clone(), "<test/>".to_string())]),
..MockSandbox::linux()
}
.with_walk_files(vec![file]);
let globs = workspace_globs(&["test-results/**"]);
let summary = collect_artifacts(&sandbox, stage_dir.path(), &globs)
.await
.unwrap();
assert_eq!(summary.files_copied, 1);
assert_eq!(summary.captured_assets[0].path, "test-results/r.xml");
}
#[tokio::test]
async fn collect_artifacts_rechecks_downloaded_file_size() {
let stage_dir = tempfile::tempdir().unwrap();
@ -390,7 +366,7 @@ mod tests {
.with_walk_files(vec![file]);
let globs = workspace_globs(&["test-results/**"]);
let summary = collect_artifacts(&sandbox, stage_dir.path(), &globs)
let summary = collect_artifacts(&sandbox.sandbox(), stage_dir.path(), &globs)
.await
.unwrap();
@ -413,7 +389,7 @@ mod tests {
]));
let globs = workspace_globs(&["**/*.md"]);
let summary = collect_artifacts(&sandbox, stage_dir.path(), &globs)
let summary = collect_artifacts(&sandbox.sandbox(), stage_dir.path(), &globs)
.await
.unwrap();
@ -430,7 +406,7 @@ mod tests {
)]));
let globs = workspace_globs(&[".ai/**/*.md", ".ai/reports/*.md"]);
let summary = collect_artifacts(&sandbox, stage_dir.path(), &globs)
let summary = collect_artifacts(&sandbox.sandbox(), stage_dir.path(), &globs)
.await
.unwrap();
@ -444,7 +420,7 @@ mod tests {
let sandbox = asset_sandbox(HashMap::new()).with_walk_files_error("permission denied");
let globs = workspace_globs(&["test-results/**"]);
let error = collect_artifacts(&sandbox, stage_dir.path(), &globs)
let error = collect_artifacts(&sandbox.sandbox(), stage_dir.path(), &globs)
.await
.expect_err("failed traversal should fail artifact collection");
@ -461,7 +437,7 @@ mod tests {
]);
let globs = workspace_globs(&["test-results/**"]);
let summary = collect_artifacts(&sandbox, stage_dir.path(), &globs)
let summary = collect_artifacts(&sandbox.sandbox(), stage_dir.path(), &globs)
.await
.unwrap();

View file

@ -2,7 +2,7 @@ use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_graphviz::graph::{Graph, Node};
use fabro_types::{StageModelUsage, StageTiming};
pub(crate) use structured_output::extract_status_fields;
@ -45,7 +45,7 @@ pub struct CodergenRunRequest<'a> {
pub context: &'a Context,
pub thread_id: Option<&'a str>,
pub emitter: &'a Arc<Emitter>,
pub sandbox: &'a Arc<dyn Sandbox>,
pub sandbox: &'a Arc<RunSandbox>,
pub tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
pub cancel_token: CancellationToken,
pub agent_tool_runtime: fabro_agent::AgentToolRuntime,
@ -57,7 +57,7 @@ pub struct OneShotRequest<'a> {
pub system_prompt: Option<&'a str>,
pub emitter: &'a Arc<Emitter>,
pub stage_scope: &'a StageScope,
pub sandbox: &'a Arc<dyn Sandbox>,
pub sandbox: &'a Arc<RunSandbox>,
pub cancel_token: CancellationToken,
}
@ -146,7 +146,7 @@ impl AgentHandler {
pub(crate) async fn validate_agent_output_sources(
schema: &OutputSchemaKind,
response_text: &str,
sandbox: &Arc<dyn Sandbox>,
sandbox: &Arc<RunSandbox>,
last_file_touched: Option<&str>,
) -> Result<ValidatedStructuredOutput, StructuredOutputError> {
if !matches!(schema, OutputSchemaKind::Routing) {
@ -179,14 +179,14 @@ pub(crate) async fn validate_agent_output_sources(
Err(fallback_error)
}
async fn read_sandbox_file(sandbox: &Arc<dyn Sandbox>, path: &str) -> Option<String> {
async fn read_sandbox_file(sandbox: &Arc<RunSandbox>, path: &str) -> Option<String> {
sandbox.read_file_text(path).await.ok()
}
/// Extract the terminal JSON object from the last-touched file when it has an
/// eligible extension. Does not check that the object contains routing fields;
/// callers validate that.
async fn read_last_file_routing_json(sandbox: &Arc<dyn Sandbox>, path: &str) -> Option<String> {
async fn read_last_file_routing_json(sandbox: &Arc<RunSandbox>, path: &str) -> Option<String> {
let extension = Path::new(path).extension()?.to_str()?;
if !LAST_FILE_ROUTING_EXTENSIONS
.iter()
@ -541,10 +541,10 @@ mod tests {
}
}
async fn sandbox_with_file(path: &str, contents: &str) -> (TempDir, Arc<dyn Sandbox>) {
async fn sandbox_with_file(path: &str, contents: &str) -> (TempDir, Arc<RunSandbox>) {
let sandbox_dir = TempDir::new().unwrap();
std::fs::write(sandbox_dir.path().join(path), contents).unwrap();
let sandbox: Arc<dyn Sandbox> = Arc::new(
let sandbox: Arc<RunSandbox> = Arc::new(
fabro_agent::local_sandbox(sandbox_dir.path().to_path_buf())
.await
.unwrap(),

View file

@ -841,13 +841,16 @@ mod tests {
#[tokio::test]
async fn command_invalid_output_schema_fails_before_execution() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 1,
}));
let spy = MockSandbox {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 1,
},
..Default::default()
};
let handler = CommandHandler;
let mut node = Node::new("audit");
node.attrs.insert(
@ -861,7 +864,7 @@ mod tests {
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let mut services = make_sandbox_services(spy.clone());
let mut services = make_sandbox_services(spy.sandbox());
let event_names = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured_event_names = Arc::clone(&event_names);
let emitter = Arc::new(crate::event::Emitter::new(fixtures::RUN_1));
@ -1420,129 +1423,7 @@ mod tests {
);
}
/// A sandbox that returns a canned `ExecResult` and captures the command,
/// proving that `CommandHandler` delegates to the sandbox rather than
/// spawning a host process.
struct SpySandbox {
exec_result: fabro_agent::sandbox::ExecResult,
exec_error: Option<String>,
captured_command: std::sync::Mutex<Option<String>>,
captured_env_vars: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
captured_cancel_token: std::sync::Mutex<Option<bool>>,
}
impl SpySandbox {
fn new(exec_result: fabro_agent::sandbox::ExecResult) -> Self {
Self {
exec_result,
exec_error: None,
captured_command: std::sync::Mutex::new(None),
captured_env_vars: std::sync::Mutex::new(None),
captured_cancel_token: std::sync::Mutex::new(None),
}
}
fn fail(message: impl Into<String>) -> Self {
Self {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(1),
termination: CommandTermination::Exited,
duration_ms: 0,
},
exec_error: Some(message.into()),
captured_command: std::sync::Mutex::new(None),
captured_env_vars: std::sync::Mutex::new(None),
captured_cancel_token: std::sync::Mutex::new(None),
}
}
fn captured_command(&self) -> Option<String> {
self.captured_command.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl fabro_agent::sandbox::Sandbox for SpySandbox {
async fn read_file_bytes(&self, _: &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,
_: &str,
_: Option<usize>,
) -> fabro_sandbox::Result<Vec<fabro_agent::sandbox::DirEntry>> {
unimplemented!()
}
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_agent::sandbox::ExecResult> {
*self.captured_command.lock().unwrap() = Some(command.to_string());
*self.captured_env_vars.lock().unwrap() = env_vars.cloned();
*self.captured_cancel_token.lock().unwrap() = Some(cancel_token.is_some());
if let Some(message) = self.exec_error.as_ref() {
return Err(fabro_sandbox::Error::message(message.clone()));
}
Ok(self.exec_result.clone())
}
async fn grep(
&self,
_: &str,
_: &str,
_: &fabro_agent::sandbox::GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn glob(&self, _: &str, _: Option<&str>) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn download_file_to_local(
&self,
_: &str,
_: &std::path::Path,
) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn upload_file_from_local(
&self,
_: &std::path::Path,
_: &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) -> &str {
"/mock"
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Mock".into()
}
}
fn make_sandbox_services(sandbox: std::sync::Arc<dyn fabro_agent::Sandbox>) -> EngineServices {
fn make_sandbox_services(sandbox: std::sync::Arc<fabro_agent::RunSandbox>) -> EngineServices {
let mut services = make_services();
services.run = services.run.with_sandbox(sandbox);
services
@ -1550,7 +1431,7 @@ mod tests {
#[tokio::test]
async fn stdin_source_serializes_parallel_results_as_compact_json() {
let mock = std::sync::Arc::new(MockSandbox::default());
let mock = MockSandbox::default();
let handler = CommandHandler;
let mut node = Node::new("merge");
node.attrs
@ -1570,7 +1451,7 @@ mod tests {
context.set(keys::PARALLEL_RESULTS, parallel_results.clone());
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_sandbox_services(mock.clone());
let services = make_sandbox_services(mock.sandbox());
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
@ -1579,15 +1460,12 @@ mod tests {
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert_eq!(
*mock.captured_stdin.lock().unwrap(),
mock.captured_stdin(),
Some(serde_json::to_vec(&parallel_results).unwrap())
);
assert!(
!mock
.captured_command
.lock()
.unwrap()
.clone()
.captured_command()
.expect("command should run")
.contains("must-not-run"),
"stdin content must not be inserted into shell source"
@ -1596,7 +1474,7 @@ mod tests {
#[tokio::test]
async fn stdin_source_passes_strings_without_adding_a_newline() {
let mock = std::sync::Arc::new(MockSandbox::default());
let mock = MockSandbox::default();
let handler = CommandHandler;
let mut node = Node::new("consume");
node.attrs
@ -1609,7 +1487,7 @@ mod tests {
context.set("input", serde_json::json!("first\nlast"));
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_sandbox_services(mock.clone());
let services = make_sandbox_services(mock.sandbox());
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
@ -1618,14 +1496,14 @@ mod tests {
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert_eq!(
mock.captured_stdin.lock().unwrap().as_deref(),
mock.captured_stdin().as_deref(),
Some(b"first\nlast".as_slice())
);
}
#[tokio::test]
async fn missing_stdin_source_fails_before_starting_the_command() {
let mock = std::sync::Arc::new(MockSandbox::default());
let mock = MockSandbox::default();
let handler = CommandHandler;
let mut node = Node::new("consume");
node.attrs
@ -1637,7 +1515,7 @@ mod tests {
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_sandbox_services(mock.clone());
let services = make_sandbox_services(mock.sandbox());
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
@ -1654,7 +1532,7 @@ mod tests {
.unwrap()
.contains("was not found in workflow context")
);
assert_eq!(*mock.captured_command.lock().unwrap(), None);
assert_eq!(mock.captured_command(), None);
}
#[tokio::test]
@ -1711,13 +1589,16 @@ mod tests {
#[tokio::test]
async fn executes_script_via_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: "SANDBOX_MARKER\n".into(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let spy = MockSandbox {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: "SANDBOX_MARKER\n".into(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
},
..Default::default()
};
let handler = CommandHandler;
let mut node = Node::new("script_node");
@ -1729,7 +1610,7 @@ mod tests {
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_sandbox_services(spy.clone());
let services = make_sandbox_services(spy.sandbox());
let outcome = handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
@ -1751,13 +1632,16 @@ mod tests {
#[tokio::test]
async fn executes_python_script_via_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: "PYTHON_SANDBOX\n".into(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let spy = MockSandbox {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: "PYTHON_SANDBOX\n".into(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
},
..Default::default()
};
let handler = CommandHandler;
let mut node = Node::new("script_node");
@ -1779,7 +1663,7 @@ mod tests {
&context,
&graph,
run_dir.path(),
&make_sandbox_services(spy.clone()),
&make_sandbox_services(spy.sandbox()),
)
.await
.unwrap();
@ -1794,13 +1678,16 @@ mod tests {
#[tokio::test]
async fn passes_env_vars_to_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let spy = MockSandbox {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
},
..Default::default()
};
let handler = CommandHandler;
let mut node = Node::new("script_node");
@ -1810,7 +1697,7 @@ mod tests {
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let mut services = make_sandbox_services(spy.clone());
let mut services = make_sandbox_services(spy.sandbox());
services
.base_env
.insert("MY_VAR".to_string(), "my_value".to_string());
@ -1820,7 +1707,7 @@ mod tests {
.await
.unwrap();
let captured_env = spy.captured_env_vars.lock().unwrap().clone().unwrap();
let captured_env = spy.captured_env_vars().unwrap();
assert_eq!(
captured_env.get("MY_VAR").map(String::as_str),
Some("my_value")
@ -1829,17 +1716,20 @@ mod tests {
#[tokio::test]
async fn refreshes_github_token_for_each_command_stage_when_near_expiry() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let spy = MockSandbox {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
},
..Default::default()
};
let minter = std::sync::Arc::new(RefreshingMinter {
calls: std::sync::atomic::AtomicUsize::new(0),
});
let mut services = make_sandbox_services(spy.clone());
let mut services = make_sandbox_services(spy.sandbox());
services.github_token = Some(fabro_github::test_support::installation_token_source(
"owner/repo",
minter.clone(),
@ -1858,9 +1748,7 @@ mod tests {
.await
.unwrap();
assert_eq!(
spy.captured_env_vars
.lock()
.unwrap()
spy.captured_env_vars()
.as_ref()
.and_then(|env| env.get("GITHUB_TOKEN"))
.map(String::as_str),
@ -1872,9 +1760,7 @@ mod tests {
.await
.unwrap();
assert_eq!(
spy.captured_env_vars
.lock()
.unwrap()
spy.captured_env_vars()
.as_ref()
.and_then(|env| env.get("GITHUB_TOKEN"))
.map(String::as_str),
@ -1885,13 +1771,16 @@ mod tests {
#[tokio::test]
async fn passes_run_cancellation_to_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let spy = MockSandbox {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
},
..Default::default()
};
let handler = CommandHandler;
let mut node = Node::new("script_node");
@ -1901,7 +1790,7 @@ mod tests {
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let mut services = make_sandbox_services(spy.clone());
let mut services = make_sandbox_services(spy.sandbox());
services.run = services
.run
.with_cancel_token(tokio_util::sync::CancellationToken::new());
@ -1911,18 +1800,21 @@ mod tests {
.await
.unwrap();
assert_eq!(*spy.captured_cancel_token.lock().unwrap(), Some(true));
assert_eq!(spy.captured_term_stops(), vec![true]);
}
#[tokio::test]
async fn script_handler_timeout_error_includes_output_tails() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: "partial stdout\n".into(),
stderr: "partial stderr\n".into(),
exit_code: None,
termination: CommandTermination::TimedOut,
duration_ms: 50,
}));
let spy = MockSandbox {
exec_result: fabro_agent::sandbox::ExecResult {
stdout: "partial stdout\n".into(),
stderr: "partial stderr\n".into(),
exit_code: None,
termination: CommandTermination::TimedOut,
duration_ms: 50,
},
..Default::default()
};
let handler = CommandHandler;
let mut node = Node::new("script_node");
@ -1940,7 +1832,7 @@ mod tests {
&context,
&graph,
run_dir.path(),
&make_sandbox_services(spy),
&make_sandbox_services(spy.sandbox()),
)
.await
.unwrap_err();
@ -2027,7 +1919,13 @@ mod tests {
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
let services = make_sandbox_services(std::sync::Arc::new(SpySandbox::fail("No such file")));
let services = make_sandbox_services(
MockSandbox {
exec_error: Some("No such file".into()),
..Default::default()
}
.sandbox(),
);
let err = handler
.execute(&node, &context, &graph, run_dir.path(), &services)

View file

@ -2,6 +2,7 @@
use std::collections::HashMap;
use std::env;
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@ -11,7 +12,7 @@ use fabro_acp::{
render_stop_reason,
};
use fabro_agent::{
AgentEvent, RefreshOutcome, Sandbox, StaticEnvProvider, SteeringItem, ToolEnvProvider,
AgentEvent, RefreshOutcome, RunSandbox, StaticEnvProvider, SteeringItem, ToolEnvProvider,
};
use fabro_github::token_source::REFRESH_MARGIN;
use fabro_graphviz::graph::Node;
@ -131,21 +132,21 @@ fn next_refresh_delay(outcome: &RefreshOutcome) -> Option<Duration> {
/// reschedules from the embedded token's expiry ([`next_refresh_delay`]); a
/// failed or timed-out tick retries after a shorter delay so a transient
/// error does not leave a longer-than-interval window with an expired token.
async fn refresh_ahead_loop(
sandbox: Arc<dyn Sandbox>,
async fn refresh_ahead_loop<Fut>(
refresh: impl Fn() -> Fut + Send,
cancel: CancellationToken,
interval: Duration,
initial_delay: Duration,
) {
) where
Fut: Future<Output = fabro_sandbox::Result<RefreshOutcome>> + Send,
{
let retry_delay = interval.min(Duration::from_mins(1));
let mut delay = initial_delay;
loop {
tokio::select! {
() = cancel.cancelled() => break,
() = sleep(delay) => {
match timeout(REFRESH_MINT_TIMEOUT, sandbox.refresh_push_credentials())
.await
{
match timeout(REFRESH_MINT_TIMEOUT, refresh()).await {
Ok(Ok(outcome)) => {
match outcome {
RefreshOutcome::Embedded(token) => {
@ -240,7 +241,7 @@ impl AgentAcpBackend {
prompt: String,
emitter: &Arc<Emitter>,
stage_scope: &StageScope,
sandbox: &Arc<dyn Sandbox>,
sandbox: &Arc<RunSandbox>,
cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
let process_spec = resolve_acp_process_spec(node)?;
@ -354,8 +355,12 @@ impl AgentAcpBackend {
};
let _refresh_ahead_guard: Option<AbortOnDrop> =
refresh_schedule.map(|(interval, initial_delay)| {
let sandbox = Arc::clone(sandbox);
AbortOnDrop(tokio::spawn(refresh_ahead_loop(
Arc::clone(sandbox),
move || {
let sandbox = Arc::clone(&sandbox);
async move { sandbox.refresh_push_credentials().await }
},
cancel_token.child_token(),
interval,
initial_delay,
@ -647,7 +652,7 @@ mod tests {
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_acp::{AcpError, AcpProcessExit};
use fabro_agent::{
RefreshOutcome, RemoteCredentialAction, Sandbox, TokenProvenance, TokenSnapshot,
RefreshOutcome, RemoteCredentialAction, RunSandbox, TokenProvenance, TokenSnapshot,
local_sandbox, shell_quote,
};
use fabro_graphviz::graph::{AttrValue, Node};
@ -711,11 +716,11 @@ mod tests {
#[tokio::test]
async fn refresh_reports_no_action_without_managed_credentials() {
// MockSandbox uses the trait default (no GitHub App creds), so refresh
// is a no-op that must report no remote action and no token — the
// signal the refresh-ahead loop relies on to log at debug rather than
// falsely claim a re-embed.
let sandbox = MockSandbox::linux();
// A mock sandbox has no cloned workspace and so no managed push
// credentials: refresh is a no-op that must report no remote action
// and no token — the signal the refresh-ahead loop relies on to log
// at debug rather than falsely claim a re-embed.
let sandbox = MockSandbox::linux().sandbox();
assert_eq!(
sandbox.refresh_push_credentials().await.unwrap(),
RefreshOutcome::none()
@ -798,14 +803,14 @@ mod tests {
assert_eq!(next_refresh_delay(&RefreshOutcome::none()), None);
}
/// Sandbox stub whose refresh outcomes are scripted, recording when each
/// refresh tick lands on the (paused) tokio clock.
struct ScriptedRefreshSandbox {
/// Scripted refresh outcomes, recording when each refresh tick lands on
/// the (paused) tokio clock.
struct ScriptedRefresh {
script: Mutex<std::collections::VecDeque<RefreshOutcome>>,
ticks: Mutex<Vec<tokio::time::Instant>>,
}
impl ScriptedRefreshSandbox {
impl ScriptedRefresh {
fn new(script: Vec<RefreshOutcome>) -> Arc<Self> {
Arc::new(Self {
script: Mutex::new(script.into()),
@ -816,101 +821,24 @@ mod tests {
fn ticks(&self) -> Vec<tokio::time::Instant> {
self.ticks.lock().expect("ticks lock").clone()
}
}
#[async_trait::async_trait]
impl Sandbox for ScriptedRefreshSandbox {
async fn refresh_push_credentials(&self) -> fabro_sandbox::Result<RefreshOutcome> {
self.ticks
.lock()
.expect("ticks lock")
.push(tokio::time::Instant::now());
Ok(self
.script
.lock()
.expect("script lock")
.pop_front()
.expect("refresh script exhausted"))
}
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
unimplemented!("refresh loop only calls refresh_push_credentials")
}
async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn file_exists(&self, _path: &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 exec_command(
&self,
_command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> fabro_sandbox::Result<fabro_sandbox::ExecResult> {
unimplemented!()
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &fabro_sandbox::GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
unimplemented!()
}
async fn download_file_to_local(
&self,
_remote_path: &str,
_local_path: &std::path::Path,
) -> fabro_sandbox::Result<()> {
unimplemented!()
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &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) -> &str {
"/workspace"
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"linux".to_string()
/// The refresh the loop calls: answers the next scripted outcome.
fn refresher(
self: &Arc<Self>,
) -> impl Fn() -> std::future::Ready<fabro_sandbox::Result<RefreshOutcome>> + Send {
let this = Arc::clone(self);
move || {
this.ticks
.lock()
.expect("ticks lock")
.push(tokio::time::Instant::now());
std::future::ready(Ok(this
.script
.lock()
.expect("script lock")
.pop_front()
.expect("refresh script exhausted")))
}
}
}
@ -923,7 +851,7 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn refresh_ahead_reschedules_from_token_expiry_across_a_long_turn() {
let interval = Duration::from_mins(45);
let sandbox = ScriptedRefreshSandbox::new(vec![
let sandbox = ScriptedRefresh::new(vec![
// Minute 45: cache still fresh (expires minute 60, margin opens
// minute 50).
minted_outcome(
@ -953,7 +881,7 @@ mod tests {
let cancel = CancellationToken::new();
let start = tokio::time::Instant::now();
let loop_task = tokio::spawn(refresh_ahead_loop(
Arc::clone(&sandbox) as Arc<dyn Sandbox>,
sandbox.refresher(),
cancel.clone(),
interval,
interval,
@ -989,7 +917,7 @@ mod tests {
true,
);
let initial_delay = next_refresh_delay(&entry_outcome).unwrap();
let sandbox = ScriptedRefreshSandbox::new(vec![minted_outcome(
let sandbox = ScriptedRefresh::new(vec![minted_outcome(
RemoteCredentialAction::Embedded,
2,
chrono::Duration::zero(),
@ -999,7 +927,7 @@ mod tests {
let cancel = CancellationToken::new();
let start = tokio::time::Instant::now();
let loop_task = tokio::spawn(refresh_ahead_loop(
Arc::clone(&sandbox) as Arc<dyn Sandbox>,
sandbox.refresher(),
cancel.clone(),
interval,
initial_delay,
@ -1040,7 +968,7 @@ mod tests {
"ACP_MODE".to_string(),
"write_file".to_string(),
)]));
let sandbox: Arc<dyn Sandbox> =
let sandbox: Arc<RunSandbox> =
Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap());
let emitter = Arc::new(Emitter::default());
let context = Context::new();
@ -1089,7 +1017,7 @@ mod tests {
);
let backend = AgentAcpBackend::new();
let sandbox: Arc<dyn Sandbox> =
let sandbox: Arc<RunSandbox> =
Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap());
let emitter = Arc::new(Emitter::default());
let context = Context::new();
@ -1161,7 +1089,7 @@ mod tests {
"steer".to_string(),
)]))
.with_steering_hub(steering_hub);
let sandbox: Arc<dyn Sandbox> =
let sandbox: Arc<RunSandbox> =
Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap());
let context = Context::new();
let result = backend
@ -1209,7 +1137,7 @@ mod tests {
"ACP_MODE".to_string(),
"write_file".to_string(),
)]));
let sandbox: Arc<dyn Sandbox> =
let sandbox: Arc<RunSandbox> =
Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap());
let emitter = Arc::new(Emitter::default());
let context = Context::new();
@ -1238,8 +1166,7 @@ mod tests {
async fn acp_backend_does_not_forward_provider_credentials() {
let mut sandbox = MockSandbox::linux();
sandbox.stdio_process_error = Some("stop before ACP handshake".to_string());
let sandbox = Arc::new(sandbox);
let sandbox_dyn: Arc<dyn Sandbox> = sandbox.clone();
let sandbox_dyn = sandbox.sandbox();
let mut node = Node::new("work");
node.attrs
@ -1267,12 +1194,7 @@ mod tests {
.await;
assert!(result.is_err());
let captured = sandbox
.captured_env_vars
.lock()
.expect("captured env lock poisoned")
.clone()
.unwrap_or_default();
let captured = sandbox.captured_env_vars().unwrap_or_default();
assert!(!captured.contains_key("OPENAI_API_KEY"));
assert!(!captured.contains_key("ANTHROPIC_API_KEY"));
assert!(!captured.contains_key("GEMINI_API_KEY"));
@ -1299,7 +1221,7 @@ mod tests {
"ACP_STOP_REASON".to_string(),
"cancelled".to_string(),
)]));
let sandbox: Arc<dyn Sandbox> =
let sandbox: Arc<RunSandbox> =
Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap());
let emitter = Arc::new(Emitter::default());
let context = Context::new();
@ -1348,7 +1270,7 @@ mod tests {
.insert("acp.config".to_string(), AttrValue::String(raw_command));
let backend = AgentAcpBackend::new();
let sandbox: Arc<dyn Sandbox> =
let sandbox: Arc<RunSandbox> =
Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap());
let emitter = Arc::new(Emitter::default());
let events = Arc::new(Mutex::new(Vec::new()));
@ -1390,8 +1312,7 @@ mod tests {
#[tokio::test]
async fn acp_backend_requires_explicit_process_attr() {
let sandbox = MockSandbox::linux();
let sandbox = Arc::new(sandbox);
let sandbox_dyn: Arc<dyn Sandbox> = sandbox.clone();
let sandbox_dyn = sandbox.sandbox();
let mut node = Node::new("work");
node.attrs
@ -1421,11 +1342,7 @@ mod tests {
.contains("requires exactly one of acp.command or acp.config")
);
assert!(
sandbox
.captured_env_vars
.lock()
.expect("captured env lock poisoned")
.is_none(),
sandbox.captured_env_vars().is_none(),
"ACP process should not launch when process attr is missing"
);
}
@ -1436,8 +1353,7 @@ mod tests {
let mut sandbox = MockSandbox::linux();
sandbox.stdio_process_error = Some(DAYTONA_UNSUPPORTED_ACP.to_string());
let sandbox = Arc::new(sandbox);
let sandbox_dyn: Arc<dyn Sandbox> = sandbox.clone();
let sandbox_dyn = sandbox.sandbox();
let mut node = Node::new("work");
node.attrs

View file

@ -7,7 +7,7 @@ use fabro_agent::subagent::{SessionFactory, SubAgentSupervisor};
use fabro_agent::tool_registry::{RegisteredTool, ToolContext, ToolRegistry, ToolSource};
use fabro_agent::{
AgentEvent, AgentProfile, AgentProfileBuilder, CompletionCoordinator, Message as AgentMessage,
Sandbox, Session, SessionOptions, SessionShutdownReason, StaticEnvProvider, ToolEnvProvider,
RunSandbox, Session, SessionOptions, SessionShutdownReason, StaticEnvProvider, ToolEnvProvider,
ToolSecrets, WebFetchSummarizer, canonical_tool_name, register_question_tools,
};
use fabro_auth::CredentialSource;
@ -998,7 +998,7 @@ impl AgentApiBackend {
async fn create_session_with_plan(
&self,
node: &Node,
sandbox: &Arc<dyn Sandbox>,
sandbox: &Arc<RunSandbox>,
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
) -> Result<(CachedAgentSession, Vec<ModelFallbackNotice>), Error> {
let model = node.model().unwrap_or(&self.model);
@ -1044,7 +1044,7 @@ impl AgentApiBackend {
provider: ProviderContext,
controls: EffectiveRequestControls,
node: &Node,
sandbox: &Arc<dyn Sandbox>,
sandbox: &Arc<RunSandbox>,
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
tool_env: Option<&Arc<dyn ToolEnvProvider>>,
@ -1937,7 +1937,7 @@ mod tests {
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>,
@ -3068,7 +3068,7 @@ reasoning = false
tokio::fs::write(workspace.path().join("data.txt"), "hello\n")
.await
.unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> =
let sandbox: Arc<fabro_agent::RunSandbox> =
Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap());
let mut session = backend
@ -3608,7 +3608,7 @@ enabled = true
let stage_scope = StageScope::for_handler(&context, &node.id);
let emitter = Arc::new(Emitter::new(fabro_types::RunId::new()));
let workspace = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> =
let sandbox: Arc<fabro_agent::RunSandbox> =
Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap());
let result = backend
@ -3674,7 +3674,7 @@ enabled = true
let stage_scope = StageScope::for_handler(&context, &node.id);
let emitter = Arc::new(Emitter::new(fabro_types::RunId::new()));
let workspace = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> =
let sandbox: Arc<fabro_agent::RunSandbox> =
Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap());
let result = backend
@ -3735,7 +3735,7 @@ enabled = true
let context = Context::new();
let emitter = Arc::new(Emitter::new(fabro_types::RunId::new()));
let workspace = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> =
let sandbox: Arc<fabro_agent::RunSandbox> =
Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap());
let result = backend
@ -3807,7 +3807,7 @@ enabled = true
let context = Context::new();
let emitter = Arc::new(Emitter::new(fabro_types::RunId::new()));
let workspace = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> =
let sandbox: Arc<fabro_agent::RunSandbox> =
Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap());
let result = backend
@ -3878,7 +3878,7 @@ enabled = true
let context = Context::new();
let emitter = Arc::new(Emitter::new(fabro_types::RunId::new()));
let workspace = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> =
let sandbox: Arc<fabro_agent::RunSandbox> =
Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap());
let result = backend
@ -3952,7 +3952,7 @@ enabled = true
}
});
let workspace = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> =
let sandbox: Arc<fabro_agent::RunSandbox> =
Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap());
let result = backend

View file

@ -1,12 +1,12 @@
use std::collections::HashSet;
use std::sync::Arc;
use fabro_agent::{Sandbox, shell_quote};
use fabro_agent::{RunSandbox, shell_quote};
const DIFF_MARKER: &str = "__FABRO_CHANGED_FILES_DIFF__";
const UNTRACKED_MARKER: &str = "__FABRO_CHANGED_FILES_UNTRACKED__";
pub async fn detect_changed_files(sandbox: &Arc<dyn Sandbox>) -> Vec<String> {
pub async fn detect_changed_files(sandbox: &Arc<RunSandbox>) -> Vec<String> {
let mut files: Vec<String> = Vec::new();
let command = format!(
"printf '%s\\n' {diff}; git diff --name-only || true; \
@ -29,7 +29,7 @@ pub async fn detect_changed_files(sandbox: &Arc<dyn Sandbox>) -> Vec<String> {
}
pub async fn files_touched_since(
sandbox: &Arc<dyn Sandbox>,
sandbox: &Arc<RunSandbox>,
files_before: &[String],
) -> (Vec<String>, Option<String>) {
let files_after = detect_changed_files(sandbox).await;

View file

@ -79,7 +79,7 @@ mod tests {
use std::sync::Arc;
use async_trait::async_trait;
use fabro_agent::{Sandbox, local_sandbox};
use fabro_agent::{RunSandbox, local_sandbox};
use fabro_graphviz::graph::{AttrValue, Node};
use fabro_model::{ReasoningEffort, Speed};
use tokio_util::sync::CancellationToken;
@ -114,7 +114,7 @@ mod tests {
#[tokio::test]
async fn router_routes_one_shot_to_api_by_default() {
let node = Node::new("test");
let sandbox: Arc<dyn Sandbox> = Arc::new(
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(tempfile::tempdir().unwrap().path().to_path_buf())
.await
.unwrap(),

View file

@ -268,7 +268,7 @@ async fn build_branch_plan(
artifact::demote_large_items_for_prompt(
&mut items,
&services.run.run_store,
&*services.run.sandbox,
&services.run.sandbox,
run_dir,
)
.await;

View file

@ -75,7 +75,7 @@ impl Handler for PromptHandler {
)?
.profile_kind;
let docs = match fabro_agent::discover_memory(
&*services.run.sandbox,
&services.run.sandbox,
working_dir,
working_dir,
profile_kind,

View file

@ -39,7 +39,7 @@ const ARTIFACT_UPLOAD_RETRY_DELAYS: [Duration; 3] = [
/// Sub-lifecycle responsible for artifact collection, offloading, and syncing.
pub(crate) struct ArtifactLifecycle {
pub sandbox: Arc<dyn fabro_sandbox::Sandbox>,
pub sandbox: Arc<fabro_sandbox::RunSandbox>,
pub run_store: RunStoreHandle,
pub emitter: Arc<Emitter>,
pub run_id: RunId,
@ -53,7 +53,7 @@ pub(crate) struct ArtifactLifecycle {
impl ArtifactLifecycle {
pub(crate) fn new(
sandbox: Arc<dyn fabro_sandbox::Sandbox>,
sandbox: Arc<fabro_sandbox::RunSandbox>,
run_store: RunStoreHandle,
emitter: Arc<Emitter>,
run_id: RunId,
@ -130,7 +130,7 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
let artifact_capture_dir =
tempfile::tempdir().map_err(|err| CoreError::Other(err.to_string()))?;
match collect_artifacts(&*self.sandbox, artifact_capture_dir.path(), artifact_globs).await {
match collect_artifacts(&self.sandbox, artifact_capture_dir.path(), artifact_globs).await {
Ok(summary) => {
self.emit_collection_problem_notice(node_id, &summary);
let new_assets = self.new_captured_assets(&summary.captured_assets);
@ -207,7 +207,7 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
// Sync file-backed artifacts to sandbox environment
if let Err(e) =
sync_artifacts_to_env(&mut result.outcome.context_updates, &*self.sandbox).await
sync_artifacts_to_env(&mut result.outcome.context_updates, &self.sandbox).await
{
self.emitter.notice(
RunNoticeLevel::Warn,

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_core::error::{Error as CoreError, Result as CoreResult};
use fabro_core::graph::NodeSpec;
use fabro_core::lifecycle::{EdgeContext, EdgeDecision, NodeDecision, RunLifecycle};
@ -31,7 +31,7 @@ struct IncomingEdgeData {
/// setup.
pub(crate) struct FidelityLifecycle {
pub graph: Arc<GvGraph>,
pub sandbox: Arc<dyn Sandbox>,
pub sandbox: Arc<RunSandbox>,
pub run_store: RunStoreHandle,
pub run_dir: PathBuf,
incoming_edge_data: Mutex<Option<IncomingEdgeData>>,
@ -43,7 +43,7 @@ pub(crate) struct FidelityLifecycle {
impl FidelityLifecycle {
pub(crate) fn new(
graph: Arc<GvGraph>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
run_store: RunStoreHandle,
run_dir: PathBuf,
) -> Self {
@ -178,7 +178,7 @@ impl RunLifecycle<WorkflowGraph> for FidelityLifecycle {
let mut resolved_values = artifact::resolved_context_snapshot(
&state.context,
&self.run_store,
&*self.sandbox,
&self.sandbox,
&self.run_dir,
)
.await
@ -186,7 +186,7 @@ impl RunLifecycle<WorkflowGraph> for FidelityLifecycle {
let mut resolved_outcomes = artifact::resolve_outcomes_for_execution(
&state.node_outcomes,
&self.run_store,
&*self.sandbox,
&self.sandbox,
&self.run_dir,
)
.await
@ -205,7 +205,7 @@ impl RunLifecycle<WorkflowGraph> for FidelityLifecycle {
&mut resolved_values,
&mut resolved_outcomes,
&self.run_store,
&*self.sandbox,
&self.sandbox,
&self.run_dir,
)
.await;
@ -468,7 +468,7 @@ mod tests {
None,
));
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let sandbox: Arc<dyn Sandbox> = Arc::new(
let sandbox: Arc<RunSandbox> = Arc::new(
fabro_agent::local_sandbox(run_dir.to_path_buf())
.await
.unwrap(),

View file

@ -85,7 +85,7 @@ pub(crate) struct PushResult {
/// checkpoint pushes (the next checkpoint re-pushes the same branch anyway),
/// generous for the terminal publish push.
pub(crate) async fn push_run_branch(
sandbox: &dyn fabro_sandbox::Sandbox,
sandbox: &fabro_sandbox::RunSandbox,
branch: &str,
plan: &fabro_sandbox::RetryPlan,
) -> Result<fabro_sandbox::PushReport, fabro_sandbox::PushError> {
@ -97,7 +97,7 @@ pub(crate) async fn push_run_branch(
/// Sub-lifecycle responsible for git operations (checkpoint commits, pushes,
/// diffs).
pub(crate) struct GitLifecycle {
pub sandbox: Arc<dyn fabro_sandbox::Sandbox>,
pub sandbox: Arc<fabro_sandbox::RunSandbox>,
pub emitter: Arc<Emitter>,
pub run_id: RunId,
pub run_store: RunStoreHandle,
@ -303,7 +303,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
let git_author = self.run_options.git_author();
let commit_result = checked_git_checkpoint(
&self.sandbox_git,
&*self.sandbox,
&self.sandbox,
&self.run_id.to_string(),
node_id,
&result.outcome.status.to_string(),
@ -388,10 +388,10 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
.as_ref()
.and_then(|git| git.base_sha.clone());
let (patch_result, numstat_result) =
tokio::join!(git_diff(&*self.sandbox, &prev), async {
tokio::join!(git_diff(&self.sandbox, &prev), async {
match summary_base.as_deref() {
Some(base) if base != sha => {
Some(list_diff_numstat(&*self.sandbox, base, &sha).await)
Some(list_diff_numstat(&self.sandbox, base, &sha).await)
}
_ => None,
}
@ -1306,7 +1306,7 @@ mod tests {
.on_checkpoint(&node, &result, Some("exit"), &checkpoint_state)
.await
.unwrap();
let finalize_sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let finalize_sandbox: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(repo_dir.path().to_path_buf())
.await
.unwrap(),

View file

@ -8,7 +8,7 @@ use fabro_core::lifecycle::{
use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner};
use fabro_sandbox::Sandbox;
use fabro_sandbox::RunSandbox;
use fabro_types::RunId;
use crate::graph::{WorkflowGraph, WorkflowNode};
@ -22,7 +22,7 @@ type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
/// Sub-lifecycle responsible for running workflow hooks.
pub(crate) struct HookLifecycle {
pub hook_runner: Option<Arc<HookRunner>>,
pub sandbox: Arc<dyn Sandbox>,
pub sandbox: Arc<RunSandbox>,
pub hook_execution_context: HookExecutionContext,
pub run_id: RunId,
pub graph_name: String,

View file

@ -21,7 +21,7 @@ use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use fabro_graphviz::graph::types::Graph as GvGraph;
use fabro_hooks::HookRunner;
use fabro_sandbox::Sandbox;
use fabro_sandbox::RunSandbox;
use fabro_types::RunId;
use self::artifact::ArtifactLifecycle;
@ -57,7 +57,7 @@ pub(crate) struct WorkflowLifecycle {
circuit_breaker: Arc<CircuitBreakerLifecycle>,
git: GitLifecycle,
artifact: ArtifactLifecycle,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
on_node: crate::OnNodeCallback,
emitter: Arc<Emitter>,
run_control: Option<Arc<RunControlState>>,
@ -85,7 +85,7 @@ impl WorkflowLifecycle {
pub(crate) fn new(
emitter: &Arc<Emitter>,
hook_runner: Option<Arc<HookRunner>>,
sandbox: &Arc<dyn Sandbox>,
sandbox: &Arc<RunSandbox>,
graph: Arc<GvGraph>,
run_dir: &Path,
run_store: &RunStoreHandle,

View file

@ -94,7 +94,7 @@ pub(crate) async fn execute_single_attempt(
let wf_context = artifact::resolve_context_for_execution(
context,
&services.run.run_store,
&*services.run.sandbox,
&services.run.sandbox,
run_dir,
)
.await

View file

@ -1918,7 +1918,7 @@ reasoning = false
..
} = session;
let runtime = sandbox
.to_run_sandbox_instance(&MockSandbox::linux(), fixtures::RUN_1)
.to_run_sandbox_instance(&MockSandbox::linux().sandbox(), fixtures::RUN_1)
.runtime;
assert_eq!(runtime.repo_cloned, Some(false));
assert_eq!(runtime.clone_origin_url, None);
@ -1987,7 +1987,7 @@ reasoning = false
..
} = session;
let runtime = sandbox
.to_run_sandbox_instance(&MockSandbox::linux(), fixtures::RUN_1)
.to_run_sandbox_instance(&MockSandbox::linux().sandbox(), fixtures::RUN_1)
.runtime;
assert_eq!(runtime.repo_cloned, Some(false));
assert_eq!(runtime.clone_origin_url, None);

View file

@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use async_trait::async_trait;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_auth::test_support as auth_test_support;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_hooks::HookSettings;
@ -40,7 +40,7 @@ use crate::records::RunSpec;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions, SetupCommand};
use crate::test_support::run_graph;
async fn local_env() -> Arc<dyn Sandbox> {
async fn local_env() -> Arc<RunSandbox> {
Arc::new(
fabro_agent::local_sandbox(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.await
@ -572,7 +572,7 @@ async fn resumed_in_flight_node_starts_a_new_stage_execution() {
async fn run_with_lifecycle(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &Graph,
run_options: RunOptions,
lifecycle: LifecycleOptions,
@ -717,7 +717,7 @@ fn interview_wait_graph(stall_timeout: Duration, node_timeout: Option<Duration>)
}
struct StopsSandboxHandler {
sandbox: Arc<MockSandbox>,
sandbox: Arc<RunSandbox>,
}
#[async_trait]
@ -896,8 +896,9 @@ async fn execute_runs_simple_workflow() {
#[tokio::test]
async fn execute_preserves_sandbox_activation_error_chain() {
let dir = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn Sandbox> =
Arc::new(MockSandbox::linux().with_activate_error("provider unavailable"));
let sandbox = MockSandbox::linux()
.with_activate_error("provider unavailable")
.sandbox();
let error = run_graph(
make_registry(),
@ -911,7 +912,6 @@ async fn execute_preserves_sandbox_activation_error_chain() {
assert_eq!(error.causes(), vec![
"failed to activate sandbox before node start",
"Mock sandbox activation failed",
"provider unavailable",
]);
}
@ -919,15 +919,15 @@ async fn execute_preserves_sandbox_activation_error_chain() {
#[tokio::test]
async fn execute_reactivates_sandbox_after_a_stage_can_leave_it_stopped() {
let dir = tempfile::tempdir().unwrap();
let sandbox = Arc::new(MockSandbox::linux());
let sandbox = MockSandbox::linux();
let mut registry = make_registry();
registry.register(
"start",
Box::new(StopsSandboxHandler {
sandbox: Arc::clone(&sandbox),
sandbox: sandbox.sandbox(),
}),
);
let sandbox_for_run: Arc<dyn Sandbox> = sandbox.clone();
let sandbox_for_run = sandbox.sandbox();
let mut run_options = test_run_options(dir.path(), "test-run");
run_options
.settings
@ -949,7 +949,11 @@ async fn execute_reactivates_sandbox_after_a_stage_can_leave_it_stopped() {
assert_eq!(outcome.status, StageOutcome::Succeeded);
assert_eq!(sandbox.stop_count(), 1);
assert!(sandbox.walk_files_was_called());
assert!(!sandbox.walked_while_inactive());
assert_eq!(
sandbox.start_count(),
1,
"the stopped sandbox is started again before the walk"
);
}
#[tokio::test]

View file

@ -312,8 +312,8 @@ async fn compute_final_patch(
};
let to_sha = "HEAD";
let (patch_result, numstat_result) = tokio::join!(
git_diff_with_timeout(&*services.sandbox, &base_sha, timeout_ms),
list_diff_numstat(&*services.sandbox, &base_sha, to_sha),
git_diff_with_timeout(&services.sandbox, &base_sha, timeout_ms),
list_diff_numstat(&services.sandbox, &base_sha, to_sha),
);
let final_patch = match patch_result {
Ok(patch) if !patch.is_empty() => Some(patch),
@ -981,7 +981,7 @@ mod tests {
fn test_services(
run_store: RunStoreHandle,
emitter: Arc<Emitter>,
sandbox: Arc<dyn fabro_agent::Sandbox>,
sandbox: Arc<fabro_agent::RunSandbox>,
metadata_runtime: Arc<RunMetadataRuntime>,
metadata_writer: Option<RunMetadataWriterHandle>,
) -> Arc<RunServices> {
@ -1018,7 +1018,7 @@ mod tests {
let emitter = Arc::new(Emitter::new(test_run_id()));
let store_logger = StoreProgressLogger::new(run_store.clone());
store_logger.register(&emitter);
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let sandbox: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(std::env::current_dir().unwrap())
.await
.unwrap(),
@ -1270,7 +1270,7 @@ mod tests {
let services = test_services(
RunStoreHandle::local(seeded_run_store().await),
emitter,
Arc::new(MockSandbox::linux()),
MockSandbox::linux().sandbox(),
Arc::new(RunMetadataRuntime::new()),
None,
);
@ -1318,7 +1318,12 @@ mod tests {
#[tokio::test]
async fn final_push_failure_becomes_terminal_publish_failure() {
let repo_dir = tempfile::tempdir().unwrap();
let sandbox = Arc::new(MockSandbox::linux());
// The sandbox is unreachable, so the final push cannot run.
let sandbox = MockSandbox {
exec_error: Some("sandbox unreachable".into()),
..MockSandbox::linux()
}
.sandbox();
let emitter = Arc::new(Emitter::new(test_run_id()));
let events = record_events(&emitter);
let services = test_services(
@ -1613,11 +1618,11 @@ mod tests {
#[tokio::test]
async fn finalize_stops_sandbox_on_terminal_without_deleting() {
let repo_dir = tempfile::tempdir().unwrap();
let sandbox = Arc::new(MockSandbox::linux());
let sandbox = MockSandbox::linux();
let services = test_services(
RunStoreHandle::local(seeded_run_store().await),
Arc::new(Emitter::new(test_run_id())),
sandbox.clone(),
sandbox.sandbox(),
Arc::new(RunMetadataRuntime::new()),
None,
);
@ -1647,11 +1652,11 @@ mod tests {
#[tokio::test]
async fn finalize_leaves_sandbox_running_when_stop_on_terminal_is_false() {
let repo_dir = tempfile::tempdir().unwrap();
let sandbox = Arc::new(MockSandbox::linux());
let sandbox = MockSandbox::linux();
let services = test_services(
RunStoreHandle::local(seeded_run_store().await),
Arc::new(Emitter::new(test_run_id())),
sandbox.clone(),
sandbox.sandbox(),
Arc::new(RunMetadataRuntime::new()),
None,
);

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use fabro_agent::{Sandbox, ToolSecrets};
use fabro_agent::{RunSandbox, ToolSecrets};
use fabro_auth::{
CredentialSource, ExtraHeadersCredentialSource, VaultCredentialSource, auth_issue_message,
};
@ -51,7 +51,7 @@ struct BuiltSandboxEnv {
async fn run_hooks(
hook_runner: Option<&HookRunner>,
hook_context: &HookContext,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
execution_context: HookExecutionContext,
) -> HookDecision {
let Some(runner) = hook_runner else {
@ -75,7 +75,7 @@ fn git_setup_intent(run_options: &RunOptions) -> GitSetupIntent {
}
async fn configure_sandbox_git_identity(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
author: &GitAuthor,
) -> Result<(), Error> {
let command = format!(
@ -431,7 +431,7 @@ pub async fn initialize(
None
};
let attach_existing = attach_instance.is_some();
let sandbox: Arc<dyn Sandbox> = if let Some(instance) = attach_instance {
let sandbox: Arc<RunSandbox> = if let Some(instance) = attach_instance {
let access = ProviderAccess {
providers: options.sandbox_providers.clone(),
daytona: options
@ -505,7 +505,7 @@ pub async fn initialize(
if !attach_existing {
let run_sandbox = options
.sandbox
.to_run_sandbox_instance(&*sandbox, options.run_options.run_id);
.to_run_sandbox_instance(&sandbox, options.run_options.run_id);
let runtime = &run_sandbox.runtime;
options.emitter.emit(&Event::SandboxInitialized {
working_directory: runtime.working_directory.clone(),
@ -576,7 +576,7 @@ pub async fn initialize(
let sandbox_has_origin = sandbox.origin_url().is_some();
if sandbox_has_origin {
sandbox_git
.ensure_git_available(&*sandbox)
.ensure_git_available(&sandbox)
.await
.map_err(|err| Error::engine_with_source("sandbox git unavailable", err))?;
}
@ -1049,15 +1049,11 @@ mod tests {
Some("fabro-bot@example.com".to_string()),
);
configure_sandbox_git_identity(&sandbox, &author)
configure_sandbox_git_identity(&sandbox.sandbox(), &author)
.await
.expect("git identity should configure");
let commands = sandbox
.captured_commands
.lock()
.expect("captured_commands lock poisoned")
.clone();
let commands = sandbox.captured_commands();
assert_eq!(commands, vec![
"git config --local user.name 'Fabro Bot' && git config --local user.email \
fabro-bot@example.com"

View file

@ -1,6 +1,6 @@
use std::collections::{HashMap, HashSet};
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_checkpoint::trailer as trailerlink;
use fabro_checkpoint::trailer::Trailer;
use fabro_sandbox::shell_quote;
@ -49,7 +49,7 @@ pub(crate) fn exec_err(label: &str, r: fabro_sandbox::ExecResult) -> GitCommandE
reason = "Checkpointing needs explicit run metadata, checkpoint settings, and author inputs."
)]
pub async fn git_checkpoint(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
run_id: &str,
node_id: &str,
status: &str,
@ -161,7 +161,7 @@ pub async fn git_checkpoint(
#[tracing::instrument(name = "git_op", skip_all, fields(op = "checkpoint-commit"))]
pub(crate) async fn checked_git_checkpoint(
runtime: &SandboxGitRuntime,
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
run_id: &str,
node_id: &str,
status: &str,
@ -189,7 +189,7 @@ pub(crate) async fn checked_git_checkpoint(
/// Run a git diff via the sandbox (30 s default timeout).
pub(crate) async fn git_diff(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
base: &str,
) -> std::result::Result<String, GitCommandError> {
git_diff_with_timeout(sandbox, base, 30_000).await
@ -202,7 +202,7 @@ pub(crate) async fn git_diff(
/// pathological workspace (FS locks, corrupted index) doesn't stall terminal
/// event emission downstream (Slack notifier, SSE, CI hooks).
pub(crate) async fn git_diff_with_timeout(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
base: &str,
timeout_ms: u64,
) -> std::result::Result<String, GitCommandError> {
@ -352,7 +352,7 @@ pub struct BlobMeta {
/// The `--numstat` side-call classifies text vs binary so callers can skip
/// binary contents without ever invoking `git cat-file --batch` on them.
pub async fn list_changed_files_raw(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
base_sha: &str,
to_sha: &str,
) -> std::result::Result<Vec<RawDiffEntry>, DiffError> {
@ -555,7 +555,7 @@ pub fn summarize_diff_numstat(numstat: &DiffNumstat) -> DiffSummary {
/// text-file `+/-` totals. The single call replaces the previous binary-only
/// helper.
pub async fn list_diff_numstat(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
base_sha: &str,
to_sha: &str,
) -> std::result::Result<DiffNumstat, DiffError> {
@ -642,7 +642,7 @@ fn extract_new_path_from_numstat(rest: &str) -> String {
/// The order of returned `BlobMeta` entries matches the input `shas` order.
/// SHAs reported as `missing` by git yield `BlobMeta { size: None, .. }`.
pub async fn stream_blob_metadata(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
shas: &[String],
) -> std::result::Result<Vec<BlobMeta>, DiffError> {
if shas.is_empty() {
@ -708,7 +708,7 @@ pub async fn stream_blob_metadata(
/// [`list_diff_numstat`] — `--batch` output stream is text-oriented and
/// non-UTF-8 bytes are lossy through the sandbox `String` channel.
pub async fn stream_blobs(
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
shas: &[String],
size_cap_bytes: u64,
) -> std::result::Result<Vec<Option<String>>, DiffError> {
@ -812,173 +812,19 @@ mod tests {
reason = "These unit tests use the real git CLI to construct sandbox-git fixture repositories and sync-write fixtures to disk."
)]
use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use fabro_agent::{DirEntry, ExecResult, GrepOptions};
use fabro_agent::ExecResult;
use fabro_sandbox::test_support::MockSandbox;
use fabro_types::CommandTermination;
use tokio_util::sync::CancellationToken;
use super::*;
struct ScriptedSandbox {
exec_results: Mutex<VecDeque<ExecResult>>,
commands: Mutex<Vec<String>>,
timeouts: Mutex<Vec<u64>>,
write_paths: Mutex<Vec<String>>,
delete_paths: Mutex<Vec<String>>,
}
impl ScriptedSandbox {
fn new(exec_results: Vec<ExecResult>) -> Self {
Self {
exec_results: Mutex::new(exec_results.into()),
commands: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
write_paths: Mutex::new(Vec::new()),
delete_paths: Mutex::new(Vec::new()),
}
}
fn commands(&self) -> Vec<String> {
self.commands
.lock()
.expect("commands lock poisoned")
.clone()
}
fn timeouts(&self) -> Vec<u64> {
self.timeouts
.lock()
.expect("timeouts lock poisoned")
.clone()
}
fn write_paths(&self) -> Vec<String> {
self.write_paths
.lock()
.expect("write_paths lock poisoned")
.clone()
}
fn delete_paths(&self) -> Vec<String> {
self.delete_paths
.lock()
.expect("delete_paths lock poisoned")
.clone()
}
}
#[async_trait]
impl Sandbox for ScriptedSandbox {
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
Err("read_file not implemented for ScriptedSandbox".into())
}
async fn write_file(&self, path: &str, _content: &str) -> fabro_sandbox::Result<()> {
self.write_paths
.lock()
.expect("write_paths lock poisoned")
.push(path.to_string());
Ok(())
}
async fn delete_file(&self, path: &str) -> fabro_sandbox::Result<()> {
self.delete_paths
.lock()
.expect("delete_paths lock poisoned")
.push(path.to_string());
Ok(())
}
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> fabro_sandbox::Result<Vec<DirEntry>> {
Ok(Vec::new())
}
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<CancellationToken>,
) -> fabro_sandbox::Result<ExecResult> {
self.commands
.lock()
.expect("commands lock poisoned")
.push(command.to_string());
self.timeouts
.lock()
.expect("timeouts lock poisoned")
.push(timeout_ms);
self.exec_results
.lock()
.expect("exec_results lock poisoned")
.pop_front()
.ok_or_else(|| fabro_sandbox::Error::message("unexpected exec_command call"))
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
Ok(Vec::new())
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Ok(Vec::new())
}
async fn download_file_to_local(
&self,
_remote_path: &str,
_local_path: &std::path::Path,
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
fn working_directory(&self) -> &str {
"/work"
}
fn platform(&self) -> &str {
"darwin"
}
fn os_version(&self) -> String {
"Darwin".to_string()
/// A sandbox answering commands from `exec_results`, in order.
fn scripted(exec_results: &[ExecResult]) -> MockSandbox {
let sandbox = MockSandbox::default();
for result in exec_results {
sandbox.push_exec_result(result);
}
sandbox
}
fn exec_ok() -> ExecResult {
@ -1019,9 +865,9 @@ mod tests {
#[tokio::test]
async fn git_checkpoint_reports_add_timeout() {
let sandbox = ScriptedSandbox::new(vec![exec_timed_out(77)]);
let sandbox = scripted(&[exec_timed_out(77)]);
let err = git_checkpoint(
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1042,12 +888,12 @@ mod tests {
#[tokio::test]
async fn checked_git_checkpoint_fails_before_checkpoint_when_probe_fails() {
let sandbox = ScriptedSandbox::new(vec![exec_failed(127, "", "git missing\n")]);
let sandbox = scripted(&[exec_failed(127, "", "git missing\n")]);
let runtime = crate::sandbox_git_runtime::SandboxGitRuntime::new();
let err = checked_git_checkpoint(
&runtime,
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1075,9 +921,9 @@ mod tests {
#[tokio::test]
async fn git_checkpoint_reports_commit_timeout() {
let sandbox = ScriptedSandbox::new(vec![exec_ok(), exec_timed_out(88)]);
let sandbox = scripted(&[exec_ok(), exec_timed_out(88)]);
let err = git_checkpoint(
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1094,9 +940,9 @@ mod tests {
#[tokio::test]
async fn git_checkpoint_reports_rev_parse_killed_without_output() {
let sandbox = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_failed(-1, "", "")]);
let sandbox = scripted(&[exec_ok(), exec_ok(), exec_failed(-1, "", "")]);
let err = git_checkpoint(
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1113,7 +959,7 @@ mod tests {
#[tokio::test]
async fn git_checkpoint_uses_unique_commit_message_paths_for_same_run_and_node() {
let sandbox = ScriptedSandbox::new(vec![
let sandbox = scripted(&[
exec_ok(),
exec_ok(),
exec_ok(),
@ -1124,7 +970,7 @@ mod tests {
let author = crate::git::GitAuthor::default();
let first = git_checkpoint(
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1135,7 +981,7 @@ mod tests {
)
.await;
let second = git_checkpoint(
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1153,7 +999,11 @@ mod tests {
second.err()
);
let write_paths = sandbox.write_paths();
let write_paths: Vec<String> = sandbox
.written_files()
.into_iter()
.map(|(path, _)| path)
.collect();
assert_eq!(write_paths.len(), 2);
assert!(
write_paths
@ -1163,10 +1013,10 @@ mod tests {
);
assert_ne!(write_paths[0], write_paths[1]);
let delete_paths = sandbox.delete_paths();
let delete_paths = sandbox.deleted_files();
assert_eq!(delete_paths, write_paths);
let commands = sandbox.commands();
let commands = sandbox.captured_commands();
let commit_commands = commands
.iter()
.filter(|command| command.contains(" commit "))
@ -1182,13 +1032,13 @@ mod tests {
#[tokio::test]
async fn git_checkpoint_uses_configured_timeout_for_add_and_commit() {
let sandbox = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_ok()]);
let sandbox = scripted(&[exec_ok(), exec_ok(), exec_ok()]);
let checkpoint = RunCheckpointSettings {
commit_timeout_ms: 600_000,
..RunCheckpointSettings::default()
};
git_checkpoint(
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1200,13 +1050,13 @@ mod tests {
.await
.expect("checkpoint should succeed");
assert_eq!(sandbox.timeouts(), vec![600_000, 600_000, 10_000]);
assert_eq!(sandbox.captured_timeouts(), vec![600_000, 600_000, 10_000]);
}
#[tokio::test]
async fn git_diff_reports_timeout() {
let sandbox = ScriptedSandbox::new(vec![exec_timed_out(99)]);
let err = git_diff_with_timeout(&sandbox, "HEAD~1", 99)
let sandbox = scripted(&[exec_timed_out(99)]);
let err = git_diff_with_timeout(&sandbox.sandbox(), "HEAD~1", 99)
.await
.unwrap_err();
@ -1215,8 +1065,8 @@ mod tests {
#[tokio::test]
async fn git_diff_reports_failure_detail() {
let sandbox = ScriptedSandbox::new(vec![exec_failed(128, "", "fatal: bad revision\n")]);
let err = git_diff_with_timeout(&sandbox, "bad-base", 100)
let sandbox = scripted(&[exec_failed(128, "", "fatal: bad revision\n")]);
let err = git_diff_with_timeout(&sandbox.sandbox(), "bad-base", 100)
.await
.unwrap_err();
@ -1230,13 +1080,13 @@ mod tests {
#[tokio::test]
async fn git_checkpoint_appends_no_verify_when_skip_hooks_enabled() {
// add, commit, rev-parse
let sandbox = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_ok()]);
let sandbox = scripted(&[exec_ok(), exec_ok(), exec_ok()]);
let checkpoint = RunCheckpointSettings {
skip_git_hooks: true,
..RunCheckpointSettings::default()
};
git_checkpoint(
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1248,7 +1098,7 @@ mod tests {
.await
.expect("checkpoint should succeed");
let commands = sandbox.commands();
let commands = sandbox.captured_commands();
let commit_cmd = commands
.iter()
.find(|c| c.contains(" commit "))
@ -1261,9 +1111,9 @@ mod tests {
#[tokio::test]
async fn git_checkpoint_omits_no_verify_when_skip_hooks_disabled() {
let sandbox = ScriptedSandbox::new(vec![exec_ok(), exec_ok(), exec_ok()]);
let sandbox = scripted(&[exec_ok(), exec_ok(), exec_ok()]);
git_checkpoint(
&sandbox,
&sandbox.sandbox(),
"run1",
"work",
"success",
@ -1275,7 +1125,7 @@ mod tests {
.await
.expect("checkpoint should succeed");
let commands = sandbox.commands();
let commands = sandbox.captured_commands();
let commit_cmd = commands
.iter()
.find(|c| c.contains(" commit "))

View file

@ -1,4 +1,4 @@
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_sandbox::shell_quote;
use fabro_util::error::SharedError;
use tokio::sync::OnceCell;
@ -37,7 +37,7 @@ impl SandboxGitRuntime {
pub(crate) async fn ensure_git_available(
&self,
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
) -> Result<(), SharedError> {
self.probe
.get_or_init(|| async { probe_sandbox_git(sandbox).await })
@ -52,7 +52,7 @@ impl Default for SandboxGitRuntime {
}
}
async fn probe_sandbox_git(sandbox: &dyn Sandbox) -> Result<(), SharedError> {
async fn probe_sandbox_git(sandbox: &RunSandbox) -> Result<(), SharedError> {
let temp = sandbox_temp_dir(sandbox, "probe", "git");
let index = format!("{temp}/index");
let probe_file = format!("{temp}/probe.txt");
@ -74,13 +74,13 @@ async fn probe_sandbox_git(sandbox: &dyn Sandbox) -> Result<(), SharedError> {
exec_ok(sandbox, &command).await
}
fn sandbox_temp_dir(sandbox: &dyn Sandbox, run_id: &str, label: &str) -> String {
fn sandbox_temp_dir(sandbox: &RunSandbox, run_id: &str, label: &str) -> String {
let cwd = sandbox.working_directory().trim_end_matches('/');
let id = uuid::Uuid::new_v4();
format!("{cwd}/.fabro/tmp/{label}-{run_id}-{id}")
}
async fn exec_ok(sandbox: &dyn Sandbox, command: &str) -> Result<(), SharedError> {
async fn exec_ok(sandbox: &RunSandbox, command: &str) -> Result<(), SharedError> {
let result = sandbox
.exec_command(command, 30_000, None, None, None)
.await

View file

@ -4,7 +4,7 @@ use std::sync::Arc;
#[cfg(test)]
use std::time::Duration;
use fabro_agent::{Sandbox, ToolEnvProvider};
use fabro_agent::{RunSandbox, ToolEnvProvider};
use fabro_auth::CredentialSource;
#[cfg(test)]
use fabro_auth::ResolvedCredentials;
@ -48,7 +48,7 @@ impl RunLocations {
#[must_use]
pub fn for_sandbox(
host_source_dir: Option<PathBuf>,
sandbox: &dyn Sandbox,
sandbox: &RunSandbox,
run_scratch_dir: PathBuf,
) -> Self {
Self::new(
@ -95,7 +95,7 @@ pub struct FabroRunToolServices {
pub struct RunServices {
pub run_store: RunStoreHandle,
pub emitter: Arc<Emitter>,
pub sandbox: Arc<dyn Sandbox>,
pub sandbox: Arc<RunSandbox>,
pub hook_runner: Option<Arc<HookRunner>>,
pub locations: RunLocations,
pub(crate) cancel_token: CancellationToken,
@ -117,7 +117,7 @@ impl RunServices {
pub(crate) fn new(
run_store: RunStoreHandle,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
hook_runner: Option<Arc<HookRunner>>,
locations: RunLocations,
cancel_token: CancellationToken,
@ -188,7 +188,7 @@ impl RunServices {
}
#[must_use]
pub fn with_sandbox(self: &Arc<Self>, sandbox: Arc<dyn Sandbox>) -> Arc<Self> {
pub fn with_sandbox(self: &Arc<Self>, sandbox: Arc<RunSandbox>) -> Arc<Self> {
let locations = self
.locations
.with_sandbox_work_dir(Some(PathBuf::from(sandbox.working_directory())));
@ -301,7 +301,7 @@ impl EngineServices {
.create_run(&fabro_types::RunId::new())
.await
.expect("slate-backed test run store should initialize");
let sandbox: Arc<dyn Sandbox> = Arc::new(
let sandbox: Arc<RunSandbox> = Arc::new(
fabro_agent::local_sandbox(
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
)

View file

@ -4,7 +4,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_auth::{CredentialSource, test_support as auth_test_support};
use fabro_graphviz::graph::Graph as GvGraph;
use fabro_interview::AutoApproveInterviewer;
@ -166,7 +166,7 @@ fn bound_emitter(run_id: fabro_types::RunId, observer: &Arc<Emitter>) -> Arc<Emi
async fn initialized(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &GvGraph,
run_options: &RunOptions,
options: InitializedOptions,
@ -301,7 +301,7 @@ async fn initialized(
pub async fn run_graph(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &GvGraph,
run_options: &RunOptions,
) -> Result<Outcome> {
@ -326,7 +326,7 @@ pub async fn run_graph(
pub async fn run_graph_with_state(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &GvGraph,
run_options: &RunOptions,
) -> Result<(Outcome, RunProjection)> {
@ -359,7 +359,7 @@ pub async fn run_graph_with_state(
pub async fn run_graph_with_hooks(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &GvGraph,
run_options: &RunOptions,
hook_runner: Arc<fabro_hooks::HookRunner>,
@ -386,7 +386,7 @@ pub async fn run_graph_with_hooks(
pub async fn run_graph_with_hooks_and_state(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &GvGraph,
run_options: &RunOptions,
hook_runner: Arc<fabro_hooks::HookRunner>,
@ -421,7 +421,7 @@ pub async fn run_graph_with_hooks_and_state(
pub async fn run_graph_from_checkpoint(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &GvGraph,
run_options: &RunOptions,
checkpoint: &Checkpoint,
@ -447,7 +447,7 @@ pub async fn run_graph_from_checkpoint(
pub async fn run_graph_from_checkpoint_with_state(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &GvGraph,
run_options: &RunOptions,
checkpoint: &Checkpoint,
@ -481,7 +481,7 @@ pub async fn run_graph_from_checkpoint_with_state(
pub async fn run_graph_with_state_and_llm_source(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
graph: &GvGraph,
run_options: &RunOptions,
llm_source: Arc<dyn CredentialSource>,
@ -520,16 +520,12 @@ pub async fn run_graph_with_state_and_llm_source(
pub struct WorkflowRunner {
registry: std::sync::Mutex<Option<HandlerRegistry>>,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
sandbox: Arc<RunSandbox>,
}
impl WorkflowRunner {
#[must_use]
pub fn new(
registry: HandlerRegistry,
emitter: Arc<Emitter>,
sandbox: Arc<dyn Sandbox>,
) -> Self {
pub fn new(registry: HandlerRegistry, emitter: Arc<Emitter>, sandbox: Arc<RunSandbox>) -> Self {
Self {
registry: std::sync::Mutex::new(Some(registry)),
emitter,

View file

@ -15,7 +15,7 @@
)]
use fabro_sandbox::reconnect::reconnect;
use fabro_sandbox::{ProviderAccess, Sandbox as _, SandboxOptions, provider_sandbox};
use fabro_sandbox::{ProviderAccess, SandboxOptions, provider_sandbox};
use fabro_types::{RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind};
const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble";

View file

@ -22,11 +22,10 @@ use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::Arc;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_sandbox::{
DaytonaCredentials, DriverSandbox, ProviderAccess, SandboxOptions, SandboxProviderKind,
provider_sandbox,
DaytonaCredentials, ProviderAccess, SandboxOptions, SandboxProviderKind, provider_sandbox,
};
use fabro_static::EnvVars;
use fabro_store::{ArtifactKey, ArtifactStore};
@ -206,7 +205,7 @@ fn live_daytona_credentials() -> DaytonaCredentials {
}
}
async fn create_env() -> DriverSandbox {
async fn create_env() -> RunSandbox {
let creds = load_github_app_credentials();
create_env_with_github_app(Some(creds)).await
}
@ -221,7 +220,7 @@ fn test_artifact_store(run_dir: &Path) -> ArtifactStore {
async fn create_env_with_github_app(
github_app: Option<fabro_github::GitHubCredentials>,
) -> DriverSandbox {
) -> RunSandbox {
provider_sandbox(
SandboxProviderKind::DAYTONA,
&daytona_access(live_daytona_credentials()),
@ -543,7 +542,7 @@ impl Handler for LargeOutputHandler {
async fn daytona_pipeline_artifact_offload_and_sync() {
let env = create_env().await;
env.initialize().await.unwrap();
let env: Arc<dyn Sandbox> = Arc::new(env);
let env: Arc<RunSandbox> = Arc::new(env);
// Pipeline: start -> big_output -> exit
let mut graph = Graph::new("DaytonaArtifactPipeline");
@ -659,7 +658,7 @@ impl Handler for FileWriterHandler {
/// Set up git inside a Daytona sandbox for checkpoint commits.
/// Returns (run_id, base_sha, branch_name) on success.
async fn setup_daytona_git(sandbox: &dyn Sandbox) -> (RunId, String, String) {
async fn setup_daytona_git(sandbox: &RunSandbox) -> (RunId, String, String) {
// Get current HEAD as base SHA
let sha_result = sandbox
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
@ -697,7 +696,7 @@ async fn setup_daytona_git(sandbox: &dyn Sandbox) -> (RunId, String, String) {
async fn daytona_git_checkpoint_remote_emits_events() {
let env = create_env().await;
env.initialize().await.unwrap();
let env: Arc<dyn Sandbox> = Arc::new(env);
let env: Arc<RunSandbox> = Arc::new(env);
// Install git if not available (the default ubuntu:22.04 image may not have it)
let git_check = env
@ -723,7 +722,7 @@ async fn daytona_git_checkpoint_remote_emits_events() {
}
// Set up git in the sandbox
let (_run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;
let (_run_id, base_sha, branch_name) = setup_daytona_git(&env).await;
// Pipeline: start -> work -> exit
let mut graph = Graph::new("DaytonaGitCheckpoint");
@ -847,7 +846,7 @@ async fn daytona_git_checkpoint_remote_emits_events() {
async fn daytona_git_checkpoint_with_shadow_branch() {
let env = create_env().await;
env.initialize().await.unwrap();
let env: Arc<dyn Sandbox> = Arc::new(env);
let env: Arc<RunSandbox> = Arc::new(env);
// Install git if not available
let git_check = env
@ -873,7 +872,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
}
// Set up git in the sandbox
let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;
let (run_id, base_sha, branch_name) = setup_daytona_git(&env).await;
// Pipeline: start -> work -> exit
let mut graph = Graph::new("DaytonaShadowBranch");
@ -1022,7 +1021,7 @@ impl Handler for AssetCreatorHandler {
async fn daytona_asset_collection() {
let env = create_env().await;
env.initialize().await.unwrap();
let env: Arc<dyn Sandbox> = Arc::new(env);
let env: Arc<RunSandbox> = Arc::new(env);
let dir = tempfile::tempdir().unwrap();
@ -1280,7 +1279,7 @@ async fn daytona_git_push_run_branch_to_origin() {
let creds = load_github_app_credentials();
let env = create_env_with_github_app(Some(creds)).await;
env.initialize().await.unwrap();
let env: Arc<dyn Sandbox> = Arc::new(env);
let env: Arc<RunSandbox> = Arc::new(env);
// Install git if not available
let git_check = env
@ -1306,7 +1305,7 @@ async fn daytona_git_push_run_branch_to_origin() {
}
// Set up git in the sandbox
let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;
let (run_id, base_sha, branch_name) = setup_daytona_git(&env).await;
// Pipeline: start -> work -> exit
let mut graph = Graph::new("DaytonaGitPush");
@ -1797,8 +1796,6 @@ async fn daytona_computer_use_browser_screenshot() {
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
async fn daytona_playwright_mcp_sandbox_transport() {
use fabro_agent::Sandbox;
// Create sandbox from daytona-medium (has Node.js + Chromium)
let options = SandboxOptions {
skip_clone: true,

View file

@ -3,12 +3,12 @@
reason = "These git integration tests intentionally exercise the real git CLI to validate repository helper behavior."
)]
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::process::{Command, Output};
use std::sync::Arc;
use fabro_agent::Sandbox;
use fabro_agent::RunSandbox;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_types::{RunEvent, WorkflowSettings, fixtures};
use fabro_workflow::event::Emitter;
@ -18,6 +18,9 @@ use fabro_workflow::handler::exit::ExitHandler;
use fabro_workflow::handler::start::StartHandler;
use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions};
use fabro_workflow::test_support::run_graph;
use sandbox_driver::{
Capabilities, DirEntry, Exec, FileMetadata, Filesystem, PlatformInfo, SandboxId, SandboxStatus,
};
use tokio_util::sync::CancellationToken;
fn assert_success(output: &Output, context: &str) {
@ -114,7 +117,7 @@ fn list_branch(repo_dir: &Path, branch: &str) -> String {
String::from_utf8(output.stdout).expect("git branch --list output should be UTF-8")
}
async fn local_env(repo: &Path) -> Arc<dyn Sandbox> {
async fn local_env(repo: &Path) -> Arc<RunSandbox> {
Arc::new(
fabro_agent::local_sandbox(repo.to_path_buf())
.await
@ -337,105 +340,121 @@ async fn git_checkpoint_skips_start_node() {
/// local checkout, but the workflow engine's run directory is reported as
/// inaccessible (as it is for Docker/Daytona) and the sandbox exposes a
/// runtime directory outside the checkout.
struct RemoteRuntimeSandbox {
inner: fabro_agent::DriverSandbox,
hidden_path: String,
struct RemoteStyleSandbox {
inner: Arc<dyn sandbox_driver::Sandbox>,
fs: HidingFs,
runtime_directory: String,
}
#[async_trait::async_trait]
impl Sandbox for RemoteRuntimeSandbox {
async fn read_file_bytes(&self, path: &str) -> fabro_sandbox::Result<Vec<u8>> {
self.inner.read_file_bytes(path).await
}
async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> {
self.inner.write_file(path, content).await
}
async fn delete_file(&self, path: &str) -> fabro_sandbox::Result<()> {
self.inner.delete_file(path).await
}
async fn file_exists(&self, path: &str) -> fabro_sandbox::Result<bool> {
if path == self.hidden_path {
return Ok(false);
impl RemoteStyleSandbox {
fn over(
inner: Arc<dyn sandbox_driver::Sandbox>,
hidden_path: String,
runtime_directory: String,
) -> Self {
Self {
fs: HidingFs {
inner: Arc::clone(&inner),
hidden_path,
},
inner,
runtime_directory,
}
self.inner.file_exists(path).await
}
}
#[async_trait::async_trait]
impl sandbox_driver::Sandbox for RemoteStyleSandbox {
fn id(&self) -> &SandboxId {
self.inner.id()
}
async fn list_directory(
&self,
path: &str,
depth: Option<usize>,
) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
self.inner.list_directory(path, depth).await
fn capabilities(&self) -> &Capabilities {
self.inner.capabilities()
}
async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> fabro_sandbox::Result<fabro_agent::ExecResult> {
self.inner
.exec_command(command, timeout_ms, working_dir, env_vars, cancel_token)
.await
}
async fn grep(
&self,
pattern: &str,
path: &str,
options: &fabro_sandbox::GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
self.inner.grep(pattern, path, options).await
}
async fn download_file_to_local(
&self,
remote_path: &str,
local_path: &Path,
) -> fabro_sandbox::Result<()> {
self.inner
.download_file_to_local(remote_path, local_path)
.await
}
async fn upload_file_from_local(
&self,
local_path: &Path,
remote_path: &str,
) -> fabro_sandbox::Result<()> {
self.inner
.upload_file_from_local(local_path, remote_path)
.await
}
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
async fn describe(&self) -> sandbox_driver::Result<SandboxStatus> {
self.inner.describe().await
}
fn working_directory(&self) -> &str {
self.inner.working_directory()
}
async fn environment(&self) -> sandbox_driver::Result<BTreeMap<String, String>> {
self.inner.environment().await
}
fn runtime_directory(&self) -> Option<&str> {
Some(&self.runtime_directory)
}
fn platform(&self) -> &str {
self.inner.platform()
async fn platform_info(&self) -> sandbox_driver::Result<PlatformInfo> {
self.inner.platform_info().await
}
fn os_version(&self) -> String {
self.inner.os_version()
async fn start(&self) -> sandbox_driver::Result<()> {
self.inner.start().await
}
async fn stop(&self) -> sandbox_driver::Result<()> {
self.inner.stop().await
}
async fn delete(&self) -> sandbox_driver::Result<()> {
self.inner.delete().await
}
fn exec(&self) -> &dyn Exec {
self.inner.exec()
}
fn fs(&self) -> &dyn Filesystem {
&self.fs
}
}
/// The real filesystem with one path reported absent.
struct HidingFs {
inner: Arc<dyn sandbox_driver::Sandbox>,
hidden_path: String,
}
#[async_trait::async_trait]
impl Filesystem for HidingFs {
async fn read(&self, path: &str) -> sandbox_driver::Result<Vec<u8>> {
self.inner.fs().read(path).await
}
async fn write(&self, path: &str, content: &[u8]) -> sandbox_driver::Result<()> {
self.inner.fs().write(path, content).await
}
async fn delete(&self, path: &str, recursive: bool) -> sandbox_driver::Result<()> {
self.inner.fs().delete(path, recursive).await
}
async fn exists(&self, path: &str) -> sandbox_driver::Result<bool> {
if path == self.hidden_path {
return Ok(false);
}
self.inner.fs().exists(path).await
}
async fn metadata(&self, path: &str) -> sandbox_driver::Result<FileMetadata> {
self.inner.fs().metadata(path).await
}
async fn list_dir(&self, path: &str, depth: usize) -> sandbox_driver::Result<Vec<DirEntry>> {
self.inner.fs().list_dir(path, depth).await
}
async fn create_dir(&self, path: &str) -> sandbox_driver::Result<()> {
self.inner.fs().create_dir(path).await
}
async fn rename(&self, from: &str, to: &str) -> sandbox_driver::Result<()> {
self.inner.fs().rename(from, to).await
}
}
@ -482,13 +501,17 @@ async fn remote_prompt_demotion_stays_outside_checkout_and_survives_checkpoint()
let run_dir = dir.path().join("run");
std::fs::create_dir_all(&run_dir).unwrap();
let sandbox = RemoteRuntimeSandbox {
inner: fabro_agent::local_sandbox(repo_dir.clone())
.await
.expect("local sandbox should be created"),
hidden_path: run_dir.to_string_lossy().to_string(),
runtime_directory: runtime_dir.to_string_lossy().to_string(),
};
let local = fabro_agent::local_sandbox(repo_dir.clone())
.await
.expect("local sandbox should be created");
let sandbox = RunSandbox::new(
fabro_sandbox::SandboxProviderKind::LOCAL,
Arc::new(RemoteStyleSandbox::over(
Arc::clone(local.handle().expect("local sandbox is initialized")),
run_dir.to_string_lossy().to_string(),
runtime_dir.to_string_lossy().to_string(),
)),
);
let store = store_test_support::test_database(
Arc::new(InMemory::new()),

View file

@ -83,7 +83,7 @@ fn catalog_with_provider_base_url(provider: &str, base_url: &str) -> Arc<Catalog
)
}
async fn local_env() -> Arc<dyn fabro_agent::Sandbox> {
async fn local_env() -> Arc<fabro_agent::RunSandbox> {
Arc::new(
fabro_agent::local_sandbox(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
@ -10476,121 +10476,13 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
// Artifact sync to remote sandboxs
// ---------------------------------------------------------------------------
/// A mock sandbox where `file_exists` always returns false,
/// simulating a remote container that doesn't have local artifact files.
struct RemoteMockEnv {
working_dir: String,
written: std::sync::Mutex<Vec<(String, String)>>,
existing_paths: std::sync::Mutex<std::collections::HashSet<String>>,
}
impl RemoteMockEnv {
fn new(working_dir: &str) -> Self {
Self {
working_dir: working_dir.to_string(),
written: std::sync::Mutex::new(Vec::new()),
existing_paths: std::sync::Mutex::new(std::collections::HashSet::new()),
}
}
}
#[async_trait::async_trait]
impl fabro_agent::Sandbox for RemoteMockEnv {
async fn read_file_bytes(&self, _path: &str) -> fabro_sandbox::Result<Vec<u8>> {
Err("not implemented".into())
}
async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> {
self.written
.lock()
.unwrap()
.push((path.to_string(), content.to_string()));
self.existing_paths.lock().unwrap().insert(path.to_string());
Ok(())
}
async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn file_exists(&self, path: &str) -> fabro_sandbox::Result<bool> {
Ok(self.existing_paths.lock().unwrap().contains(path))
}
fn runtime_directory(&self) -> Option<&str> {
Some("/tmp/fabro/runtime")
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
Err("not implemented".into())
}
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_agent::ExecResult> {
Err("not implemented".into())
}
async fn grep(
&self,
_pattern: &str,
_path: &str,
_options: &fabro_agent::GrepOptions,
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn glob(
&self,
_pattern: &str,
_path: Option<&str>,
) -> fabro_sandbox::Result<Vec<String>> {
Err("not implemented".into())
}
async fn initialize(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn cleanup(&self) -> fabro_sandbox::Result<()> {
Ok(())
}
async fn download_file_to_local(
&self,
_: &str,
_: &std::path::Path,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
async fn upload_file_from_local(
&self,
_: &std::path::Path,
_: &str,
) -> fabro_sandbox::Result<()> {
Err("not implemented".into())
}
fn working_directory(&self) -> &str {
&self.working_dir
}
fn platform(&self) -> &str {
"linux"
}
fn os_version(&self) -> String {
"Linux 5.15".to_string()
/// A remote sandbox: the engine's run directory does not exist inside it,
/// and it offers a runtime directory outside the checkout.
fn remote_mock_env() -> fabro_sandbox::test_support::MockSandbox {
fabro_sandbox::test_support::MockSandbox {
working_dir: "/sandbox",
runtime_dir: Some("/tmp/fabro/runtime"),
..fabro_sandbox::test_support::MockSandbox::linux()
}
}
@ -10598,7 +10490,7 @@ impl fabro_agent::Sandbox for RemoteMockEnv {
async fn artifact_pointers_rewritten_for_remote_sandbox() {
// Pipeline: start -> big_output -> exit
// big_output uses LargeOutputHandler which returns a >100KB context_update.
// RemoteMockEnv simulates a container where local files don't exist.
// The remote sandbox has none of the run directory's files.
let mut graph = make_graph_with_start_exit("ArtifactSync");
graph.attrs.insert(
"goal".to_string(),
@ -10620,8 +10512,8 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() {
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let remote_env = Arc::new(RemoteMockEnv::new("/sandbox"));
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_env.clone());
let remote_env = remote_mock_env();
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_env.sandbox());
let run_options = RunOptions {
settings: WorkflowSettings::default(),
run_dir: dir.path().to_path_buf(),
@ -10661,7 +10553,7 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() {
"offloaded value should round-trip through the run store"
);
let written = remote_env.written.lock().unwrap();
let written = remote_env.written_files();
assert!(
written.is_empty(),
"blob materialization should not happen until a downstream execution needs it"
@ -10790,8 +10682,8 @@ async fn downstream_remote_execution_resolves_response_blob_refs_as_text() {
}),
);
let remote_env = Arc::new(RemoteMockEnv::new("/sandbox"));
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_env.clone());
let remote_env = remote_mock_env();
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_env.sandbox());
let run_options = RunOptions {
settings: WorkflowSettings::default(),
run_dir: dir.path().to_path_buf(),
@ -10818,7 +10710,7 @@ async fn downstream_remote_execution_resolves_response_blob_refs_as_text() {
// directory, but nowhere else.
let captured_value = captured.lock().unwrap().first().cloned().unwrap();
assert_eq!(captured_value, "x".repeat(150 * 1024));
let written = remote_env.written.lock().unwrap();
let written = remote_env.written_files();
assert!(
!written.is_empty(),
"prompt demotion materializes the oversized response into the sandbox"
@ -11083,7 +10975,7 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() {
let emitter = Emitter::default();
let events = collect_events(&emitter);
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let env: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(worktree_path.clone())
.await
.expect("local sandbox should be created"),
@ -11251,7 +11143,7 @@ async fn git_checkpoint_host_skips_metadata_branch_without_writer_prereqs() {
std::fs::write(run_dir.path().join("graph.fabro"), "digraph {}").unwrap();
let emitter = Emitter::default();
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let env: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(worktree_path.clone())
.await
.expect("local sandbox should be created"),
@ -11434,7 +11326,7 @@ async fn parallel_shared_checkout_host_e2e() {
let emitter = Emitter::default();
let events = collect_events(&emitter);
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let env: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(worktree_path.clone())
.await
.expect("local sandbox should be created"),
@ -11692,7 +11584,7 @@ async fn git_checkpoint_host_skips_empty_diff_patch() {
let emitter = Emitter::default();
let _events = collect_events(&emitter);
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let env: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(worktree_path.clone())
.await
.expect("local sandbox should be created"),
@ -13397,7 +13289,7 @@ async fn asset_collection_local_sandbox_success() {
let work_dir = tempfile::tempdir().unwrap();
let run_dir = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let sandbox: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(work_dir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
@ -13545,7 +13437,7 @@ async fn asset_collection_local_sandbox_symlink_working_directory() {
.expect("workspace symlink should create");
let run_dir = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let sandbox: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(symlink_work_dir)
.await
.expect("local sandbox should be created"),
@ -13648,7 +13540,7 @@ async fn asset_collection_local_sandbox_on_failure() {
let work_dir = tempfile::tempdir().unwrap();
let run_dir = tempfile::tempdir().unwrap();
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let sandbox: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::local_sandbox(work_dir.path().to_path_buf())
.await
.expect("local sandbox should be created"),
@ -13758,7 +13650,7 @@ async fn asset_collection_docker_sandbox() {
skip_clone: true,
..Default::default()
};
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(
let sandbox: Arc<fabro_agent::RunSandbox> = Arc::new(
fabro_agent::provider_sandbox(
fabro_agent::SandboxProviderKind::DOCKER,
&fabro_agent::ProviderAccess::default(),