Close 3 exe.dev gaps: ExeConfig.image, run ID tracking, preflight config

Add image field to ExeConfig so users can specify a Docker image for
exe.dev VMs via TOML config. Pass run_id to ExeSandbox for VM-to-run
correlation in sandbox_info(). Wire resolved exe config into the
preflight path for consistency with the main execution path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-09 09:28:23 -04:00
parent 740f95414a
commit 54de2aa651
4 changed files with 75 additions and 17 deletions

View file

@ -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<String>,
}
/// 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<GitCloneParams>,
run_id: Option<String>,
origin_url: tokio::sync::OnceCell<String>,
}
impl ExeSandbox {
/// Creates a new `ExeSandbox` with a management-plane SSH runner.
pub fn new(mgmt_ssh: Box<dyn SshRunner>, clone_params: Option<GitCloneParams>) -> Self {
pub fn new(
mgmt_ssh: Box<dyn SshRunner>,
config: ExeConfig,
clone_params: Option<GitCloneParams>,
run_id: Option<String>,
) -> Self {
Self {
mgmt_ssh,
data_ssh: tokio::sync::OnceCell::new(),
@ -104,7 +113,9 @@ impl ExeSandbox {
.map(|r| Box::new(r) as Box<dyn SshRunner>)
})
}),
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<Mutex<Option<Box<dyn SshRunner>>>> =
Arc::new(Mutex::new(Some(Box::new(data_for_init))));
@ -1303,7 +1322,7 @@ mod tests {
let events: Arc<Mutex<Vec<String>>> = 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 <vm_name>`
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<Mutex<Option<Box<dyn SshRunner>>>> =
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:?}"));
}));

View file

@ -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();

View file

@ -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<arc_exe::ExeConfig> {
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<Arc<dyn Sandbox>, 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<dyn Sandbox>)
}
Err(e) => Err(format!("exe.dev SSH connection failed: {e}")),

View file

@ -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 {