diff --git a/crates/arc-exe/src/lib.rs b/crates/arc-exe/src/lib.rs index fd6e7a796..0038629f2 100644 --- a/crates/arc-exe/src/lib.rs +++ b/crates/arc-exe/src/lib.rs @@ -53,7 +53,9 @@ pub trait SshRunner: Send + Sync { /// Configuration for an exe.dev sandbox (TOML target for `[sandbox.exe]`). #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct ExeConfig {} +pub struct ExeConfig { + pub image: Option, +} /// Parameters for cloning a git repo into the sandbox during initialization. #[derive(Clone, Debug)] @@ -82,13 +84,20 @@ pub struct ExeSandbox { /// In production, this connects to the VM host via OpensshRunner. /// In tests, this is replaced with a closure that returns a MockSshRunner. data_ssh_factory: DataSshFactory, + config: ExeConfig, clone_params: Option, + run_id: Option, origin_url: tokio::sync::OnceCell, } impl ExeSandbox { /// Creates a new `ExeSandbox` with a management-plane SSH runner. - pub fn new(mgmt_ssh: Box, clone_params: Option) -> Self { + pub fn new( + mgmt_ssh: Box, + config: ExeConfig, + clone_params: Option, + run_id: Option, + ) -> Self { Self { mgmt_ssh, data_ssh: tokio::sync::OnceCell::new(), @@ -104,7 +113,9 @@ impl ExeSandbox { .map(|r| Box::new(r) as Box) }) }), + config, clone_params, + run_id, origin_url: tokio::sync::OnceCell::new(), } } @@ -270,7 +281,11 @@ impl Sandbox for ExeSandbox { let init_start = Instant::now(); // Create a new VM via the management plane - let output = self.mgmt_ssh.run_command("new --json").await.map_err(|e| { + let mut cmd = "new --json".to_string(); + if let Some(ref image) = self.config.image { + cmd.push_str(&format!(" --image {image}")); + } + let output = self.mgmt_ssh.run_command(&cmd).await.map_err(|e| { let err = format!("Failed to create exe.dev VM: {e}"); let duration_ms = u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); self.emit(SandboxEvent::InitializeFailed { @@ -696,7 +711,11 @@ impl Sandbox for ExeSandbox { } fn sandbox_info(&self) -> String { - self.vm_name.get().cloned().unwrap_or_default() + match (self.vm_name.get(), &self.run_id) { + (Some(name), Some(id)) => format!("{name} (run {id})"), + (Some(name), None) => name.clone(), + _ => String::new(), + } } } @@ -851,7 +870,7 @@ mod tests { /// Helper: create an ExeSandbox with mock data SSH already initialized (skipping lifecycle). fn sandbox_with_mock_data(data_ssh: impl SshRunner + 'static) -> ExeSandbox { let mgmt = MockSshRunner::new(); - let sandbox = ExeSandbox::new(Box::new(mgmt), None); + let sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None); let _ = sandbox.vm_name.set("test-vm".to_string()); let _ = sandbox.data_host.set("test-vm.exe.xyz".to_string()); let _ = sandbox.data_ssh.set(Box::new(data_ssh)); @@ -895,7 +914,7 @@ mod tests { #[test] fn ssh_command_errors_before_init() { let mgmt = MockSshRunner::new(); - let sandbox = ExeSandbox::new(Box::new(mgmt), None); + let sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None); assert!(sandbox.ssh_command().is_err()); } @@ -1271,7 +1290,7 @@ mod tests { let data_for_init = MockSshRunner::new(); - let mut sandbox = ExeSandbox::new(Box::new(mgmt), None); + let mut sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None); // Override factory to return our mock data SSH let data_box: Arc>>> = Arc::new(Mutex::new(Some(Box::new(data_for_init)))); @@ -1303,7 +1322,7 @@ mod tests { let events: Arc>> = Arc::new(Mutex::new(Vec::new())); let events_cb = Arc::clone(&events); - let mut sandbox = ExeSandbox::new(Box::new(mgmt), None); + let mut sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None); sandbox.set_event_callback(Arc::new(move |event| { events_cb.lock().unwrap().push(format!("{event:?}")); })); @@ -1342,7 +1361,7 @@ mod tests { // Response for `rm ` mgmt.queue_response("", "", 0); - let sandbox = ExeSandbox::new(Box::new(mgmt), None); + let sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None); let _ = sandbox.vm_name.set("doomed-vm".to_string()); sandbox.cleanup().await.unwrap(); @@ -1354,7 +1373,7 @@ mod tests { #[tokio::test] async fn cleanup_before_initialize_is_noop() { let mgmt = MockSshRunner::new(); - let sandbox = ExeSandbox::new(Box::new(mgmt), None); + let sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None); // Should not error — no VM to destroy sandbox.cleanup().await.unwrap(); } @@ -1385,7 +1404,12 @@ mod tests { display_url: "https://github.com/org/repo.git".to_string(), branch: Some("main".to_string()), }; - let mut sandbox = ExeSandbox::new(Box::new(mgmt), Some(clone_params)); + let mut sandbox = ExeSandbox::new( + Box::new(mgmt), + ExeConfig::default(), + Some(clone_params), + None, + ); sandbox.data_ssh_factory = Box::new(move |_host: &str| { let data_box = Arc::clone(&data_box); Box::pin(async move { @@ -1430,7 +1454,7 @@ mod tests { let data_box: Arc>>> = Arc::new(Mutex::new(Some(Box::new(data)))); - let mut sandbox = ExeSandbox::new(Box::new(mgmt), None); + let mut sandbox = ExeSandbox::new(Box::new(mgmt), ExeConfig::default(), None, None); sandbox.data_ssh_factory = Box::new(move |_host: &str| { let data_box = Arc::clone(&data_box); Box::pin(async move { @@ -1476,7 +1500,12 @@ mod tests { display_url: "https://github.com/org/repo.git".to_string(), branch: None, }; - let mut sandbox = ExeSandbox::new(Box::new(mgmt), Some(clone_params)); + let mut sandbox = ExeSandbox::new( + Box::new(mgmt), + ExeConfig::default(), + Some(clone_params), + None, + ); sandbox.set_event_callback(Arc::new(move |event| { events_cb.lock().unwrap().push(format!("{event:?}")); })); diff --git a/crates/arc-exe/tests/integration.rs b/crates/arc-exe/tests/integration.rs index d3f461f30..8491aff4e 100644 --- a/crates/arc-exe/tests/integration.rs +++ b/crates/arc-exe/tests/integration.rs @@ -1,5 +1,5 @@ use arc_agent::sandbox::Sandbox; -use arc_exe::{ExeSandbox, OpensshRunner}; +use arc_exe::{ExeConfig, ExeSandbox, OpensshRunner}; /// Full lifecycle test against a real exe.dev account. /// Requires SSH agent with exe.dev credentials. @@ -12,7 +12,7 @@ async fn exe_sandbox_full_lifecycle() { .await .expect("SSH to exe.dev failed — is your SSH agent running?"); - let sandbox = ExeSandbox::new(Box::new(mgmt_ssh), None); + let sandbox = ExeSandbox::new(Box::new(mgmt_ssh), ExeConfig::default(), None, None); // Initialize (creates VM) sandbox.initialize().await.unwrap(); diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 7471ac73a..e48f5d24b 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -175,6 +175,17 @@ fn resolve_daytona_config( }) } +/// Resolve exe.dev config: TOML config > run defaults. +fn resolve_exe_config( + run_cfg: Option<&WorkflowRunConfig>, + run_defaults: &RunDefaults, +) -> Option { + run_cfg + .and_then(|c| c.sandbox.as_ref()) + .and_then(|e| e.exe.clone()) + .or_else(|| run_defaults.sandbox.as_ref().and_then(|s| s.exe.clone())) +} + /// Resolve the fallback chain from config. /// /// `apply_defaults` must be called on `run_cfg` before this — it merges @@ -480,6 +491,7 @@ pub async fn run_command( let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let daytona_config = resolve_daytona_config(run_cfg.as_ref(), &run_defaults); + let exe_config = resolve_exe_config(run_cfg.as_ref(), &run_defaults); // Wrap emitter in Arc now so we can share it with exec env callbacks let emitter = Arc::new(emitter); @@ -564,7 +576,13 @@ pub async fn run_command( let mgmt_ssh = arc_exe::OpensshRunner::connect_raw("exe.dev") .await .map_err(|e| anyhow::anyhow!("Failed to connect to exe.dev: {e}"))?; - let mut env = arc_exe::ExeSandbox::new(Box::new(mgmt_ssh), clone_params); + let config = exe_config.unwrap_or_default(); + let mut env = arc_exe::ExeSandbox::new( + Box::new(mgmt_ssh), + config, + clone_params, + Some(run_id.clone()), + ); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); @@ -1438,6 +1456,7 @@ async fn run_preflight( // 2. Sandbox boot check let original_cwd = std::env::current_dir()?; let daytona_config = resolve_daytona_config(run_cfg.as_ref(), run_defaults); + let exe_config = resolve_exe_config(run_cfg.as_ref(), run_defaults); let sandbox_result: Result, String> = match sandbox_provider { SandboxProvider::Docker => { @@ -1464,7 +1483,8 @@ async fn run_preflight( }, SandboxProvider::Exe => match arc_exe::OpensshRunner::connect_raw("exe.dev").await { Ok(mgmt_ssh) => { - let env = arc_exe::ExeSandbox::new(Box::new(mgmt_ssh), None); + let config = exe_config.unwrap_or_default(); + let env = arc_exe::ExeSandbox::new(Box::new(mgmt_ssh), config, None, None); Ok(Arc::new(env) as Arc) } Err(e) => Err(format!("exe.dev SSH connection failed: {e}")), diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index e5ebfcdf6..ee4c8f096 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -152,6 +152,15 @@ impl WorkflowRunConfig { (None, Some(_)) => task.daytona = default.daytona.clone(), _ => {} } + match (&mut task.exe, &default.exe) { + (Some(task_e), Some(default_e)) => { + if task_e.image.is_none() { + task_e.image = default_e.image.clone(); + } + } + (None, Some(_)) => task.exe = default.exe.clone(), + _ => {} + } if let Some(ref default_env) = default.env { let mut merged = default_env.clone(); if let Some(ref task_env) = task.env {