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(),