Add SandboxProvider::Exe for exe.dev VM sandboxes

New `arc-exe` crate that runs agent tool operations inside ephemeral
exe.dev VMs via SSH. Uses two SSH connections: a management plane
(`ssh exe.dev`) for VM lifecycle and a data plane (`ssh vmname.exe.xyz`)
for command execution and file I/O.

Includes SshRunner trait with MockSshRunner for unit tests and
OpensshRunner for real SSH, with raw_mode for the exe.dev management
plane's custom command handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-07 09:18:00 -05:00
parent a67c1be788
commit 5df7e178ac
11 changed files with 1415 additions and 10 deletions

49
Cargo.lock generated
View file

@ -236,6 +236,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "arc-exe"
version = "0.1.0"
dependencies = [
"arc-agent",
"async-trait",
"base64",
"openssh",
"serde",
"serde_json",
"tempfile",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "arc-git-storage"
version = "0.1.0"
@ -339,6 +355,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"arc-agent",
"arc-exe",
"arc-git-storage",
"arc-llm",
"arc-util",
@ -806,7 +823,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1273,7 +1290,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -1360,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2672,7 +2689,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2809,6 +2826,20 @@ dependencies = [
"serde_json",
]
[[package]]
name = "openssh"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d534c4bfecb0ed71dea4db444a5922a294d15cf40e700548f27295e1feb0ef18"
dependencies = [
"libc",
"once_cell",
"shell-escape",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "openssl"
version = "0.10.75"
@ -3217,7 +3248,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@ -3606,7 +3637,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3674,7 +3705,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4484,7 +4515,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5363,7 +5394,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]

View file

@ -48,6 +48,7 @@ toml = "0.8"
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
openssh = "0.11"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", package = "daytona-api-client" }

View file

@ -933,6 +933,7 @@ mod runs {
}),
network: Some(arc_workflows::daytona_sandbox::DaytonaNetwork::Block),
}),
exe: None,
}),
vars: Some(std::collections::HashMap::from([
("repo_url".into(), "https://github.com/org/api-server".into()),
@ -1051,6 +1052,7 @@ mod workflows {
}),
network: None,
}),
exe: None,
}),
vars: Some(std::collections::HashMap::from([
("repo_url".into(), "https://github.com/org/service".into()),
@ -1114,6 +1116,7 @@ mod workflows {
}),
network: None,
}),
exe: None,
}),
vars: Some(std::collections::HashMap::from([
("spec_path".into(), "specs/feature.md".into()),
@ -1188,6 +1191,7 @@ mod workflows {
}),
network: None,
}),
exe: None,
}),
vars: Some(std::collections::HashMap::from([
("source_env".into(), "production".into()),
@ -1253,6 +1257,7 @@ mod workflows {
}),
network: None,
}),
exe: None,
}),
vars: Some(std::collections::HashMap::from([
("analytics_window".into(), "30d".into()),
@ -2590,6 +2595,7 @@ mod settings {
snapshot: None,
network: Some(arc_workflows::daytona_sandbox::DaytonaNetwork::Block),
}),
exe: None,
}),
vars: None,
},

24
crates/arc-exe/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "arc-exe"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "exe.dev VM sandbox for Arc agent tool operations"
[lib]
doctest = false
[dependencies]
arc-agent = { path = "../arc-agent" }
async-trait.workspace = true
tokio.workspace = true
tokio-util.workspace = true
openssh.workspace = true
serde_json.workspace = true
base64.workspace = true
tracing.workspace = true
serde.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"

1124
crates/arc-exe/src/lib.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,123 @@
use async_trait::async_trait;
use openssh::{KnownHosts, Session};
use crate::{SshOutput, SshRunner};
/// Real SSH implementation using the `openssh` crate (multiplexed connections).
pub struct OpensshRunner {
session: Session,
/// When true, commands are sent via `raw_command` (no shell wrapping).
/// Used for the exe.dev management plane which has a custom SSH command handler.
raw_mode: bool,
}
impl OpensshRunner {
/// Connect to a host via SSH, using the user's SSH agent for authentication.
/// Commands are executed through a shell (`sh -c`).
pub async fn connect(host: &str) -> Result<Self, String> {
let session = Session::connect(host, KnownHosts::Accept)
.await
.map_err(|e| format!("SSH connection to {host} failed: {e}"))?;
Ok(Self {
session,
raw_mode: false,
})
}
/// Connect to a host via SSH in raw mode (no shell wrapping).
/// Commands are sent directly as the SSH command string.
/// Used for the exe.dev management plane which has a custom command handler.
pub async fn connect_raw(host: &str) -> Result<Self, String> {
let session = Session::connect(host, KnownHosts::Accept)
.await
.map_err(|e| format!("SSH connection to {host} failed: {e}"))?;
Ok(Self {
session,
raw_mode: true,
})
}
fn build_command(&self, command: &str) -> openssh::OwningCommand<&Session> {
if self.raw_mode {
self.session.raw_command(command)
} else {
self.session.shell(command)
}
}
}
#[async_trait]
impl SshRunner for OpensshRunner {
async fn run_command(&self, command: &str) -> Result<SshOutput, String> {
let output = self
.build_command(command)
.output()
.await
.map_err(|e| format!("SSH command failed: {e}"))?;
let exit_code = output.status.code().unwrap_or(-1);
Ok(SshOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code,
})
}
async fn run_command_with_timeout(
&self,
command: &str,
timeout: std::time::Duration,
) -> Result<SshOutput, String> {
let mut child = self.build_command(command);
let fut = child.output();
match tokio::time::timeout(timeout, fut).await {
Ok(Ok(output)) => {
let exit_code = output.status.code().unwrap_or(-1);
Ok(SshOutput {
stdout: output.stdout,
stderr: output.stderr,
exit_code,
})
}
Ok(Err(e)) => Err(format!("SSH command failed: {e}")),
Err(_) => Err("Command timed out".to_string()),
}
}
async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String> {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(content);
let cmd = format!(
"echo '{}' | base64 -d > '{}'",
encoded,
path.replace('\'', "'\\''"),
);
let output = self
.build_command(&cmd)
.output()
.await
.map_err(|e| format!("SSH upload failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Upload to {path} failed: {stderr}"));
}
Ok(())
}
async fn download_file(&self, path: &str) -> Result<Vec<u8>, String> {
let cmd = format!("cat '{}'", path.replace('\'', "'\\''"));
let output = self
.build_command(&cmd)
.output()
.await
.map_err(|e| format!("SSH download failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Download of {path} failed: {stderr}"));
}
Ok(output.stdout)
}
}

View file

@ -0,0 +1,49 @@
use arc_agent::sandbox::Sandbox;
use arc_exe::{ExeSandbox, OpensshRunner};
/// Full lifecycle test against a real exe.dev account.
/// Requires SSH agent with exe.dev credentials.
///
/// Run with: cargo test -p arc-exe -- --ignored
#[tokio::test]
#[ignore]
async fn exe_sandbox_full_lifecycle() {
let mgmt_ssh = OpensshRunner::connect_raw("exe.dev")
.await
.expect("SSH to exe.dev failed — is your SSH agent running?");
let sandbox = ExeSandbox::new(Box::new(mgmt_ssh));
// Initialize (creates VM)
sandbox.initialize().await.unwrap();
assert!(!sandbox.sandbox_info().is_empty());
assert_eq!(sandbox.platform(), "linux");
// exec_command
let result = sandbox
.exec_command("echo hello", 10_000, None, None, None)
.await
.unwrap();
assert_eq!(result.stdout.trim(), "hello");
assert_eq!(result.exit_code, 0);
// write_file + read_file
sandbox
.write_file("test.txt", "line1\nline2\nline3")
.await
.unwrap();
let content = sandbox.read_file("test.txt", None, None).await.unwrap();
assert!(content.contains("1 | line1"));
assert!(content.contains("2 | line2"));
// file_exists
assert!(sandbox.file_exists("test.txt").await.unwrap());
assert!(!sandbox.file_exists("nonexistent.txt").await.unwrap());
// delete_file
sandbox.delete_file("test.txt").await.unwrap();
assert!(!sandbox.file_exists("test.txt").await.unwrap());
// Cleanup (destroys VM)
sandbox.cleanup().await.unwrap();
}

View file

@ -17,6 +17,7 @@ clap.workspace = true
anyhow.workspace = true
dotenvy.workspace = true
arc-agent = { path = "../arc-agent" }
arc-exe = { path = "../arc-exe" }
arc-util = { path = "../arc-util" }
arc-git-storage = { path = "../arc-git-storage" }
arc-llm = { path = "../arc-llm" }

View file

@ -28,6 +28,8 @@ pub enum SandboxProvider {
Docker,
/// Run tools inside a Daytona cloud sandbox
Daytona,
/// Run tools inside an exe.dev VM
Exe,
}
impl fmt::Display for SandboxProvider {
@ -36,6 +38,7 @@ impl fmt::Display for SandboxProvider {
Self::Local => write!(f, "local"),
Self::Docker => write!(f, "docker"),
Self::Daytona => write!(f, "daytona"),
Self::Exe => write!(f, "exe"),
}
}
}
@ -48,6 +51,7 @@ impl FromStr for SandboxProvider {
"local" => Ok(Self::Local),
"docker" => Ok(Self::Docker),
"daytona" => Ok(Self::Daytona),
"exe" => Ok(Self::Exe),
other => Err(format!("unknown sandbox provider: {other}")),
}
}
@ -255,6 +259,14 @@ mod tests {
"LOCAL".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Local
);
assert_eq!(
"exe".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Exe
);
assert_eq!(
"EXE".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Exe
);
assert!("invalid".parse::<SandboxProvider>().is_err());
}
@ -263,6 +275,7 @@ mod tests {
assert_eq!(SandboxProvider::Local.to_string(), "local");
assert_eq!(SandboxProvider::Docker.to_string(), "docker");
assert_eq!(SandboxProvider::Daytona.to_string(), "daytona");
assert_eq!(SandboxProvider::Exe.to_string(), "exe");
}
#[test]

View file

@ -267,7 +267,7 @@ pub async fn run_command(
SandboxProvider::Local | SandboxProvider::Docker => {
crate::git::ensure_clean(&original_cwd).is_ok()
}
SandboxProvider::Daytona => false,
SandboxProvider::Daytona | SandboxProvider::Exe => false,
};
if args.preflight {
@ -457,6 +457,17 @@ pub async fn run_command(
daytona_sandbox_ref = Some(Arc::clone(&daytona_arc));
daytona_arc
}
SandboxProvider::Exe => {
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));
let emitter_cb = Arc::clone(&emitter);
env.set_event_callback(Arc::new(move |event| {
emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event });
}));
Arc::new(env)
}
SandboxProvider::Local => {
let mut env = LocalSandbox::new(cwd);
let emitter_cb = Arc::clone(&emitter);
@ -667,6 +678,7 @@ pub async fn run_command(
SandboxProvider::Daytona => daytona_base_sha
.as_ref()
.map(|_| GitCheckpointMode::Remote(original_cwd.clone())),
SandboxProvider::Exe => None,
},
base_sha: worktree_base_sha.or(daytona_base_sha),
run_branch: worktree_branch.or(daytona_branch),
@ -1202,6 +1214,13 @@ async fn run_preflight(
}
Err(e) => Err(format!("Daytona client creation failed: {e}")),
},
SandboxProvider::Exe => match arc_exe::OpensshRunner::connect_raw("exe.dev").await {
Ok(mgmt_ssh) => {
let env = arc_exe::ExeSandbox::new(Box::new(mgmt_ssh));
Ok(Arc::new(env) as Arc<dyn Sandbox>)
}
Err(e) => Err(format!("exe.dev SSH connection failed: {e}")),
},
SandboxProvider::Local => {
Ok(Arc::new(LocalSandbox::new(original_cwd.clone())) as Arc<dyn Sandbox>)
}
@ -1653,6 +1672,7 @@ mod tests {
provider: None,
preserve: Some(false),
daytona: None,
exe: None,
}),
vars: None,
hooks: Vec::new(),
@ -1674,6 +1694,7 @@ mod tests {
provider: None,
preserve: Some(true),
daytona: None,
exe: None,
}),
vars: None,
hooks: Vec::new(),
@ -1683,6 +1704,7 @@ mod tests {
provider: None,
preserve: Some(false),
daytona: None,
exe: None,
}),
..RunDefaults::default()
};
@ -1696,6 +1718,7 @@ mod tests {
provider: None,
preserve: Some(true),
daytona: None,
exe: None,
}),
..RunDefaults::default()
};

View file

@ -42,6 +42,7 @@ pub struct SandboxConfig {
pub provider: Option<String>,
pub preserve: Option<bool>,
pub daytona: Option<DaytonaConfig>,
pub exe: Option<arc_exe::ExeConfig>,
}
/// Defaults for workflow runs, loaded from the server config.
@ -754,6 +755,7 @@ preserve = true
provider: None,
preserve: Some(false),
daytona: None,
exe: None,
}),
..RunDefaults::default()
};
@ -779,6 +781,7 @@ provider = "docker"
provider: None,
preserve: Some(true),
daytona: None,
exe: None,
}),
..RunDefaults::default()
};
@ -807,6 +810,7 @@ provider = "daytona"
auto_stop_interval: Some(30),
..DaytonaConfig::default()
}),
exe: None,
}),
..RunDefaults::default()
};
@ -842,6 +846,7 @@ auto_stop_interval = 60
labels: Some(HashMap::from([("env".into(), "prod".into())])),
..DaytonaConfig::default()
}),
exe: None,
}),
..RunDefaults::default()
};
@ -876,6 +881,7 @@ env = "from_task"
])),
..DaytonaConfig::default()
}),
exe: None,
}),
..RunDefaults::default()
};
@ -914,6 +920,7 @@ cpu = 2
}),
..DaytonaConfig::default()
}),
exe: None,
}),
..RunDefaults::default()
};
@ -951,6 +958,7 @@ auto_stop_interval = 60
}),
..DaytonaConfig::default()
}),
exe: None,
}),
..RunDefaults::default()
};
@ -1183,6 +1191,7 @@ network = "block"
network: Some(crate::daytona_sandbox::DaytonaNetwork::AllowAll),
..DaytonaConfig::default()
}),
exe: None,
}),
..RunDefaults::default()
};
@ -1216,6 +1225,7 @@ auto_stop_interval = 60
])),
..DaytonaConfig::default()
}),
exe: None,
}),
..RunDefaults::default()
};