diff --git a/AGENTS.md b/AGENTS.md index 411d07e4a..b9a118355 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index b61186339..bf2ef98ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a36df5526..1ffe390c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/docs/internal/parallel-strategy.md b/docs/internal/parallel-strategy.md index 590a8169b..e686b4028 100644 --- a/docs/internal/parallel-strategy.md +++ b/docs/internal/parallel-strategy.md @@ -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` as the parent run; +- receives the same `Arc` 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. diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 7c0fb1dd0..025871528 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -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> { 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> { pub fn new( llm_client: Client, provider_profile: Arc, - sandbox: Arc, + sandbox: Arc, 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, String>; - async fn read_file_text(&self, path: &str) -> Result; - async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result; - 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; - async fn list_directory(&self, path: &str, depth: Option) -> Result, String>; - async fn exec_command( +impl RunSandbox { + pub async fn read_file_bytes(&self, path: &str) -> Result>; + pub async fn read_file_text(&self, path: &str) -> Result; + pub async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result; + 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; + pub async fn list_directory(&self, path: &str, depth: Option) -> Result>; + pub async fn exec_command( &self, command: &str, timeout_ms: u64, working_dir: Option<&str>, env_vars: Option<&HashMap>, cancel_token: Option, - ) -> Result; - async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result, String>; - async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result, String>; - async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, 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; + pub async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result>; + pub async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result>; + pub async fn glob(&self, pattern: &str, path: Option<&str>) -> Result>; + 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; // ... diff --git a/lib/apps/fabro-server/src/run_files.rs b/lib/apps/fabro-server/src/run_files.rs index 0dc38f04a..a1efb138f 100644 --- a/lib/apps/fabro-server/src/run_files.rs +++ b/lib/apps/fabro-server/src/run_files.rs @@ -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 { @@ -1201,7 +1194,7 @@ async fn reconnect_run_sandbox( state: &Arc, run_id: &RunId, projection: &fabro_store::RunProjection, -) -> std::result::Result, ApiError> { +) -> std::result::Result { 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>), 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>), ApiError> { let ref_q = shell_quote(git_ref); @@ -1615,7 +1608,7 @@ fn collect_blob_shas(classified: &[ClassifiedEntry]) -> Vec { /// 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>, ApiError> { if shas.is_empty() { @@ -1747,25 +1740,14 @@ mod tests { } } - struct ScriptedWorkingTreeSandbox { - commands: StdMutex>, - } - - #[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>, - _cancel_token: Option, - ) -> fabro_sandbox::Result { - 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> { - 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 { - unimplemented!() - } - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> fabro_sandbox::Result> { - unimplemented!() - } - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &fabro_sandbox::GrepOptions, - ) -> fabro_sandbox::Result> { - unimplemented!() - } - async fn glob( - &self, - _pattern: &str, - _path: Option<&str>, - ) -> fabro_sandbox::Result> { - 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>, - _cancel_token: Option, - ) -> SandboxResult { + /// 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> { - 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 { - unimplemented!() - } - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> SandboxResult> { - unimplemented!() - } - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &fabro_sandbox::GrepOptions, - ) -> SandboxResult> { - unimplemented!() - } - async fn glob(&self, _pattern: &str, _path: Option<&str>) -> SandboxResult> { - 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\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()))); diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 94d2d82aa..c06601728 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -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, String> = spec.build(None).await.map_err(|err| { + let sandbox_result: Result, String> = spec.build(None).await.map_err(|err| { if *sandbox_provider == SandboxProviderKind::DAYTONA { format!("Daytona sandbox creation failed: {err}") } else { diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index e3f150f53..43c0c0d44 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -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::{ diff --git a/lib/apps/fabro-server/src/server/handler/sandbox.rs b/lib/apps/fabro-server/src/server/handler/sandbox.rs index 28ad514e8..7cd27fa31 100644 --- a/lib/apps/fabro-server/src/server/handler/sandbox.rs +++ b/lib/apps/fabro-server/src/server/handler/sandbox.rs @@ -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>; } -impl VncSandbox for DriverSandbox { +impl VncSandbox for RunSandbox { fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result> { 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, run_id: &RunId, -) -> Result, Response> { +) -> Result { 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, run_id: &RunId, record: &RunSandboxInstance, -) -> Result, 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, - run_id: &RunId, - record: &RunSandboxInstance, -) -> Result { +) -> Result { let access = load_provider_access(state).await?; let sandbox = reconnect_driver_for_run(record, &access, Some(*run_id), None) .await diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 676a87f3d..4ddd0c382 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -725,7 +725,7 @@ async fn build_agent_session( .activate() .await .map_err(|err| AskFabroBuildError::SandboxUnavailable(anyhow::Error::new(err)))?; - let sandbox: Arc = 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 = Arc::new( + let sandbox = Arc::new( fabro_agent::local_sandbox(std::env::current_dir().unwrap()) .await .unwrap(), diff --git a/lib/components/fabro-acp/src/session.rs b/lib/components/fabro-acp/src/session.rs index 9842c1313..98a3ad9f9 100644 --- a/lib/components/fabro-acp/src/session.rs +++ b/lib/components/fabro-acp/src/session.rs @@ -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, pub env: HashMap, - pub sandbox: Arc, + pub sandbox: Arc, pub cancel_token: CancellationToken, pub on_activity: Option>, pub live_control: Option, diff --git a/lib/components/fabro-acp/src/transport.rs b/lib/components/fabro-acp/src/transport.rs index f599c5a27..a479bacde 100644 --- a/lib/components/fabro-acp/src/transport.rs +++ b/lib/components/fabro-acp/src/transport.rs @@ -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, - sandbox: Arc, + sandbox: Arc, state: TransportState, } @@ -101,7 +101,7 @@ impl SandboxAcpTransport { command: AcpProcessSpec, cwd: String, env: HashMap, - sandbox: Arc, + sandbox: Arc, state: TransportState, ) -> Self { Self { diff --git a/lib/components/fabro-acp/tests/session.rs b/lib/components/fabro-acp/tests/session.rs index c4e7633a5..f896272b0 100644 --- a/lib/components/fabro-acp/tests/session.rs +++ b/lib/components/fabro-acp/tests/session.rs @@ -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 = 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 = 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 = Arc::new( + let sandbox: Arc = 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 = Arc::new( + let sandbox: Arc = 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 = Arc::new( + let sandbox: Arc = 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 = Arc::new( + let sandbox: Arc = 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 = Arc::new( + let sandbox: Arc = Arc::new( local_sandbox(tempdir.to_path_buf()) .await .expect("local sandbox should be created"), diff --git a/lib/components/fabro-agent/Cargo.toml b/lib/components/fabro-agent/Cargo.toml index 3b3b0a0a6..928656f75 100644 --- a/lib/components/fabro-agent/Cargo.toml +++ b/lib/components/fabro-agent/Cargo.toml @@ -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 } diff --git a/lib/components/fabro-agent/README.md b/lib/components/fabro-agent/README.md index 59f3cc665..116171592 100644 --- a/lib/components/fabro-agent/README.md +++ b/lib/components/fabro-agent/README.md @@ -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, String>; - async fn read_file_text(&self, path: &str) -> Result; - async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result; // 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; - async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result, String>; - async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result, String>; - async fn glob(&self, pattern: &str, path: Option<&str>) -> Result, String>; +impl RunSandbox { + pub async fn read_file_bytes(&self, path: &str) -> Result>; + pub async fn read_file_text(&self, path: &str) -> Result; + pub async fn read_file(&self, path: &str, offset: Option, limit: Option) -> Result; // 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; + pub async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result>; + pub async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result>; + pub async fn glob(&self, pattern: &str, path: Option<&str>) -> Result>; // ... 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 diff --git a/lib/components/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs index 15140a3e2..5ca3fc068 100644 --- a/lib/components/fabro-agent/src/agent_profile.rs +++ b/lib/components/fabro-agent/src/agent_profile.rs @@ -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")); diff --git a/lib/components/fabro-agent/src/apply_patch.rs b/lib/components/fabro-agent/src/apply_patch.rs index b9e13388a..5a13cd16f 100644 --- a/lib/components/fabro-agent/src/apply_patch.rs +++ b/lib/components/fabro-agent/src/apply_patch.rs @@ -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 { 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()); diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index 89a9c9f12..aa5bd5e45 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -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 = Arc::new( + let env: Arc = Arc::new( local_sandbox(cwd) .await .context("failed to create the local sandbox")?, diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index 8b33bd8d9..6768e57d2 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -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, diff --git a/lib/components/fabro-agent/src/local_sandbox.rs b/lib/components/fabro-agent/src/local_sandbox.rs index 460ffceab..a6653b8d9 100644 --- a/lib/components/fabro-agent/src/local_sandbox.rs +++ b/lib/components/fabro-agent/src/local_sandbox.rs @@ -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; diff --git a/lib/components/fabro-agent/src/mcp_integration.rs b/lib/components/fabro-agent/src/mcp_integration.rs index fe766562e..ece1112a5 100644 --- a/lib/components/fabro-agent/src/mcp_integration.rs +++ b/lib/components/fabro-agent/src/mcp_integration.rs @@ -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 = Arc::new(MockSandbox::default()); + let env = MockSandbox::default().sandbox(); let result = (tool.executor)( serde_json::json!({"message": "test message"}), ToolContext { diff --git a/lib/components/fabro-agent/src/memory.rs b/lib/components/fabro-agent/src/memory.rs index 94a88eb40..942243157 100644 --- a/lib/components/fabro-agent/src/memory.rs +++ b/lib/components/fabro-agent/src/memory.rs @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = Arc::new(MockSandbox { + let env = MockSandbox { files, ..Default::default() - }); + } + .sandbox(); let docs = discover_memory( env.as_ref(), "/repo", diff --git a/lib/components/fabro-agent/src/profiles/anthropic.rs b/lib/components/fabro-agent/src/profiles/anthropic.rs index 338c141fa..d1335c41a 100644 --- a/lib/components/fabro-agent/src/profiles/anthropic.rs +++ b/lib/components/fabro-agent/src/profiles/anthropic.rs @@ -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("")); @@ -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"), &[]); diff --git a/lib/components/fabro-agent/src/profiles/claude5.rs b/lib/components/fabro-agent/src/profiles/claude5.rs index 97cdeff1d..acf1c06de 100644 --- a/lib/components/fabro-agent/src/profiles/claude5.rs +++ b/lib/components/fabro-agent/src/profiles/claude5.rs @@ -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")); diff --git a/lib/components/fabro-agent/src/profiles/claude5_tools.rs b/lib/components/fabro-agent/src/profiles/claude5_tools.rs index 6f53622b0..a063c172d 100644 --- a/lib/components/fabro-agent/src/profiles/claude5_tools.rs +++ b/lib/components/fabro-agent/src/profiles/claude5_tools.rs @@ -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, + env: MockSandbox::default().sandbox(), cancel: CancellationToken::new(), tool_env_provider: None, session_id: Some("root".to_string()), diff --git a/lib/components/fabro-agent/src/profiles/gemini.rs b/lib/components/fabro-agent/src/profiles/gemini.rs index f6b3d494d..f7b7d5e23 100644 --- a/lib/components/fabro-agent/src/profiles/gemini.rs +++ b/lib/components/fabro-agent/src/profiles/gemini.rs @@ -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("")); assert!(prompt.contains("linux")); diff --git a/lib/components/fabro-agent/src/profiles/gpt56.rs b/lib/components/fabro-agent/src/profiles/gpt56.rs index d0fb93ed1..cf167f368 100644 --- a/lib/components/fabro-agent/src/profiles/gpt56.rs +++ b/lib/components/fabro-agent/src/profiles/gpt56.rs @@ -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, diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index e04a7d19c..019d65611 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -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")); diff --git a/lib/components/fabro-agent/src/profiles/kimi_tools.rs b/lib/components/fabro-agent/src/profiles/kimi_tools.rs index 07b6ec05e..6836c1778 100644 --- a/lib/components/fabro-agent/src/profiles/kimi_tools.rs +++ b/lib/components/fabro-agent/src/profiles/kimi_tools.rs @@ -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) -> ToolContext { + fn ctx(env: Arc) -> ToolContext { ToolContext { env, cancel: CancellationToken::new(), @@ -391,10 +391,14 @@ mod tests { } } - fn sandbox_with(path: &str, content: &str) -> Arc { + fn sandbox_with(path: &str, content: &str) -> Arc { 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) -> Result { - let env: Arc = 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?; diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index 2ecc237bb..0b9b9ce7c 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -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![ "".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("")); assert!(block.contains("")); @@ -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 = Arc::new(MockSandbox::default()); + let env = MockSandbox::default().sandbox(); let context = |session_id: &str| ToolContext { env: Arc::clone(&env), cancel: CancellationToken::new(), diff --git a/lib/components/fabro-agent/src/profiles/openai.rs b/lib/components/fabro-agent/src/profiles/openai.rs index d3010c859..29091c3ff 100644 --- a/lib/components/fabro-agent/src/profiles/openai.rs +++ b/lib/components/fabro-agent/src/profiles/openai.rs @@ -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("")); @@ -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")); } diff --git a/lib/components/fabro-agent/src/question_tools.rs b/lib/components/fabro-agent/src/question_tools.rs index 63eb58522..078413377 100644 --- a/lib/components/fabro-agent/src/question_tools.rs +++ b/lib/components/fabro-agent/src/question_tools.rs @@ -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()), diff --git a/lib/components/fabro-agent/src/sandbox.rs b/lib/components/fabro-agent/src/sandbox.rs index 3194dd104..b4ff2317c 100644 --- a/lib/components/fabro-agent/src/sandbox.rs +++ b/lib/components/fabro-agent/src/sandbox.rs @@ -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, }; diff --git a/lib/components/fabro-agent/src/session.rs b/lib/components/fabro-agent/src/session.rs index 3d0cf6ef0..304cc10a6 100644 --- a/lib/components/fabro-agent/src/session.rs +++ b/lib/components/fabro-agent/src/session.rs @@ -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, - sandbox: Arc, + sandbox: Arc, control_state: Arc>, control_notify: Arc, followup_queue: Arc>>, @@ -420,7 +420,7 @@ impl Session { pub fn new( llm_client: Client, provider_profile: Arc, - sandbox: Arc, + sandbox: Arc, config: SessionOptions, subagent_supervisor: Option, ) -> Self { @@ -471,7 +471,7 @@ impl Session { source: &dyn CredentialSource, catalog: Arc, provider_profile: Arc, - sandbox: Arc, + sandbox: Arc, config: SessionOptions, subagent_supervisor: Option, ) -> Result { @@ -490,7 +490,7 @@ impl Session { runtime_context: &[SessionMessage], llm_client: Client, provider_profile: Arc, - sandbox: Arc, + sandbox: Arc, config: SessionOptions, subagent_supervisor: Option, ) -> Result { @@ -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).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).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).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).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).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 = Arc::new(TestProfile::new()); - let env: Arc = 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, + sandbox: Arc, 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::::new()); let tool = make_use_skill_tool(skills_arc); let mut rx = session.subscribe(); - let env: Arc = Arc::new(MockSandbox::default()); + let env = MockSandbox::default().sandbox(); let ctx = ToolContext { env, cancel: CancellationToken::new(), diff --git a/lib/components/fabro-agent/src/skills.rs b/lib/components/fabro-agent/src/skills.rs index f1aff7031..35fbeb4fd 100644 --- a/lib/components/fabro-agent/src/skills.rs +++ b/lib/components/fabro-agent/src/skills.rs @@ -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, 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 = 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 = 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 = 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 = 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, diff --git a/lib/components/fabro-agent/src/subagent.rs b/lib/components/fabro-agent/src/subagent.rs index 697aa8893..9e0998129 100644 --- a/lib/components/fabro-agent/src/subagent.rs +++ b/lib/components/fabro-agent/src/subagent.rs @@ -1646,7 +1646,7 @@ mod tests { let provider_ref = provider.clone(); let client = make_client(provider as Arc).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).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(); diff --git a/lib/components/fabro-agent/src/test_support.rs b/lib/components/fabro-agent/src/test_support.rs index b68c9ed8a..9ba6cc774 100644 --- a/lib/components/fabro-agent/src/test_support.rs +++ b/lib/components/fabro-agent/src/test_support.rs @@ -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) -> 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, 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) } diff --git a/lib/components/fabro-agent/src/todo_runtime.rs b/lib/components/fabro-agent/src/todo_runtime.rs index dbf2791f5..030e22e4c 100644 --- a/lib/components/fabro-agent/src/todo_runtime.rs +++ b/lib/components/fabro-agent/src/todo_runtime.rs @@ -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) -> ToolContext { - let env: Arc = Arc::new(MockSandbox::default()); + let env = MockSandbox::default().sandbox(); ToolContext { env, cancel: CancellationToken::new(), diff --git a/lib/components/fabro-agent/src/todo_tools.rs b/lib/components/fabro-agent/src/todo_tools.rs index 2eb136ed8..61341bf6c 100644 --- a/lib/components/fabro-agent/src/todo_tools.rs +++ b/lib/components/fabro-agent/src/todo_tools.rs @@ -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 = 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 = Arc::new(MockSandbox::default()); + let env = MockSandbox::default().sandbox(); ToolContext { env, cancel: CancellationToken::new(), diff --git a/lib/components/fabro-agent/src/tool_execution.rs b/lib/components/fabro-agent/src/tool_execution.rs index 55ca0a57a..d419ab4ac 100644 --- a/lib/components/fabro-agent/src/tool_execution.rs +++ b/lib/components/fabro-agent/src/tool_execution.rs @@ -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, + env: Arc, tool_hooks: Option<&Arc>, 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, + env: Arc, tool_hooks: Option<&Arc>, 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, + env: Arc, tool_hooks: Option<&Arc>, 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, + env: Arc, tool_hooks: Option<&Arc>, 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, + env: Arc, tool_hooks: Option<&Arc>, 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, + env: Arc, tool_hooks: Option<&Arc>, 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, - env: Arc, + env: Arc, tool_hooks: Option<&Arc>, cancel_token: CancellationToken, config: &SessionOptions, @@ -518,7 +518,7 @@ struct ExecutedToolResult { async fn execute_one_tool( tc: &ToolCall, registered_tool: Option<&RegisteredTool>, - env: Arc, + env: Arc, cancel_token: CancellationToken, emitter: &Emitter, session_id: &str, @@ -897,7 +897,7 @@ mod tests { } } - async fn make_sandbox() -> Arc { + async fn make_sandbox() -> Arc { 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 { - Arc::new(MockSandbox { + fn shell_sandbox(result: fabro_sandbox::ExecResult) -> Arc { + MockSandbox { exec_result: result, ..Default::default() - }) + } + .sandbox() } fn exited(exit_code: i32) -> fabro_sandbox::ExecResult { diff --git a/lib/components/fabro-agent/src/tool_registry.rs b/lib/components/fabro-agent/src/tool_registry.rs index 7e955aed0..f5f9a522c 100644 --- a/lib/components/fabro-agent/src/tool_registry.rs +++ b/lib/components/fabro-agent/src/tool_registry.rs @@ -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, + pub env: Arc, pub cancel: CancellationToken, pub tool_env_provider: Option>, /// 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 = Arc::new(MockSandbox::default()); + let env = MockSandbox::default().sandbox(); let ctx = ToolContext { env, cancel: CancellationToken::new(), diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index 487cad146..462dda65c 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -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, 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 `::` grep output. @@ -619,10 +623,10 @@ pub(crate) fn make_list_dir_tool() -> RegisteredTool { let lines: Vec = 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 = 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::>() .join("\n"); - let env: Arc = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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) -> ToolContext { + fn shell_context(env: Arc) -> ToolContext { ToolContext { env, cancel: CancellationToken::new(), @@ -1109,7 +1116,7 @@ mod tests { } } - fn shell_context_with_emitter(env: Arc, emitter: &Emitter) -> ToolContext { + fn shell_context_with_emitter(env: Arc, 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 { - 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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::>() .join("\n"); assert!(stdout.len() > 30_000); - let env: Arc = 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 = Arc::new( + let env: Arc = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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: "

hello

".into(), stderr: String::new(), @@ -1794,8 +1812,8 @@ mod tests { duration_ms: 100, }, ..Default::default() - }); - let env_clone: Arc = 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(""), "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 = 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 = 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 = 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 = 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 = 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 = Arc::new(MockSandbox { + let env = MockSandbox { exec_result: ExecResult { stdout: "

Lots of content about Rust...

" .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 = Arc::new(MockSandbox { + let env = MockSandbox { exec_result: ExecResult { stdout: "

Rust is a systems programming language.

" @@ -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 = Arc::new(MockSandbox { + let env = MockSandbox { exec_result: ExecResult { stdout: "

Page content

".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 = Arc::new(MockSandbox::default()); + let env = MockSandbox::default().sandbox(); let result = (tool.executor)( serde_json::json!({"query": "rust programming language"}), ToolContext { diff --git a/lib/components/fabro-agent/src/web_search.rs b/lib/components/fabro-agent/src/web_search.rs index 08c23863a..15290ac0c 100644 --- a/lib/components/fabro-agent/src/web_search.rs +++ b/lib/components/fabro-agent/src/web_search.rs @@ -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 { - let env: Arc = Arc::new(MockSandbox::default()); + let env = MockSandbox::default().sandbox(); (tool.executor)(args, ToolContext { env, cancel: CancellationToken::new(), diff --git a/lib/components/fabro-agent/tests/it/docker_shell.rs b/lib/components/fabro-agent/tests/it/docker_shell.rs index 5c747207a..17d2d691d 100644 --- a/lib/components/fabro-agent/tests/it/docker_shell.rs +++ b/lib/components/fabro-agent/tests/it/docker_shell.rs @@ -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, + env: sandbox.clone(), cancel: CancellationToken::new(), tool_env_provider: None, session_id: Some("test-session".to_string()), diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index 49f99a3c6..0cf1a919d 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -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 = Arc::new( + let env: Arc = Arc::new( local_sandbox(cwd.to_path_buf()) .await .expect("local sandbox should be created"), diff --git a/lib/components/fabro-hooks/src/bridge.rs b/lib/components/fabro-hooks/src/bridge.rs index 2ba94b373..17d560b14 100644 --- a/lib/components/fabro-hooks/src/bridge.rs +++ b/lib/components/fabro-hooks/src/bridge.rs @@ -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, - pub sandbox: Arc, + pub sandbox: Arc, 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, + _sandbox: Arc, execution_context: &HookExecutionContext, _llm_source: &dyn fabro_auth::CredentialSource, _catalog: Arc, @@ -129,7 +129,7 @@ mod tests { } } - async fn make_sandbox() -> Arc { + async fn make_sandbox() -> Arc { Arc::new( fabro_agent::local_sandbox(std::env::current_dir().unwrap()) .await @@ -139,7 +139,7 @@ mod tests { fn make_bridge( hook_runner: Arc, - sandbox: Arc, + sandbox: Arc, hook_execution_context: HookExecutionContext, ) -> WorkflowToolHookCallback { WorkflowToolHookCallback { diff --git a/lib/components/fabro-hooks/src/executor.rs b/lib/components/fabro-hooks/src/executor.rs index 6aeffbae9..8a3a77dae 100644 --- a/lib/components/fabro-hooks/src/executor.rs +++ b/lib/components/fabro-hooks/src/executor.rs @@ -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, + sandbox: Arc, execution_context: &HookExecutionContext, llm_source: &dyn CredentialSource, catalog: Arc, @@ -128,7 +128,7 @@ impl HookExecutorImpl { definition: &HookDefinition, command: &InterpString, context: &HookContext, - sandbox: &Arc, + sandbox: &Arc, execution_context: &HookExecutionContext, ) -> HookDecision { let command = match resolve_interp(command) { @@ -346,7 +346,7 @@ impl HookExecutorImpl { model: Option<&InterpString>, max_tool_rounds: Option, context: &HookContext, - sandbox: Arc, + sandbox: Arc, llm_source: &dyn CredentialSource, catalog: Arc, ) -> HookDecision { @@ -627,7 +627,7 @@ impl HookExecutor for HookExecutorImpl { &self, definition: &HookDefinition, context: &HookContext, - sandbox: Arc, + sandbox: Arc, execution_context: &HookExecutionContext, llm_source: &dyn CredentialSource, catalog: Arc, @@ -740,7 +740,7 @@ mod tests { HookContext::new(HookEvent::StageStart, fixtures::RUN_1, "test-wf".into()) } - async fn make_sandbox() -> Arc { + async fn make_sandbox() -> Arc { Arc::new( fabro_agent::local_sandbox(std::env::current_dir().unwrap()) .await diff --git a/lib/components/fabro-hooks/src/runner.rs b/lib/components/fabro-hooks/src/runner.rs index 1543cf591..5e191a5ce 100644 --- a/lib/components/fabro-hooks/src/runner.rs +++ b/lib/components/fabro-hooks/src/runner.rs @@ -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, + sandbox: Arc, execution_context: HookExecutionContext, ) -> HookDecision { let matching = self.filter_hooks(context); @@ -141,7 +141,7 @@ impl HookRunner { &self, hooks: &[&HookDefinition], context: &HookContext, - sandbox: Arc, + sandbox: Arc, execution_context: &HookExecutionContext, ) -> HookDecision { let mut merged = HookDecision::Proceed; @@ -197,7 +197,7 @@ impl HookRunner { &self, hooks: &[&HookDefinition], context: &HookContext, - sandbox: Arc, + sandbox: Arc, execution_context: &HookExecutionContext, ) -> HookDecision { for hook in hooks { @@ -254,7 +254,7 @@ mod tests { &self, definition: &HookDefinition, _context: &HookContext, - _sandbox: Arc, + _sandbox: Arc, _execution_context: &HookExecutionContext, _llm_source: &dyn CredentialSource, _catalog: Arc, @@ -267,7 +267,7 @@ mod tests { } } - async fn make_sandbox() -> Arc { + async fn make_sandbox() -> Arc { Arc::new( fabro_agent::local_sandbox(std::env::current_dir().unwrap()) .await diff --git a/lib/components/fabro-hooks/tests/host_command_hooks.rs b/lib/components/fabro-hooks/tests/host_command_hooks.rs index 72673f68f..608cec856 100644 --- a/lib/components/fabro-hooks/tests/host_command_hooks.rs +++ b/lib/components/fabro-hooks/tests/host_command_hooks.rs @@ -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 { Arc::new(Catalog::from_builtin().expect("default catalog should build")) } -async fn test_sandbox() -> Arc { +async fn test_sandbox() -> Arc { Arc::new( local_sandbox(std::env::current_dir().expect("test process should have a cwd")) .await diff --git a/lib/components/fabro-sandbox/Cargo.toml b/lib/components/fabro-sandbox/Cargo.toml index dbeaf4fd8..77523695b 100644 --- a/lib/components/fabro-sandbox/Cargo.toml +++ b/lib/components/fabro-sandbox/Cargo.toml @@ -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 diff --git a/lib/components/fabro-sandbox/src/daytona.rs b/lib/components/fabro-sandbox/src/daytona.rs index b9ea3cf0c..0ee8cf0ee 100644 --- a/lib/components/fabro-sandbox/src/daytona.rs +++ b/lib/components/fabro-sandbox/src/daytona.rs @@ -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, diff --git a/lib/components/fabro-sandbox/src/driver_sandbox.rs b/lib/components/fabro-sandbox/src/driver_sandbox.rs index ba95faa02..4db4b78e1 100644 --- a/lib/components/fabro-sandbox/src/driver_sandbox.rs +++ b/lib/components/fabro-sandbox/src/driver_sandbox.rs @@ -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) -> crate::Result { +pub async fn local_sandbox(working_directory: impl Into) -> crate::Result { 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) -> 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, } -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, + platform: impl Into, + os_version: impl Into, + ) -> 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> { + /// 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> { 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> { +impl RunSandbox { + pub async fn read_file_bytes(&self, path: &str) -> crate::Result> { 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::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, + limit: Option, + ) -> crate::Result { + 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 { + pub async fn file_exists(&self, path: &str) -> crate::Result { 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, ) -> crate::Result> { - 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 = 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 { 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> { - 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> { + 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> { - 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> { + 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::>(); + 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 { + pub fn snapshot_info(&self) -> Option { self.snapshot.get().cloned() } - fn workspace_layout(&self) -> Option { + pub fn workspace_layout(&self) -> Option { 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> { + pub async fn setup_git(&self, intent: &GitSetupIntent) -> crate::Result> { 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 { + pub fn resume_setup_commands(&self, run_branch: &str) -> Vec { 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 { + pub async fn git_push_ref( + &self, + refspec: &str, + plan: &RetryPlan, + ) -> Result { 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 { + pub async fn refresh_push_credentials(&self) -> crate::Result { 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> { + pub fn push_token_source(&self) -> Option> { 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> { + pub async fn ssh_access_command(&self) -> crate::Result> { 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)>> { @@ -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(); diff --git a/lib/components/fabro-sandbox/src/exec.rs b/lib/components/fabro-sandbox/src/exec.rs index 1aad8a396..a01eacb9c 100644 --- a/lib/components/fabro-sandbox/src/exec.rs +++ b/lib/components/fabro-sandbox/src/exec.rs @@ -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; diff --git a/lib/components/fabro-sandbox/src/lib.rs b/lib/components/fabro-sandbox/src/lib.rs index d3e8fa796..1819a166e 100644 --- a/lib/components/fabro-sandbox/src/lib.rs +++ b/lib/components/fabro-sandbox/src/lib.rs @@ -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}; diff --git a/lib/components/fabro-sandbox/src/provider_sandbox.rs b/lib/components/fabro-sandbox/src/provider_sandbox.rs index e1b5c008e..d5e445699 100644 --- a/lib/components/fabro-sandbox/src/provider_sandbox.rs +++ b/lib/components/fabro-sandbox/src/provider_sandbox.rs @@ -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, clone_tag: Option, clone_commit_sha: Option, -) -> crate::Result { +) -> crate::Result { 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, run_id: Option, -) -> crate::Result { +) -> crate::Result { 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); diff --git a/lib/components/fabro-sandbox/src/push_credentials.rs b/lib/components/fabro-sandbox/src/push_credentials.rs index d1896b120..01ad446e6 100644 --- a/lib/components/fabro-sandbox/src/push_credentials.rs +++ b/lib/components/fabro-sandbox/src/push_credentials.rs @@ -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 { @@ -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!( diff --git a/lib/components/fabro-sandbox/src/reconnect.rs b/lib/components/fabro-sandbox/src/reconnect.rs index 986043280..a9e6432e3 100644 --- a/lib/components/fabro-sandbox/src/reconnect.rs +++ b/lib/components/fabro-sandbox/src/reconnect.rs @@ -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> { +pub async fn reconnect(record: &RunSandboxInstance, access: &ProviderAccess) -> Result { reconnect_for_run(record, access, None).await } @@ -22,7 +19,7 @@ pub async fn reconnect_for_run( record: &RunSandboxInstance, access: &ProviderAccess, run_id: Option, -) -> Result> { +) -> Result { 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, event_callback: Option, -) -> Result> { - let sandbox = reconnect_driver_for_run(record, access, run_id, event_callback).await?; - Ok(Box::new(sandbox)) +) -> Result { + 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, event_callback: Option, -) -> Result { +) -> Result { 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 diff --git a/lib/components/fabro-sandbox/src/sandbox.rs b/lib/components/fabro-sandbox/src/sandbox.rs index 9d779237c..26f92aeba 100644 --- a/lib/components/fabro-sandbox/src/sandbox.rs +++ b/lib/components/fabro-sandbox/src/sandbox.rs @@ -1,17 +1,15 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::HashMap; use std::fmt::Write; use std::future::Future; -use std::path::Path; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use fabro_github::token_source::{InstallationTokenSource, TokenSnapshot}; +use fabro_github::token_source::TokenSnapshot; pub use fabro_types::run_event::GitCredentialAction as RemoteCredentialAction; use fabro_types::{CommandOutputStream, CommandTermination}; use fabro_util::shell; -use fabro_util::workspace_glob::WorkspaceGlob; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; use tokio::sync::Mutex as TokioMutex; @@ -19,6 +17,7 @@ use tokio::task::JoinHandle; use tokio::time; use tokio_util::sync::CancellationToken; +use crate::driver_sandbox::RunSandbox; use crate::git_retry::{self, CredentialContext, GitRetryReason, RetryPlan}; use crate::push_credentials::{CredentialLease, PushCredentialState, RefreshErrorKind}; @@ -59,224 +58,6 @@ pub enum GitSetupIntent { }, } -/// Generates an `#[async_trait] impl Sandbox` block for a decorator type -/// that wraps an `Arc`. The caller provides custom method -/// implementations; all remaining trait methods delegate to the inner field. -/// -/// # Usage -/// -/// ```ignore -/// delegate_sandbox! { -/// MyDecorator => inner { -/// // Only provide methods with custom logic — the rest delegate automatically. -/// async fn read_file_bytes(&self, path: &str) -> $crate::Result> { -/// // custom logic... -/// } -/// } -/// } -/// ``` -#[macro_export] -macro_rules! delegate_sandbox { - ( - $type:ty => $field:ident { - $($custom:item)* - } - ) => { - #[async_trait::async_trait] - impl $crate::Sandbox for $type { - $($custom)* - - async fn file_exists(&self, path: &str) -> $crate::Result { - self.$field.file_exists(path).await - } - - async fn list_directory( - &self, - path: &str, - depth: Option, - ) -> $crate::Result> { - self.$field.list_directory(path, depth).await - } - - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - cancel_token: Option, - ) -> $crate::Result<$crate::ExecResult> { - self.$field - .exec_command(command, timeout_ms, working_dir, env_vars, cancel_token) - .await - } - - async fn exec_command_streaming( - &self, - request: $crate::ExecStreamingRequest<'_>, - ) -> $crate::Result<$crate::ExecStreamingResult> { - self.$field.exec_command_streaming(request).await - } - - async fn spawn_stdio_process( - &self, - command: &str, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - cancel_token: Option, - ) -> $crate::Result<$crate::StdioProcess> { - self.$field - .spawn_stdio_process(command, working_dir, env_vars, cancel_token) - .await - } - - async fn glob(&self, pattern: &str, path: Option<&str>) -> $crate::Result> { - self.$field.glob(pattern, path).await - } - - async fn walk_files( - &self, - base: &str, - relative_start: &str, - options: &$crate::WalkOptions, - ) -> $crate::Result> { - self.$field - .walk_files(base, relative_start, options) - .await - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &std::path::Path, - ) -> $crate::Result<()> { - self.$field.download_file_to_local(remote_path, local_path).await - } - - async fn upload_file_from_local( - &self, - local_path: &std::path::Path, - remote_path: &str, - ) -> $crate::Result<()> { - self.$field.upload_file_from_local(local_path, remote_path).await - } - - async fn initialize(&self) -> $crate::Result<()> { - self.$field.initialize().await - } - - async fn activate(&self) -> $crate::Result<()> { - self.$field.activate().await - } - - async fn start(&self) -> $crate::Result<()> { - self.$field.start().await - } - - async fn stop(&self) -> $crate::Result<()> { - self.$field.stop().await - } - - async fn delete(&self) -> $crate::Result<()> { - self.$field.delete().await - } - - async fn cleanup(&self) -> $crate::Result<()> { - self.$field.cleanup().await - } - - fn working_directory(&self) -> &str { - self.$field.working_directory() - } - - fn platform(&self) -> &str { - self.$field.platform() - } - - fn os_version(&self) -> String { - self.$field.os_version() - } - - fn sandbox_info(&self) -> String { - self.$field.sandbox_info() - } - - fn snapshot_info(&self) -> Option { - self.$field.snapshot_info() - } - - fn workspace_layout(&self) -> Option<$crate::SandboxWorkspaceLayout> { - self.$field.workspace_layout() - } - - async fn refresh_push_credentials(&self) -> $crate::Result<$crate::RefreshOutcome> { - self.$field.refresh_push_credentials().await - } - - fn push_token_source( - &self, - ) -> Option> { - self.$field.push_token_source() - } - - async fn set_autostop_interval(&self, minutes: i32) -> $crate::Result<()> { - self.$field.set_autostop_interval(minutes).await - } - - async fn setup_git(&self, intent: &$crate::GitSetupIntent) -> $crate::Result> { - self.$field.setup_git(intent).await - } - - fn resume_setup_commands(&self, run_branch: &str) -> Vec { - self.$field.resume_setup_commands(run_branch) - } - - async fn git_push_ref( - &self, - refspec: &str, - plan: &$crate::RetryPlan, - ) -> Result<$crate::PushReport, $crate::PushError> { - self.$field.git_push_ref(refspec, plan).await - } - - async fn ssh_access_command(&self) -> $crate::Result> { - self.$field.ssh_access_command().await - } - - fn origin_url(&self) -> Option<&str> { - self.$field.origin_url() - } - - async fn get_preview_url(&self, port: u16) -> $crate::Result)>> { - self.$field.get_preview_url(port).await - } - - async fn read_file_bytes(&self, path: &str) -> $crate::Result> { - self.$field.read_file_bytes(path).await - } - - async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> $crate::Result { - self.$field.read_file(path, offset, limit).await - } - - async fn grep( - &self, - pattern: &str, - path: &str, - options: &$crate::GrepOptions, - ) -> $crate::Result> { - self.$field.grep(pattern, path, options).await - } - } - }; -} - -/// Events emitted during sandbox lifecycle operations. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SandboxEvent { // -- Common lifecycle -- @@ -733,83 +514,6 @@ impl OutputCaptureStats { } } -/// A byte buffer that keeps an equal-sized stable prefix and rolling suffix. -/// -/// The process is always drained. Once the optional cap is full, bytes from -/// the middle are discarded while the newest suffix replaces the old tail. -#[derive(Debug)] -pub(crate) struct OutputCaptureBuffer { - max_bytes: Option, - head: Vec, - tail: VecDeque, - observed_bytes: usize, -} - -impl OutputCaptureBuffer { - #[must_use] - pub(crate) fn new(max_bytes: Option) -> Self { - Self { - max_bytes, - head: Vec::new(), - tail: VecDeque::new(), - observed_bytes: 0, - } - } - - pub(crate) fn push(&mut self, bytes: &[u8]) { - self.observed_bytes = self.observed_bytes.saturating_add(bytes.len()); - let Some(max_bytes) = self.max_bytes else { - self.head.extend_from_slice(bytes); - return; - }; - - let head_budget = max_bytes / 2; - let tail_budget = max_bytes.saturating_sub(head_budget); - let head_remaining = head_budget.saturating_sub(self.head.len()); - let head_take = head_remaining.min(bytes.len()); - self.head.extend_from_slice(&bytes[..head_take]); - - let tail_bytes = &bytes[head_take..]; - let overflow = self - .tail - .len() - .saturating_add(tail_bytes.len()) - .saturating_sub(tail_budget); - if overflow >= self.tail.len() { - let skip = overflow.saturating_sub(self.tail.len()); - self.tail.clear(); - self.tail.extend(&tail_bytes[skip..]); - } else { - self.tail.drain(..overflow); - self.tail.extend(tail_bytes); - } - } - - #[must_use] - pub(crate) fn stats(&self) -> OutputCaptureStats { - let retained_bytes = self.head.len().saturating_add(self.tail.len()); - OutputCaptureStats { - observed_bytes: self.observed_bytes, - retained_bytes, - omitted_bytes: self.observed_bytes.saturating_sub(retained_bytes), - } - } - - #[must_use] - pub(crate) fn into_parts(self) -> (Vec, OutputCaptureStats) { - let stats = self.stats(); - let Self { - head: mut bytes, - tail, - .. - } = self; - let (front, back) = tail.as_slices(); - bytes.extend_from_slice(front); - bytes.extend_from_slice(back); - (bytes, stats) - } -} - pub type CommandOutputCallback = Arc< dyn Fn(CommandOutputStream, Vec) -> Pin> + Send>> + Send @@ -855,55 +559,6 @@ impl<'a> ExecStreamingRequest<'a> { } } -pub(crate) async fn replay_exec_result( - mut result: ExecResult, - streams_separated: bool, - output_callback: Option<&CommandOutputCallback>, - stream_output_bytes_cap: Option, -) -> crate::Result { - if let Some(output_callback) = output_callback { - if !result.stdout.is_empty() { - output_callback( - CommandOutputStream::Stdout, - result.stdout.as_bytes().to_vec(), - ) - .await?; - } - if !result.stderr.is_empty() { - output_callback( - CommandOutputStream::Stderr, - result.stderr.as_bytes().to_vec(), - ) - .await?; - } - } - let stdout_capture = capture_replayed_stream(&mut result.stdout, stream_output_bytes_cap); - let stderr_capture = capture_replayed_stream(&mut result.stderr, stream_output_bytes_cap); - - Ok(ExecStreamingResult { - result, - streams_separated, - live_streaming: false, - stdout_capture, - stderr_capture, - }) -} - -/// Bound one replayed stream in place, leaving it untouched when it already -/// fits the cap. -fn capture_replayed_stream(text: &mut String, cap: Option) -> OutputCaptureStats { - match cap { - Some(cap) if text.len() > cap => { - let mut buffer = OutputCaptureBuffer::new(Some(cap)); - buffer.push(text.as_bytes()); - let (bytes, stats) = buffer.into_parts(); - *text = String::from_utf8_lossy(&bytes).into_owned(); - stats - } - _ => OutputCaptureStats::complete(text.len()), - } -} - pub struct StdioProcess { pub stdin: Pin>, pub stdout: Pin>, @@ -1044,13 +699,6 @@ pub(crate) trait StdioProcessControl: Send + Sync { async fn wait(&self) -> crate::Result; } -#[derive(Debug, Clone)] -pub struct DirEntry { - pub name: String, - pub is_dir: bool, - pub size: Option, -} - /// A regular file discovered inside a sandbox. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SandboxFile { @@ -1061,39 +709,11 @@ pub struct SandboxFile { pub size: u64, } -/// Provider-neutral controls for recursive file traversal. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct WalkOptions { - /// Directory basenames that providers must prune at every depth. - pub excluded_directory_names: Vec, -} - -impl WalkOptions { - #[must_use] - pub fn excludes_name(&self, name: &str) -> bool { - self.excluded_directory_names - .iter() - .any(|excluded| excluded == name) - } - - #[must_use] - pub fn excludes_relative_path(&self, relative_path: &str) -> bool { - relative_path - .split('/') - .any(|segment| self.excludes_name(segment)) - } -} - -#[derive(Debug, Clone, Default)] -pub struct GrepOptions { - pub glob_filter: Option, - pub case_insensitive: bool, - pub max_results: Option, -} - -/// Outcome of [`Sandbox::refresh_push_credentials`]: what this call did to the -/// remote, and the non-secret description of the token embedded in it. -/// `token` is `None` only when `action` is [`RemoteCredentialAction::None`]. +/// Outcome of +/// [`RunSandbox::refresh_push_credentials`](crate::RunSandbox::refresh_push_credentials): +/// what this call did to the remote, and the non-secret description of the +/// token embedded in it. `token` is `None` only when `action` is +/// [`RemoteCredentialAction::None`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefreshOutcome { /// No managed credentials exist for this sandbox. @@ -1139,329 +759,6 @@ impl RefreshOutcome { } } -#[async_trait] -pub trait Sandbox: Send + Sync { - async fn read_file_bytes(&self, path: &str) -> crate::Result>; - - async fn read_file_text(&self, path: &str) -> crate::Result { - String::from_utf8(self.read_file_bytes(path).await?) - .map_err(|err| crate::Error::context("File is not valid UTF-8", err)) - } - - async fn read_file( - &self, - path: &str, - offset: Option, - limit: Option, - ) -> crate::Result { - Ok(format_lines_numbered( - &self.read_file_text(path).await?, - offset, - limit, - )) - } - - async fn write_file(&self, path: &str, content: &str) -> crate::Result<()>; - - /// Write a file that the caller has already confirmed exists. - /// - /// Providers can override this method to skip setup that is only needed - /// when creating a new path. The default preserves the behavior of - /// [`Sandbox::write_file`]. - async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> { - self.write_file(path, content).await - } - - async fn delete_file(&self, path: &str) -> crate::Result<()>; - async fn file_exists(&self, path: &str) -> crate::Result; - async fn list_directory( - &self, - path: &str, - depth: Option, - ) -> crate::Result>; - /// Run `command` to completion and return its captured output. - /// - /// On Unix production sandboxes `command` is **Bash source**: it is - /// evaluated as a non-login Bash program, equivalent to `bash -c - /// `. Implementations select the interpreter, not its options — - /// they must not add login mode, `errexit`, `pipefail`, or any other - /// implicit shell option, and must never fall back to `sh` or delegate - /// evaluation to a provider's ambient shell. A caller that wants different - /// semantics writes them into the command itself (`sh -c ...`, a - /// `#!/bin/sh` script, an explicit `set -o pipefail`), which then runs - /// beneath this Bash boundary. - /// - /// Providers resolve the Bash executable differently: the local sandbox - /// resolves `bash` through the worker's `PATH` (NixOS has no `/bin/bash`), - /// while the Linux remote providers require `/bin/bash`. - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - cancel_token: Option, - ) -> crate::Result; - /// Stream a command's output as it runs. - /// - /// `command` carries exactly the same interpreter semantics as - /// [`exec_command`](Self::exec_command) — the two paths must not differ in - /// interpreter or shell options, so Bash-only syntax behaves identically - /// through both. - /// - /// When `request.stdin` is set, providers must write those exact bytes to - /// the process's standard input and then close it to deliver EOF. The bytes - /// must remain separate from command source and diagnostics. - /// - /// **Production sandboxes must override this.** The default falls back to - /// the non-streaming [`exec_command`](Self::exec_command) and replays its - /// output through `output_callback` at the end when one is supplied, - /// marking `live_streaming: false`. Passing `None` captures the final - /// result without paying per-chunk callback costs. That's the right - /// behavior for test mocks but silently drops live output for any real - /// sandbox that wraps another — decorators in particular must forward to - /// the inner sandbox's streaming implementation rather than relying on - /// this default. The fallback rejects `request.stdin` because - /// [`exec_command`](Self::exec_command) has no stdin channel. - async fn exec_command_streaming( - &self, - request: ExecStreamingRequest<'_>, - ) -> crate::Result { - if request.stdin.is_some() { - return Err(crate::Error::message( - "This sandbox does not support standard input for streaming commands", - )); - } - let fallback_timeout_ms = request.timeout_ms.unwrap_or(u64::MAX); - let result = self - .exec_command( - request.command, - fallback_timeout_ms, - request.working_dir, - request.env_vars, - request.cancel_token, - ) - .await?; - replay_exec_result( - result, - true, - request.output_callback.as_ref(), - request.stream_output_bytes_cap, - ) - .await - } - - /// Launch a long-lived process with bidirectional stdio attached. - /// - /// Where supported, `_command` is evaluated under the same non-login Bash - /// contract as [`exec_command`](Self::exec_command) before the shell - /// replaces itself with the requested process. Providers without - /// bidirectional stdio keep this default and report the capability as - /// unsupported rather than substituting another interpreter. - async fn spawn_stdio_process( - &self, - _command: &str, - _working_dir: Option<&str>, - _env_vars: Option<&HashMap>, - _cancel_token: Option, - ) -> crate::Result { - Err(crate::Error::message( - "ACP backend requires bidirectional stdio; this sandbox provider does not support it", - )) - } - - async fn grep( - &self, - pattern: &str, - path: &str, - options: &GrepOptions, - ) -> crate::Result>; - - /// Recursively enumerate regular files below a caller-declared base. - /// - /// `relative_start` is a normalized literal directory path relative to - /// `base`; it is an optimization boundary, not a matching expression. - /// Every returned `relative_path` remains relative to `base`. - /// Implementations resolve `base` itself but must not recurse through - /// symlinks encountered in `relative_start` or below it. - /// - /// Production providers that support filesystem search must override this. - async fn walk_files( - &self, - _base: &str, - _relative_start: &str, - _options: &WalkOptions, - ) -> crate::Result> { - Err(crate::Error::message( - "recursive file traversal is not supported by this sandbox", - )) - } - - /// Match a workspace-relative glob using provider-independent semantics. - async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result> { - 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::>(); - files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); - Ok(files.into_iter().map(|file| file.path).collect()) - } - /// Copy a file from the sandbox to a local filesystem path. - /// Handles binary files correctly across all sandbox types. - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &Path, - ) -> crate::Result<()>; - /// Copy a file from the local filesystem into the sandbox. - /// Handles binary files correctly across all sandbox types. - async fn upload_file_from_local( - &self, - local_path: &Path, - remote_path: &str, - ) -> crate::Result<()>; - async fn initialize(&self) -> crate::Result<()>; - /// Ensure the provider resource is running and not paused before access. - /// - /// This access-time operation must be idempotent. Providers that can stop - /// independently should avoid restarting an already-active sandbox. This - /// lightweight check does not require the full health verification done by - /// [`Sandbox::start`], and it does not keep a sandbox active between calls. - async fn activate(&self) -> crate::Result<()> { - self.start().await - } - async fn start(&self) -> crate::Result<()> { - Ok(()) - } - async fn stop(&self) -> crate::Result<()> { - Ok(()) - } - async fn delete(&self) -> crate::Result<()> { - self.cleanup().await - } - async fn cleanup(&self) -> crate::Result<()>; - fn working_directory(&self) -> &str; - /// Run-scoped directory for Fabro-owned runtime files inside the sandbox, - /// or `None` when the sandbox has no such directory. - /// - /// The directory sits outside every repository checkout, so runtime files - /// Fabro materializes beneath it — for example oversized prompt values - /// projected out of the durable blob store — never appear in `git status` - /// and can never be committed by a checkpoint. Its contents are - /// disposable: everything beneath it can be recreated from durable - /// storage on demand. - /// - /// Providers that provision an isolated per-run environment (Docker, - /// Daytona) create the directory during initialization with private - /// permissions and return its path. Sandboxes that execute directly on - /// the worker host return `None`; the workflow engine owns a host-side - /// runtime directory for those runs. - fn runtime_directory(&self) -> Option<&str> { - None - } - fn platform(&self) -> &str; - fn os_version(&self) -> String; - /// Return a human-readable identifier for the sandbox (e.g. container ID, - /// sandbox name). Used when `--preserve-sandbox` is active to tell the - /// user how to reconnect. - fn sandbox_info(&self) -> String { - String::new() - } - - /// Return the provider snapshot used by an initialized sandbox, when the - /// provider has a snapshot concept. - fn snapshot_info(&self) -> Option { - None - } - - /// The clone-based workspace layout of an initialized sandbox, for the - /// run record. `None` for a sandbox that works in a designated - /// directory. - fn workspace_layout(&self) -> Option { - None - } - - /// Refresh git push credentials (e.g. rotate an expiring GitHub App token). - /// Default is a no-op; Docker/Daytona override to resolve a token through - /// the shared source and update the remote URL when the embedded - /// generation is stale. Returns [`RefreshOutcome`] so callers can tell - /// what happened to the remote and which token it carries. - async fn refresh_push_credentials(&self) -> crate::Result { - Ok(RefreshOutcome::none()) - } - - /// The shared installation-token source feeding this sandbox's push - /// credentials, when the provider manages GitHub credentials. - /// - /// Consumers outside the sandbox (e.g. the run-metadata writer) share - /// this source so every GitHub-token consumer for the origin repository - /// reuses one cached token instead of minting its own. - fn push_token_source(&self) -> Option> { - None - } - - /// Set the auto-stop interval in minutes (0 to disable). - /// Default is a no-op; Daytona overrides to call the Daytona API. - async fn set_autostop_interval(&self, _minutes: i32) -> crate::Result<()> { - Ok(()) - } - - /// Set up git state for a workflow run. - /// Sandboxes that manage their own git clone (e.g., remote VMs) should - /// create a run branch and return the git info. - async fn setup_git(&self, _intent: &GitSetupIntent) -> crate::Result> { - Ok(None) - } - - /// Commands to run inside the sandbox when resuming on an existing run - /// branch. - fn resume_setup_commands(&self, _run_branch: &str) -> Vec { - Vec::new() - } - - /// Push a full refspec to origin from inside the sandbox, retrying per - /// `plan` with a pinned credential generation. Failures keep their - /// attempt history in the returned [`PushError`]. - async fn git_push_ref( - &self, - _refspec: &str, - _plan: &RetryPlan, - ) -> Result { - Err(PushError { - report: PushReport::default(), - error: crate::Error::message("git_push_ref not implemented for this sandbox"), - }) - } - - /// Return an SSH command string for connecting to this sandbox, if - /// supported. - async fn ssh_access_command(&self) -> crate::Result> { - Ok(None) - } - - /// The display URL of the cloned origin remote, if known. - fn origin_url(&self) -> Option<&str> { - None - } - - /// Get an authenticated preview URL for a port exposed by this sandbox. - /// Returns `Ok(None)` when the sandbox does not support port previews. - /// Used to connect to services (e.g. MCP servers) running inside the - /// sandbox. - async fn get_preview_url( - &self, - _port: u16, - ) -> crate::Result)>> { - Ok(None) - } -} - -/// Resolve a path: relative paths are prepended with the working directory. pub(crate) fn resolve_path(path: &str, working_dir: &str) -> String { if std::path::Path::new(path).is_absolute() { path.to_string() @@ -1493,7 +790,7 @@ pub fn shell_quote(s: &str) -> String { /// Helper for sandbox implementations that manage git internally. /// Executes git commands inside the sandbox to create a run branch. pub async fn setup_git_via_exec( - sandbox: &dyn Sandbox, + sandbox: &RunSandbox, intent: &GitSetupIntent, ) -> crate::Result { // Get current branch name @@ -1556,7 +853,7 @@ pub async fn setup_git_via_exec( #[tracing::instrument(name = "git_op", skip_all, fields(op = "fetch"))] pub(crate) async fn fetch_source_run_ref( - sandbox: &dyn Sandbox, + sandbox: &RunSandbox, source_run_id: &str, checkpoint_sha: &str, ) -> crate::Result<()> { @@ -1674,7 +971,7 @@ fn push_failure_looks_auth_shaped(error: &crate::Error) -> bool { /// local sandbox, or a workspace without managed credentials). #[tracing::instrument(name = "git_op", skip_all, fields(op = "push"))] pub(crate) async fn git_push_via_exec( - sandbox: &dyn Sandbox, + sandbox: &RunSandbox, credentials: Option<(&PushCredentialState, &str)>, refspec: &str, plan: &RetryPlan, @@ -1861,6 +1158,8 @@ mod push_tests { use fabro_github::InstallationToken; use fabro_github::test_support::{InstallationTokenMinter, installation_token_source}; use fabro_github::token_source::{InstallationTokenSource, REFRESH_MARGIN}; + use fabro_types::SandboxProviderKind; + use sandbox_driver_testing::ScriptedSandbox; use tokio::sync::Mutex as AsyncMutex; use super::*; @@ -1870,7 +1169,7 @@ mod push_tests { const ORIGIN: &str = "https://github.com/fabro-testing/repo"; const REFSPEC: &str = "refs/heads/fabro/run/01M0DH033P2XSTHAGVBHG6922F"; - fn ok_exec() -> ExecResult { + fn ok_fabro_exec() -> ExecResult { ExecResult { stdout: String::new(), stderr: String::new(), @@ -1900,143 +1199,92 @@ mod push_tests { } } - /// Sandbox stub that scripts `git push` results and records the exec - /// commands the push driver runs. `git remote set-url` execs succeed - /// unless scripted otherwise. + /// A run sandbox over a scripted driver double: `git push` answers come + /// from a script, `git remote set-url` succeeds unless scripted + /// otherwise, and every command is recorded. struct ScriptedGitSandbox { - push_results: Mutex>, - set_url_results: Mutex>, - push_commands: Mutex>, - set_url_commands: Mutex>, + run: RunSandbox, + driver: Arc, } impl ScriptedGitSandbox { fn new(push_results: Vec) -> Self { - Self { - push_results: Mutex::new(push_results.into()), - set_url_results: Mutex::new(VecDeque::new()), - push_commands: Mutex::new(Vec::new()), - set_url_commands: Mutex::new(Vec::new()), - } + Self::with_set_url_results(push_results, Vec::new()) } - fn with_set_url_results(self, results: Vec) -> Self { - *self.set_url_results.lock().unwrap() = results.into(); - self + fn with_set_url_results( + push_results: Vec, + set_url_results: Vec, + ) -> Self { + let driver = Arc::new(ScriptedSandbox::with_id_and_working_dir( + "scripted-git", + "/workspace", + )); + let pushes = Mutex::new(VecDeque::from(push_results)); + let set_urls = Mutex::new(VecDeque::from(set_url_results)); + driver.scripted_exec().respond_with(move |spec| { + let script = spec.args.last().map(String::as_str).unwrap_or_default(); + if script.contains("remote set-url") { + return Some( + set_urls + .lock() + .unwrap() + .pop_front() + .map_or_else(ok_exec, driver_result), + ); + } + assert!(script.contains("push origin"), "unexpected exec: {script}"); + Some(driver_result( + pushes + .lock() + .unwrap() + .pop_front() + .expect("push script exhausted"), + )) + }); + let run = RunSandbox::new(SandboxProviderKind::LOCAL, Arc::clone(&driver) as _); + Self { run, driver } + } + + fn commands(&self) -> Vec { + self.driver.scripted_exec().commands() } fn push_count(&self) -> usize { - self.push_commands.lock().unwrap().len() + self.commands() + .iter() + .filter(|command| command.contains("push origin")) + .count() } fn set_url_commands(&self) -> Vec { - self.set_url_commands.lock().unwrap().clone() + self.commands() + .into_iter() + .filter(|command| command.contains("remote set-url")) + .collect() } } - #[async_trait] - impl Sandbox for ScriptedGitSandbox { - async fn exec_command( - &self, - command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&HashMap>, - _cancel_token: Option, - ) -> crate::Result { - if command.contains("remote set-url") { - self.set_url_commands - .lock() - .unwrap() - .push(command.to_string()); - return Ok(self - .set_url_results - .lock() - .unwrap() - .pop_front() - .unwrap_or_else(ok_exec)); - } - assert!( - command.contains("push origin"), - "unexpected exec: {command}" - ); - self.push_commands.lock().unwrap().push(command.to_string()); - Ok(self - .push_results - .lock() - .unwrap() - .pop_front() - .expect("push script exhausted")) - } + /// The driver-level result fabro's exec policy reads back as the fabro + /// result the push tests script. + fn driver_result(result: ExecResult) -> sandbox_driver::ExecResult { + let termination = match result.termination { + CommandTermination::Exited => sandbox_driver::Termination::Exited, + CommandTermination::TimedOut => sandbox_driver::Termination::TimedOut, + CommandTermination::Cancelled => sandbox_driver::Termination::Cancelled, + }; + let mut driver = sandbox_driver::ExecResult::new( + termination, + result.exit_code, + Duration::from_millis(result.duration_ms), + ); + driver.stdout = result.stdout.into_bytes(); + driver.stderr = result.stderr.into_bytes(); + driver + } - async fn read_file_bytes(&self, _path: &str) -> crate::Result> { - unimplemented!() - } - - async fn write_file(&self, _path: &str, _content: &str) -> crate::Result<()> { - unimplemented!() - } - - async fn delete_file(&self, _path: &str) -> crate::Result<()> { - unimplemented!() - } - - async fn file_exists(&self, _path: &str) -> crate::Result { - unimplemented!() - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> crate::Result> { - unimplemented!() - } - - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &GrepOptions, - ) -> crate::Result> { - unimplemented!() - } - - async fn download_file_to_local( - &self, - _remote_path: &str, - _local_path: &Path, - ) -> crate::Result<()> { - unimplemented!() - } - - async fn upload_file_from_local( - &self, - _local_path: &Path, - _remote_path: &str, - ) -> crate::Result<()> { - unimplemented!() - } - - async fn initialize(&self) -> crate::Result<()> { - Ok(()) - } - - async fn cleanup(&self) -> crate::Result<()> { - Ok(()) - } - - fn working_directory(&self) -> &'static str { - "/workspace" - } - - fn platform(&self) -> &'static str { - "linux" - } - - fn os_version(&self) -> String { - "linux".to_string() - } + fn ok_exec() -> sandbox_driver::ExecResult { + driver_result(ok_fabro_exec()) } enum MintAction { @@ -2124,11 +1372,11 @@ mod push_tests { let sandbox = ScriptedGitSandbox::new(vec![ failed_exec("remote: Repository not found."), failed_exec("remote: Repository not found."), - ok_exec(), + ok_fabro_exec(), ]); let report = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::checkpoint_push(), @@ -2169,11 +1417,11 @@ mod push_tests { failed_exec("remote: Repository not found."), failed_exec("remote: Repository not found."), failed_exec("remote: Repository not found."), - ok_exec(), + ok_fabro_exec(), ]); let report = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::publish_push(), @@ -2200,11 +1448,11 @@ mod push_tests { let sandbox = ScriptedGitSandbox::new(vec![ failed_exec("remote: Repository not found."), failed_exec("remote: Repository not found."), - ok_exec(), + ok_fabro_exec(), ]); let report = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::checkpoint_push(), @@ -2236,7 +1484,7 @@ mod push_tests { )]); let push_error = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::publish_push(), @@ -2261,10 +1509,10 @@ mod push_tests { MintAction::Error("mint failed"), ]); seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::new(vec![ok_exec()]); + let sandbox = ScriptedGitSandbox::new(vec![ok_fabro_exec()]); let report = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::checkpoint_push(), @@ -2293,7 +1541,7 @@ mod push_tests { let sandbox = ScriptedGitSandbox::new(vec![]); let push_error = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::checkpoint_push(), @@ -2319,11 +1567,11 @@ mod push_tests { seed_clone_token(&state).await; let sandbox = ScriptedGitSandbox::new(vec![ failed_exec("fatal: Authentication failed for 'https://github.com'"), - ok_exec(), + ok_fabro_exec(), ]); let report = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::checkpoint_push(), @@ -2356,14 +1604,16 @@ mod push_tests { MintAction::Token("ghs_gen2", chrono::Duration::minutes(60)), ]); seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::new(vec![ - failed_exec("error: RPC failed; connection reset by peer"), - ok_exec(), - ]) - .with_set_url_results(vec![failed_exec("error: could not lock config file")]); + let sandbox = ScriptedGitSandbox::with_set_url_results( + vec![ + failed_exec("error: RPC failed; connection reset by peer"), + ok_fabro_exec(), + ], + vec![failed_exec("error: could not lock config file")], + ); let report = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::checkpoint_push(), @@ -2403,10 +1653,10 @@ mod push_tests { MintAction::Token("ghs_gen2", chrono::Duration::minutes(60)), ]); seed_clone_token(&state).await; - let sandbox = ScriptedGitSandbox::new(vec![]).with_set_url_results(vec![timed_out_exec()]); + let sandbox = ScriptedGitSandbox::with_set_url_results(vec![], vec![timed_out_exec()]); let push_error = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::checkpoint_push(), @@ -2433,11 +1683,11 @@ mod push_tests { failed_exec( "fatal: could not read Username for 'https://github.com': No such device or address\nremote: Repository not found.", ), - ok_exec(), + ok_fabro_exec(), ]); let report = git_push_via_exec( - &sandbox, + &sandbox.run, Some((&state, ORIGIN)), REFSPEC, &RetryPlan::checkpoint_push(), @@ -2463,9 +1713,9 @@ mod push_tests { #[tokio::test(start_paused = true)] async fn push_without_managed_credentials_reports_no_token() { - let sandbox = ScriptedGitSandbox::new(vec![ok_exec()]); + let sandbox = ScriptedGitSandbox::new(vec![ok_fabro_exec()]); - let report = git_push_via_exec(&sandbox, None, REFSPEC, &RetryPlan::checkpoint_push()) + let report = git_push_via_exec(&sandbox.run, None, REFSPEC, &RetryPlan::checkpoint_push()) .await .expect("push succeeds"); @@ -2480,7 +1730,7 @@ mod push_tests { "fatal: Authentication failed for 'https://github.com/fabro-testing/repo'", )]); - let push_error = git_push_via_exec(&sandbox, None, REFSPEC, &RetryPlan::publish_push()) + let push_error = git_push_via_exec(&sandbox.run, None, REFSPEC, &RetryPlan::publish_push()) .await .expect_err("no credentials to wait on"); @@ -2492,7 +1742,7 @@ mod push_tests { async fn timed_out_push_is_not_retried_while_the_remote_process_may_still_run() { let sandbox = ScriptedGitSandbox::new(vec![timed_out_exec()]); - let push_error = git_push_via_exec(&sandbox, None, REFSPEC, &RetryPlan::publish_push()) + let push_error = git_push_via_exec(&sandbox.run, None, REFSPEC, &RetryPlan::publish_push()) .await .expect_err("an unconfirmed timeout must fail without another push"); @@ -2509,7 +1759,7 @@ mod push_tests { let mut plan = RetryPlan::checkpoint_push(); plan.max_elapsed = Some(Duration::from_secs(1)); - let push_error = git_push_via_exec(&sandbox, Some((&state, ORIGIN)), REFSPEC, &plan) + let push_error = git_push_via_exec(&sandbox.run, Some((&state, ORIGIN)), REFSPEC, &plan) .await .expect_err("credential acquisition must stop at the operation deadline"); @@ -2524,7 +1774,7 @@ mod push_tests { let mut plan = RetryPlan::checkpoint_push(); plan.max_elapsed = Some(Duration::ZERO); - let push_error = git_push_via_exec(&sandbox, None, REFSPEC, &plan) + let push_error = git_push_via_exec(&sandbox.run, None, REFSPEC, &plan) .await .expect_err("an expired operation must stop before exec"); @@ -2537,43 +1787,6 @@ mod push_tests { mod tests { use super::*; - #[test] - fn output_capture_buffer_keeps_stable_head_and_rolling_tail() { - let mut buffer = OutputCaptureBuffer::new(Some(8)); - buffer.push(b"abc"); - buffer.push(b"defghi"); - buffer.push(b"jkl"); - - let (bytes, stats) = buffer.into_parts(); - assert_eq!(bytes, b"abcdijkl"); - assert_eq!(stats.observed_bytes, 12); - assert_eq!(stats.retained_bytes, 8); - assert_eq!(stats.omitted_bytes, 4); - } - - #[test] - fn output_capture_buffer_without_cap_retains_everything() { - let mut buffer = OutputCaptureBuffer::new(None); - buffer.push(b"abc"); - buffer.push(b"def"); - - let (bytes, stats) = buffer.into_parts(); - assert_eq!(bytes, b"abcdef"); - assert_eq!(stats, OutputCaptureStats::complete(6)); - } - - #[test] - fn zero_byte_output_capture_buffer_still_counts_drained_bytes() { - let mut buffer = OutputCaptureBuffer::new(Some(0)); - buffer.push(b"abcdef"); - - let (bytes, stats) = buffer.into_parts(); - assert!(bytes.is_empty()); - assert_eq!(stats.observed_bytes, 6); - assert_eq!(stats.retained_bytes, 0); - assert_eq!(stats.omitted_bytes, 6); - } - #[test] fn exec_result_fields() { let result = ExecResult { @@ -2769,26 +1982,6 @@ mod tests { ); } - #[test] - fn dir_entry_fields() { - let entry = DirEntry { - name: "src".into(), - is_dir: true, - size: None, - }; - assert_eq!(entry.name, "src"); - assert!(entry.is_dir); - assert!(entry.size.is_none()); - } - - #[test] - fn grep_options_defaults() { - let opts = GrepOptions::default(); - assert!(opts.glob_filter.is_none()); - assert!(!opts.case_insensitive); - assert!(opts.max_results.is_none()); - } - #[test] fn sandbox_event_serialization_round_trip() { let events = vec![ diff --git a/lib/components/fabro-sandbox/src/sandbox_spec.rs b/lib/components/fabro-sandbox/src/sandbox_spec.rs index cb1f23c8a..f3b6c4985 100644 --- a/lib/components/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/components/fabro-sandbox/src/sandbox_spec.rs @@ -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, - ) -> Result, anyhow::Error> { + ) -> Result, 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); diff --git a/lib/components/fabro-sandbox/src/terminal.rs b/lib/components/fabro-sandbox/src/terminal.rs index a7cc72bd0..f42d6fd94 100644 --- a/lib/components/fabro-sandbox/src/terminal.rs +++ b/lib/components/fabro-sandbox/src/terminal.rs @@ -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 { diff --git a/lib/components/fabro-sandbox/src/test_support.rs b/lib/components/fabro-sandbox/src/test_support.rs index 19e5aa6c1..059c6d5b7 100644 --- a/lib/components/fabro-sandbox/src/test_support.rs +++ b/lib/components/fabro-sandbox/src/test_support.rs @@ -1,69 +1,100 @@ +//! Test doubles for fabro's sandbox layer. +//! +//! [`MockSandbox`] is a configuration and a recorder over the sandbox +//! driver's scripted double: a test writes down the files, the command +//! answer, and the failures it wants, takes a [`RunSandbox`] from it, and +//! reads back what the code under test ran or wrote. Nothing here fakes +//! fabro's own logic; every call goes through the real `RunSandbox` and +//! fabro's exec policy, down to the scripted driver. + use std::collections::HashMap; -use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; use std::time::Duration; -use async_trait::async_trait; -use fabro_types::CommandTermination; -use tokio::fs; -use tokio::io::{DuplexStream, duplex}; -use tokio::time::sleep; -use tokio_util::sync::CancellationToken; +use fabro_types::{CommandTermination, SandboxProviderKind}; +use sandbox_driver::{GrepMatch, PlatformInfo, SandboxState, Termination, WalkedFile}; +pub use sandbox_driver_testing::{ScriptedExec, ScriptedSandbox, ScriptedStdioProcess}; +use tokio::io::DuplexStream; -use crate::sandbox::{self, StdioProcessControl}; -use crate::{ - DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult, ExecStreamingRequest, GrepOptions, - Sandbox, SandboxEvent, SandboxEventCallback, SandboxFile, StderrCollector, StdioProcess, - StdioProcessHandle, StdioProcessTermination, WalkOptions, -}; +use crate::driver_sandbox::RunSandbox; +use crate::sandbox::{ExecResult, SandboxEventCallback, SandboxFile, StderrCollector}; // --- MockSandbox --- +/// What a test wants its sandbox to be, and what the code under test did +/// with it. +/// +/// Build it with a struct literal over [`MockSandbox::default`] (or +/// [`MockSandbox::linux`]), then take the run sandbox with +/// [`MockSandbox::sandbox`]. Every command answers with `exec_result` +/// unless `exec_error` is set, in which case every command fails as a +/// transport error. Files seed an in-memory filesystem under +/// `working_dir`; absolute paths are kept as given. pub struct MockSandbox { - pub files: HashMap, - pub exec_result: ExecResult, - pub grep_results: Vec, - pub glob_results: Vec, - pub working_dir: &'static str, - pub platform_str: &'static str, - pub os_version_str: String, - /// Captures (path, content) pairs from `write_file` calls. - pub written_files: Mutex>, - /// Counts calls to `write_existing_file`. - pub existing_file_writes: AtomicUsize, - /// Captures the `timeout_ms` argument from `exec_command` calls. - pub captured_timeout: Mutex>, - /// Captures the `command` argument from `exec_command` calls (last only). - pub captured_command: Mutex>, - /// Captures all `command` arguments from `exec_command` calls in order. - pub captured_commands: Mutex>, - /// Captures all `working_dir` arguments from `exec_command` calls in order. - pub captured_working_dirs: Mutex>>, - /// Captures the `env_vars` argument from `exec_command` calls. - pub captured_env_vars: Mutex>>, - /// Captures the bytes passed to a streaming command's standard input. - pub captured_stdin: Mutex>>, - pub active: AtomicBool, - pub activate_error: Option, - pub activate_calls: Mutex, - pub start_calls: Mutex, - pub stop_calls: Mutex, - pub delete_calls: Mutex, - pub event_callback: Option, - pub stdio_process_error: Option, - pub stdio_process: Mutex>, - /// Fails `exec_command` and `exec_command_streaming` before any process - /// runs, so callers see a transport error rather than an `ExecResult`. - pub exec_error: Option, - /// Files returned by `walk_files`, before traversal-root and exclusion - /// filtering. - pub walk_files: Vec, - pub walk_files_error: Option, - pub walk_files_called: AtomicBool, - pub walked_while_inactive: AtomicBool, - /// Reported by `exec_command_streaming`. Set to `false` to model a - /// provider that cannot separate stdout from stderr. - pub streams_separated: bool, + pub files: HashMap, + pub exec_result: ExecResult, + /// Fails every command before any process runs, so callers see a + /// transport error rather than an `ExecResult`. + pub exec_error: Option, + pub working_dir: &'static str, + /// The run-scoped scratch directory the sandbox reports, outside any + /// checkout; `None` models a provider without one. + pub runtime_dir: Option<&'static str>, + pub platform_str: &'static str, + pub os_version_str: String, + /// Fails `activate` after the sandbox is built, as a sandbox whose + /// Bash contract broke would. + pub activate_error: Option, + pub event_callback: Option, + pub stdio_process: Option, + pub stdio_process_error: Option, + /// Lines every grep returns, as `path:line:content`. + pub grep_results: Vec, + /// Files returned by `walk_files` instead of the seeded files, before + /// traversal-root and exclusion filtering. + pub walk_files: Vec, + pub walk_files_error: Option, + /// Reported by streaming execution. Set to `false` to model a provider + /// that cannot separate stdout from stderr. + pub streams_separated: bool, + /// The sandbox once built. Public only so `..Default::default()` works + /// from other crates; leave it at its default. + pub built: OnceLock, +} + +/// The lazily built sandbox and its scripted driver. +pub struct Built { + run: Arc, + driver: Arc, +} + +impl Default for MockSandbox { + fn default() -> Self { + Self { + files: HashMap::new(), + exec_result: ExecResult { + stdout: "mock output".into(), + stderr: String::new(), + exit_code: Some(0), + termination: CommandTermination::Exited, + duration_ms: 10, + }, + exec_error: None, + working_dir: "/work", + runtime_dir: None, + platform_str: "darwin", + os_version_str: "Darwin 24.0.0".into(), + activate_error: None, + event_callback: None, + stdio_process: None, + stdio_process_error: None, + grep_results: Vec::new(), + walk_files: Vec::new(), + walk_files_error: None, + streams_separated: true, + built: OnceLock::new(), + } + } } impl MockSandbox { @@ -72,51 +103,10 @@ impl MockSandbox { working_dir: "/home/test", platform_str: "linux", os_version_str: "Linux 6.1.0".into(), - ..Default::default() + ..Self::default() } } - pub fn start_count(&self) -> u32 { - *self.start_calls.lock().expect("start_calls lock poisoned") - } - - pub fn activate_count(&self) -> u32 { - *self - .activate_calls - .lock() - .expect("activate_calls lock poisoned") - } - - pub fn stop_count(&self) -> u32 { - *self.stop_calls.lock().expect("stop_calls lock poisoned") - } - - pub fn walk_files_was_called(&self) -> bool { - self.walk_files_called.load(Ordering::Relaxed) - } - - pub fn walked_while_inactive(&self) -> bool { - self.walked_while_inactive.load(Ordering::Relaxed) - } - - pub fn delete_count(&self) -> u32 { - *self - .delete_calls - .lock() - .expect("delete_calls lock poisoned") - } - - pub fn existing_file_write_count(&self) -> usize { - self.existing_file_writes.load(Ordering::Relaxed) - } - - pub fn set_stdio_process(&self, process: MockStdioProcess) { - *self - .stdio_process - .lock() - .expect("stdio_process lock poisoned") = Some(process); - } - #[must_use] pub fn with_walk_files(mut self, files: Vec) -> Self { self.walk_files = files; @@ -134,67 +124,275 @@ impl MockSandbox { self.activate_error = Some(error.into()); self } -} -impl MockSandbox { - fn emit(&self, event: SandboxEvent) { - event.trace(); - if let Some(ref cb) = self.event_callback { - cb(event); + /// The run sandbox this configuration describes, built once: repeated + /// calls return the same sandbox over the same recorder. + pub fn sandbox(&self) -> Arc { + Arc::clone(&self.built().run) + } + + /// The scripted driver double behind [`MockSandbox::sandbox`], for + /// scripting beyond what the fields express. + pub fn driver(&self) -> Arc { + Arc::clone(&self.built().driver) + } + + /// Answers commands by their Bash source, ahead of the queue and + /// `exec_result`: a responder that returns `Some` decides the result, + /// `None` falls through. For tests that interleave different commands + /// and want each answered by what it is rather than by its position. + pub fn respond_with( + &self, + responder: impl Fn(&str) -> Option + Send + Sync + 'static, + ) -> &Self { + self.driver().scripted_exec().respond_with(move |spec| { + let command = spec.args.last().map(String::as_str).unwrap_or_default(); + responder(command).map(|result| driver_result(&result)) + }); + self + } + + /// Queues the result for the next command, ahead of `exec_result`. + /// Results answer in the order they were pushed. + pub fn push_exec_result(&self, result: &ExecResult) -> &Self { + self.driver() + .scripted_exec() + .push_result(driver_result(result)); + self + } + + fn built(&self) -> &Built { + self.built.get_or_init(|| { + let driver = Arc::new(self.build_driver()); + // An isolated provider: explicit environment passes as the + // caller composed it, as it does for Docker and Daytona runs. + let mut run = RunSandbox::new_with_platform( + SandboxProviderKind::DOCKER, + Arc::clone(&driver) as Arc, + self.platform_str, + self.os_version_str.clone(), + ); + if let Some(callback) = &self.event_callback { + run.set_event_callback(Arc::clone(callback)); + } + Built { + run: Arc::new(run), + driver, + } + }) + } + + fn build_driver(&self) -> ScriptedSandbox { + let mut driver = + ScriptedSandbox::with_id_and_working_dir("mock-sandbox", self.working_dir).platform( + PlatformInfo::new(self.platform_str, "x86_64", self.os_version_str.clone()), + ); + if let Some(directory) = self.runtime_dir { + driver = driver.runtime_directory(directory); } + if let Some(message) = &self.activate_error { + // A stopped sandbox whose provider cannot start it. + driver = driver + .state(SandboxState::Stopped) + .start_error(message.clone()); + } + for (path, content) in &self.files { + driver = driver.file(path, content); + } + let exec = driver.scripted_exec(); + match &self.exec_error { + Some(message) => exec.fail_by_default(message.clone()), + None => exec.set_default(driver_result(&self.exec_result)), + }; + exec.set_streams_separated(self.streams_separated); + if let Some(message) = &self.stdio_process_error { + exec.set_stdio_error(message.clone()); + } + if let Some(process) = self.stdio_process.as_ref() { + if let Some(scripted) = process.take() { + exec.set_stdio_process(scripted); + } + } + let search = driver.scripted_search(); + search.set_grep( + self.grep_results + .iter() + .map(|line| { + let mut parts = line.splitn(3, ':'); + let path = parts.next().unwrap_or_default(); + let line_number = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); + GrepMatch::new(path, line_number, parts.next().unwrap_or_default()) + }) + .collect(), + ); + if let Some(message) = &self.walk_files_error { + search.set_walk_error(message.clone()); + } else if !self.walk_files.is_empty() { + search.set_walk( + self.walk_files + .iter() + .map(|file| WalkedFile::new(file.relative_path.clone(), Some(file.size))) + .collect(), + ); + } + driver + } + + fn recorded(&self) -> Vec { + self.built + .get() + .map(|built| built.driver.scripted_exec().recorded()) + .unwrap_or_default() + } + + /// The Bash source of every command run so far, in order. + pub fn captured_commands(&self) -> Vec { + self.recorded() + .iter() + .map(|spec| spec.args.last().cloned().unwrap_or_default()) + .collect() + } + + /// The last command's Bash source. + pub fn captured_command(&self) -> Option { + self.captured_commands().pop() + } + + /// The last command's timeout in milliseconds. + pub fn captured_timeout(&self) -> Option { + self.recorded() + .last() + .and_then(|spec| spec.timeout) + .map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)) + } + + /// The timeout of every command in milliseconds, in order. + pub fn captured_timeouts(&self) -> Vec { + self.recorded() + .iter() + .filter_map(|spec| spec.timeout) + .map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)) + .collect() + } + + /// Whether each command was given the run's cancellation to stop on, + /// in order. + pub fn captured_term_stops(&self) -> Vec { + self.built + .get() + .map(|built| built.driver.scripted_exec().term_stops()) + .unwrap_or_default() + } + + /// The working directory of every command, in order. + pub fn captured_working_dirs(&self) -> Vec> { + self.recorded() + .iter() + .map(|spec| spec.working_dir.clone()) + .collect() + } + + /// The explicit variables of the last command as the caller passed them. + /// The exec policy's own `BASH_ENV` blank is not the caller's. + pub fn captured_env_vars(&self) -> Option> { + self.recorded().last().map(|spec| { + spec.env + .iter() + .filter(|(key, _)| key.as_str() != sandbox_driver::BASH_ENV_VAR) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + } + + /// The bytes the last streaming command was fed on standard input. + pub fn captured_stdin(&self) -> Option> { + self.built + .get() + .and_then(|built| built.driver.scripted_exec().captured_stdin().pop()) + } + + /// Every file written so far as `(path, content)`, in order. + pub fn written_files(&self) -> Vec<(String, String)> { + self.built + .get() + .map(|built| { + built + .driver + .memory_fs() + .writes() + .into_iter() + .map(|(path, bytes)| (path, String::from_utf8_lossy(&bytes).into_owned())) + .collect() + }) + .unwrap_or_default() + } + + /// Every file deleted so far by absolute path, in order. + pub fn deleted_files(&self) -> Vec { + self.built + .get() + .map(|built| built.driver.memory_fs().deletes()) + .unwrap_or_default() + } + + /// How many times the code under test asked whether a path exists. + pub fn exists_calls(&self) -> usize { + self.built + .get() + .map_or(0, |built| built.driver.memory_fs().exists_calls()) + } + + pub fn start_count(&self) -> u32 { + self.built + .get() + .map_or(0, |built| built.driver.start_count()) + } + + pub fn stop_count(&self) -> u32 { + self.built + .get() + .map_or(0, |built| built.driver.stop_count()) + } + + pub fn delete_count(&self) -> u32 { + self.built + .get() + .map_or(0, |built| built.driver.delete_count()) + } + + /// How many walks the code under test ran. + pub fn walk_files_was_called(&self) -> bool { + self.built + .get() + .is_some_and(|built| built.driver.scripted_search().walk_calls() > 0) } } -impl Default for MockSandbox { - fn default() -> Self { - Self { - files: HashMap::new(), - exec_result: ExecResult { - stdout: "mock output".into(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 10, - }, - grep_results: vec![], - glob_results: vec![], - working_dir: "/work", - platform_str: "darwin", - os_version_str: "Darwin 24.0.0".into(), - written_files: Mutex::new(Vec::new()), - existing_file_writes: AtomicUsize::new(0), - captured_timeout: Mutex::new(None), - captured_command: Mutex::new(None), - captured_commands: Mutex::new(Vec::new()), - captured_working_dirs: Mutex::new(Vec::new()), - captured_env_vars: Mutex::new(None), - captured_stdin: Mutex::new(None), - active: AtomicBool::new(true), - activate_error: None, - activate_calls: Mutex::new(0), - start_calls: Mutex::new(0), - stop_calls: Mutex::new(0), - delete_calls: Mutex::new(0), - event_callback: None, - stdio_process_error: None, - stdio_process: Mutex::new(None), - exec_error: None, - walk_files: Vec::new(), - walk_files_error: None, - walk_files_called: AtomicBool::new(false), - walked_while_inactive: AtomicBool::new(false), - streams_separated: true, - } - } +/// The driver result fabro's exec policy reads back as `result`. +fn driver_result(result: &ExecResult) -> sandbox_driver::ExecResult { + let termination = match result.termination { + CommandTermination::Exited => Termination::Exited, + CommandTermination::TimedOut => Termination::TimedOut, + CommandTermination::Cancelled => Termination::Cancelled, + }; + let mut driver = sandbox_driver::ExecResult::new( + termination, + result.exit_code, + Duration::from_millis(result.duration_ms), + ); + driver.stdout = result.stdout.clone().into_bytes(); + driver.stderr = result.stderr.clone().into_bytes(); + driver } -type StdioProcessDriver = - Box; +// --- MockStdioProcess --- +/// A stdio process a test drives, over the driver's scripted process. +/// +/// The driver closure receives the process's end of standard input, its +/// end of standard output, and fabro's stderr collector for the process. pub struct MockStdioProcess { - exit_code: Option, - wait_delay: Duration, - driver: StdioProcessDriver, + inner: std::sync::Mutex>, } impl MockStdioProcess { @@ -202,528 +400,32 @@ impl MockStdioProcess { driver: impl FnOnce(DuplexStream, DuplexStream, StderrCollector) + Send + 'static, ) -> Self { Self { - exit_code: Some(0), - wait_delay: Duration::ZERO, - driver: Box::new(driver), + inner: std::sync::Mutex::new(Some(ScriptedStdioProcess::new( + move |stdin, stdout, tail| { + driver(stdin, stdout, StderrCollector::from_driver_tail(tail)); + }, + ))), } } #[must_use] - pub fn with_exit_code(mut self, exit_code: Option) -> Self { - self.exit_code = exit_code; - self - } - - #[must_use] - pub fn with_wait_delay(mut self, wait_delay: Duration) -> Self { - self.wait_delay = wait_delay; - self - } -} - -struct MockStdioProcessControl { - exit_code: Option, - wait_delay: Duration, -} - -#[async_trait] -impl StdioProcessControl for MockStdioProcessControl { - async fn terminate(&self) -> crate::Result<()> { - Ok(()) - } - - async fn wait(&self) -> crate::Result { - if !self.wait_delay.is_zero() { - sleep(self.wait_delay).await; - } - Ok(StdioProcessTermination::exited(self.exit_code)) - } -} - -#[async_trait] -impl Sandbox for MockSandbox { - async fn read_file_bytes(&self, path: &str) -> crate::Result> { - self.files - .get(path) - .map(|content| content.as_bytes().to_vec()) - .ok_or_else(|| crate::Error::message(format!("File not found: {path}"))) - } - - async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> { - self.written_files - .lock() - .expect("written_files lock poisoned") - .push((path.to_string(), content.to_string())); - Ok(()) - } - - async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> { - self.existing_file_writes.fetch_add(1, Ordering::Relaxed); - self.write_file(path, content).await - } - - async fn delete_file(&self, _path: &str) -> crate::Result<()> { - Ok(()) - } - - async fn file_exists(&self, path: &str) -> crate::Result { - Ok(self.files.contains_key(path)) - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> crate::Result> { - Ok(vec![]) - } - - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> crate::Result { - *self - .captured_timeout - .lock() - .expect("captured_timeout lock poisoned") = Some(timeout_ms); - *self - .captured_command - .lock() - .expect("captured_command lock poisoned") = Some(command.to_string()); - self.captured_commands - .lock() - .expect("captured_commands lock poisoned") - .push(command.to_string()); - self.captured_working_dirs - .lock() - .expect("captured_working_dirs lock poisoned") - .push(working_dir.map(String::from)); - *self - .captured_env_vars - .lock() - .expect("captured_env_vars lock poisoned") = env_vars.cloned(); - match &self.exec_error { - Some(error) => Err(crate::Error::message(error.clone())), - None => Ok(self.exec_result.clone()), - } - } - - async fn exec_command_streaming( - &self, - request: ExecStreamingRequest<'_>, - ) -> crate::Result { - let ExecStreamingRequest { - command, - timeout_ms, - working_dir, - env_vars, - cancel_token, - stdin, - output_callback, - stream_output_bytes_cap, - } = request; - *self - .captured_stdin - .lock() - .expect("captured_stdin lock poisoned") = stdin; - let result = self - .exec_command( - command, - timeout_ms.unwrap_or(u64::MAX), - working_dir, - env_vars, - cancel_token, - ) - .await?; - sandbox::replay_exec_result( - result, - self.streams_separated, - output_callback.as_ref(), - stream_output_bytes_cap, - ) - .await - } - - async fn spawn_stdio_process( - &self, - command: &str, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> crate::Result { - *self - .captured_command - .lock() - .expect("captured_command lock poisoned") = Some(command.to_string()); - self.captured_commands - .lock() - .expect("captured_commands lock poisoned") - .push(command.to_string()); - self.captured_working_dirs - .lock() - .expect("captured_working_dirs lock poisoned") - .push(working_dir.map(String::from)); - *self - .captured_env_vars - .lock() - .expect("captured_env_vars lock poisoned") = env_vars.cloned(); - - if let Some(error) = &self.stdio_process_error { - return Err(crate::Error::message(error.clone())); - } - - if let Some(process) = self - .stdio_process - .lock() - .expect("stdio_process lock poisoned") - .take() - { - let (stdin, stdin_reader) = duplex(4096); - let (stdout_writer, stdout) = duplex(4096); - let stderr = StderrCollector::new(DEFAULT_EXEC_OUTPUT_TAIL_BYTES); - (process.driver)(stdin_reader, stdout_writer, stderr.clone()); - return Ok(StdioProcess { - stdin: Box::pin(stdin), - stdout: Box::pin(stdout), - stderr, - handle: StdioProcessHandle::new(MockStdioProcessControl { - exit_code: process.exit_code, - wait_delay: process.wait_delay, - }), - }); - } - - let (stdin, _stdin_read) = duplex(1024); - let (_stdout_write, stdout) = duplex(1024); - Ok(StdioProcess { - stdin: Box::pin(stdin), - stdout: Box::pin(stdout), - stderr: StderrCollector::new(DEFAULT_EXEC_OUTPUT_TAIL_BYTES), - handle: StdioProcessHandle::new(MockStdioProcessControl { - exit_code: Some(0), - wait_delay: Duration::ZERO, - }), - }) - } - - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &GrepOptions, - ) -> crate::Result> { - Ok(self.grep_results.clone()) - } - - async fn glob(&self, _pattern: &str, _path: Option<&str>) -> crate::Result> { - Ok(self.glob_results.clone()) - } - - async fn walk_files( - &self, - _base: &str, - relative_start: &str, - options: &WalkOptions, - ) -> crate::Result> { - self.walk_files_called.store(true, Ordering::Relaxed); - if !self.active.load(Ordering::Relaxed) { - self.walked_while_inactive.store(true, Ordering::Relaxed); - return Err(crate::Error::message("Sandbox is stopped")); - } - if let Some(error) = &self.walk_files_error { - return Err(crate::Error::message(error.clone())); - } - - Ok(self - .walk_files - .iter() - .filter(|file| { - relative_start.is_empty() - || file.relative_path == relative_start - || file - .relative_path - .strip_prefix(relative_start) - .is_some_and(|suffix| suffix.starts_with('/')) - }) - .filter(|file| { - let parent = file - .relative_path - .rsplit_once('/') - .map_or("", |(parent, _)| parent); - !options.excludes_relative_path(parent) - }) - .cloned() - .collect()) - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &std::path::Path, - ) -> crate::Result<()> { - let content = self - .files - .get(remote_path) - .ok_or_else(|| crate::Error::message(format!("File not found: {remote_path}")))?; - if let Some(parent) = local_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| crate::Error::context("Failed to create parent dirs", e))?; - } - fs::write(local_path, content.as_bytes()) - .await - .map_err(|e| { - crate::Error::context(format!("Failed to write {}", local_path.display()), e) - })?; - Ok(()) - } - - async fn upload_file_from_local( - &self, - local_path: &std::path::Path, - _remote_path: &str, - ) -> crate::Result<()> { - if !local_path.exists() { - return Err(crate::Error::message(format!( - "File not found: {}", - local_path.display() - ))); - } - Ok(()) - } - - async fn initialize(&self) -> crate::Result<()> { - self.active.store(true, Ordering::Relaxed); - self.emit(SandboxEvent::Initializing { - provider: "mock".into(), - }); - self.emit(SandboxEvent::Ready { - provider: "mock".into(), - duration_ms: 0, - name: None, - cpu: None, - memory: None, - url: None, - }); - Ok(()) - } - - async fn activate(&self) -> crate::Result<()> { - *self - .activate_calls - .lock() - .expect("activate_calls lock poisoned") += 1; - if let Some(error) = &self.activate_error { - return Err(crate::Error::context( - "Mock sandbox activation failed", - std::io::Error::other(error.clone()), - )); - } - self.start().await - } - - async fn start(&self) -> crate::Result<()> { - *self.start_calls.lock().expect("start_calls lock poisoned") += 1; - self.active.store(true, Ordering::Relaxed); - Ok(()) - } - - async fn stop(&self) -> crate::Result<()> { - *self.stop_calls.lock().expect("stop_calls lock poisoned") += 1; - self.active.store(false, Ordering::Relaxed); - Ok(()) - } - - async fn delete(&self) -> crate::Result<()> { - *self - .delete_calls - .lock() - .expect("delete_calls lock poisoned") += 1; - Ok(()) - } - - async fn cleanup(&self) -> crate::Result<()> { - self.emit(SandboxEvent::CleanupStarted { - provider: "mock".into(), - }); - self.emit(SandboxEvent::CleanupCompleted { - provider: "mock".into(), - duration_ms: 0, - }); - Ok(()) - } - - fn working_directory(&self) -> &str { - self.working_dir - } - - fn platform(&self) -> &str { - self.platform_str - } - - fn os_version(&self) -> String { - self.os_version_str.clone() - } -} - -// --- MutableMockSandbox --- - -/// A mock sandbox with Mutex-protected files for tests that need -/// write operations to be visible to subsequent reads (e.g., `apply_patch` -/// tests). -pub struct MutableMockSandbox { - pub files: Mutex>, -} - -impl MutableMockSandbox { - pub fn new(files: HashMap) -> Self { + pub fn with_exit_code(self, exit_code: Option) -> Self { + let inner = self.inner.lock().expect("stdio process").take(); Self { - files: Mutex::new(files), + inner: std::sync::Mutex::new(inner.map(|process| process.exit_code(exit_code))), } } -} -#[async_trait] -impl Sandbox for MutableMockSandbox { - async fn read_file_bytes(&self, path: &str) -> crate::Result> { - self.files - .lock() - .expect("files lock poisoned") - .get(path) - .map(|content| content.as_bytes().to_vec()) - .ok_or_else(|| crate::Error::message(format!("File not found: {path}"))) - } - - async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> { - self.files - .lock() - .expect("files lock poisoned") - .insert(path.to_string(), content.to_string()); - Ok(()) - } - - async fn delete_file(&self, path: &str) -> crate::Result<()> { - self.files.lock().expect("files lock poisoned").remove(path); - Ok(()) - } - - async fn file_exists(&self, path: &str) -> crate::Result { - Ok(self - .files - .lock() - .expect("files lock poisoned") - .contains_key(path)) - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> crate::Result> { - Ok(vec![]) - } - - async fn exec_command( - &self, - _command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> crate::Result { - Ok(ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 0, - }) - } - - async fn grep( - &self, - pattern: &str, - _path: &str, - _options: &GrepOptions, - ) -> crate::Result> { - let files = self.files.lock().expect("files lock poisoned"); - let mut results = Vec::new(); - for (path, content) in files.iter() { - for (i, line) in content.lines().enumerate() { - if line.contains(pattern) { - results.push(format!("{}:{}:{}", path, i + 1, line)); - } - } + #[must_use] + pub fn with_wait_delay(self, wait_delay: Duration) -> Self { + let inner = self.inner.lock().expect("stdio process").take(); + Self { + inner: std::sync::Mutex::new(inner.map(|process| process.wait_delay(wait_delay))), } - Ok(results) } - async fn glob(&self, _pattern: &str, _path: Option<&str>) -> crate::Result> { - Ok(vec![]) - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &std::path::Path, - ) -> crate::Result<()> { - let content = self - .files - .lock() - .expect("files lock poisoned") - .get(remote_path) - .cloned() - .ok_or_else(|| crate::Error::message(format!("File not found: {remote_path}")))?; - if let Some(parent) = local_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| crate::Error::context("Failed to create parent dirs", e))?; - } - fs::write(local_path, content.as_bytes()) - .await - .map_err(|e| { - crate::Error::context(format!("Failed to write {}", local_path.display()), e) - })?; - Ok(()) - } - - async fn upload_file_from_local( - &self, - local_path: &std::path::Path, - remote_path: &str, - ) -> crate::Result<()> { - let content = fs::read_to_string(local_path).await.map_err(|e| { - crate::Error::context(format!("Failed to read {}", local_path.display()), e) - })?; - self.files - .lock() - .expect("files lock poisoned") - .insert(remote_path.to_string(), content); - Ok(()) - } - - async fn initialize(&self) -> crate::Result<()> { - Ok(()) - } - - async fn cleanup(&self) -> crate::Result<()> { - Ok(()) - } - - fn working_directory(&self) -> &'static str { - "/work" - } - - fn platform(&self) -> &'static str { - "linux" - } - - fn os_version(&self) -> String { - "Linux 6.1.0".into() + fn take(&self) -> Option { + self.inner.lock().expect("stdio process").take() } } diff --git a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs index 3e6560650..73d927225 100644 --- a/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs +++ b/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs @@ -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) -> Result<()> { + async fn run_smoke(sandbox: Arc) -> 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, @@ -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, diff --git a/lib/components/fabro-sandbox/tests/docker_streaming.rs b/lib/components/fabro-sandbox/tests/docker_streaming.rs index 7eb12ce64..07b0e082a 100644 --- a/lib/components/fabro-sandbox/tests/docker_streaming.rs +++ b/lib/components/fabro-sandbox/tests/docker_streaming.rs @@ -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; diff --git a/lib/components/fabro-sandbox/tests/driver_bench.rs b/lib/components/fabro-sandbox/tests/driver_bench.rs index f7dca6708..5f9646319 100644 --- a/lib/components/fabro-sandbox/tests/driver_bench.rs +++ b/lib/components/fabro-sandbox/tests/driver_bench.rs @@ -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 { +async fn bench_fabro(label: &'static str, sandbox: &RunSandbox, repo: &Repository) -> Vec { 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 { 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") diff --git a/lib/components/fabro-workflow/Cargo.toml b/lib/components/fabro-workflow/Cargo.toml index b92b293c9..298d7dc7b 100644 --- a/lib/components/fabro-workflow/Cargo.toml +++ b/lib/components/fabro-workflow/Cargo.toml @@ -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 diff --git a/lib/components/fabro-workflow/src/artifact.rs b/lib/components/fabro-workflow/src/artifact.rs index b7d68c879..0aaabb027 100644 --- a/lib/components/fabro-workflow/src/artifact.rs +++ b/lib/components/fabro-workflow/src/artifact.rs @@ -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, node_outcomes: &mut HashMap, 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 { @@ -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 { @@ -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 { +fn remote_materialized_blob_path(env: &RunSandbox, blob_hash: &BlobHash) -> Result { 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 { 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, run_store: &RunStoreHandle, - env: &dyn Sandbox, + env: &RunSandbox, run_dir: &Path, ) -> Result> { 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> { 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, - 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, 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 { @@ -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 { +async fn resolve_explicit_file_ref(value: &str, env: &RunSandbox) -> Result { 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 { + async fn is_local(&mut self, env: &RunSandbox, run_dir: &Path) -> Result { 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>, - working_dir: String, - runtime_dir: Option, - exists_calls: Mutex, - } - - 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> { - 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 { - *self.exists_calls.lock().unwrap() += 1; - Ok(self.accessible) - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> fabro_sandbox::Result> { - Err("not implemented".into()) - } - - async fn exec_command( - &self, - _command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> fabro_sandbox::Result { - Err("not implemented".into()) - } - - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &fabro_agent::GrepOptions, - ) -> fabro_sandbox::Result> { - Err("not implemented".into()) - } - - async fn glob( - &self, - _pattern: &str, - _path: Option<&str>, - ) -> fabro_sandbox::Result> { - 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); diff --git a/lib/components/fabro-workflow/src/artifact_snapshot.rs b/lib/components/fabro-workflow/src/artifact_snapshot.rs index 3fe7f3574..3eda8b2f4 100644 --- a/lib/components/fabro-workflow/src/artifact_snapshot.rs +++ b/lib/components/fabro-workflow/src/artifact_snapshot.rs @@ -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 { - 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(), "".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(); diff --git a/lib/components/fabro-workflow/src/handler/agent.rs b/lib/components/fabro-workflow/src/handler/agent.rs index d18f3bcf0..2cc7e865d 100644 --- a/lib/components/fabro-workflow/src/handler/agent.rs +++ b/lib/components/fabro-workflow/src/handler/agent.rs @@ -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, - pub sandbox: &'a Arc, + pub sandbox: &'a Arc, pub tool_hooks: Option>, 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, pub stage_scope: &'a StageScope, - pub sandbox: &'a Arc, + pub sandbox: &'a Arc, 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, + sandbox: &Arc, last_file_touched: Option<&str>, ) -> Result { 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, path: &str) -> Option { +async fn read_sandbox_file(sandbox: &Arc, path: &str) -> Option { 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, path: &str) -> Option { +async fn read_last_file_routing_json(sandbox: &Arc, path: &str) -> Option { 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) { + async fn sandbox_with_file(path: &str, contents: &str) -> (TempDir, Arc) { let sandbox_dir = TempDir::new().unwrap(); std::fs::write(sandbox_dir.path().join(path), contents).unwrap(); - let sandbox: Arc = Arc::new( + let sandbox: Arc = Arc::new( fabro_agent::local_sandbox(sandbox_dir.path().to_path_buf()) .await .unwrap(), diff --git a/lib/components/fabro-workflow/src/handler/command.rs b/lib/components/fabro-workflow/src/handler/command.rs index 0b58e52f8..8d024ece9 100644 --- a/lib/components/fabro-workflow/src/handler/command.rs +++ b/lib/components/fabro-workflow/src/handler/command.rs @@ -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, - captured_command: std::sync::Mutex>, - captured_env_vars: std::sync::Mutex>>, - captured_cancel_token: std::sync::Mutex>, - } - - 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) -> 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 { - 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> { - 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 { - unimplemented!() - } - async fn list_directory( - &self, - _: &str, - _: Option, - ) -> fabro_sandbox::Result> { - unimplemented!() - } - async fn exec_command( - &self, - command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - cancel_token: Option, - ) -> fabro_sandbox::Result { - *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> { - unimplemented!() - } - async fn glob(&self, _: &str, _: Option<&str>) -> fabro_sandbox::Result> { - 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) -> EngineServices { + fn make_sandbox_services(sandbox: std::sync::Arc) -> 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(¶llel_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) diff --git a/lib/components/fabro-workflow/src/handler/llm/acp.rs b/lib/components/fabro-workflow/src/handler/llm/acp.rs index e48dd3b6c..6f42d4361 100644 --- a/lib/components/fabro-workflow/src/handler/llm/acp.rs +++ b/lib/components/fabro-workflow/src/handler/llm/acp.rs @@ -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 { /// 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, +async fn refresh_ahead_loop( + refresh: impl Fn() -> Fut + Send, cancel: CancellationToken, interval: Duration, initial_delay: Duration, -) { +) where + Fut: Future> + 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, stage_scope: &StageScope, - sandbox: &Arc, + sandbox: &Arc, cancel_token: CancellationToken, ) -> Result { let process_spec = resolve_acp_process_spec(node)?; @@ -354,8 +355,12 @@ impl AgentAcpBackend { }; let _refresh_ahead_guard: Option = 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>, ticks: Mutex>, } - impl ScriptedRefreshSandbox { + impl ScriptedRefresh { fn new(script: Vec) -> Arc { Arc::new(Self { script: Mutex::new(script.into()), @@ -816,101 +821,24 @@ mod tests { fn ticks(&self) -> Vec { self.ticks.lock().expect("ticks lock").clone() } - } - #[async_trait::async_trait] - impl Sandbox for ScriptedRefreshSandbox { - async fn refresh_push_credentials(&self) -> fabro_sandbox::Result { - 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> { - 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 { - unimplemented!() - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> fabro_sandbox::Result> { - unimplemented!() - } - - async fn exec_command( - &self, - _command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&HashMap>, - _cancel_token: Option, - ) -> fabro_sandbox::Result { - unimplemented!() - } - - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &fabro_sandbox::GrepOptions, - ) -> fabro_sandbox::Result> { - 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, + ) -> impl Fn() -> std::future::Ready> + 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, + 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, + sandbox.refresher(), cancel.clone(), interval, initial_delay, @@ -1040,7 +968,7 @@ mod tests { "ACP_MODE".to_string(), "write_file".to_string(), )])); - let sandbox: Arc = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = 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 = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = 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 = sandbox.clone(); + let sandbox_dyn = sandbox.sandbox(); let mut node = Node::new("work"); node.attrs diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 2e47e0d97..f79778612 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -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, + sandbox: &Arc, tool_hooks: Option>, ) -> Result<(CachedAgentSession, Vec), Error> { let model = node.model().unwrap_or(&self.model); @@ -1044,7 +1044,7 @@ impl AgentApiBackend { provider: ProviderContext, controls: EffectiveRequestControls, node: &Node, - sandbox: &Arc, + sandbox: &Arc, source: &dyn CredentialSource, catalog: Arc, tool_env: Option<&Arc>, @@ -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 = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = + let sandbox: Arc = 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 = + let sandbox: Arc = Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap()); let result = backend diff --git a/lib/components/fabro-workflow/src/handler/llm/changed_files.rs b/lib/components/fabro-workflow/src/handler/llm/changed_files.rs index 6b07e2f5d..b7d93c7c7 100644 --- a/lib/components/fabro-workflow/src/handler/llm/changed_files.rs +++ b/lib/components/fabro-workflow/src/handler/llm/changed_files.rs @@ -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) -> Vec { +pub async fn detect_changed_files(sandbox: &Arc) -> Vec { let mut files: Vec = 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) -> Vec { } pub async fn files_touched_since( - sandbox: &Arc, + sandbox: &Arc, files_before: &[String], ) -> (Vec, Option) { let files_after = detect_changed_files(sandbox).await; diff --git a/lib/components/fabro-workflow/src/handler/llm/router.rs b/lib/components/fabro-workflow/src/handler/llm/router.rs index c035d6385..7250ce930 100644 --- a/lib/components/fabro-workflow/src/handler/llm/router.rs +++ b/lib/components/fabro-workflow/src/handler/llm/router.rs @@ -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 = Arc::new( + let sandbox: Arc = Arc::new( local_sandbox(tempfile::tempdir().unwrap().path().to_path_buf()) .await .unwrap(), diff --git a/lib/components/fabro-workflow/src/handler/parallel.rs b/lib/components/fabro-workflow/src/handler/parallel.rs index 08e8f6f28..0ae35afe3 100644 --- a/lib/components/fabro-workflow/src/handler/parallel.rs +++ b/lib/components/fabro-workflow/src/handler/parallel.rs @@ -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; diff --git a/lib/components/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs index 5d0b7222f..cc93bd0d6 100644 --- a/lib/components/fabro-workflow/src/handler/prompt.rs +++ b/lib/components/fabro-workflow/src/handler/prompt.rs @@ -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, diff --git a/lib/components/fabro-workflow/src/lifecycle/artifact.rs b/lib/components/fabro-workflow/src/lifecycle/artifact.rs index a5d3f9afc..2d0383485 100644 --- a/lib/components/fabro-workflow/src/lifecycle/artifact.rs +++ b/lib/components/fabro-workflow/src/lifecycle/artifact.rs @@ -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, + pub sandbox: Arc, pub run_store: RunStoreHandle, pub emitter: Arc, pub run_id: RunId, @@ -53,7 +53,7 @@ pub(crate) struct ArtifactLifecycle { impl ArtifactLifecycle { pub(crate) fn new( - sandbox: Arc, + sandbox: Arc, run_store: RunStoreHandle, emitter: Arc, run_id: RunId, @@ -130,7 +130,7 @@ impl RunLifecycle 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 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, diff --git a/lib/components/fabro-workflow/src/lifecycle/fidelity.rs b/lib/components/fabro-workflow/src/lifecycle/fidelity.rs index ce49d0afe..8362ba043 100644 --- a/lib/components/fabro-workflow/src/lifecycle/fidelity.rs +++ b/lib/components/fabro-workflow/src/lifecycle/fidelity.rs @@ -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, - pub sandbox: Arc, + pub sandbox: Arc, pub run_store: RunStoreHandle, pub run_dir: PathBuf, incoming_edge_data: Mutex>, @@ -43,7 +43,7 @@ pub(crate) struct FidelityLifecycle { impl FidelityLifecycle { pub(crate) fn new( graph: Arc, - sandbox: Arc, + sandbox: Arc, run_store: RunStoreHandle, run_dir: PathBuf, ) -> Self { @@ -178,7 +178,7 @@ impl RunLifecycle 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 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 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 = Arc::new( + let sandbox: Arc = Arc::new( fabro_agent::local_sandbox(run_dir.to_path_buf()) .await .unwrap(), diff --git a/lib/components/fabro-workflow/src/lifecycle/git.rs b/lib/components/fabro-workflow/src/lifecycle/git.rs index 5f9cc4531..8ac6f7fae 100644 --- a/lib/components/fabro-workflow/src/lifecycle/git.rs +++ b/lib/components/fabro-workflow/src/lifecycle/git.rs @@ -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 { @@ -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, + pub sandbox: Arc, pub emitter: Arc, pub run_id: RunId, pub run_store: RunStoreHandle, @@ -303,7 +303,7 @@ impl RunLifecycle 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 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 = Arc::new( + let finalize_sandbox: Arc = Arc::new( fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) .await .unwrap(), diff --git a/lib/components/fabro-workflow/src/lifecycle/hook.rs b/lib/components/fabro-workflow/src/lifecycle/hook.rs index 9ea5a83b0..73a001b3e 100644 --- a/lib/components/fabro-workflow/src/lifecycle/hook.rs +++ b/lib/components/fabro-workflow/src/lifecycle/hook.rs @@ -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>; /// Sub-lifecycle responsible for running workflow hooks. pub(crate) struct HookLifecycle { pub hook_runner: Option>, - pub sandbox: Arc, + pub sandbox: Arc, pub hook_execution_context: HookExecutionContext, pub run_id: RunId, pub graph_name: String, diff --git a/lib/components/fabro-workflow/src/lifecycle/mod.rs b/lib/components/fabro-workflow/src/lifecycle/mod.rs index dd4fa7338..e08ca87e2 100644 --- a/lib/components/fabro-workflow/src/lifecycle/mod.rs +++ b/lib/components/fabro-workflow/src/lifecycle/mod.rs @@ -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, git: GitLifecycle, artifact: ArtifactLifecycle, - sandbox: Arc, + sandbox: Arc, on_node: crate::OnNodeCallback, emitter: Arc, run_control: Option>, @@ -85,7 +85,7 @@ impl WorkflowLifecycle { pub(crate) fn new( emitter: &Arc, hook_runner: Option>, - sandbox: &Arc, + sandbox: &Arc, graph: Arc, run_dir: &Path, run_store: &RunStoreHandle, diff --git a/lib/components/fabro-workflow/src/node_handler.rs b/lib/components/fabro-workflow/src/node_handler.rs index 70a35a34b..cd5518de3 100644 --- a/lib/components/fabro-workflow/src/node_handler.rs +++ b/lib/components/fabro-workflow/src/node_handler.rs @@ -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 diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index 24533cf38..48e9abb5b 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -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); diff --git a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs index 584c4531e..c7be3e5ac 100644 --- a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs @@ -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 { +async fn local_env() -> Arc { 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, - sandbox: Arc, + sandbox: Arc, graph: &Graph, run_options: RunOptions, lifecycle: LifecycleOptions, @@ -717,7 +717,7 @@ fn interview_wait_graph(stall_timeout: Duration, node_timeout: Option) } struct StopsSandboxHandler { - sandbox: Arc, + sandbox: Arc, } #[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 = - 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 = 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] diff --git a/lib/components/fabro-workflow/src/pipeline/finalize.rs b/lib/components/fabro-workflow/src/pipeline/finalize.rs index e72fddab4..f88071bf3 100644 --- a/lib/components/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/components/fabro-workflow/src/pipeline/finalize.rs @@ -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, - sandbox: Arc, + sandbox: Arc, metadata_runtime: Arc, metadata_writer: Option, ) -> Arc { @@ -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 = Arc::new( + let sandbox: Arc = 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, ); diff --git a/lib/components/fabro-workflow/src/pipeline/initialize.rs b/lib/components/fabro-workflow/src/pipeline/initialize.rs index 250be871e..4d8c1e52a 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -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, + sandbox: Arc, 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 = if let Some(instance) = attach_instance { + let sandbox: Arc = 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" diff --git a/lib/components/fabro-workflow/src/sandbox_git.rs b/lib/components/fabro-workflow/src/sandbox_git.rs index ad522048e..400a5772d 100644 --- a/lib/components/fabro-workflow/src/sandbox_git.rs +++ b/lib/components/fabro-workflow/src/sandbox_git.rs @@ -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 { 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 { @@ -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, 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 { @@ -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, 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>, 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>, - commands: Mutex>, - timeouts: Mutex>, - write_paths: Mutex>, - delete_paths: Mutex>, - } - - impl ScriptedSandbox { - fn new(exec_results: Vec) -> 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 { - self.commands - .lock() - .expect("commands lock poisoned") - .clone() - } - - fn timeouts(&self) -> Vec { - self.timeouts - .lock() - .expect("timeouts lock poisoned") - .clone() - } - - fn write_paths(&self) -> Vec { - self.write_paths - .lock() - .expect("write_paths lock poisoned") - .clone() - } - - fn delete_paths(&self) -> Vec { - 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> { - 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 { - Ok(false) - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> fabro_sandbox::Result> { - Ok(Vec::new()) - } - - async fn exec_command( - &self, - command: &str, - timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> fabro_sandbox::Result { - 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> { - Ok(Vec::new()) - } - - async fn glob( - &self, - _pattern: &str, - _path: Option<&str>, - ) -> fabro_sandbox::Result> { - 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 = 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 ")) diff --git a/lib/components/fabro-workflow/src/sandbox_git_runtime.rs b/lib/components/fabro-workflow/src/sandbox_git_runtime.rs index 1178d5c69..21d404ba3 100644 --- a/lib/components/fabro-workflow/src/sandbox_git_runtime.rs +++ b/lib/components/fabro-workflow/src/sandbox_git_runtime.rs @@ -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 diff --git a/lib/components/fabro-workflow/src/services.rs b/lib/components/fabro-workflow/src/services.rs index 2db33d130..be60bf7f8 100644 --- a/lib/components/fabro-workflow/src/services.rs +++ b/lib/components/fabro-workflow/src/services.rs @@ -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, - 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, - pub sandbox: Arc, + pub sandbox: Arc, pub hook_runner: Option>, pub locations: RunLocations, pub(crate) cancel_token: CancellationToken, @@ -117,7 +117,7 @@ impl RunServices { pub(crate) fn new( run_store: RunStoreHandle, emitter: Arc, - sandbox: Arc, + sandbox: Arc, hook_runner: Option>, locations: RunLocations, cancel_token: CancellationToken, @@ -188,7 +188,7 @@ impl RunServices { } #[must_use] - pub fn with_sandbox(self: &Arc, sandbox: Arc) -> Arc { + pub fn with_sandbox(self: &Arc, sandbox: Arc) -> Arc { 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 = Arc::new( + let sandbox: Arc = Arc::new( fabro_agent::local_sandbox( std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), ) diff --git a/lib/components/fabro-workflow/src/test_support.rs b/lib/components/fabro-workflow/src/test_support.rs index 039cb91cf..f477937f6 100644 --- a/lib/components/fabro-workflow/src/test_support.rs +++ b/lib/components/fabro-workflow/src/test_support.rs @@ -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) -> Arc, - sandbox: Arc, + sandbox: Arc, graph: &GvGraph, run_options: &RunOptions, options: InitializedOptions, @@ -301,7 +301,7 @@ async fn initialized( pub async fn run_graph( registry: HandlerRegistry, emitter: Arc, - sandbox: Arc, + sandbox: Arc, graph: &GvGraph, run_options: &RunOptions, ) -> Result { @@ -326,7 +326,7 @@ pub async fn run_graph( pub async fn run_graph_with_state( registry: HandlerRegistry, emitter: Arc, - sandbox: Arc, + sandbox: Arc, 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, - sandbox: Arc, + sandbox: Arc, graph: &GvGraph, run_options: &RunOptions, hook_runner: Arc, @@ -386,7 +386,7 @@ pub async fn run_graph_with_hooks( pub async fn run_graph_with_hooks_and_state( registry: HandlerRegistry, emitter: Arc, - sandbox: Arc, + sandbox: Arc, graph: &GvGraph, run_options: &RunOptions, hook_runner: Arc, @@ -421,7 +421,7 @@ pub async fn run_graph_with_hooks_and_state( pub async fn run_graph_from_checkpoint( registry: HandlerRegistry, emitter: Arc, - sandbox: Arc, + sandbox: Arc, 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, - sandbox: Arc, + sandbox: Arc, 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, - sandbox: Arc, + sandbox: Arc, graph: &GvGraph, run_options: &RunOptions, llm_source: Arc, @@ -520,16 +520,12 @@ pub async fn run_graph_with_state_and_llm_source( pub struct WorkflowRunner { registry: std::sync::Mutex>, emitter: Arc, - sandbox: Arc, + sandbox: Arc, } impl WorkflowRunner { #[must_use] - pub fn new( - registry: HandlerRegistry, - emitter: Arc, - sandbox: Arc, - ) -> Self { + pub fn new(registry: HandlerRegistry, emitter: Arc, sandbox: Arc) -> Self { Self { registry: std::sync::Mutex::new(Some(registry)), emitter, diff --git a/lib/components/fabro-workflow/tests/it/cp_integration.rs b/lib/components/fabro-workflow/tests/it/cp_integration.rs index d0acf5ca7..f78200a21 100644 --- a/lib/components/fabro-workflow/tests/it/cp_integration.rs +++ b/lib/components/fabro-workflow/tests/it/cp_integration.rs @@ -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"; diff --git a/lib/components/fabro-workflow/tests/it/daytona_integration.rs b/lib/components/fabro-workflow/tests/it/daytona_integration.rs index 80d0db9b9..16b20d610 100644 --- a/lib/components/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/components/fabro-workflow/tests/it/daytona_integration.rs @@ -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, -) -> 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 = Arc::new(env); + let env: Arc = 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 = Arc::new(env); + let env: Arc = 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 = Arc::new(env); + let env: Arc = 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 = Arc::new(env); + let env: Arc = 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 = Arc::new(env); + let env: Arc = 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, diff --git a/lib/components/fabro-workflow/tests/it/git_integration.rs b/lib/components/fabro-workflow/tests/it/git_integration.rs index a004fc9dd..4bc216555 100644 --- a/lib/components/fabro-workflow/tests/it/git_integration.rs +++ b/lib/components/fabro-workflow/tests/it/git_integration.rs @@ -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 { +async fn local_env(repo: &Path) -> Arc { 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, + fs: HidingFs, runtime_directory: String, } -#[async_trait::async_trait] -impl Sandbox for RemoteRuntimeSandbox { - async fn read_file_bytes(&self, path: &str) -> fabro_sandbox::Result> { - 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 { - if path == self.hidden_path { - return Ok(false); +impl RemoteStyleSandbox { + fn over( + inner: Arc, + 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, - ) -> fabro_sandbox::Result> { - 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>, - cancel_token: Option, - ) -> fabro_sandbox::Result { - 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> { - 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 { + self.inner.describe().await } fn working_directory(&self) -> &str { self.inner.working_directory() } + async fn environment(&self) -> sandbox_driver::Result> { + 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 { + 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, + hidden_path: String, +} + +#[async_trait::async_trait] +impl Filesystem for HidingFs { + async fn read(&self, path: &str) -> sandbox_driver::Result> { + 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 { + if path == self.hidden_path { + return Ok(false); + } + self.inner.fs().exists(path).await + } + + async fn metadata(&self, path: &str) -> sandbox_driver::Result { + self.inner.fs().metadata(path).await + } + + async fn list_dir(&self, path: &str, depth: usize) -> sandbox_driver::Result> { + 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()), diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index 30c045900..c7be6eafd 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -83,7 +83,7 @@ fn catalog_with_provider_base_url(provider: &str, base_url: &str) -> Arc Arc { +async fn local_env() -> Arc { 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>, - existing_paths: std::sync::Mutex>, -} - -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> { - 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 { - 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, - ) -> fabro_sandbox::Result> { - Err("not implemented".into()) - } - - async fn exec_command( - &self, - _command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> fabro_sandbox::Result { - Err("not implemented".into()) - } - - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &fabro_agent::GrepOptions, - ) -> fabro_sandbox::Result> { - Err("not implemented".into()) - } - - async fn glob( - &self, - _pattern: &str, - _path: Option<&str>, - ) -> fabro_sandbox::Result> { - 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 = Arc::new( + let env: Arc = 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 = Arc::new( + let env: Arc = 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 = Arc::new( + let env: Arc = 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 = Arc::new( + let env: Arc = 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 = Arc::new( + let sandbox: Arc = 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 = Arc::new( + let sandbox: Arc = 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 = Arc::new( + let sandbox: Arc = 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 = Arc::new( + let sandbox: Arc = Arc::new( fabro_agent::provider_sandbox( fabro_agent::SandboxProviderKind::DOCKER, &fabro_agent::ProviderAccess::default(),