From ba3adb92c7246d52ff9ccd9cde60badfe46d796e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 9 Sep 2026 16:09:45 -0600 Subject: [PATCH] Serve the local sandbox through the driver Host provider SandboxSpec::Local and run reconnect now build a DriverSandbox over the sandbox-driver Host provider instead of fabro's own LocalSandbox, which is deleted. fabro_sandbox::local_sandbox designates the working directory (created when missing, never removed), creates the Host handle in a per-process registry, and learns the platform up front. A local sandbox reports no provider id: it is its directory, which the run record already carries, so reconnect rebuilds the handle over that directory rather than by id. The credential filter for explicit environment variables and the Bash readiness probe now come from the exec layer and the driver's activate helper. Test call sites move to the async constructor; test factories that must stay synchronous share the parent session's sandbox handle. Co-Authored-By: Claude Fable 5.1 --- .../src/server/handler/sessions.rs | 24 +- lib/components/fabro-acp/tests/session.rs | 32 +- lib/components/fabro-agent/src/apply_patch.rs | 6 +- lib/components/fabro-agent/src/cli.rs | 10 +- lib/components/fabro-agent/src/lib.rs | 2 +- .../fabro-agent/src/local_sandbox.rs | 5 +- .../fabro-agent/src/tool_execution.rs | 40 +- lib/components/fabro-agent/src/tools.rs | 11 +- .../fabro-agent/tests/it/compaction.rs | 12 +- .../fabro-agent/tests/it/parity_matrix.rs | 35 +- lib/components/fabro-hooks/src/bridge.rs | 22 +- lib/components/fabro-hooks/src/executor.rs | 28 +- lib/components/fabro-hooks/src/runner.rs | 22 +- .../fabro-hooks/tests/host_command_hooks.rs | 14 +- .../fabro-sandbox/src/driver_sandbox.rs | 71 +- lib/components/fabro-sandbox/src/lib.rs | 5 +- lib/components/fabro-sandbox/src/local.rs | 2142 ----------------- lib/components/fabro-sandbox/src/reconnect.rs | 9 +- .../fabro-sandbox/src/sandbox_spec.rs | 11 +- .../fabro-sandbox/tests/driver_bench.rs | 12 +- lib/components/fabro-workflow/src/artifact.rs | 8 +- .../fabro-workflow/src/handler/agent.rs | 58 +- .../fabro-workflow/src/handler/llm/acp.rs | 22 +- .../fabro-workflow/src/handler/llm/api.rs | 52 +- .../fabro-workflow/src/handler/llm/router.rs | 10 +- .../fabro-workflow/src/handler/parallel.rs | 16 +- .../fabro-workflow/src/handler/prompt.rs | 16 +- .../fabro-workflow/src/lifecycle/fidelity.rs | 7 +- .../fabro-workflow/src/lifecycle/git.rs | 42 +- .../src/pipeline/execute/tests.rs | 34 +- .../fabro-workflow/src/pipeline/finalize.rs | 70 +- .../fabro-workflow/src/pipeline/initialize.rs | 7 +- .../fabro-workflow/src/sandbox_git.rs | 28 +- lib/components/fabro-workflow/src/services.rs | 17 +- .../tests/it/git_integration.rs | 16 +- .../fabro-workflow/tests/it/integration.rs | 297 ++- 36 files changed, 663 insertions(+), 2550 deletions(-) delete mode 100644 lib/components/fabro-sandbox/src/local.rs diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 871b94114..a43405ba5 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -1824,13 +1824,15 @@ reasoning = false ]); } - #[test] - fn ask_fabro_prompt_lists_effective_tools_without_denied_tools() { + #[tokio::test] + async fn ask_fabro_prompt_lists_effective_tools_without_denied_tools() { let registry = ask_fabro_test_registry(); let policy = build_ask_fabro_tool_access_policy(); let prompt = build_ask_fabro_system_prompt( - &fabro_agent::LocalSandbox::new(std::env::current_dir().unwrap()), + &fabro_agent::local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), &fabro_agent::EnvContext::default(), &[], None, @@ -1874,8 +1876,8 @@ reasoning = false assert!(prompt.contains("Use workspace file tools only when the question asks")); } - #[test] - fn ask_fabro_prompt_keeps_tool_descriptions_inert() { + #[tokio::test] + async fn ask_fabro_prompt_keeps_tool_descriptions_inert() { let mut registry = ToolRegistry::new(); let mut tool = stub_tool("read_file"); tool.definition.description = "{{ inputs.env_block }}".to_string(); @@ -1883,7 +1885,9 @@ reasoning = false let policy = build_ask_fabro_tool_access_policy(); let prompt = build_ask_fabro_system_prompt( - &fabro_agent::LocalSandbox::new(std::env::current_dir().unwrap()), + &fabro_agent::local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), &fabro_agent::EnvContext::default(), &[], None, @@ -2030,9 +2034,11 @@ reasoning = false tool_exposure_mode: ToolExposureMode::AutoApprovedOnly, ..SessionOptions::default() }; - let sandbox: Arc = Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )); + let sandbox: Arc = Arc::new( + fabro_agent::local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), + ); for tool_name in denied_tools { let result = fabro_agent::tool_execution::execute_and_emit_one_tool( diff --git a/lib/components/fabro-acp/tests/session.rs b/lib/components/fabro-acp/tests/session.rs index 46a1f4991..c4e7633a5 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::{LocalSandbox, Sandbox, shell_quote}; +use fabro_sandbox::{Sandbox, local_sandbox, shell_quote}; use fabro_types::SteeringMessage; use fabro_util::error::collect_chain; use tokio::fs::{read_to_string, write}; @@ -104,7 +104,11 @@ 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(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = Arc::new( + local_sandbox(tempdir.path().to_path_buf()) + .await + .expect("local sandbox should be created"), + ); let result = run_acp_turn(AcpRunRequest { command, @@ -145,7 +149,11 @@ 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(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = Arc::new( + local_sandbox(tempdir.path().to_path_buf()) + .await + .expect("local sandbox should be created"), + ); let control_handle = AcpControlHandle::new(); let handle_for_activity = control_handle.clone(); let queued = Arc::new(AtomicBool::new(false)); @@ -208,7 +216,11 @@ 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(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = Arc::new( + local_sandbox(tempdir.path().to_path_buf()) + .await + .expect("local sandbox should be created"), + ); let control_handle = AcpControlHandle::new(); let handle_for_activity = control_handle.clone(); let queued = Arc::new(AtomicBool::new(false)); @@ -283,7 +295,11 @@ 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(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = Arc::new( + local_sandbox(tempdir.path().to_path_buf()) + .await + .expect("local sandbox should be created"), + ); let control_handle = AcpControlHandle::new(); let handle_for_activity = control_handle.clone(); let interrupted = Arc::new(AtomicBool::new(false)); @@ -670,7 +686,11 @@ 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(LocalSandbox::new(tempdir.to_path_buf())); + let sandbox: Arc = Arc::new( + local_sandbox(tempdir.to_path_buf()) + .await + .expect("local sandbox should be created"), + ); env.entry("LC_ALL".to_string()) .or_insert_with(|| "C".to_string()); diff --git a/lib/components/fabro-agent/src/apply_patch.rs b/lib/components/fabro-agent/src/apply_patch.rs index f3d264c66..b9e13388a 100644 --- a/lib/components/fabro-agent/src/apply_patch.rs +++ b/lib/components/fabro-agent/src/apply_patch.rs @@ -509,7 +509,7 @@ mod tests { use tokio_util::sync::CancellationToken; use super::*; - use crate::LocalSandbox; + use crate::local_sandbox; use crate::test_support::MutableMockSandbox; use crate::tool_registry::ToolContext; @@ -906,7 +906,7 @@ mod tests { fs::write(&path, "fn hello() {\n println!(\"old\");\n}\n") .await .unwrap(); - let env = LocalSandbox::new(dir.path().to_path_buf()); + let env = local_sandbox(dir.path().to_path_buf()).await.unwrap(); let patch = "\ *** Begin Patch *** Update File: src/lib.rs @@ -1044,7 +1044,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("insert_only.txt"); fs::write(&path, "alpha\nomega\n").await.unwrap(); - let env = LocalSandbox::new(dir.path().to_path_buf()); + let env = local_sandbox(dir.path().to_path_buf()).await.unwrap(); let patch = "\ *** Begin Patch *** Update File: insert_only.txt diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index f7cfdd7f9..89a9c9f12 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -33,8 +33,8 @@ use crate::subagent::{SessionFactory, SubAgentSupervisor}; use crate::tool_permissions::{is_auto_approved, tool_category}; use crate::tools::WebFetchSummarizer; use crate::{ - AgentEvent, AgentProfile, AgentProfileBuilder, LocalSandbox, Message, Sandbox, Session, - SessionOptions, SessionShutdownReason, + AgentEvent, AgentProfile, AgentProfileBuilder, Message, Sandbox, Session, SessionOptions, + SessionShutdownReason, local_sandbox, }; #[expect( @@ -557,7 +557,11 @@ 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(LocalSandbox::new(cwd)); + let env: Arc = Arc::new( + local_sandbox(cwd) + .await + .context("failed to create the local sandbox")?, + ); // Build tool approval callback let permissions = args.permissions.unwrap_or(PermissionLevel::ReadWrite); diff --git a/lib/components/fabro-agent/src/lib.rs b/lib/components/fabro-agent/src/lib.rs index f1c9c5f17..88e5c7965 100644 --- a/lib/components/fabro-agent/src/lib.rs +++ b/lib/components/fabro-agent/src/lib.rs @@ -45,7 +45,7 @@ pub use event::Emitter; pub use fabro_mcp::config::McpServerSettings; pub use fabro_types::SteeringMessage; pub use history::History; -pub use local_sandbox::LocalSandbox; +pub use local_sandbox::{DriverSandbox, local_sandbox}; pub use loop_detection::detect_loop; pub use memory::{MemoryDocument, discover_memory}; pub use native_tool::{NativeTool, ToolVocabulary}; diff --git a/lib/components/fabro-agent/src/local_sandbox.rs b/lib/components/fabro-agent/src/local_sandbox.rs index 7303b5a9a..460ffceab 100644 --- a/lib/components/fabro-agent/src/local_sandbox.rs +++ b/lib/components/fabro-agent/src/local_sandbox.rs @@ -1,2 +1,3 @@ -// Re-export from fabro-sandbox -pub use fabro_sandbox::local::LocalSandbox; +//! The host-backed sandbox fabro calls `local`, re-exported from +//! fabro-sandbox so agent consumers construct it without a second import. +pub use fabro_sandbox::{DriverSandbox, local_sandbox}; diff --git a/lib/components/fabro-agent/src/tool_execution.rs b/lib/components/fabro-agent/src/tool_execution.rs index cc2943aaf..55ca0a57a 100644 --- a/lib/components/fabro-agent/src/tool_execution.rs +++ b/lib/components/fabro-agent/src/tool_execution.rs @@ -644,7 +644,7 @@ mod tests { ToolAccess, ToolAccessPolicy, ToolExposureMode, ToolHookCallback, ToolHookDecision, }; use crate::event::Emitter; - use crate::local_sandbox::LocalSandbox; + use crate::local_sandbox; use crate::question_tools::{ AgentQuestion, AgentQuestionAnswer, AgentQuestionAnswerStatus, AgentQuestionRuntime, AgentToolRuntime, register_question_tools, @@ -775,7 +775,11 @@ mod tests { &tool_calls, true, ®istry, - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())), + Arc::new( + local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), + ), None, &CancellationToken::new(), &SessionOptions::default(), @@ -823,7 +827,11 @@ mod tests { &tool_calls, true, ®istry, - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())), + Arc::new( + local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), + ), None, &CancellationToken::new(), &SessionOptions::default(), @@ -889,8 +897,12 @@ mod tests { } } - fn make_sandbox() -> Arc { - Arc::new(LocalSandbox::new(std::env::current_dir().unwrap())) + async fn make_sandbox() -> Arc { + Arc::new( + local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), + ) } #[tokio::test] @@ -910,7 +922,7 @@ mod tests { let result = execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, Some(&hooks), CancellationToken::new(), &config, @@ -941,7 +953,7 @@ mod tests { let result = execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, Some(&hooks), CancellationToken::new(), &config, @@ -969,7 +981,7 @@ mod tests { let result = execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, None, CancellationToken::new(), &SessionOptions::default(), @@ -1034,7 +1046,7 @@ mod tests { let result = execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, None, CancellationToken::new(), &SessionOptions::default(), @@ -1129,7 +1141,7 @@ mod tests { execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, Some(&hooks), CancellationToken::new(), &config, @@ -1165,7 +1177,7 @@ mod tests { execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, Some(&hooks), CancellationToken::new(), &config, @@ -1198,7 +1210,7 @@ mod tests { let result = execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, None, CancellationToken::new(), &config, @@ -1247,7 +1259,7 @@ mod tests { let result = execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, None, CancellationToken::new(), &config, @@ -1302,7 +1314,7 @@ mod tests { let result = execute_and_emit_one_tool( &tc, ®istry, - make_sandbox(), + make_sandbox().await, None, CancellationToken::new(), &config, diff --git a/lib/components/fabro-agent/src/tools.rs b/lib/components/fabro-agent/src/tools.rs index 24ad2c80f..487cad146 100644 --- a/lib/components/fabro-agent/src/tools.rs +++ b/lib/components/fabro-agent/src/tools.rs @@ -748,13 +748,12 @@ mod tests { use super::*; use crate::config::{NativeToolOptions, SessionOptions, ToolSecrets}; use crate::event::{Emitter, SessionBoundEmitter}; - use crate::local_sandbox::LocalSandbox; use crate::sandbox::*; use crate::test_support::MockSandbox; use crate::tool_registry::ToolContext; - use crate::truncation; use crate::types::SessionEvent; use crate::web_search::make_web_search_tool_with_api_key; + use crate::{local_sandbox, truncation}; #[test] fn core_tool_descriptions_include_actionable_guidance() { @@ -1432,9 +1431,11 @@ mod tests { #[tokio::test] async fn shell_reports_real_local_process_outcome() { let tool = make_shell_tool(); - let env: Arc = Arc::new(LocalSandbox::new( - std::env::current_dir().expect("current dir"), - )); + let env: Arc = Arc::new( + local_sandbox(std::env::current_dir().expect("current dir")) + .await + .unwrap(), + ); let emitter = Emitter::new(); let mut receiver = emitter.subscribe(); diff --git a/lib/components/fabro-agent/tests/it/compaction.rs b/lib/components/fabro-agent/tests/it/compaction.rs index 14cb88d5e..9514a0fdc 100644 --- a/lib/components/fabro-agent/tests/it/compaction.rs +++ b/lib/components/fabro-agent/tests/it/compaction.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; -use fabro_agent::{AgentProfile, LocalSandbox, OpenAiProfile, Session, SessionOptions}; +use fabro_agent::{AgentProfile, OpenAiProfile, Session, SessionOptions, local_sandbox}; use fabro_llm::client::Client; use fabro_llm::provider::ProviderAdapter; use fabro_llm::providers::OpenAiAdapter; @@ -23,7 +23,7 @@ async fn openai_twin_compaction_preserves_tool_call_pairs() { load_compaction_scenarios(&api_key).await; - let mut session = make_openai_session(tmp.path(), base_url, api_key); + let mut session = make_openai_session(tmp.path(), base_url, api_key).await; session.initialize().await.unwrap(); let result = session @@ -44,14 +44,18 @@ async fn openai_twin_compaction_preserves_tool_call_pairs() { ); } -fn make_openai_session(cwd: &Path, base_url: String, api_key: String) -> Session { +async fn make_openai_session(cwd: &Path, base_url: String, api_key: String) -> Session { let adapter: Arc = Arc::new(OpenAiAdapter::new(api_key).with_base_url(base_url)); let mut providers = HashMap::new(); providers.insert(ProviderId::OPENAI.to_string(), adapter); let client = Client::new(providers, Some(ProviderId::OPENAI.to_string()), Vec::new()); let profile: Arc = Arc::new(OpenAiProfile::new(MODEL)); - let sandbox = Arc::new(LocalSandbox::new(cwd.to_path_buf())); + let sandbox = Arc::new( + local_sandbox(cwd.to_path_buf()) + .await + .expect("local sandbox should be created"), + ); let options = SessionOptions { enable_context_compaction: true, compaction_threshold_percent: 80, diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index 3f8f9e55e..49f99a3c6 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use fabro_agent::subagent::SessionFactory; use fabro_agent::{ - AgentEvent, AgentProfile, AgentProfileBuilder, LocalSandbox, OpenAiProfile, Session, - SessionOptions, SubAgentSupervisor, ToolSecrets, WebFetchSummarizer, + AgentEvent, AgentProfile, AgentProfileBuilder, OpenAiProfile, Session, SessionOptions, + SubAgentSupervisor, ToolSecrets, WebFetchSummarizer, local_sandbox, }; use fabro_auth::EnvCredentialSource; use fabro_llm::client::Client; @@ -82,21 +82,25 @@ 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::new(LocalSandbox::new(cwd.to_path_buf())); + let env: Arc = Arc::new( + local_sandbox(cwd.to_path_buf()) + .await + .expect("local sandbox should be created"), + ); // Register subagent tools so spawn_agent / wait / send_input / close_agent are - // available + // available. Subagents share the parent's sandbox: same directory, same + // host, and a session factory is synchronous. let supervisor = SubAgentSupervisor::new(3); let factory_client = client.clone(); - let factory_cwd = cwd.to_path_buf(); + let factory_env = Arc::clone(&env); let factory_profile_builder = profile_builder; let factory: SessionFactory = Arc::new(move || { let sub_profile: Arc = Arc::from(factory_profile_builder.build()); - let sub_env = Arc::new(LocalSandbox::new(factory_cwd.clone())); Session::new( factory_client.clone(), sub_profile, - sub_env, + Arc::clone(&factory_env), SessionOptions::default(), None, ) @@ -123,7 +127,11 @@ async fn make_session_with_config( let client = make_client(&provider, twin.as_ref()).await; let profile: Arc = Arc::from(profile_builder(&provider, model, &client, ToolSecrets::default()).build()); - let env = Arc::new(LocalSandbox::new(cwd.to_path_buf())); + let env = Arc::new( + local_sandbox(cwd.to_path_buf()) + .await + .expect("local sandbox should be created"), + ); Session::new(client, profile, env, config, None) } @@ -158,7 +166,7 @@ fn make_openai_compatible_twin_client(provider: &Provider, twin: &OpenAiTwinOpti Client::new(providers, Some(provider_name), Vec::new()) } -fn make_openai_compatible_twin_session( +async fn make_openai_compatible_twin_session( provider: Provider, model: &str, cwd: &Path, @@ -182,7 +190,11 @@ fn make_openai_compatible_twin_session( ); let profile: Arc = Arc::new(OpenAiProfile::new(model).with_route(provider, catalog)); - let env = Arc::new(LocalSandbox::new(cwd.to_path_buf())); + let env = Arc::new( + local_sandbox(cwd.to_path_buf()) + .await + .expect("local sandbox should be created"), + ); Session::new(client, profile, env, config, None) } @@ -342,7 +354,8 @@ async fn openai_compatible_twin_uses_json_edit_file_tool() { tmp.path(), SessionOptions::default(), &twin, - ); + ) + .await; session.initialize().await.unwrap(); let mut rx = session.subscribe(); diff --git a/lib/components/fabro-hooks/src/bridge.rs b/lib/components/fabro-hooks/src/bridge.rs index 6aa7e60f6..2ba94b373 100644 --- a/lib/components/fabro-hooks/src/bridge.rs +++ b/lib/components/fabro-hooks/src/bridge.rs @@ -129,10 +129,12 @@ mod tests { } } - fn make_sandbox() -> Arc { - Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )) + async fn make_sandbox() -> Arc { + Arc::new( + fabro_agent::local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), + ) } fn make_bridge( @@ -162,7 +164,7 @@ mod tests { hooks: vec![make_hook(HookEvent::PreToolUse)], }; let runner = Arc::new(HookRunner::with_executor(config, executor)); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let bridge = make_bridge(runner, sandbox, HookExecutionContext::default()); bridge @@ -194,7 +196,7 @@ mod tests { hooks: vec![make_hook(HookEvent::PreToolUse)], }; let runner = Arc::new(HookRunner::with_executor(config, executor)); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let bridge = make_bridge(runner, sandbox, HookExecutionContext::default()); let decision = bridge.pre_tool_use("shell", &serde_json::json!({})).await; @@ -214,7 +216,7 @@ mod tests { hooks: vec![make_hook(HookEvent::PreToolUse)], }; let runner = Arc::new(HookRunner::with_executor(config, executor)); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let bridge = make_bridge(runner, sandbox, HookExecutionContext::default()); let decision = bridge.pre_tool_use("shell", &serde_json::json!({})).await; @@ -233,7 +235,7 @@ mod tests { hooks: vec![make_hook(HookEvent::PostToolUse)], }; let runner = Arc::new(HookRunner::with_executor(config, executor)); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let bridge = make_bridge(runner, sandbox, HookExecutionContext::default()); bridge @@ -263,7 +265,7 @@ mod tests { hooks: vec![make_hook(HookEvent::PostToolUseFailure)], }; let runner = Arc::new(HookRunner::with_executor(config, executor)); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let bridge = make_bridge(runner, sandbox, HookExecutionContext::default()); bridge @@ -294,7 +296,7 @@ mod tests { hooks: vec![make_hook(HookEvent::PreToolUse)], }; let runner = Arc::new(HookRunner::with_executor(config, executor)); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let hook_execution_context = HookExecutionContext { host_source_dir: Some(PathBuf::from("/host/source")), sandbox_work_dir: Some(PathBuf::from("/supplied/sandbox")), diff --git a/lib/components/fabro-hooks/src/executor.rs b/lib/components/fabro-hooks/src/executor.rs index 4aa87d168..6aeffbae9 100644 --- a/lib/components/fabro-hooks/src/executor.rs +++ b/lib/components/fabro-hooks/src/executor.rs @@ -740,10 +740,12 @@ mod tests { HookContext::new(HookEvent::StageStart, fixtures::RUN_1, "test-wf".into()) } - fn make_sandbox() -> Arc { - Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )) + async fn make_sandbox() -> Arc { + Arc::new( + fabro_agent::local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), + ) } fn test_llm_source() -> Arc { @@ -833,7 +835,7 @@ mod tests { let executor = HookExecutorImpl; let def = make_definition("exit 0"); let ctx = make_context(); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let source = test_llm_source(); let result = executor .execute( @@ -854,7 +856,7 @@ mod tests { let executor = HookExecutorImpl; let def = make_definition("exit 1"); let ctx = make_context(); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let source = test_llm_source(); let result = executor .execute( @@ -874,7 +876,7 @@ mod tests { let executor = HookExecutorImpl; let def = make_definition("exit 2"); let ctx = make_context(); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let source = test_llm_source(); let result = executor .execute( @@ -894,7 +896,7 @@ mod tests { let executor = HookExecutorImpl; let def = make_definition(r#"echo '{"decision": "skip", "reason": "test skip"}'"#); let ctx = make_context(); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let source = test_llm_source(); let result = executor .execute( @@ -918,7 +920,7 @@ mod tests { let def = make_definition("echo $ARC_EVENT:$ARC_RUN_ID:$ARC_WORKFLOW"); let mut ctx = make_context(); ctx.node_id = Some("plan".into()); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let source = test_llm_source(); let result = executor .execute( @@ -947,7 +949,7 @@ mod tests { sandbox: Some(false), }; let ctx = make_context(); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let source = test_llm_source(); let result = executor .execute( @@ -1433,7 +1435,7 @@ mod tests { sandbox: Some(false), }; let ctx = make_context(); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let source = test_llm_source(); let result = executor .execute( @@ -1453,7 +1455,7 @@ mod tests { #[tokio::test] async fn command_hook_missing_env_blocks() { - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let decision = HookExecutorImpl::execute_command( &make_definition("echo {{ env.MISSING_HOOK_VALUE }}"), &interp("echo {{ env.MISSING_HOOK_VALUE }}"), @@ -1502,7 +1504,7 @@ mod tests { None, Some(1), &make_context(), - make_sandbox(), + make_sandbox().await, test_llm_source().as_ref(), test_catalog(), ) diff --git a/lib/components/fabro-hooks/src/runner.rs b/lib/components/fabro-hooks/src/runner.rs index c3ec53987..1543cf591 100644 --- a/lib/components/fabro-hooks/src/runner.rs +++ b/lib/components/fabro-hooks/src/runner.rs @@ -267,10 +267,12 @@ mod tests { } } - fn make_sandbox() -> Arc { - Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )) + async fn make_sandbox() -> Arc { + Arc::new( + fabro_agent::local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), + ) } fn make_context(event: HookEvent) -> HookContext { @@ -302,7 +304,7 @@ mod tests { async fn no_hooks_returns_proceed() { let runner = HookRunner::new(HookSettings::default(), test_llm_source(), test_catalog()); let ctx = make_context(HookEvent::RunStart); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let decision = runner .run(&ctx, sandbox.clone(), HookExecutionContext::default()) .await; @@ -414,7 +416,7 @@ mod tests { }), ); let ctx = make_context(HookEvent::RunStart); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let decision = runner .run(&ctx, sandbox.clone(), HookExecutionContext::default()) .await; @@ -435,7 +437,7 @@ mod tests { }), ); let ctx = make_context(HookEvent::StageStart); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let decision = runner .run(&ctx, sandbox.clone(), HookExecutionContext::default()) .await; @@ -456,7 +458,7 @@ mod tests { }), ); let ctx = make_context(HookEvent::StageComplete); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let decision = runner .run(&ctx, sandbox.clone(), HookExecutionContext::default()) .await; @@ -475,7 +477,7 @@ mod tests { }; let runner = HookRunner::new(config, test_llm_source(), test_catalog()); let ctx = make_context(HookEvent::RunStart); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let decision = runner .run(&ctx, sandbox.clone(), HookExecutionContext::default()) .await; @@ -493,7 +495,7 @@ mod tests { }; let runner = HookRunner::new(config, test_llm_source(), test_catalog()); let ctx = make_context(HookEvent::RunStart); - let sandbox = make_sandbox(); + let sandbox = make_sandbox().await; let decision = runner .run(&ctx, sandbox.clone(), HookExecutionContext::default()) .await; diff --git a/lib/components/fabro-hooks/tests/host_command_hooks.rs b/lib/components/fabro-hooks/tests/host_command_hooks.rs index fe73371dd..72673f68f 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::{LocalSandbox, Sandbox}; +use fabro_agent::{Sandbox, local_sandbox}; use fabro_auth::{CredentialSource, test_support}; use fabro_hooks::{ HookContext, HookDecision, HookDefinition, HookEvent, HookExecutionContext, HookRunner, @@ -19,10 +19,12 @@ fn test_catalog() -> Arc { Arc::new(Catalog::from_builtin().expect("default catalog should build")) } -fn local_sandbox() -> Arc { - Arc::new(LocalSandbox::new( - std::env::current_dir().expect("test process should have a cwd"), - )) +async fn test_sandbox() -> Arc { + Arc::new( + local_sandbox(std::env::current_dir().expect("test process should have a cwd")) + .await + .expect("local sandbox should be created"), + ) } #[tokio::test] @@ -62,7 +64,7 @@ async fn host_command_hook_uses_host_workdir_not_sandbox_workdir() { ); let decision = runner - .run(&context, local_sandbox(), HookExecutionContext { + .run(&context, test_sandbox().await, HookExecutionContext { host_source_dir: Some(host_work_dir.clone()), sandbox_work_dir: Some(container_only_work_dir.to_path_buf()), }) diff --git a/lib/components/fabro-sandbox/src/driver_sandbox.rs b/lib/components/fabro-sandbox/src/driver_sandbox.rs index 949ef96fe..71d222d76 100644 --- a/lib/components/fabro-sandbox/src/driver_sandbox.rs +++ b/lib/components/fabro-sandbox/src/driver_sandbox.rs @@ -12,18 +12,46 @@ //! results relative to a caller-declared base). use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use async_trait::async_trait; use fabro_types::SandboxProviderKind; use sandbox_driver::{ - FileKind, LifecycleTimers, Sandbox as DriverHandle, SandboxState, Search as _, WaitOptions, + FileKind, LifecycleTimers, Sandbox as DriverHandle, SandboxProvider as _, SandboxSource, + SandboxSpec as DriverSpec, SandboxState, Search as _, WaitOptions, }; +use sandbox_driver_host::HostProvider; +use tokio::fs; use tokio_util::sync::CancellationToken; use crate::RetryPlan; + +/// A sandbox on the worker host at `working_directory`, the fabro `local` +/// kind, served by the driver's in-process Host provider. +/// +/// The directory is designated: the sandbox uses it in place and never +/// removes it. It is created when missing so a run can point at a fresh +/// 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 { + let working_directory: PathBuf = working_directory.into(); + fs::create_dir_all(&working_directory) + .await + .map_err(|error| crate::Error::context("Failed to create working directory", error))?; + let provider = HostProvider::new(); + let spec = DriverSpec::new(SandboxSource::HostDirectory) + .working_directory(working_directory.display().to_string()); + let handle = provider + .create(&spec, None) + .await + .map_err(|error| crate::Error::context("Failed to create local sandbox", error))?; + let sandbox = DriverSandbox::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, @@ -107,6 +135,12 @@ impl DriverSandbox { /// platform. Shared by initialize and start. async fn make_ready(&self) -> crate::Result<()> { sandbox_driver::activate(self.handle.as_ref(), &WaitOptions::default()).await?; + self.learn_platform().await + } + + /// Ask the sandbox for its platform once; `platform` and `os_version` + /// report `unknown` until this has run. + async fn learn_platform(&self) -> crate::Result<()> { if self.platform.get().is_none() { let info = self.handle.platform_info().await?; let platform = fabro_platform_name(&info.os).to_string(); @@ -355,7 +389,7 @@ impl Sandbox for DriverSandbox { Ok(()) => self.emit(SandboxEvent::Ready { provider: self.provider_name(), duration_ms, - name: Some(self.handle.id().to_string()), + name: Some(self.sandbox_info()).filter(|name| !name.is_empty()), cpu: None, memory: None, url: None, @@ -484,8 +518,15 @@ impl Sandbox for DriverSandbox { ) } + /// The provider's id for this sandbox, or empty for `local`: a local + /// sandbox is its working directory, which the run record already + /// carries, and its Host registry id does not outlive the process. fn sandbox_info(&self) -> String { - self.handle.id().to_string() + if self.kind.is_local() { + String::new() + } else { + self.handle.id().to_string() + } } async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> { @@ -788,8 +829,12 @@ mod tests { assert!(f.sandbox.os_version().starts_with(expected)); assert_eq!( f.sandbox.sandbox_info(), - f.sandbox.handle().id().to_string() + "", + "local sandboxes are identified by directory" ); + let isolated = + DriverSandbox::new(SandboxProviderKind::DOCKER, Arc::clone(f.sandbox.handle())); + assert_eq!(isolated.sandbox_info(), f.sandbox.handle().id().to_string()); f.sandbox.stop().await.unwrap(); f.sandbox.activate().await.unwrap(); @@ -831,6 +876,22 @@ mod tests { })); } + #[tokio::test] + async fn local_sandbox_designates_the_directory_and_knows_its_platform() { + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path().join("fresh"); + let sandbox = local_sandbox(&workspace).await.unwrap(); + assert!(workspace.is_dir(), "a missing working directory is created"); + assert_eq!(sandbox.kind(), &SandboxProviderKind::LOCAL); + assert_ne!(sandbox.platform(), "unknown"); + assert_eq!( + Path::new(sandbox.working_directory()), + workspace.canonicalize().unwrap() + ); + sandbox.cleanup().await.unwrap(); + assert!(workspace.is_dir()); + } + #[tokio::test] async fn preview_urls_come_from_the_access_facet() { let f = fixture().await; diff --git a/lib/components/fabro-sandbox/src/lib.rs b/lib/components/fabro-sandbox/src/lib.rs index b9566e1b1..1424d8411 100644 --- a/lib/components/fabro-sandbox/src/lib.rs +++ b/lib/components/fabro-sandbox/src/lib.rs @@ -29,8 +29,6 @@ pub mod reconnect; pub mod terminal; -pub mod local; - #[cfg(feature = "docker")] pub mod docker; @@ -43,7 +41,7 @@ pub mod test_support; pub use details::sandbox_details; #[cfg(feature = "docker")] pub use docker::{DockerSandbox, DockerSandboxOptions}; -pub use driver_sandbox::DriverSandbox; +pub use driver_sandbox::{DriverSandbox, 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::{ @@ -53,7 +51,6 @@ pub use fabro_types::{RunSandboxInstance, SandboxProviderKind}; pub use git_retry::{ CredentialContext, GitRetryReason, RetryPlan, classify_failure, retry_git_operation, }; -pub use local::LocalSandbox; #[cfg(feature = "daytona")] pub use provider::daytona::DaytonaSandboxProvider; #[cfg(feature = "docker")] diff --git a/lib/components/fabro-sandbox/src/local.rs b/lib/components/fabro-sandbox/src/local.rs deleted file mode 100644 index e0de7b588..000000000 --- a/lib/components/fabro-sandbox/src/local.rs +++ /dev/null @@ -1,2142 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::time::Instant; - -use async_trait::async_trait; -use fabro_static::EnvVars; -use fabro_types::{CommandOutputStream, CommandTermination}; -use fabro_util::time::elapsed_ms; -use tokio::io::{AsyncRead, AsyncReadExt}; -use tokio::process::{Child, Command}; -use tokio::sync::watch; -use tokio::task::spawn_blocking; -use tokio::{fs, time}; -use tokio_util::sync::CancellationToken; - -use crate::exec::is_sensitive_env_var; -use crate::sandbox::{ - self, BASH_ENV_VAR, BASH_PROBE_SCRIPT, BASH_PROBE_TIMEOUT_MS, OutputCaptureBuffer, - StdioProcessControl, optional_timeout, validate_bash_probe, write_process_stdin, -}; -use crate::{ - CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult, - ExecStreamingRequest, ExecStreamingResult, GrepOptions, Sandbox, SandboxEvent, - SandboxEventCallback, SandboxFile, StderrCollector, StdioProcess, StdioProcessHandle, - StdioProcessTermination, WalkOptions, -}; - -/// Remediation shown when the worker has no usable Bash. -/// -/// Local sandboxes resolve `bash` through `PATH` rather than requiring -/// `/bin/bash`, which is what keeps them working on NixOS. -const LOCAL_BASH_REMEDIATION: &str = "Local sandboxes require Bash on the worker PATH. Install bash, or run this workflow in a \ - Docker or Daytona sandbox."; - -/// Where [`LocalSandbox`] gets its Bash executable. -/// -/// The non-default variants exist so tests can exercise missing and non-Bash -/// interpreters without mutating the process-global `PATH`, which would make -/// parallel tests racy. -enum BashExecutable { - /// Resolve `bash` through the worker's `PATH`. - ResolveFromPath, - #[cfg(test)] - Fixed(PathBuf), - #[cfg(test)] - Unavailable, -} - -pub struct LocalSandbox { - working_directory: PathBuf, - event_callback: Option, - rg_available: std::sync::OnceLock, - bash_executable: BashExecutable, - bash_path: std::sync::OnceLock, -} - -impl LocalSandbox { - #[must_use] - pub fn new(working_directory: PathBuf) -> Self { - Self { - working_directory, - event_callback: None, - rg_available: std::sync::OnceLock::new(), - bash_executable: BashExecutable::ResolveFromPath, - bash_path: std::sync::OnceLock::new(), - } - } - - #[cfg(test)] - fn with_bash_executable(working_directory: PathBuf, bash_executable: BashExecutable) -> Self { - Self { - bash_executable, - ..Self::new(working_directory) - } - } - - /// Resolve, and then remember, the Bash executable this sandbox runs - /// commands with. - /// - /// Every command path — non-streaming, streaming, and stdio — goes through - /// here so a single sandbox can never split across two interpreters. - fn bash(&self) -> crate::Result { - if let Some(path) = self.bash_path.get() { - return Ok(path.clone()); - } - - let resolved = match &self.bash_executable { - BashExecutable::ResolveFromPath => Self::binary_path_on_path("bash") - .ok_or_else(|| crate::Error::message(LOCAL_BASH_REMEDIATION))?, - #[cfg(test)] - BashExecutable::Fixed(path) => path.clone(), - #[cfg(test)] - BashExecutable::Unavailable => { - return Err(crate::Error::message(LOCAL_BASH_REMEDIATION)); - } - }; - - Ok(self.bash_path.get_or_init(|| resolved).clone()) - } - - /// Verify the resolved executable is non-login Bash. - /// - /// Runs on fresh initialization and again on resume, so a worker that lost - /// its Bash between runs fails before the first command instead of during - /// it. - async fn probe_bash(&self) -> crate::Result<()> { - let bash = self.bash()?; - let remediation = format!( - "{} is not usable as non-login Bash. {LOCAL_BASH_REMEDIATION}", - bash.display() - ); - let result = self - .exec_command(BASH_PROBE_SCRIPT, BASH_PROBE_TIMEOUT_MS, None, None, None) - .await - .map_err(|err| { - crate::Error::context( - format!("Failed to run the local Bash check. {remediation}"), - err, - ) - })?; - validate_bash_probe(result, remediation) - } - - /// Ensure command execution has a working directory and a usable Bash. - /// - /// Fresh initialization and resumed starts share this path so they report - /// directory failures with the same context before attempting the Bash - /// probe. - async fn prepare_command_environment(&self) -> crate::Result<()> { - fs::create_dir_all(&self.working_directory) - .await - .map_err(|error| crate::Error::context("Failed to create working directory", error))?; - self.probe_bash().await - } - - pub fn set_event_callback(&mut self, cb: SandboxEventCallback) { - self.event_callback = Some(cb); - } - - fn emit(&self, event: SandboxEvent) { - event.trace(); - if let Some(ref cb) = self.event_callback { - cb(event); - } - } - - fn should_filter_env_var(key: &str) -> bool { - is_sensitive_env_var(key) - } - - fn resolve_path(&self, path: &str) -> PathBuf { - let p = Path::new(path); - if p.is_absolute() { - p.to_path_buf() - } else { - self.working_directory.join(p) - } - } - - fn binary_on_path(binary: &str) -> bool { - Self::binary_path_on_path(binary).is_some() - } - - #[expect( - clippy::disallowed_methods, - reason = "Local sandbox command execution checks PATH/PATHEXT to select optional helpers." - )] - fn binary_path_on_path(binary: &str) -> Option { - let paths = std::env::var_os(EnvVars::PATH)?; - - #[cfg(windows)] - let extensions: Vec = std::env::var_os(EnvVars::PATHEXT) - .map(|value| { - value - .to_string_lossy() - .split(';') - .map(|ext| ext.to_ascii_lowercase()) - .collect() - }) - .unwrap_or_else(|| vec![".exe".to_string(), ".cmd".to_string(), ".bat".to_string()]); - - for dir in std::env::split_paths(&paths) { - let candidate = dir.join(binary); - if candidate.is_file() { - return Some(candidate); - } - - #[cfg(windows)] - { - if candidate.extension().is_none() { - for ext in &extensions { - let with_ext = dir.join(format!("{binary}{ext}")); - if with_ext.is_file() { - return Some(with_ext); - } - } - } - } - } - - None - } -} - -#[expect( - clippy::disallowed_methods, - reason = "Local sandbox must snapshot the ambient process env before applying its fail-closed filter." -)] -fn process_env_vars() -> Vec<(String, String)> { - std::env::vars().collect() -} - -#[derive(Debug, Clone, Copy)] -enum ExplicitEnvPolicy { - FilterSensitive, - TrustCaller, -} - -fn filtered_env_vars( - env_vars: Option<&std::collections::HashMap>, - explicit_policy: ExplicitEnvPolicy, -) -> Vec<(String, String)> { - let mut filtered_env: Vec<(String, String)> = process_env_vars() - .into_iter() - .filter(|(key, _)| key != BASH_ENV_VAR && !LocalSandbox::should_filter_env_var(key)) - .collect(); - - if let Some(extra) = env_vars { - for (key, value) in extra { - if key != BASH_ENV_VAR - && (matches!(explicit_policy, ExplicitEnvPolicy::TrustCaller) - || !LocalSandbox::should_filter_env_var(key)) - { - filtered_env.push((key.clone(), value.clone())); - } - } - } - - filtered_env -} - -async fn drain_pipe(mut pipe: Option, stream: CommandOutputStream) -> String -where - R: AsyncRead + Unpin, -{ - let mut buf = String::new(); - if let Some(ref mut reader) = pipe { - if let Err(err) = reader.read_to_string(&mut buf).await { - tracing::warn!(error = %err, ?stream, "Failed to drain child output"); - } - } - buf -} - -type LocalStdioOutcome = Result; - -struct LocalStdioProcessControl { - terminate_tx: watch::Sender, - termination_rx: watch::Receiver>, -} - -impl LocalStdioProcessControl { - fn new(mut child: Child) -> Self { - let (terminate_tx, mut terminate_rx) = watch::channel(false); - let (termination_tx, termination_rx) = watch::channel(None); - - tokio::spawn(async move { - let outcome = tokio::select! { - status = child.wait() => { - status - .map(|status| StdioProcessTermination::exited(status.code())) - .map_err(|err| format!("Failed to wait for stdio process: {err}")) - } - changed = terminate_rx.changed() => { - if changed.is_err() || !*terminate_rx.borrow() { - child.wait() - .await - .map(|status| StdioProcessTermination::exited(status.code())) - .map_err(|err| format!("Failed to wait for stdio process: {err}")) - } else { - sigterm_then_kill(&mut child).await; - Ok(StdioProcessTermination::cancelled()) - } - } - }; - let _ = termination_tx.send(Some(outcome)); - }); - - Self { - terminate_tx, - termination_rx, - } - } - - async fn wait_for_termination(&self) -> crate::Result { - let mut termination_rx = self.termination_rx.clone(); - loop { - if let Some(outcome) = termination_rx.borrow().clone() { - return outcome.map_err(crate::Error::message); - } - termination_rx.changed().await.map_err(|_| { - crate::Error::message( - "stdio process supervisor stopped before reporting termination", - ) - })?; - } - } -} - -#[async_trait] -impl StdioProcessControl for LocalStdioProcessControl { - async fn terminate(&self) -> crate::Result<()> { - if self.termination_rx.borrow().is_some() { - return Ok(()); - } - - self.terminate_tx.send_replace(true); - self.wait_for_termination().await.map(|_| ()) - } - - async fn wait(&self) -> crate::Result { - self.wait_for_termination().await - } -} - -#[async_trait] -impl Sandbox for LocalSandbox { - async fn read_file_bytes(&self, path: &str) -> crate::Result> { - let full_path = self.resolve_path(path); - fs::read(&full_path).await.map_err(|e| { - crate::Error::context(format!("Failed to read {}", full_path.display()), e) - }) - } - - async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> { - let full_path = self.resolve_path(path); - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| crate::Error::context("Failed to create parent dirs", e))?; - } - fs::write(&full_path, content).await.map_err(|e| { - crate::Error::context(format!("Failed to write {}", full_path.display()), e) - }) - } - - async fn delete_file(&self, path: &str) -> crate::Result<()> { - let full_path = self.resolve_path(path); - fs::remove_file(&full_path).await.map_err(|e| { - crate::Error::context(format!("Failed to delete {}", full_path.display()), e) - }) - } - - async fn file_exists(&self, path: &str) -> crate::Result { - let full_path = self.resolve_path(path); - Ok(full_path.exists()) - } - - async fn list_directory( - &self, - path: &str, - depth: Option, - ) -> crate::Result> { - #[expect( - clippy::disallowed_methods, - reason = "sync recursive read_dir; caller wraps invocation in tokio::task::spawn_blocking" - )] - fn list_recursive( - base: &std::path::Path, - prefix: &str, - current_depth: usize, - max_depth: usize, - entries: &mut Vec, - ) -> crate::Result<()> { - let mut dir_entries: Vec = std::fs::read_dir(base) - .map_err(|e| { - crate::Error::context(format!("Failed to read directory {}", base.display()), e) - })? - .filter_map(std::result::Result::ok) - .collect(); - dir_entries.sort_by_key(std::fs::DirEntry::file_name); - - for entry in dir_entries { - let metadata = entry - .metadata() - .map_err(|e| crate::Error::context("Failed to read metadata", e))?; - let name = if prefix.is_empty() { - entry.file_name().to_string_lossy().into_owned() - } else { - format!("{prefix}/{}", entry.file_name().to_string_lossy()) - }; - let is_dir = metadata.is_dir(); - entries.push(DirEntry { - name: name.clone(), - is_dir, - size: if metadata.is_file() { - Some(metadata.len()) - } else { - None - }, - }); - if is_dir && current_depth + 1 < max_depth { - list_recursive(&entry.path(), &name, current_depth + 1, max_depth, entries)?; - } - } - Ok(()) - } - - let full_path = self.resolve_path(path); - let max_depth = depth.unwrap_or(1); - spawn_blocking(move || { - let mut entries = Vec::new(); - list_recursive(&full_path, "", 0, max_depth, &mut entries)?; - Ok(entries) - }) - .await - .map_err(|e| crate::Error::context("list_directory task failed", e))? - } - - 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 { - let start = Instant::now(); - - let filtered_env = filtered_env_vars(env_vars, ExplicitEnvPolicy::FilterSensitive); - - let effective_dir = - working_dir.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from); - - let mut cmd = Command::new(self.bash()?); - cmd.arg("-c") - .arg(command) - .current_dir(&effective_dir) - .env_clear() - .envs(filtered_env) - .kill_on_drop(true) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - #[cfg(unix)] - fabro_proc::pre_exec_setpgid(cmd.as_std_mut()); - - let mut child = cmd - .spawn() - .map_err(|e| crate::Error::context("Failed to spawn command", e))?; - - let timeout_duration = std::time::Duration::from_millis(timeout_ms); - let token = cancel_token.unwrap_or_default(); - - // Take stdout/stderr handles before entering the select! so we can - // drain them concurrently. Without this, the child can deadlock: if - // it writes more than the OS pipe buffer (~64 KB) the write() syscall - // blocks until the parent drains the pipe, but the parent is blocked - // on child.wait(). - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - let stdout_task = - tokio::spawn(async move { drain_pipe(stdout_pipe, CommandOutputStream::Stdout).await }); - let stderr_task = - tokio::spawn(async move { drain_pipe(stderr_pipe, CommandOutputStream::Stderr).await }); - - let (termination, exit_code) = tokio::select! { - status_result = child.wait() => { - let status = status_result - .map_err(|e| crate::Error::context("Failed to wait for process", e))?; - (CommandTermination::Exited, status.code()) - } - () = time::sleep(timeout_duration) => { - sigterm_then_kill(&mut child).await; - (CommandTermination::TimedOut, None) - } - () = token.cancelled() => { - sigterm_then_kill(&mut child).await; - (CommandTermination::Cancelled, None) - } - }; - - let duration_ms = elapsed_ms(start); - - let stdout_str = stdout_task.await.unwrap_or_default(); - let stderr_str = stderr_task.await.unwrap_or_default(); - - Ok(ExecResult { - stdout: stdout_str, - stderr: stderr_str, - exit_code, - termination, - duration_ms, - }) - } - - 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; - let start = Instant::now(); - - let filtered_env = filtered_env_vars(env_vars, ExplicitEnvPolicy::FilterSensitive); - - let effective_dir = - working_dir.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from); - - let mut cmd = Command::new(self.bash()?); - cmd.arg("-c") - .arg(command) - .current_dir(&effective_dir) - .env_clear() - .envs(filtered_env) - .kill_on_drop(true) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - if stdin.is_some() { - cmd.stdin(std::process::Stdio::piped()); - } - - #[cfg(unix)] - fabro_proc::pre_exec_setpgid(cmd.as_std_mut()); - - let mut child = cmd - .spawn() - .map_err(|e| crate::Error::context("Failed to spawn command", e))?; - - let timeout_future = optional_timeout(timeout_ms); - tokio::pin!(timeout_future); - let token = cancel_token.unwrap_or_default(); - - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - let stdin_task = match stdin { - Some(stdin) => { - let stdin_pipe = child.stdin.take().ok_or_else(|| { - crate::Error::message("Failed to open command standard input") - })?; - Some(tokio::spawn(async move { - write_process_stdin(stdin_pipe, &stdin).await - })) - } - None => None, - }; - let stdout_callback = output_callback.clone(); - let stderr_callback = output_callback; - let stdout_task = tokio::spawn(async move { - drain_command_pipe( - stdout_pipe, - CommandOutputStream::Stdout, - stdout_callback, - stream_output_bytes_cap, - ) - .await - }); - let stderr_task = tokio::spawn(async move { - drain_command_pipe( - stderr_pipe, - CommandOutputStream::Stderr, - stderr_callback, - stream_output_bytes_cap, - ) - .await - }); - - let (termination, exit_code) = tokio::select! { - status_result = child.wait() => { - let status = status_result - .map_err(|e| crate::Error::context("Failed to wait for process", e))?; - (CommandTermination::Exited, status.code()) - } - () = &mut timeout_future => { - sigterm_then_kill(&mut child).await; - (CommandTermination::TimedOut, None) - } - () = token.cancelled() => { - sigterm_then_kill(&mut child).await; - (CommandTermination::Cancelled, None) - } - }; - - let duration_ms = elapsed_ms(start); - if let Some(stdin_task) = stdin_task { - // The process is gone, so unwritten stdin bytes are unwanted. - // Abort instead of joining unbounded: a backgrounded grandchild - // that inherited the pipe could otherwise block the writer - // forever. - stdin_task.abort(); - match stdin_task.await { - Ok(result) => result?, - Err(join_error) if join_error.is_cancelled() => {} - Err(join_error) => { - return Err(crate::Error::context( - "stdin stream task failed", - join_error, - )); - } - } - } - let stdout_capture = stdout_task - .await - .map_err(|e| crate::Error::context("stdout stream task failed", e))??; - let stderr_capture = stderr_task - .await - .map_err(|e| crate::Error::context("stderr stream task failed", e))??; - let (stdout_bytes, stdout_capture) = stdout_capture.into_parts(); - let (stderr_bytes, stderr_capture) = stderr_capture.into_parts(); - - Ok(ExecStreamingResult { - result: ExecResult { - stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(), - stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), - exit_code, - termination, - duration_ms, - }, - streams_separated: true, - live_streaming: true, - stdout_capture, - stderr_capture, - }) - } - - async fn spawn_stdio_process( - &self, - command: &str, - working_dir: Option<&str>, - env_vars: Option<&std::collections::HashMap>, - cancel_token: Option, - ) -> crate::Result { - let filtered_env = filtered_env_vars(env_vars, ExplicitEnvPolicy::TrustCaller); - - let effective_dir = - working_dir.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from); - - let mut cmd = Command::new(self.bash()?); - cmd.arg("-c") - .arg(format!("exec {command}")) - .current_dir(&effective_dir) - .env_clear() - .envs(filtered_env) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - #[cfg(unix)] - fabro_proc::pre_exec_setpgid(cmd.as_std_mut()); - - let mut child = cmd - .spawn() - .map_err(|e| crate::Error::context("Failed to spawn stdio process", e))?; - - let stdin = child - .stdin - .take() - .ok_or_else(|| crate::Error::message("Failed to open stdio process stdin"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| crate::Error::message("Failed to open stdio process stdout"))?; - let stderr = child - .stderr - .take() - .ok_or_else(|| crate::Error::message("Failed to open stdio process stderr"))?; - - let stderr_collector = StderrCollector::new(DEFAULT_EXEC_OUTPUT_TAIL_BYTES); - stderr_collector.spawn_reader(stderr); - - let handle = StdioProcessHandle::new(LocalStdioProcessControl::new(child)); - - if let Some(token) = cancel_token { - let handle_for_cancel = handle.clone(); - tokio::spawn(async move { - token.cancelled().await; - if let Err(err) = handle_for_cancel.terminate().await { - tracing::warn!(error = %err, "Failed to terminate cancelled stdio process"); - } - }); - } - - Ok(StdioProcess { - stdin: Box::pin(stdin), - stdout: Box::pin(stdout), - stderr: stderr_collector, - handle, - }) - } - - async fn grep( - &self, - pattern: &str, - path: &str, - options: &GrepOptions, - ) -> crate::Result> { - let full_path = self.resolve_path(path); - - // Try rg (ripgrep) first, fall back to grep - let use_rg = *self.rg_available.get_or_init(|| Self::binary_on_path("rg")); - - let output = if use_rg { - let mut args = vec!["-n".to_string()]; - if options.case_insensitive { - args.push("-i".into()); - } - if let Some(ref glob_filter) = options.glob_filter { - args.push("--glob".into()); - args.push(glob_filter.clone()); - } - if let Some(max) = options.max_results { - args.push("-m".into()); - args.push(max.to_string()); - } - args.push(pattern.into()); - args.push(full_path.to_string_lossy().into_owned()); - - Command::new("rg") - .args(&args) - .output() - .await - .map_err(|e| crate::Error::context("Failed to run rg", e))? - } else { - let mut args = vec!["-rn".to_string()]; - if options.case_insensitive { - args.push("-i".into()); - } - if let Some(ref glob_filter) = options.glob_filter { - args.push("--include".into()); - args.push(glob_filter.clone()); - } - if let Some(max) = options.max_results { - args.push("-m".into()); - args.push(max.to_string()); - } - args.push(pattern.into()); - args.push(full_path.to_string_lossy().into_owned()); - - Command::new("grep") - .args(&args) - .output() - .await - .map_err(|e| crate::Error::context("Failed to run grep", e))? - }; - - let stdout = String::from_utf8_lossy(&output.stdout); - let results: Vec = stdout - .lines() - .map(String::from) - .filter(|l| !l.is_empty()) - .collect(); - Ok(results) - } - - async fn walk_files( - &self, - base: &str, - relative_start: &str, - options: &WalkOptions, - ) -> crate::Result> { - let base = self.resolve_path(base); - walk_local_files(&base, relative_start, options).await - } - - async fn download_file_to_local( - &self, - remote_path: &str, - local_path: &Path, - ) -> crate::Result<()> { - let full_path = self.resolve_path(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::copy(&full_path, local_path).await.map_err(|e| { - crate::Error::context( - format!( - "Failed to copy {} to {}", - full_path.display(), - local_path.display() - ), - e, - ) - })?; - Ok(()) - } - - async fn upload_file_from_local( - &self, - local_path: &Path, - remote_path: &str, - ) -> crate::Result<()> { - let full_path = self.resolve_path(remote_path); - if let Some(parent) = full_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| crate::Error::context("Failed to create parent dirs", e))?; - } - fs::copy(local_path, &full_path).await.map_err(|e| { - crate::Error::context( - format!( - "Failed to copy {} to {}", - local_path.display(), - full_path.display() - ), - e, - ) - })?; - Ok(()) - } - - async fn initialize(&self) -> crate::Result<()> { - self.emit(SandboxEvent::Initializing { - provider: "local".into(), - }); - let start = Instant::now(); - let result = self.prepare_command_environment().await; - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - match &result { - Ok(()) => self.emit(SandboxEvent::Ready { - provider: "local".into(), - duration_ms, - name: None, - cpu: None, - memory: None, - url: None, - }), - Err(e) => self.emit(SandboxEvent::InitializeFailed { - provider: "local".into(), - error: e.to_string(), - causes: e.causes(), - duration_ms, - }), - } - result - } - - /// Re-verify Bash before a resumed workflow can issue commands. - /// - /// A worker that was rebuilt or reprovisioned between runs can lose the - /// Bash it initialized with; there is deliberately no fallback interpreter - /// when that happens. - async fn start(&self) -> crate::Result<()> { - self.emit(SandboxEvent::StartStarted { - provider: "local".into(), - }); - let start = Instant::now(); - let result = self.prepare_command_environment().await; - match &result { - Ok(()) => self.emit(SandboxEvent::StartCompleted { - provider: "local".into(), - duration_ms: elapsed_ms(start), - }), - Err(err) => self.emit(SandboxEvent::StartFailed { - provider: "local".into(), - error: err.to_string(), - causes: err.causes(), - }), - } - result - } - - async fn activate(&self) -> crate::Result<()> { - // Local sandboxes have no provider resource that can stop or pause. - // Resume paths still call `start()` to recreate the directory and - // verify Bash. - Ok(()) - } - - async fn git_push_ref( - &self, - refspec: &str, - plan: &crate::RetryPlan, - ) -> Result { - let has_origin = match self - .exec_command("git remote get-url origin", 10_000, None, None, None) - .await - { - Ok(result) if result.is_success() => true, - Ok(_) => false, - Err(err) => { - return Err(crate::PushError { - report: crate::PushReport::default(), - error: crate::Error::context("git remote get-url origin", err), - }); - } - }; - if !has_origin { - return Ok(crate::PushReport::default()); - } - - // Local pushes use whatever credentials the host repository already - // carries; there is no managed credential state to lease. - sandbox::git_push_via_exec(self, None, refspec, plan).await - } - - async fn cleanup(&self) -> crate::Result<()> { - self.emit(SandboxEvent::CleanupStarted { - provider: "local".into(), - }); - let start = Instant::now(); - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::CleanupCompleted { - provider: "local".into(), - duration_ms, - }); - Ok(()) - } - - async fn stop(&self) -> crate::Result<()> { - self.emit(SandboxEvent::StopStarted { - provider: "local".into(), - }); - let start = Instant::now(); - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::StopCompleted { - provider: "local".into(), - duration_ms, - }); - Ok(()) - } - - async fn delete(&self) -> crate::Result<()> { - Ok(()) - } - - fn working_directory(&self) -> &str { - self.working_directory.to_str().unwrap_or(".") - } - - fn platform(&self) -> &str { - if cfg!(target_os = "macos") { - "darwin" - } else if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "windows") { - "windows" - } else { - "unknown" - } - } - - #[expect( - clippy::disallowed_methods, - reason = "This synchronous host metadata probe only runs uname once while building the sandbox platform string." - )] - fn os_version(&self) -> String { - #[cfg(unix)] - { - let output = std::process::Command::new("uname").arg("-r").output(); - match output { - Ok(out) => { - let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); - format!("{} {version}", self.platform()) - } - Err(_) => self.platform().to_string(), - } - } - #[cfg(not(unix))] - { - self.platform().to_string() - } - } -} - -/// Send SIGTERM to the process group, wait 2s for graceful shutdown, then -/// SIGKILL. -async fn sigterm_then_kill(child: &mut Child) { - #[cfg(unix)] - if let Some(pid) = child.id() { - fabro_proc::sigterm_process_group(pid); - if time::timeout(std::time::Duration::from_secs(2), child.wait()) - .await - .is_err() - { - let _ = child.kill().await; - let _ = child.wait().await; - } - } else { - let _ = child.kill().await; - let _ = child.wait().await; - } - #[cfg(not(unix))] - { - let _ = child.kill().await; - let _ = child.wait().await; - } -} - -async fn drain_command_pipe( - mut reader: Option, - stream: CommandOutputStream, - output_callback: Option, - stream_output_bytes_cap: Option, -) -> crate::Result -where - R: AsyncRead + Unpin, -{ - let mut output = OutputCaptureBuffer::new(stream_output_bytes_cap); - let Some(reader) = reader.as_mut() else { - return Ok(output); - }; - - let mut buf = [0_u8; 8192]; - loop { - let read = reader - .read(&mut buf) - .await - .map_err(|e| crate::Error::context("Failed to read command output", e))?; - if read == 0 { - return Ok(output); - } - output.push(&buf[..read]); - if let Some(output_callback) = output_callback.as_ref() { - output_callback(stream, buf[..read].to_vec()).await?; - } - } -} - -async fn walk_local_files( - base: &Path, - relative_start: &str, - options: &WalkOptions, -) -> crate::Result> { - if options.excludes_relative_path(relative_start) { - return Ok(Vec::new()); - } - - let Some((root, root_metadata)) = resolve_local_walk_root(base, relative_start).await? else { - return Ok(Vec::new()); - }; - let mut files = Vec::new(); - let mut stack = vec![(root, Some(root_metadata))]; - - while let Some((path, known_metadata)) = stack.pop() { - let metadata = match known_metadata { - Some(metadata) => metadata, - None => match fs::symlink_metadata(&path).await { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, - Err(err) => { - return Err(crate::Error::context( - format!("Failed to stat {}", path.display()), - err, - )); - } - }, - }; - - let file_type = metadata.file_type(); - if file_type.is_file() { - let relative_path = path.strip_prefix(base).map_err(|error| { - crate::Error::context( - format!( - "Failed to make {} relative to {}", - path.display(), - base.display() - ), - error, - ) - })?; - files.push(SandboxFile { - path: path.to_string_lossy().into_owned(), - relative_path: relative_path - .to_string_lossy() - .replace(std::path::MAIN_SEPARATOR, "/"), - size: metadata.len(), - }); - } else if file_type.is_dir() { - if path != base - && path - .file_name() - .and_then(std::ffi::OsStr::to_str) - .is_some_and(|file_name| options.excludes_name(file_name)) - { - continue; - } - let mut entries = match fs::read_dir(&path).await { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, - Err(err) => { - return Err(crate::Error::context( - format!("Failed to read directory {}", path.display()), - err, - )); - } - }; - while let Some(entry) = entries.next_entry().await.map_err(|err| { - crate::Error::context( - format!("Failed to read directory entry in {}", path.display()), - err, - ) - })? { - stack.push((entry.path(), None)); - } - } - } - - Ok(files) -} - -async fn resolve_local_walk_root( - base: &Path, - relative_start: &str, -) -> crate::Result> { - if relative_start.is_empty() { - return match fs::metadata(base).await { - Ok(metadata) => Ok(Some((base.to_path_buf(), metadata))), - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory - ) => - { - Ok(None) - } - Err(error) => Err(crate::Error::context( - format!("Failed to stat traversal base {}", base.display()), - error, - )), - }; - } - - let mut root = base.to_path_buf(); - let mut metadata = None; - for segment in relative_start.split('/') { - root.push(segment); - let component_metadata = match fs::symlink_metadata(&root).await { - Ok(metadata) => metadata, - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory - ) => - { - return Ok(None); - } - Err(error) => { - return Err(crate::Error::context( - format!("Failed to stat traversal root component {}", root.display()), - error, - )); - } - }; - if component_metadata.file_type().is_symlink() { - return Ok(None); - } - metadata = Some(component_metadata); - } - - Ok(metadata.map(|metadata| (root, metadata))) -} - -#[cfg(test)] -#[expect( - clippy::disallowed_methods, - reason = "sandbox tests stage fixtures with sync std::fs writes/reads" -)] -mod tests { - use std::collections::HashMap; - use std::io; - use std::path::PathBuf; - use std::pin::Pin; - use std::sync::{Arc, Mutex}; - use std::task::{Context as TaskContext, Poll}; - - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, ReadBuf}; - - use super::*; - - fn temp_dir() -> PathBuf { - let dir = std::env::temp_dir().join(format!("local_env_test_{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[tokio::test] - async fn drain_pipe_returns_empty_buffer_after_read_failure() { - struct FailingReader; - - impl AsyncRead for FailingReader { - fn poll_read( - self: Pin<&mut Self>, - _cx: &mut TaskContext<'_>, - _buf: &mut ReadBuf<'_>, - ) -> Poll> { - Poll::Ready(Err(io::Error::other("simulated read failure"))) - } - } - - let output = drain_pipe(Some(FailingReader), CommandOutputStream::Stdout).await; - - assert!(output.is_empty()); - } - - #[tokio::test] - async fn read_file_with_line_numbers() { - let dir = temp_dir(); - std::fs::write(dir.join("test.txt"), "hello\nworld\nfoo").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let result = env.read_file("test.txt", None, None).await.unwrap(); - - assert_eq!(result, "1 | hello\n2 | world\n3 | foo\n"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn read_file_line_number_padding() { - let dir = temp_dir(); - let content = - (1..=12) - .map(|i| format!("line {i}\n")) - .fold(String::new(), |mut acc, line| { - acc.push_str(&line); - acc - }); - std::fs::write(dir.join("padded.txt"), content.trim_end()).unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let result = env.read_file("padded.txt", None, None).await.unwrap(); - - assert!(result.starts_with(" 1 | line 1\n")); - assert!(result.contains("12 | line 12\n")); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn read_file_not_found() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env.read_file("nonexistent.txt", None, None).await; - assert!(result.is_err()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn write_file_creates_parent_dirs() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - env.write_file("sub/dir/test.txt", "content").await.unwrap(); - - let written = std::fs::read_to_string(dir.join("sub/dir/test.txt")).unwrap(); - assert_eq!(written, "content"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn file_exists_true() { - let dir = temp_dir(); - std::fs::write(dir.join("exists.txt"), "data").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - assert!(env.file_exists("exists.txt").await.unwrap()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn file_exists_false() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - assert!(!env.file_exists("nope.txt").await.unwrap()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn list_directory_sorted() { - let dir = temp_dir(); - std::fs::write(dir.join("b.txt"), "b").unwrap(); - std::fs::write(dir.join("a.txt"), "a").unwrap(); - std::fs::create_dir(dir.join("c_dir")).unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let entries = env.list_directory(".", None).await.unwrap(); - - assert_eq!(entries.len(), 3); - assert_eq!(entries[0].name, "a.txt"); - assert!(!entries[0].is_dir); - assert!(entries[0].size.is_some()); - assert_eq!(entries[1].name, "b.txt"); - assert_eq!(entries[2].name, "c_dir"); - assert!(entries[2].is_dir); - assert!(entries[2].size.is_none()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_echo() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env - .exec_command("echo hello", 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.stdout.trim(), "hello"); - assert_eq!(result.exit_code, Some(0)); - assert_eq!(result.termination, CommandTermination::Exited); - assert!(result.duration_ms < 5000); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn stdio_process_round_trips_lines() { - let dir = temp_dir(); - let sandbox = LocalSandbox::new(dir.clone()); - let process = sandbox - .spawn_stdio_process( - "python3 -u -c 'import sys; [print(line.strip()[::-1], flush=True) for line in sys.stdin]'", - None, - None, - None, - ) - .await - .unwrap(); - - let mut stdin = process.stdin; - let mut stdout = BufReader::new(process.stdout); - - stdin.write_all(b"abc\n").await.unwrap(); - stdin.flush().await.unwrap(); - - let mut line = String::new(); - stdout.read_line(&mut line).await.unwrap(); - assert_eq!(line.trim_end(), "cba"); - - process.handle.terminate().await.unwrap(); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn stdio_process_forwards_explicit_provider_credentials() { - let dir = temp_dir(); - let sandbox = LocalSandbox::new(dir.clone()); - let env = HashMap::from([("OPENAI_API_KEY".to_string(), "test-key".to_string())]); - let process = sandbox - .spawn_stdio_process( - "python3 -u -c 'import os; print(os.environ.get(\"OPENAI_API_KEY\", \"missing\"), flush=True)'", - None, - Some(&env), - None, - ) - .await - .unwrap(); - - let mut stdout = BufReader::new(process.stdout); - let mut line = String::new(); - stdout.read_line(&mut line).await.unwrap(); - assert_eq!(line.trim_end(), "test-key"); - - process.handle.wait().await.unwrap(); - std::fs::remove_dir_all(&dir).unwrap(); - } - - /// Bash-only syntax: `[[ ]]`, arrays, and `${arr[@]}` all fail under `sh`. - const BASH_ONLY_COMMAND: &str = "arr=(one two three); [[ ${#arr[@]} -eq 3 ]] && echo \ - ${arr[1]}"; - - /// Prints `login` or `nonlogin` for the shell evaluating it. - const LOGIN_SHELL_REPORT: &str = "shopt -q login_shell && echo login || echo nonlogin"; - - #[tokio::test] - async fn exec_command_runs_bash_only_syntax() { - let dir = temp_dir(); - let sandbox = LocalSandbox::new(dir.clone()); - - let result = sandbox - .exec_command(BASH_ONLY_COMMAND, 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.exit_code, Some(0), "stderr: {}", result.stderr); - assert_eq!(result.stdout.trim(), "two"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_streaming_runs_bash_only_syntax() { - let dir = temp_dir(); - let sandbox = LocalSandbox::new(dir.clone()); - - let result = sandbox - .exec_command_streaming(ExecStreamingRequest { - timeout_ms: Some(5000), - output_callback: Some(Arc::new(|_, _| Box::pin(async { Ok(()) }))), - ..ExecStreamingRequest::new(BASH_ONLY_COMMAND) - }) - .await - .unwrap(); - - assert_eq!( - result.result.exit_code, - Some(0), - "stderr: {}", - result.result.stderr - ); - assert_eq!(result.result.stdout.trim(), "two"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_streaming_drains_full_output_after_capture_cap() { - let dir = temp_dir(); - let sandbox = LocalSandbox::new(dir.clone()); - let callback_bytes = Arc::new(Mutex::new(Vec::new())); - let callback_bytes_for_stream = Arc::clone(&callback_bytes); - - let result = sandbox - .exec_command_streaming(ExecStreamingRequest { - timeout_ms: Some(5000), - output_callback: Some(Arc::new(move |stream, bytes| { - let callback_bytes = Arc::clone(&callback_bytes_for_stream); - Box::pin(async move { - if stream == CommandOutputStream::Stdout { - callback_bytes.lock().unwrap().extend(bytes); - } - Ok(()) - }) - })), - stream_output_bytes_cap: Some(8), - ..ExecStreamingRequest::new("printf 'abcdefghijklmnopqrst'") - }) - .await - .unwrap(); - - assert_eq!(result.result.stdout, "abcdqrst"); - assert_eq!(result.stdout_capture.observed_bytes, 20); - assert_eq!(result.stdout_capture.retained_bytes, 8); - assert_eq!(result.stdout_capture.omitted_bytes, 12); - assert_eq!(&*callback_bytes.lock().unwrap(), b"abcdefghijklmnopqrst"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_streaming_writes_exact_stdin_and_closes_it() { - let dir = temp_dir(); - let sandbox = LocalSandbox::new(dir.clone()); - let stdin = b"first line\n$(touch must-not-run)\nlast line".to_vec(); - - let result = sandbox - .exec_command_streaming(ExecStreamingRequest { - timeout_ms: Some(5000), - stdin: Some(stdin.clone()), - ..ExecStreamingRequest::new("cat") - }) - .await - .unwrap(); - - assert_eq!(result.result.exit_code, Some(0)); - assert_eq!(result.result.stdout.as_bytes(), stdin); - assert!(!dir.join("must-not-run").exists()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn stdio_process_evaluates_bash_before_exec() { - let dir = temp_dir(); - let sandbox = LocalSandbox::new(dir.clone()); - - // The sandbox prepends `exec`; `exec` with only a redirection applies - // it without replacing the shell, so the Bash-only guard runs in the - // wrapping shell before it execs the requested process. - let process = sandbox - .spawn_stdio_process( - "3>&1; [[ -d / ]] || exit 9; exec python3 -u -c 'import sys; \ - [print(line.strip()[::-1], flush=True) for line in sys.stdin]'", - None, - None, - None, - ) - .await - .unwrap(); - - let mut stdin = process.stdin; - let mut stdout = BufReader::new(process.stdout); - stdin.write_all(b"abc\n").await.unwrap(); - stdin.flush().await.unwrap(); - - let mut line = String::new(); - stdout.read_line(&mut line).await.unwrap(); - assert_eq!(line.trim_end(), "cba"); - - process.handle.terminate().await.unwrap(); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn every_command_path_uses_clean_non_login_bash() { - let dir = temp_dir(); - let sandbox = LocalSandbox::new(dir.clone()); - let bash_env_path = dir.join("bash-env"); - std::fs::write(&bash_env_path, "printf 'startup-source-loaded\\n'\n").unwrap(); - let env_vars = HashMap::from([( - BASH_ENV_VAR.to_string(), - bash_env_path.to_string_lossy().into_owned(), - )]); - - let non_streaming = sandbox - .exec_command(LOGIN_SHELL_REPORT, 5000, None, Some(&env_vars), None) - .await - .unwrap(); - assert_eq!(non_streaming.stdout.trim(), "nonlogin"); - - let streaming = sandbox - .exec_command_streaming(ExecStreamingRequest { - timeout_ms: Some(5000), - env_vars: Some(&env_vars), - output_callback: Some(Arc::new(|_, _| Box::pin(async { Ok(()) }))), - ..ExecStreamingRequest::new(LOGIN_SHELL_REPORT) - }) - .await - .unwrap(); - assert_eq!(streaming.result.stdout.trim(), "nonlogin"); - - // `spawn_stdio_process` prepends `exec`, and `exec` with only a - // redirection applies it without replacing the shell — so the rest of - // this script reports on the wrapping shell itself. - let process = sandbox - .spawn_stdio_process( - &format!("3>&1; {LOGIN_SHELL_REPORT}; exec cat"), - None, - Some(&env_vars), - None, - ) - .await - .unwrap(); - let mut stdout = BufReader::new(process.stdout); - let mut line = String::new(); - stdout.read_line(&mut line).await.unwrap(); - assert_eq!(line.trim_end(), "nonlogin"); - process.handle.terminate().await.unwrap(); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn initialize_fails_without_bash_instead_of_reporting_ready() { - use std::sync::Mutex; - - use crate::SandboxEvent; - - let dir = temp_dir(); - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let events_clone = Arc::clone(&events); - - let mut sandbox = - LocalSandbox::with_bash_executable(dir.clone(), BashExecutable::Unavailable); - sandbox.set_event_callback(Arc::new(move |e| { - events_clone.lock().unwrap().push(e); - })); - - let err = sandbox - .initialize() - .await - .expect_err("initialize should fail without Bash"); - assert!( - err.display_with_causes() - .contains("Bash on the worker PATH"), - "missing Bash should be actionable: {}", - err.display_with_causes() - ); - - let captured = events.lock().unwrap(); - assert!( - matches!(&captured[0], SandboxEvent::Initializing { provider } if provider == "local") - ); - assert!(matches!( - &captured[1], - SandboxEvent::InitializeFailed { provider, .. } if provider == "local" - )); - assert!( - !captured - .iter() - .any(|event| matches!(event, SandboxEvent::Ready { .. })), - "a sandbox without Bash must never report ready: {captured:?}" - ); - drop(captured); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn initialize_fails_when_the_resolved_executable_is_not_bash() { - let dir = temp_dir(); - let sandbox = LocalSandbox::with_bash_executable( - dir.clone(), - BashExecutable::Fixed(PathBuf::from("/usr/bin/true")), - ); - - let err = sandbox - .initialize() - .await - .expect_err("a non-Bash interpreter should fail the probe"); - - assert!( - err.display_with_causes().contains("non-login Bash"), - "non-Bash interpreter should be named as the problem: {}", - err.display_with_causes() - ); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn start_recreates_missing_working_directory_before_bash_probe() { - let dir = temp_dir(); - std::fs::remove_dir_all(&dir).unwrap(); - let sandbox = LocalSandbox::new(dir.clone()); - - sandbox - .start() - .await - .expect("resume should recreate the working directory before probing Bash"); - - assert!(dir.is_dir()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn start_repeats_the_bash_probe_on_resume() { - use std::sync::Mutex; - - let dir = temp_dir(); - let successful_events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let successful_events_clone = Arc::clone(&successful_events); - let mut available = LocalSandbox::new(dir.clone()); - available.set_event_callback(Arc::new(move |event| { - successful_events_clone.lock().unwrap().push(event); - })); - - available - .start() - .await - .expect("resume should succeed when Bash is present"); - - let failed_events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let failed_events_clone = Arc::clone(&failed_events); - let mut unavailable = - LocalSandbox::with_bash_executable(dir.clone(), BashExecutable::Unavailable); - unavailable.set_event_callback(Arc::new(move |event| { - failed_events_clone.lock().unwrap().push(event); - })); - unavailable - .start() - .await - .expect_err("resume should fail when Bash disappeared between runs"); - - let successful_events = successful_events.lock().unwrap(); - assert!(matches!( - &successful_events[..], - [ - SandboxEvent::StartStarted { provider: started }, - SandboxEvent::StartCompleted { - provider: completed, - .. - } - ] if started == "local" && completed == "local" - )); - let failed_events = failed_events.lock().unwrap(); - assert!(matches!( - &failed_events[..], - [ - SandboxEvent::StartStarted { provider: started }, - SandboxEvent::StartFailed { - provider: failed, - .. - } - ] if started == "local" && failed == "local" - )); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_exit_code() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env - .exec_command("exit 42", 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.exit_code, Some(42)); - assert_eq!(result.termination, CommandTermination::Exited); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_timeout() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env - .exec_command("sleep 10", 200, None, None, None) - .await - .unwrap(); - - assert_eq!(result.termination, CommandTermination::TimedOut); - assert_eq!(result.exit_code, None); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_cancelled() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let token = CancellationToken::new(); - token.cancel(); - let result = env - .exec_command("sleep 10", 5000, None, None, Some(token)) - .await - .unwrap(); - - assert_eq!(result.termination, CommandTermination::Cancelled); - assert_eq!(result.exit_code, None); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_stderr() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let result = env - .exec_command("echo err >&2", 5000, None, None, None) - .await - .unwrap(); - - assert_eq!(result.stderr.trim(), "err"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn exec_command_filters_sensitive_explicit_env_vars() { - let dir = temp_dir(); - let env = LocalSandbox::new(dir.clone()); - let extra = HashMap::from([ - ("FABRO_WORKER_TOKEN".to_string(), "leaked".to_string()), - ("MY_VAR".to_string(), "ok".to_string()), - ]); - let result = env - .exec_command("env", 5000, None, Some(&extra), None) - .await - .unwrap(); - - assert!(!result.stdout.contains("FABRO_WORKER_TOKEN=leaked")); - assert!(result.stdout.contains("MY_VAR=ok")); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn env_var_filtering() { - assert!(LocalSandbox::should_filter_env_var("OPENAI_API_KEY")); - assert!(LocalSandbox::should_filter_env_var("ANTHROPIC_API_KEY")); - assert!(LocalSandbox::should_filter_env_var("DB_PASSWORD")); - assert!(LocalSandbox::should_filter_env_var("AWS_SECRET")); - assert!(LocalSandbox::should_filter_env_var("AUTH_TOKEN")); - assert!(LocalSandbox::should_filter_env_var("MY_CREDENTIAL")); - assert!(LocalSandbox::should_filter_env_var("FABRO_WORKER_TOKEN")); - assert!(LocalSandbox::should_filter_env_var("SESSION_SECRET")); - // Case insensitive - assert!(LocalSandbox::should_filter_env_var("my_api_key")); - assert!(LocalSandbox::should_filter_env_var("Some_Secret")); - // Should not filter - assert!(!LocalSandbox::should_filter_env_var("PATH")); - assert!(!LocalSandbox::should_filter_env_var("HOME")); - assert!(!LocalSandbox::should_filter_env_var("EDITOR")); - assert!(!LocalSandbox::should_filter_env_var("SECRET_PATH")); - } - - #[test] - fn platform_is_known() { - let env = LocalSandbox::new(PathBuf::from("/tmp")); - let platform = env.platform(); - assert!( - platform == "darwin" || platform == "linux" || platform == "windows", - "Unknown platform: {platform}" - ); - } - - #[test] - fn os_version_contains_platform() { - let env = LocalSandbox::new(PathBuf::from("/tmp")); - let version = env.os_version(); - assert!( - version.contains(env.platform()), - "OS version should contain platform: {version}" - ); - } - - #[test] - fn working_directory_accessor() { - let env = LocalSandbox::new(PathBuf::from("/tmp/test_dir")); - assert_eq!(env.working_directory(), "/tmp/test_dir"); - } - - #[tokio::test] - async fn initialize_creates_directory() { - let dir = std::env::temp_dir().join(format!("init_test_{}", uuid::Uuid::new_v4())); - let env = LocalSandbox::new(dir.clone()); - env.initialize().await.unwrap(); - assert!(dir.exists()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn initialize_emits_events() { - use std::sync::{Arc, Mutex}; - - use crate::SandboxEvent; - - let dir = std::env::temp_dir().join(format!("init_event_test_{}", uuid::Uuid::new_v4())); - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let events_clone = Arc::clone(&events); - - let mut env = LocalSandbox::new(dir.clone()); - env.set_event_callback(Arc::new(move |e| { - events_clone.lock().unwrap().push(e); - })); - - env.initialize().await.unwrap(); - - let captured = events.lock().unwrap(); - assert_eq!(captured.len(), 2); - assert!( - matches!(&captured[0], SandboxEvent::Initializing { provider } if provider == "local") - ); - assert!( - matches!(&captured[1], SandboxEvent::Ready { provider, .. } if provider == "local") - ); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn cleanup_emits_events() { - use std::sync::{Arc, Mutex}; - - use crate::SandboxEvent; - - let dir = temp_dir(); - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let events_clone = Arc::clone(&events); - - let mut env = LocalSandbox::new(dir.clone()); - env.set_event_callback(Arc::new(move |e| { - events_clone.lock().unwrap().push(e); - })); - - env.cleanup().await.unwrap(); - - let captured = events.lock().unwrap(); - assert_eq!(captured.len(), 2); - assert!( - matches!(&captured[0], SandboxEvent::CleanupStarted { provider } if provider == "local") - ); - assert!( - matches!(&captured[1], SandboxEvent::CleanupCompleted { provider, .. } if provider == "local") - ); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn stop_emits_events() { - use std::sync::{Arc, Mutex}; - - use crate::SandboxEvent; - - let dir = temp_dir(); - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let events_clone = Arc::clone(&events); - - let mut env = LocalSandbox::new(dir.clone()); - env.set_event_callback(Arc::new(move |e| { - events_clone.lock().unwrap().push(e); - })); - - env.stop().await.unwrap(); - - let captured = events.lock().unwrap(); - assert_eq!(captured.len(), 2); - assert!( - matches!(&captured[0], SandboxEvent::StopStarted { provider } if provider == "local") - ); - assert!( - matches!(&captured[1], SandboxEvent::StopCompleted { provider, .. } if provider == "local") - ); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn grep_finds_matches() { - let dir = temp_dir(); - std::fs::write( - dir.join("test.rs"), - "fn main() {\n println!(\"hello\");\n}\n", - ) - .unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env - .grep("println", "test.rs", &GrepOptions::default()) - .await - .unwrap(); - - assert_eq!(results.len(), 1); - assert!(results[0].contains("println")); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn grep_case_insensitive() { - let dir = temp_dir(); - std::fs::write(dir.join("test.txt"), "Hello\nhello\nHELLO\n").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env - .grep("hello", "test.txt", &GrepOptions { - case_insensitive: true, - ..Default::default() - }) - .await - .unwrap(); - - assert_eq!(results.len(), 3); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn grep_max_results() { - let dir = temp_dir(); - std::fs::write(dir.join("test.txt"), "match1\nmatch2\nmatch3\nmatch4\n").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env - .grep("match", "test.txt", &GrepOptions { - max_results: Some(2), - ..Default::default() - }) - .await - .unwrap(); - - assert_eq!(results.len(), 2); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn glob_finds_files() { - let dir = temp_dir(); - std::fs::write(dir.join("a.rs"), "").unwrap(); - std::fs::write(dir.join("b.rs"), "").unwrap(); - std::fs::write(dir.join("c.txt"), "").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env.glob("*.rs", None).await.unwrap(); - - assert_eq!(results.len(), 2); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn glob_resolves_relative_search_path_against_working_directory() { - let dir = temp_dir(); - std::fs::create_dir_all(dir.join("src")).unwrap(); - std::fs::write(dir.join("src/lib.rs"), "").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env.glob("*.rs", Some("src")).await.unwrap(); - - assert_eq!(results, vec![ - dir.join("src/lib.rs").to_string_lossy().into_owned() - ]); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn glob_recursive_pattern_finds_files_at_any_depth() { - let dir = temp_dir(); - std::fs::create_dir_all(dir.join("src/nested")).unwrap(); - std::fs::write(dir.join("a.rs"), "").unwrap(); - std::fs::write(dir.join("src/lib.rs"), "").unwrap(); - std::fs::write(dir.join("src/nested/main.rs"), "").unwrap(); - std::fs::write(dir.join("src/nested/readme.md"), "").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env.glob("**/*.rs", None).await.unwrap(); - - assert_eq!(results, vec![ - dir.join("a.rs").to_string_lossy().into_owned(), - dir.join("src/lib.rs").to_string_lossy().into_owned(), - dir.join("src/nested/main.rs") - .to_string_lossy() - .into_owned(), - ]); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn glob_finds_skill_files_one_level_below_search_dir() { - let dir = temp_dir(); - let skills = dir.join(".fabro/skills"); - std::fs::create_dir_all(skills.join("patch")).unwrap(); - std::fs::create_dir_all(skills.join("nested/deeper")).unwrap(); - std::fs::write(skills.join("SKILL.md"), "").unwrap(); - std::fs::write(skills.join("patch/SKILL.md"), "").unwrap(); - std::fs::write(skills.join("nested/deeper/SKILL.md"), "").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let skills_path = skills.to_string_lossy().into_owned(); - let results = env.glob("*/SKILL.md", Some(&skills_path)).await.unwrap(); - - assert_eq!(results, vec![ - skills.join("patch/SKILL.md").to_string_lossy().into_owned() - ]); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[cfg(unix)] - #[tokio::test] - async fn glob_does_not_recurse_through_symlinked_directories() { - let dir = temp_dir(); - let target = dir.join("target"); - std::fs::create_dir_all(&target).unwrap(); - std::fs::write(target.join("lib.rs"), "").unwrap(); - std::os::unix::fs::symlink(&target, dir.join("linked")).unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let results = env.glob("linked/**/*.rs", None).await.unwrap(); - - assert!(results.is_empty()); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[cfg(unix)] - #[tokio::test] - async fn glob_follows_the_declared_symlinked_search_root_only() { - let parent = temp_dir(); - let workspace = parent.join("workspace"); - let outside = parent.join("outside"); - let search_root = parent.join("workspace-link"); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::create_dir_all(&outside).unwrap(); - std::fs::write(workspace.join("README.md"), "").unwrap(); - std::fs::write(outside.join("outside.md"), "").unwrap(); - std::os::unix::fs::symlink(&outside, workspace.join("linked")).unwrap(); - std::os::unix::fs::symlink(&workspace, &search_root).unwrap(); - - let env = LocalSandbox::new(search_root.clone()); - let results = env.glob("**/*.md", None).await.unwrap(); - - assert_eq!(results, vec![ - search_root.join("README.md").to_string_lossy().into_owned() - ]); - std::fs::remove_dir_all(&parent).unwrap(); - } - - #[tokio::test] - async fn walk_files_returns_relative_paths_and_prunes_directories() { - let dir = temp_dir(); - std::fs::create_dir_all(dir.join(".ai/reports")).unwrap(); - std::fs::create_dir_all(dir.join(".ai/target")).unwrap(); - std::fs::write(dir.join(".ai/reports/result.md"), "report").unwrap(); - std::fs::write(dir.join(".ai/reports/empty.md"), "").unwrap(); - std::fs::write(dir.join(".ai/target/ignored.md"), "ignored").unwrap(); - - let sandbox = LocalSandbox::new(dir.clone()); - let files = sandbox - .walk_files(sandbox.working_directory(), ".ai", &WalkOptions { - excluded_directory_names: vec!["target".to_string()], - }) - .await - .unwrap(); - - let mut file_metadata = files - .iter() - .map(|file| (file.relative_path.as_str(), file.size)) - .collect::>(); - file_metadata.sort_unstable(); - assert_eq!(file_metadata, vec![ - (".ai/reports/empty.md", 0), - (".ai/reports/result.md", 6), - ]); - let root = dir.to_string_lossy(); - assert!( - files - .iter() - .all(|file| file.path.starts_with(root.as_ref())) - ); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn local_sandbox_download_file_to_local() { - let dir = temp_dir(); - std::fs::write(dir.join("source.txt"), "hello download").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let dest = dir.join("output/downloaded.txt"); - env.download_file_to_local("source.txt", &dest) - .await - .unwrap(); - - assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello download"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn local_sandbox_download_file_to_local_creates_parent_dirs() { - let dir = temp_dir(); - std::fs::write(dir.join("data.bin"), "binary-ish").unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let dest = dir.join("deep/nested/dir/data.bin"); - env.download_file_to_local("data.bin", &dest).await.unwrap(); - - assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary-ish"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn local_sandbox_download_file_to_local_binary() { - let dir = temp_dir(); - let binary_data: Vec = (0u8..=255).collect(); - std::fs::write(dir.join("binary.bin"), &binary_data).unwrap(); - - let env = LocalSandbox::new(dir.clone()); - let dest = dir.join("out/binary.bin"); - env.download_file_to_local("binary.bin", &dest) - .await - .unwrap(); - - assert_eq!(std::fs::read(&dest).unwrap(), binary_data); - std::fs::remove_dir_all(&dir).unwrap(); - } -} diff --git a/lib/components/fabro-sandbox/src/reconnect.rs b/lib/components/fabro-sandbox/src/reconnect.rs index 6aaf2ee73..89fd96cf2 100644 --- a/lib/components/fabro-sandbox/src/reconnect.rs +++ b/lib/components/fabro-sandbox/src/reconnect.rs @@ -12,7 +12,7 @@ use crate::SandboxEventCallback; use crate::daytona::DaytonaSandbox; #[cfg(feature = "docker")] use crate::docker::DockerSandbox; -use crate::local::LocalSandbox; +use crate::driver_sandbox::local_sandbox; /// Reconnect to a sandbox from a saved record. /// @@ -54,8 +54,13 @@ pub async fn reconnect_for_run_with_callback( ) -> Result> { let runtime = &record.runtime; match record.provider.bundled() { + // A local sandbox is its working directory: rebuilding the handle + // over that directory is the reconnect. The per-process Host + // registry holds no state worth attaching to. Some(BundledProvider::Local) => { - let mut sandbox = LocalSandbox::new(PathBuf::from(&runtime.working_directory)); + let mut sandbox = local_sandbox(PathBuf::from(&runtime.working_directory)) + .await + .context("Failed to reconnect local sandbox")?; if let Some(callback) = event_callback { sandbox.set_event_callback(callback); } diff --git a/lib/components/fabro-sandbox/src/sandbox_spec.rs b/lib/components/fabro-sandbox/src/sandbox_spec.rs index 040e9352a..162dc4f01 100644 --- a/lib/components/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/components/fabro-sandbox/src/sandbox_spec.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use std::sync::Arc; -#[cfg(feature = "docker")] use anyhow::Context as _; #[cfg(any(feature = "docker", feature = "daytona"))] use fabro_github::GitHubCredentials; @@ -17,7 +16,7 @@ use crate::clone_source; use crate::daytona::{self, DaytonaConfig, DaytonaSandbox}; #[cfg(feature = "docker")] use crate::docker::{self, DockerSandbox, DockerSandboxOptions}; -use crate::local::LocalSandbox; +use crate::driver_sandbox::local_sandbox; use crate::{Sandbox, SandboxEventCallback}; /// Options for sandbox initialization and construction. @@ -185,17 +184,15 @@ impl SandboxSpec { } } - #[allow( - clippy::unused_async, - reason = "Only Daytona construction awaits; local and Docker builds share the async API." - )] pub async fn build( &self, event_callback: Option, ) -> Result, anyhow::Error> { match self { Self::Local { working_directory } => { - let mut sandbox = LocalSandbox::new(working_directory.clone()); + let mut sandbox = local_sandbox(working_directory.clone()) + .await + .context("Failed to create local sandbox")?; if let Some(callback) = event_callback { sandbox.set_event_callback(callback); } diff --git a/lib/components/fabro-sandbox/tests/driver_bench.rs b/lib/components/fabro-sandbox/tests/driver_bench.rs index a3e11718c..1536b1423 100644 --- a/lib/components/fabro-sandbox/tests/driver_bench.rs +++ b/lib/components/fabro-sandbox/tests/driver_bench.rs @@ -7,7 +7,7 @@ //! - Docker file reads and content search: fabro's `DockerSandbox` (archive API //! reads, `docker exec` grep) against the driver `DockerProvider` (archive //! API reads, exec-derived search) in-process. -//! - Host tool calls: fabro's `LocalSandbox` against the driver `HostProvider` +//! - Host tool calls: fabro's local sandbox against the driver `HostProvider` //! in-process, to confirm no regression on the path every local run takes. //! - The wire: the driver Host and Docker providers served over the JSON-RPC //! protocol on an in-process duplex pipe, to size the budget for running a @@ -37,7 +37,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use bollard::Docker; -use fabro_sandbox::{DockerSandbox, DockerSandboxOptions, LocalSandbox, Sandbox as FabroSandbox}; +use fabro_sandbox::{DockerSandbox, DockerSandboxOptions, Sandbox as FabroSandbox, local_sandbox}; use sandbox_driver::{ ExecSpec, GrepOptions, Sandbox as DriverSandbox, SandboxProvider, SandboxSource, SandboxSpec, Search, @@ -335,12 +335,14 @@ async fn agent_tool_call_latency_through_the_driver() { let repo = Repository::pack(); let mut rows = Vec::new(); - // -- Host, in-process: fabro LocalSandbox vs driver HostProvider. + // -- Host, in-process: fabro local sandbox vs driver HostProvider. let host_dir = tempfile::tempdir().expect("tempdir"); - let local = LocalSandbox::new(host_dir.path().to_path_buf()); + let local = local_sandbox(host_dir.path().to_path_buf()) + .await + .expect("local sandbox should be created"); local.initialize().await.expect("local init"); unpack_fabro(&local, &repo).await; - rows.extend(bench_fabro("fabro LocalSandbox", &local, &repo).await); + rows.extend(bench_fabro("fabro local sandbox", &local, &repo).await); let host_provider = Arc::new(HostProvider::new()); let host = host_provider diff --git a/lib/components/fabro-workflow/src/artifact.rs b/lib/components/fabro-workflow/src/artifact.rs index 743f5c23a..b7d68c879 100644 --- a/lib/components/fabro-workflow/src/artifact.rs +++ b/lib/components/fabro-workflow/src/artifact.rs @@ -1550,7 +1550,9 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let run_dir = tmp.path().join("run"); std::fs::create_dir_all(&run_dir).unwrap(); - let sandbox = fabro_agent::LocalSandbox::new(tmp.path().to_path_buf()); + let sandbox = fabro_agent::local_sandbox(tmp.path().to_path_buf()) + .await + .unwrap(); let dataset = serde_json::json!({ "rows": vec![serde_json::json!({"payload": "x".repeat(64)}); 256] @@ -1703,7 +1705,9 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let run_dir = tmp.path().join("run"); std::fs::create_dir_all(&run_dir).unwrap(); - let sandbox = fabro_agent::LocalSandbox::new(tmp.path().to_path_buf()); + let sandbox = fabro_agent::local_sandbox(tmp.path().to_path_buf()) + .await + .unwrap(); let inherited_preamble = "p".repeat(PROMPT_INLINE_VALUE_MAX + 1); let mut values = HashMap::from([( diff --git a/lib/components/fabro-workflow/src/handler/agent.rs b/lib/components/fabro-workflow/src/handler/agent.rs index e15e6d36f..d18f3bcf0 100644 --- a/lib/components/fabro-workflow/src/handler/agent.rs +++ b/lib/components/fabro-workflow/src/handler/agent.rs @@ -541,17 +541,19 @@ mod tests { } } - 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(fabro_agent::LocalSandbox::new( - sandbox_dir.path().to_path_buf(), - )); + let sandbox: Arc = Arc::new( + fabro_agent::local_sandbox(sandbox_dir.path().to_path_buf()) + .await + .unwrap(), + ); (sandbox_dir, sandbox) } async fn execute_with_last_file(path: &str, contents: &str) -> Outcome { - let (_sandbox_dir, sandbox) = sandbox_with_file(path, contents); + let (_sandbox_dir, sandbox) = sandbox_with_file(path, contents).await; let handler = AgentHandler::new(Some(Box::new(LastFileBackend { path: path.to_string(), @@ -574,7 +576,7 @@ mod tests { path: &str, contents: &str, ) -> Result { - let (_sandbox_dir, sandbox) = sandbox_with_file(path, contents); + let (_sandbox_dir, sandbox) = sandbox_with_file(path, contents).await; validate_agent_output_sources( &OutputSchemaKind::Routing, @@ -707,12 +709,11 @@ mod tests { let tmp = TempDir::new().unwrap(); let mut services = EngineServices::test_default(); - services.run = - services - .run - .with_sandbox(std::sync::Arc::new(fabro_agent::LocalSandbox::new( - sandbox_dir.path().to_path_buf(), - ))); + services.run = services.run.with_sandbox(std::sync::Arc::new( + fabro_agent::local_sandbox(sandbox_dir.path().to_path_buf()) + .await + .unwrap(), + )); let outcome = handler .execute(&node, &context, &graph, tmp.path(), &services) @@ -760,12 +761,11 @@ mod tests { let tmp = TempDir::new().unwrap(); let mut services = EngineServices::test_default(); - services.run = - services - .run - .with_sandbox(std::sync::Arc::new(fabro_agent::LocalSandbox::new( - sandbox_dir.path().to_path_buf(), - ))); + services.run = services.run.with_sandbox(std::sync::Arc::new( + fabro_agent::local_sandbox(sandbox_dir.path().to_path_buf()) + .await + .unwrap(), + )); let outcome = handler .execute(&node, &context, &graph, tmp.path(), &services) @@ -873,12 +873,11 @@ All checks passed. let tmp = TempDir::new().unwrap(); let mut services = EngineServices::test_default(); - services.run = - services - .run - .with_sandbox(std::sync::Arc::new(fabro_agent::LocalSandbox::new( - sandbox_dir.path().to_path_buf(), - ))); + services.run = services.run.with_sandbox(std::sync::Arc::new( + fabro_agent::local_sandbox(sandbox_dir.path().to_path_buf()) + .await + .unwrap(), + )); let outcome = handler .execute(&node, &context, &graph, tmp.path(), &services) @@ -972,12 +971,11 @@ All checks passed. let tmp = TempDir::new().unwrap(); let mut services = EngineServices::test_default(); - services.run = - services - .run - .with_sandbox(std::sync::Arc::new(fabro_agent::LocalSandbox::new( - sandbox_dir.path().to_path_buf(), - ))); + services.run = services.run.with_sandbox(std::sync::Arc::new( + fabro_agent::local_sandbox(sandbox_dir.path().to_path_buf()) + .await + .unwrap(), + )); let outcome = handler .execute(&node, &context, &graph, tmp.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 9e9f30c39..e48dd3b6c 100644 --- a/lib/components/fabro-workflow/src/handler/llm/acp.rs +++ b/lib/components/fabro-workflow/src/handler/llm/acp.rs @@ -647,8 +647,8 @@ mod tests { use fabro_acp::test_support::fake_acp_agent_script; use fabro_acp::{AcpError, AcpProcessExit}; use fabro_agent::{ - LocalSandbox, RefreshOutcome, RemoteCredentialAction, Sandbox, TokenProvenance, - TokenSnapshot, shell_quote, + RefreshOutcome, RemoteCredentialAction, Sandbox, TokenProvenance, TokenSnapshot, + local_sandbox, shell_quote, }; use fabro_graphviz::graph::{AttrValue, Node}; use fabro_sandbox::test_support::MockSandbox; @@ -1040,7 +1040,8 @@ mod tests { "ACP_MODE".to_string(), "write_file".to_string(), )])); - let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = + Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap()); let emitter = Arc::new(Emitter::default()); let context = Context::new(); let result = backend @@ -1088,7 +1089,8 @@ mod tests { ); let backend = AgentAcpBackend::new(); - let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = + Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap()); let emitter = Arc::new(Emitter::default()); let context = Context::new(); let result = backend @@ -1159,7 +1161,8 @@ mod tests { "steer".to_string(), )])) .with_steering_hub(steering_hub); - let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = + Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap()); let context = Context::new(); let result = backend .run(CodergenRunRequest { @@ -1206,7 +1209,8 @@ mod tests { "ACP_MODE".to_string(), "write_file".to_string(), )])); - let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = + Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap()); let emitter = Arc::new(Emitter::default()); let context = Context::new(); let result = backend @@ -1295,7 +1299,8 @@ mod tests { "ACP_STOP_REASON".to_string(), "cancelled".to_string(), )])); - let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); + let sandbox: Arc = + Arc::new(local_sandbox(tempdir.path().to_path_buf()).await.unwrap()); let emitter = Arc::new(Emitter::default()); let context = Context::new(); let result = backend @@ -1343,7 +1348,8 @@ mod tests { .insert("acp.config".to_string(), AttrValue::String(raw_command)); let backend = AgentAcpBackend::new(); - let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); + 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())); emitter.on_event({ diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 30b765f4e..2e47e0d97 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -1880,7 +1880,7 @@ mod tests { use chrono::TimeZone; use fabro_agent::subagent::SessionFactory; - use fabro_agent::{AgentProfile, LocalSandbox, ToolRegistry}; + use fabro_agent::{AgentProfile, ToolRegistry, local_sandbox}; use fabro_api::types; use fabro_auth::{VaultCredentialSource, test_support as auth_test_support}; use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; @@ -2369,7 +2369,7 @@ reasoning = false "start": false }] }), - tool_context(), + tool_context().await, ) .await .expect("create tool should succeed"); @@ -2395,7 +2395,7 @@ reasoning = false "workflow": "child.fabro" }] }), - tool_context(), + tool_context().await, ) .await .expect("create tool should succeed"); @@ -2423,7 +2423,7 @@ reasoning = false "start": false }] }), - tool_context(), + tool_context().await, ) .await .expect_err("conflicting parent should be rejected"); @@ -2448,7 +2448,7 @@ reasoning = false "start": false }] }), - tool_context(), + tool_context().await, ) .await .expect("create should succeed"); @@ -2461,7 +2461,7 @@ reasoning = false "run_ids": [child_run_id().to_string()], "timeout_seconds": 0 }), - tool_context(), + tool_context().await, ) .await .expect("gather should succeed"); @@ -2475,7 +2475,7 @@ reasoning = false "run_id": child_run_id().to_string(), "first": 5 }), - tool_context(), + tool_context().await, ) .await .expect("events should succeed"); @@ -2502,7 +2502,7 @@ reasoning = false "run_id": child_run_id().to_string(), "action": action }), - tool_context(), + tool_context().await, ) .await .expect_err("workflow agents must not approve or deny runs"); @@ -2533,7 +2533,7 @@ reasoning = false "action": "status", "run_id": child_run_id().to_string() }), - tool_context(), + tool_context().await, ) .await .expect("pair status should succeed"); @@ -2563,9 +2563,9 @@ reasoning = false (services, backend) } - fn tool_context() -> ToolContext { + async fn tool_context() -> ToolContext { ToolContext { - env: Arc::new(LocalSandbox::new(PathBuf::from("."))), + env: Arc::new(local_sandbox(PathBuf::from(".")).await.unwrap()), cancel: CancellationToken::new(), tool_env_provider: None, session_id: None, @@ -3069,7 +3069,7 @@ reasoning = false .await .unwrap(); let sandbox: Arc = - Arc::new(LocalSandbox::new(workspace.path().to_path_buf())); + Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap()); let mut session = backend .create_session_with_plan(&node, &sandbox, Some(hooks)) @@ -3609,7 +3609,7 @@ enabled = true let emitter = Arc::new(Emitter::new(fabro_types::RunId::new())); let workspace = tempfile::tempdir().unwrap(); let sandbox: Arc = - Arc::new(LocalSandbox::new(workspace.path().to_path_buf())); + Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap()); let result = backend .one_shot(OneShotRequest { @@ -3675,7 +3675,7 @@ enabled = true let emitter = Arc::new(Emitter::new(fabro_types::RunId::new())); let workspace = tempfile::tempdir().unwrap(); let sandbox: Arc = - Arc::new(LocalSandbox::new(workspace.path().to_path_buf())); + Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap()); let result = backend .one_shot(OneShotRequest { @@ -3736,7 +3736,7 @@ enabled = true let emitter = Arc::new(Emitter::new(fabro_types::RunId::new())); let workspace = tempfile::tempdir().unwrap(); let sandbox: Arc = - Arc::new(LocalSandbox::new(workspace.path().to_path_buf())); + Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap()); let result = backend .run(CodergenRunRequest { @@ -3808,7 +3808,7 @@ enabled = true let emitter = Arc::new(Emitter::new(fabro_types::RunId::new())); let workspace = tempfile::tempdir().unwrap(); let sandbox: Arc = - Arc::new(LocalSandbox::new(workspace.path().to_path_buf())); + Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap()); let result = backend .run(CodergenRunRequest { @@ -3879,7 +3879,7 @@ enabled = true let emitter = Arc::new(Emitter::new(fabro_types::RunId::new())); let workspace = tempfile::tempdir().unwrap(); let sandbox: Arc = - Arc::new(LocalSandbox::new(workspace.path().to_path_buf())); + Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap()); let result = backend .run(CodergenRunRequest { @@ -3953,7 +3953,7 @@ enabled = true }); let workspace = tempfile::tempdir().unwrap(); let sandbox: Arc = - Arc::new(LocalSandbox::new(workspace.path().to_path_buf())); + Arc::new(local_sandbox(workspace.path().to_path_buf()).await.unwrap()); let result = backend .run(CodergenRunRequest { @@ -4017,9 +4017,11 @@ enabled = true let session = Session::new( client, Arc::new(ShutdownTestProfile::new()), - Arc::new(fabro_agent::LocalSandbox::new( - tempfile::tempdir().unwrap().path().to_path_buf(), - )), + Arc::new( + fabro_agent::local_sandbox(tempfile::tempdir().unwrap().path().to_path_buf()) + .await + .unwrap(), + ), SessionOptions::default(), None, ); @@ -4060,9 +4062,11 @@ enabled = true let mut session = Session::new( client, Arc::new(ShutdownTestProfile::new()), - Arc::new(LocalSandbox::new( - tempfile::tempdir().unwrap().path().to_path_buf(), - )), + Arc::new( + local_sandbox(tempfile::tempdir().unwrap().path().to_path_buf()) + .await + .unwrap(), + ), SessionOptions::default(), None, ); diff --git a/lib/components/fabro-workflow/src/handler/llm/router.rs b/lib/components/fabro-workflow/src/handler/llm/router.rs index 24dc97e0e..c035d6385 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::{LocalSandbox, Sandbox}; + use fabro_agent::{Sandbox, local_sandbox}; use fabro_graphviz::graph::{AttrValue, Node}; use fabro_model::{ReasoningEffort, Speed}; use tokio_util::sync::CancellationToken; @@ -114,9 +114,11 @@ mod tests { #[tokio::test] async fn router_routes_one_shot_to_api_by_default() { let node = Node::new("test"); - let sandbox: Arc = Arc::new(LocalSandbox::new( - tempfile::tempdir().unwrap().path().to_path_buf(), - )); + let sandbox: Arc = Arc::new( + local_sandbox(tempfile::tempdir().unwrap().path().to_path_buf()) + .await + .unwrap(), + ); let context = Context::new(); let router = BackendRouter::new(Box::new(StubBackend), AgentAcpBackend::new()); let emitter = Arc::new(Emitter::default()); diff --git a/lib/components/fabro-workflow/src/handler/parallel.rs b/lib/components/fabro-workflow/src/handler/parallel.rs index 35796115c..08e8f6f28 100644 --- a/lib/components/fabro-workflow/src/handler/parallel.rs +++ b/lib/components/fabro-workflow/src/handler/parallel.rs @@ -1828,9 +1828,11 @@ mod tests { services.run = services .run .with_run_store(run_store.into()) - .with_sandbox(Arc::new(fabro_agent::LocalSandbox::new( - run_dir.path().to_path_buf(), - ))); + .with_sandbox(Arc::new( + fabro_agent::local_sandbox(run_dir.path().to_path_buf()) + .await + .unwrap(), + )); let (node, graph) = for_each_graph("context.items", 2); let context = test_context(); let oversized_payload = "x".repeat(65 * 1024); @@ -2161,9 +2163,11 @@ mod tests { services.run = services .run .with_run_store(run_store.into()) - .with_sandbox(Arc::new(fabro_agent::LocalSandbox::new( - sandbox_dir.path().to_path_buf(), - ))); + .with_sandbox(Arc::new( + fabro_agent::local_sandbox(sandbox_dir.path().to_path_buf()) + .await + .unwrap(), + )); let (node, graph) = for_each_graph("items", 1); let context = test_context(); context.set("items", serde_json::json!(format_blob_ref(&blob_hash))); diff --git a/lib/components/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs index 5e2058c3c..5d0b7222f 100644 --- a/lib/components/fabro-workflow/src/handler/prompt.rs +++ b/lib/components/fabro-workflow/src/handler/prompt.rs @@ -720,9 +720,11 @@ reasoning = false let mut services = make_services(); services.run = services .run - .with_sandbox(Arc::new(fabro_agent::LocalSandbox::new( - workspace.path().to_path_buf(), - ))) + .with_sandbox(Arc::new( + fabro_agent::local_sandbox(workspace.path().to_path_buf()) + .await + .unwrap(), + )) .with_catalog_context( Arc::clone(&catalog), fabro_model::ProviderId::new("acme"), @@ -795,9 +797,11 @@ reasoning = false let mut services = make_services(); services.run = services .run - .with_sandbox(Arc::new(fabro_agent::LocalSandbox::new( - workspace.path().to_path_buf(), - ))) + .with_sandbox(Arc::new( + fabro_agent::local_sandbox(workspace.path().to_path_buf()) + .await + .unwrap(), + )) .with_catalog_context( Arc::clone(&catalog), fabro_model::ProviderId::new("acme"), diff --git a/lib/components/fabro-workflow/src/lifecycle/fidelity.rs b/lib/components/fabro-workflow/src/lifecycle/fidelity.rs index 8d624bb76..ce49d0afe 100644 --- a/lib/components/fabro-workflow/src/lifecycle/fidelity.rs +++ b/lib/components/fabro-workflow/src/lifecycle/fidelity.rs @@ -468,8 +468,11 @@ mod tests { None, )); let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); - let sandbox: Arc = - Arc::new(fabro_agent::LocalSandbox::new(run_dir.to_path_buf())); + let sandbox: Arc = Arc::new( + fabro_agent::local_sandbox(run_dir.to_path_buf()) + .await + .unwrap(), + ); FidelityLifecycle::new( graph.0.clone(), sandbox, diff --git a/lib/components/fabro-workflow/src/lifecycle/git.rs b/lib/components/fabro-workflow/src/lifecycle/git.rs index 4f1b30602..5f9cc4531 100644 --- a/lib/components/fabro-workflow/src/lifecycle/git.rs +++ b/lib/components/fabro-workflow/src/lifecycle/git.rs @@ -808,7 +808,7 @@ mod tests { events } - fn git_lifecycle( + async fn git_lifecycle( repo: &Path, emitter: Arc, run_store: RunStoreHandle, @@ -828,9 +828,10 @@ mod tests { metadata_runtime, metadata_writer, ) + .await } - fn git_lifecycle_with_writer( + async fn git_lifecycle_with_writer( repo: &Path, emitter: Arc, run_store: RunStoreHandle, @@ -840,7 +841,11 @@ mod tests { ) -> GitLifecycle { GitLifecycle { stage_executions: StageExecutionTracker::default(), - sandbox: Arc::new(fabro_agent::LocalSandbox::new(repo.to_path_buf())), + sandbox: Arc::new( + fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(), + ), emitter, run_id: fixtures::RUN_1, run_store, @@ -879,7 +884,8 @@ mod tests { handle, run_options(repo_dir.path(), branch), Arc::new(RunMetadataRuntime::new()), - ); + ) + .await; let graph = workflow_graph(); let state = ExecutionState::new(&graph).unwrap(); @@ -916,7 +922,8 @@ mod tests { RunStoreHandle::new(Arc::new(FailingStateStore)), run_options(repo_dir.path(), branch), Arc::new(RunMetadataRuntime::new()), - ); + ) + .await; let graph = workflow_graph(); let state = ExecutionState::new(&graph).unwrap(); @@ -975,7 +982,8 @@ mod tests { run_options(repo_dir.path(), branch), Arc::clone(&runtime), Some(metadata_writer), - ); + ) + .await; let graph = workflow_graph(); let state = ExecutionState::new(&graph).unwrap(); @@ -1014,7 +1022,8 @@ mod tests { RunStoreHandle::new(Arc::new(FailingStateStore)), run_options(repo_dir.path(), branch), Arc::new(RunMetadataRuntime::new()), - ); + ) + .await; let graph = workflow_graph(); let node = graph.get_node("build").unwrap(); let mut state = ExecutionState::new(&graph).unwrap(); @@ -1064,7 +1073,8 @@ mod tests { RunStoreHandle::local(run_store), run_options(repo_dir.path(), branch), Arc::new(RunMetadataRuntime::new()), - ); + ) + .await; let graph = workflow_graph(); let node = graph.get_node("build").unwrap(); let mut state = ExecutionState::new(&graph).unwrap(); @@ -1132,7 +1142,8 @@ mod tests { Arc::new(options), Arc::new(RunMetadataRuntime::new()), None, - ); + ) + .await; let graph = workflow_graph(); let node = graph.get_node("build").unwrap(); let mut state = ExecutionState::new(&graph).unwrap(); @@ -1206,7 +1217,8 @@ mod tests { Arc::new(options), Arc::new(RunMetadataRuntime::new()), None, - ); + ) + .await; let graph = workflow_graph(); let node = graph.get_node("build").unwrap(); let mut state = ExecutionState::new(&graph).unwrap(); @@ -1249,7 +1261,8 @@ mod tests { RunStoreHandle::local(run_store(fixtures::RUN_1).await), run_options(repo_dir.path(), "fabro/metadata/run"), runtime, - ); + ) + .await; let graph = workflow_graph(); let state = ExecutionState::new(&graph).unwrap(); @@ -1271,7 +1284,8 @@ mod tests { RunStoreHandle::new(Arc::new(FailingStateStore)), run_options(repo_dir.path(), "fabro/metadata/run"), runtime, - ); + ) + .await; let graph = workflow_graph(); let state = ExecutionState::new(&graph).unwrap(); @@ -1293,7 +1307,9 @@ mod tests { .await .unwrap(); let finalize_sandbox: Arc = Arc::new( - fabro_agent::LocalSandbox::new(repo_dir.path().to_path_buf()), + fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) + .await + .unwrap(), ); let finalize_locations = crate::services::RunLocations::for_sandbox( None, diff --git a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs index d07180062..b78de7e48 100644 --- a/lib/components/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/components/fabro-workflow/src/pipeline/execute/tests.rs @@ -40,10 +40,12 @@ use crate::records::RunSpec; use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions, SetupCommand}; use crate::test_support::run_graph; -fn local_env() -> Arc { - Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - )) +async fn local_env() -> Arc { + Arc::new( + fabro_agent::local_sandbox(std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) + .await + .unwrap(), + ) } fn simple_graph() -> Graph { @@ -874,7 +876,7 @@ async fn execute_runs_simple_workflow() { let outcome = run_graph( make_registry(), test_emitter_arc("test-run"), - local_env(), + local_env().await, &simple_graph(), &test_run_options(dir.path(), "test-run"), ) @@ -972,7 +974,7 @@ async fn execute_emits_events() { run_graph( make_registry(), Arc::new(emitter), - local_env(), + local_env().await, &simple_graph(), &test_run_options(dir.path(), "test-run"), ) @@ -988,7 +990,7 @@ async fn execute_error_when_no_start_node() { let result = run_graph( make_registry(), test_emitter_arc("test-run"), - local_env(), + local_env().await, &Graph::new("empty"), &test_run_options(dir.path(), "test-run"), ) @@ -1264,7 +1266,7 @@ async fn execute_cancelled_mid_run() { let result = run_graph( registry, test_emitter_arc("test-run"), - local_env(), + local_env().await, &g, &run_options, ) @@ -1313,7 +1315,7 @@ async fn max_node_visits_errors_on_cycle() { let result = run_graph( make_registry(), test_emitter_arc("test-run"), - local_env(), + local_env().await, &g, &test_run_options(dir.path(), "test-run"), ) @@ -1348,7 +1350,7 @@ async fn panic_handler_returns_panic_message() { let result = run_graph( registry, test_emitter_arc("test-run"), - local_env(), + local_env().await, &g, &test_run_options(dir.path(), "test-run"), ) @@ -1369,7 +1371,7 @@ async fn loop_circuit_breaker_aborts_on_repeated_failure() { let result = run_graph( registry, test_emitter_arc("test-run"), - local_env(), + local_env().await, &looping_fail_graph(), &test_run_options(dir.path(), "test-run"), ) @@ -1418,7 +1420,7 @@ async fn stall_watchdog_triggers_on_hung_handler() { let result = run_graph( registry, test_emitter_arc("test-run"), - local_env(), + local_env().await, &g, &test_run_options(dir.path(), "test-run"), ) @@ -1477,7 +1479,7 @@ async fn stall_watchdog_suspends_while_run_waits_for_human_input() { let outcome = run_graph( registry, test_emitter_arc("test-run"), - local_env(), + local_env().await, &graph, &test_run_options(dir.path(), "test-run"), ) @@ -1505,7 +1507,7 @@ async fn node_timeout_excludes_human_input_wait() { let outcome = run_graph( registry, test_emitter_arc("test-run"), - local_env(), + local_env().await, &graph, &test_run_options(dir.path(), "test-run"), ) @@ -1570,7 +1572,7 @@ async fn retry_emits_stage_started_per_attempt() { let outcome = run_graph( registry, Arc::new(emitter), - local_env(), + local_env().await, &g, &test_run_options(dir.path(), "retry-events-test"), ) @@ -1610,7 +1612,7 @@ async fn run_with_lifecycle_emits_initialize_and_setup_events() { let outcome = run_with_lifecycle( make_registry(), Arc::new(emitter), - local_env(), + local_env().await, &simple_graph(), test_run_options(dir.path(), "order-test"), test_lifecycle(vec!["echo ok"]), diff --git a/lib/components/fabro-workflow/src/pipeline/finalize.rs b/lib/components/fabro-workflow/src/pipeline/finalize.rs index a91ebbd6e..e72fddab4 100644 --- a/lib/components/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/components/fabro-workflow/src/pipeline/finalize.rs @@ -1018,9 +1018,11 @@ 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(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap(), - )); + let sandbox: Arc = Arc::new( + fabro_agent::local_sandbox(std::env::current_dir().unwrap()) + .await + .unwrap(), + ); let locations = crate::services::RunLocations::for_sandbox(None, sandbox.as_ref(), run_dir.clone()); let services = RunServices::new( @@ -1085,9 +1087,11 @@ mod tests { let services = test_services( handle, emitter, - Arc::new(fabro_agent::LocalSandbox::new( - repo_dir.path().to_path_buf(), - )), + Arc::new( + fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) + .await + .unwrap(), + ), Arc::new(RunMetadataRuntime::new()), Some(RunMetadataWriterHandle::new_for_test_repo( repo_dir.path(), @@ -1122,9 +1126,11 @@ mod tests { let services = test_services( RunStoreHandle::new(Arc::new(FailingStateStore)), emitter, - Arc::new(fabro_agent::LocalSandbox::new( - repo_dir.path().to_path_buf(), - )), + Arc::new( + fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) + .await + .unwrap(), + ), Arc::new(RunMetadataRuntime::new()), Some(RunMetadataWriterHandle::new_for_test_repo( repo_dir.path(), @@ -1174,9 +1180,11 @@ mod tests { let services = test_services( RunStoreHandle::local(run_store), emitter, - Arc::new(fabro_agent::LocalSandbox::new( - repo_dir.path().to_path_buf(), - )), + Arc::new( + fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) + .await + .unwrap(), + ), runtime, Some(RunMetadataWriterHandle::new_for_test_repo( repo_dir.path(), @@ -1211,9 +1219,11 @@ mod tests { let services = test_services( RunStoreHandle::local(run_store), Arc::clone(&emitter), - Arc::new(fabro_agent::LocalSandbox::new( - repo_dir.path().to_path_buf(), - )), + Arc::new( + fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) + .await + .unwrap(), + ), Arc::new(RunMetadataRuntime::new()), Some(RunMetadataWriterHandle::new_for_test_repo( repo_dir.path(), @@ -1405,9 +1415,11 @@ mod tests { let services = test_services( RunStoreHandle::local(seeded_run_store().await), emitter, - Arc::new(fabro_agent::LocalSandbox::new( - repo_dir.path().to_path_buf(), - )), + Arc::new( + fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) + .await + .unwrap(), + ), Arc::new(RunMetadataRuntime::new()), None, ); @@ -1470,9 +1482,11 @@ mod tests { let services = test_services( RunStoreHandle::local(seeded_run_store().await), emitter, - Arc::new(fabro_agent::LocalSandbox::new( - repo_dir.path().to_path_buf(), - )), + Arc::new( + fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) + .await + .unwrap(), + ), Arc::new(RunMetadataRuntime::new()), None, ); @@ -1528,9 +1542,11 @@ mod tests { let services = test_services( RunStoreHandle::local(seeded_run_store().await), emitter, - Arc::new(fabro_agent::LocalSandbox::new( - repo_dir.path().to_path_buf(), - )), + Arc::new( + fabro_agent::local_sandbox(repo_dir.path().to_path_buf()) + .await + .unwrap(), + ), Arc::new(RunMetadataRuntime::new()), None, ); @@ -1682,7 +1698,11 @@ mod tests { let services = test_services( RunStoreHandle::local(run_store), Arc::clone(&emitter), - Arc::new(fabro_agent::LocalSandbox::new(repo.to_path_buf())), + Arc::new( + fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(), + ), 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 d05d9acd9..3db130fd1 100644 --- a/lib/components/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/components/fabro-workflow/src/pipeline/initialize.rs @@ -1184,9 +1184,14 @@ mod tests { .await .expect("a forked run should materialize a fresh sandbox before resuming"); + // The Host provider reports the designated directory canonically + // (macOS resolves `/var` to `/private/var`). + let expected = workspace + .canonicalize() + .expect("materialized workspace should exist"); assert_eq!( initialized.engine.run.sandbox.working_directory(), - workspace.to_string_lossy().as_ref() + expected.to_string_lossy().as_ref() ); } diff --git a/lib/components/fabro-workflow/src/sandbox_git.rs b/lib/components/fabro-workflow/src/sandbox_git.rs index c6084a977..ad522048e 100644 --- a/lib/components/fabro-workflow/src/sandbox_git.rs +++ b/lib/components/fabro-workflow/src/sandbox_git.rs @@ -1318,7 +1318,9 @@ mod tests { std::fs::create_dir_all(repo.join(".venv/lib")).unwrap(); std::fs::write(repo.join(".venv/lib/site.py"), "venv").unwrap(); - let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf()); + let sandbox = fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(); let author = crate::git::GitAuthor::default(); // Call git_checkpoint with empty user excludes — built-in excludes should still @@ -1417,7 +1419,9 @@ mod tests { std::fs::remove_file(repo.join("drop.txt")).unwrap(); let head = git_commit_all(repo, "change"); - let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf()); + let sandbox = fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(); let entries = list_changed_files_raw(&sandbox, &base, &head) .await .unwrap(); @@ -1456,7 +1460,9 @@ mod tests { std::fs::write(repo.join("new.txt"), &content).unwrap(); let head = git_commit_all(repo, "rename"); - let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf()); + let sandbox = fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(); let entries = list_changed_files_raw(&sandbox, &base, &head) .await .unwrap(); @@ -1500,7 +1506,9 @@ mod tests { std::fs::write(repo.join("logo.png"), png).unwrap(); let head = git_commit_all(repo, "change"); - let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf()); + let sandbox = fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(); let stats = list_diff_numstat(&sandbox, &base, &head).await.unwrap(); assert!( @@ -1544,7 +1552,9 @@ mod tests { sha_by_name.insert(path.to_string(), sha.to_string()); } - let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf()); + let sandbox = fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(); let shas = vec![sha_by_name["a.txt"].clone(), sha_by_name["b.txt"].clone()]; let metas = stream_blob_metadata(&sandbox, &shas).await.unwrap(); assert_eq!(metas.len(), 2); @@ -1580,7 +1590,9 @@ mod tests { sha_by_name.insert(path.to_string(), sha.to_string()); } - let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf()); + let sandbox = fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(); let shas = vec![sha_by_name["a.txt"].clone(), sha_by_name["big.txt"].clone()]; // size_cap = 100 bytes — "hello\n" (6) stays, 200-byte blob truncates. @@ -1598,7 +1610,9 @@ mod tests { std::fs::write(repo.join("x"), "x").unwrap(); git_commit_all(repo, "seed"); - let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf()); + let sandbox = fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .unwrap(); let err = list_changed_files_raw(&sandbox, "0000000000000000000000000000000000000000", "HEAD") .await diff --git a/lib/components/fabro-workflow/src/services.rs b/lib/components/fabro-workflow/src/services.rs index 9fa4c21e1..2db33d130 100644 --- a/lib/components/fabro-workflow/src/services.rs +++ b/lib/components/fabro-workflow/src/services.rs @@ -291,23 +291,28 @@ impl EngineServices { Duration::from_millis(1), None, )); - let run_store = std::thread::spawn(move || { + let (run_store, sandbox) = std::thread::spawn(move || { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("test runtime should initialize") .block_on(async { - store + let run_store = store .create_run(&fabro_types::RunId::new()) .await - .expect("slate-backed test run store should initialize") + .expect("slate-backed test run store should initialize"); + let sandbox: Arc = Arc::new( + fabro_agent::local_sandbox( + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + ) + .await + .expect("local sandbox should be created"), + ); + (run_store, sandbox) }) }) .join() .expect("test run store thread should join"); - let sandbox: Arc = Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - )); let locations = RunLocations::for_sandbox(None, sandbox.as_ref(), PathBuf::from(".")); Self { diff --git a/lib/components/fabro-workflow/tests/it/git_integration.rs b/lib/components/fabro-workflow/tests/it/git_integration.rs index a38a248e4..a004fc9dd 100644 --- a/lib/components/fabro-workflow/tests/it/git_integration.rs +++ b/lib/components/fabro-workflow/tests/it/git_integration.rs @@ -114,8 +114,12 @@ fn list_branch(repo_dir: &Path, branch: &str) -> String { String::from_utf8(output.stdout).expect("git branch --list output should be UTF-8") } -fn local_env(repo: &Path) -> Arc { - Arc::new(fabro_agent::LocalSandbox::new(repo.to_path_buf())) +async fn local_env(repo: &Path) -> Arc { + Arc::new( + fabro_agent::local_sandbox(repo.to_path_buf()) + .await + .expect("local sandbox should be created"), + ) } fn simple_graph() -> Graph { @@ -304,7 +308,7 @@ async fn git_checkpoint_skips_start_node() { Box::pin(run_graph( make_registry(), Arc::new(emitter), - local_env(repo), + local_env(repo).await, &g, &run_options, )) @@ -334,7 +338,7 @@ async fn git_checkpoint_skips_start_node() { /// inaccessible (as it is for Docker/Daytona) and the sandbox exposes a /// runtime directory outside the checkout. struct RemoteRuntimeSandbox { - inner: fabro_agent::LocalSandbox, + inner: fabro_agent::DriverSandbox, hidden_path: String, runtime_directory: String, } @@ -479,7 +483,9 @@ async fn remote_prompt_demotion_stays_outside_checkout_and_survives_checkpoint() std::fs::create_dir_all(&run_dir).unwrap(); let sandbox = RemoteRuntimeSandbox { - inner: fabro_agent::LocalSandbox::new(repo_dir.clone()), + 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(), }; diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index 399f07f37..2c9eade65 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -83,10 +83,14 @@ fn catalog_with_provider_base_url(provider: &str, base_url: &str) -> Arc Arc { - Arc::new(fabro_agent::LocalSandbox::new( - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), - )) +async fn local_env() -> Arc { + Arc::new( + fabro_agent::local_sandbox( + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ) + .await + .expect("local sandbox should be created"), + ) } fn test_run_id(label: &str) -> RunId { @@ -443,7 +447,7 @@ async fn end_to_end_linear_pipeline() { let engine = WorkflowRunner::new( make_linear_registry(), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -577,7 +581,7 @@ async fn end_to_end_branching_pipeline() { registry.register("agent", Box::new(AgentHandler::new(None))); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -700,7 +704,7 @@ async fn end_to_end_human_gate_pipeline() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -799,7 +803,7 @@ async fn human_gate_interrupted_input_fails_closed_without_fail_route() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -943,7 +947,7 @@ async fn human_gate_timeout_routes_to_default_choice_when_unanswered() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1058,7 +1062,7 @@ async fn human_gate_interrupted_input_routes_via_outcome_fail_condition() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1182,7 +1186,7 @@ async fn run_on_failure(graph: &Graph, emitter: Emitter) -> OnFailureRun { let engine = WorkflowRunner::new( on_failure_registry(Arc::clone(&visits)), Arc::new(emitter), - local_env(), + local_env().await, ); let run_dir = tempfile::tempdir().expect("temporary run dir should be created"); let (outcome, state) = engine @@ -1492,7 +1496,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { registry.register("exit", Box::new(ExitHandler)); registry.register("always_fail", Box::new(AlwaysFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -1615,7 +1619,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2030,7 +2034,7 @@ async fn retry_on_failure_then_succeed() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2104,7 +2108,7 @@ async fn pipeline_with_many_nodes() { let engine = WorkflowRunner::new( make_linear_registry(), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -2371,7 +2375,7 @@ async fn command_schema_validation_failure_does_not_consume_retries() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("command", Box::new(CommandHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env().await); let mut run_options = make_run_options(dir.path()); run_options.run_id = test_run_id("command-schema-no-retry"); @@ -2514,7 +2518,7 @@ async fn smoke_test_with_mock_codergen_backend() { ); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2755,7 +2759,7 @@ reasoning = false registry.register("exit", Box::new(ExitHandler)); let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2891,7 +2895,7 @@ base_url = "{}" }); let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, emitter, local_env()); + let engine = WorkflowRunner::new(registry, emitter, local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -2987,7 +2991,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3103,7 +3107,7 @@ async fn resume_from_checkpoint_completes_pipeline() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3205,7 +3209,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3247,7 +3251,7 @@ async fn graph_goal_in_context() { let engine = WorkflowRunner::new( make_linear_registry(), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -3290,7 +3294,7 @@ async fn event_streaming_lifecycle() { let dir = tempfile::tempdir().unwrap(); let emitter = Emitter::default(); let events = collect_events(&emitter); - let engine = WorkflowRunner::new(make_linear_registry(), Arc::new(emitter), local_env()); + let engine = WorkflowRunner::new(make_linear_registry(), Arc::new(emitter), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3369,7 +3373,7 @@ async fn context_flow_between_stages() { let engine = WorkflowRunner::new( make_linear_registry(), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -3428,7 +3432,7 @@ async fn tool_handler_e2e() { let engine = WorkflowRunner::new( make_full_registry(interviewer), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -3499,7 +3503,7 @@ async fn auto_approve_interviewer_e2e() { let engine = WorkflowRunner::new( make_full_registry(interviewer), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -3539,7 +3543,7 @@ async fn codergen_without_backend_simulated() { let engine = WorkflowRunner::new( make_linear_registry(), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -3648,7 +3652,7 @@ async fn branching_loop_back_on_failure() { call_count: std::sync::atomic::AtomicU32::new(0), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3734,7 +3738,7 @@ async fn human_gate_loops_back() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3796,7 +3800,7 @@ async fn scenario_ship_a_feature() { let engine = WorkflowRunner::new( make_full_registry(interviewer), Arc::new(emitter), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -3885,7 +3889,7 @@ async fn scenario_parallel_expert_review() { ); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -3975,7 +3979,7 @@ async fn scenario_node_retries_on_retry_status() { call_count: std::sync::atomic::AtomicU32::new(0), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4043,7 +4047,7 @@ async fn scenario_loop_restart_resets_context() { call_count: Arc::clone(&call_count), }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4111,7 +4115,7 @@ async fn scenario_bug_triage_router() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("conditional", Box::new(ConditionalHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4176,7 +4180,7 @@ async fn scenario_crash_recovery() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4289,7 +4293,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("done_setter", Box::new(DoneSetterHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4374,7 +4378,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4520,7 +4524,7 @@ async fn conditional_branching_success_fail_paths() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("always_fail", Box::new(AlwaysFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4579,7 +4583,7 @@ async fn edge_selection_condition_match_wins_over_weight() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4632,7 +4636,7 @@ async fn edge_selection_weight_breaks_ties() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4677,7 +4681,7 @@ async fn edge_selection_lexical_tiebreak() { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4741,7 +4745,7 @@ async fn context_updates_visible_across_nodes() { registry.register("exit", Box::new(ExitHandler)); registry.register("conditional", Box::new(ConditionalHandler)); registry.register("context_setter", Box::new(ContextSetterHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4790,7 +4794,7 @@ async fn stylesheet_applies_model_override() { let engine = WorkflowRunner::new( make_linear_registry(), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -4847,7 +4851,7 @@ async fn custom_handler_registration_and_execution() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry.register("my_custom", Box::new(CustomHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -4925,7 +4929,7 @@ async fn integration_smoke_plan_implement_review_done() { let engine = WorkflowRunner::new( make_full_registry(interviewer), Arc::new(emitter), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -5018,7 +5022,7 @@ async fn manager_loop_runs_child_engine_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5155,7 +5159,7 @@ async fn manager_loop_context_flows_e2e() { registry.register("setter", Box::new(SetterHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5234,7 +5238,7 @@ async fn manager_loop_child_workflow_e2e() { registry.register("exit", Box::new(ExitHandler)); registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5349,7 +5353,7 @@ async fn import_e2e_through_engine() { let engine = WorkflowRunner::new( make_linear_registry(), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(), @@ -5529,7 +5533,7 @@ async fn fidelity_default_is_compact() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5586,7 +5590,7 @@ async fn fidelity_graph_default_applied() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5639,7 +5643,7 @@ async fn fidelity_node_overrides_graph_default() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5698,7 +5702,7 @@ async fn fidelity_edge_overrides_node_and_graph() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5747,7 +5751,7 @@ async fn fidelity_full_produces_empty_preamble() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5806,7 +5810,7 @@ async fn fidelity_truncate_preamble_minimal() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5878,7 +5882,7 @@ async fn fidelity_summary_low_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -5945,7 +5949,7 @@ async fn fidelity_summary_medium_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6012,7 +6016,7 @@ async fn fidelity_summary_high_mode() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6072,7 +6076,7 @@ async fn fidelity_full_sets_thread_id_in_context() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6143,7 +6147,7 @@ async fn fidelity_full_nodes_share_thread_id() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6224,7 +6228,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6321,7 +6325,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6405,7 +6409,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6447,7 +6451,7 @@ async fn fidelity_stored_in_checkpoint_context() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6543,7 +6547,7 @@ async fn fidelity_precedence_multi_node_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6611,7 +6615,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6686,7 +6690,11 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { captures: captures_low.clone(), }), ); - let engine_low = WorkflowRunner::new(registry_low, Arc::new(Emitter::default()), local_env()); + let engine_low = WorkflowRunner::new( + registry_low, + Arc::new(Emitter::default()), + local_env().await, + ); let run_options_low = RunOptions { settings: WorkflowSettings::default(), run_dir: dir_low.path().to_path_buf(), @@ -6753,7 +6761,11 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { captures: captures_med.clone(), }), ); - let engine_med = WorkflowRunner::new(registry_med, Arc::new(Emitter::default()), local_env()); + let engine_med = WorkflowRunner::new( + registry_med, + Arc::new(Emitter::default()), + local_env().await, + ); let run_options_med = RunOptions { settings: WorkflowSettings::default(), run_dir: dir_med.path().to_path_buf(), @@ -6825,7 +6837,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6879,7 +6891,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6936,7 +6948,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -6994,7 +7006,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7062,7 +7074,7 @@ async fn fidelity_from_parsed_dot_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7111,7 +7123,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7187,7 +7199,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7274,7 +7286,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7505,7 +7517,7 @@ mod real_llm { )))), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7681,7 +7693,7 @@ mod real_llm { ); let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7804,7 +7816,7 @@ mod real_llm { Box::new(AgentHandler::new(Some(make_llm_backend(client)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -7937,7 +7949,7 @@ mod real_llm { ); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8038,7 +8050,7 @@ mod real_llm { ))), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8170,7 +8182,7 @@ async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() { registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8287,7 +8299,7 @@ async fn human_gate_freeform_only_routes_text() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8421,7 +8433,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8541,7 +8553,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8672,7 +8684,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -8781,7 +8793,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { registry.register("exit", Box::new(ExitHandler)); registry.register("human", Box::new(HumanHandler::new(interviewer))); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -9031,7 +9043,7 @@ impl HookTestRunner { run_graph_with_hooks( make_linear_registry(), Arc::clone(&self.emitter), - local_env(), + local_env().await, graph, run_options, Arc::clone(&self.hook_runner), @@ -9049,7 +9061,7 @@ impl HookTestRunner { fabro_workflow::test_support::run_graph_with_hooks_and_state( make_linear_registry(), Arc::clone(&self.emitter), - local_env(), + local_env().await, graph, run_options, Arc::clone(&self.hook_runner), @@ -10031,7 +10043,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { Box::new(AgentHandler::new(Some(Box::new(MockCodergenBackend)))), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -10150,7 +10162,7 @@ async fn run_parallel_fidelity_capture( ); let dir = tempfile::tempdir().expect("parallel fidelity run directory should be created"); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -10403,7 +10415,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { let emitter = Emitter::default(); let events = collect_events(&emitter); - let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -10699,7 +10711,7 @@ async fn downstream_local_execution_resolves_response_blob_refs_as_text() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -10911,7 +10923,7 @@ async fn node_dir_uses_visit_count_on_revisit() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -11071,8 +11083,11 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { let emitter = Emitter::default(); let events = collect_events(&emitter); - let env: Arc = - Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone())); + let env: Arc = Arc::new( + fabro_agent::local_sandbox(worktree_path.clone()) + .await + .expect("local sandbox should be created"), + ); let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); @@ -11236,8 +11251,11 @@ 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(fabro_agent::LocalSandbox::new(worktree_path.clone())); + let env: Arc = Arc::new( + fabro_agent::local_sandbox(worktree_path.clone()) + .await + .expect("local sandbox should be created"), + ); let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); @@ -11416,8 +11434,11 @@ async fn parallel_shared_checkout_host_e2e() { let emitter = Emitter::default(); let events = collect_events(&emitter); - let env: Arc = - Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone())); + let env: Arc = Arc::new( + fabro_agent::local_sandbox(worktree_path.clone()) + .await + .expect("local sandbox should be created"), + ); let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler)); registry.register("start", Box::new(StartHandler)); @@ -11671,8 +11692,11 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { let emitter = Emitter::default(); let _events = collect_events(&emitter); - let env: Arc = - Arc::new(fabro_agent::LocalSandbox::new(worktree_path.clone())); + let env: Arc = Arc::new( + fabro_agent::local_sandbox(worktree_path.clone()) + .await + .expect("local sandbox should be created"), + ); let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); @@ -12048,7 +12072,7 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { )), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12095,7 +12119,7 @@ async fn e2e_circuit_breaker_custom_limit() { Box::new(DeterministicFailHandler::new("same error every time")), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12135,7 +12159,7 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { registry.register("exit", Box::new(ExitHandler)); registry.register("test_handler", Box::new(TransientInfraFailHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12182,7 +12206,7 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12222,7 +12246,7 @@ async fn e2e_circuit_breaker_loop_restart() { Box::new(DeterministicFailHandler::new("verify step failed")), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12285,7 +12309,7 @@ async fn e2e_failure_signature_persisted_in_context() { Box::new(DeterministicFailHandler::new("test assertion failed")), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12352,7 +12376,7 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { registry.register("exit", Box::new(ExitHandler)); registry.register("hint_handler", Box::new(SignatureHintHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12413,7 +12437,7 @@ async fn e2e_signature_maps_persist_in_checkpoint() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12544,7 +12568,7 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { Box::new(DeterministicFailHandler::new("assertion failed")), ); - let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12611,7 +12635,7 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12710,7 +12734,7 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { )), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12808,7 +12832,7 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { Box::new(ClassifiedFailHandler::always("deterministic")), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12848,7 +12872,7 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { Box::new(ClassifiedFailHandler::always("structural")), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12888,7 +12912,7 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { Box::new(ClassifiedFailHandler::always("budget_exhausted")), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12928,7 +12952,7 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { Box::new(ClassifiedFailHandler::always("canceled")), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -12965,7 +12989,7 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { Box::new(ClassifiedFailHandler::always("compilation_loop")), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -13006,7 +13030,7 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { Box::new(ClassifiedFailHandler::succeed_on("transient_infra", 1)), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -13116,7 +13140,7 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { events_clone.lock().unwrap().push(format!("{event:?}")); }); - let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -13172,7 +13196,7 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { }), ); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -13218,7 +13242,7 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { registry.register("exit", Box::new(ExitHandler)); registry.register("slow", Box::new(SlowTestHandler { sleep_ms: 50 })); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -13284,7 +13308,7 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { registry.register("exit", Box::new(ExitHandler)); registry.register("hanging", Box::new(HangingHandler)); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); + let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env().await); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: dir.path().to_path_buf(), @@ -13373,9 +13397,11 @@ async fn asset_collection_local_sandbox_success() { let work_dir = tempfile::tempdir().unwrap(); let run_dir = tempfile::tempdir().unwrap(); - let sandbox: Arc = Arc::new(fabro_agent::LocalSandbox::new( - work_dir.path().to_path_buf(), - )); + let sandbox: Arc = Arc::new( + fabro_agent::local_sandbox(work_dir.path().to_path_buf()) + .await + .expect("local sandbox should be created"), + ); sandbox.initialize().await.unwrap(); let mut registry = HandlerRegistry::new(Box::new(AssetCreatorHandler::success())); @@ -13519,8 +13545,11 @@ 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(fabro_agent::LocalSandbox::new(symlink_work_dir)); + let sandbox: Arc = Arc::new( + fabro_agent::local_sandbox(symlink_work_dir) + .await + .expect("local sandbox should be created"), + ); sandbox.initialize().await.unwrap(); let mut registry = HandlerRegistry::new(Box::new(AssetCreatorHandler::success())); @@ -13619,9 +13648,11 @@ 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(fabro_agent::LocalSandbox::new( - work_dir.path().to_path_buf(), - )); + let sandbox: Arc = Arc::new( + fabro_agent::local_sandbox(work_dir.path().to_path_buf()) + .await + .expect("local sandbox should be created"), + ); sandbox.initialize().await.unwrap(); let mut registry = HandlerRegistry::new(Box::new(AssetCreatorHandler::failing())); @@ -13850,7 +13881,7 @@ async fn wait_timer_e2e() { let engine = WorkflowRunner::new( make_full_registry(interviewer), Arc::new(Emitter::default()), - local_env(), + local_env().await, ); let run_options = RunOptions { settings: WorkflowSettings::default(),