Add SSH sandbox provider (arc-ssh) for user-provided hosts

Adds a generic SSH sandbox that connects to any user-provided host via
openssh, without VM lifecycle management. Supports git clone with GitHub
App credentials, configurable working directory, and arc cp reconnection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-11 22:29:51 -04:00
parent 2ce89e5e5e
commit e0b3c4cde4
12 changed files with 1641 additions and 1 deletions

19
Cargo.lock generated
View file

@ -409,6 +409,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "arc-ssh"
version = "0.2.0"
dependencies = [
"arc-agent",
"arc-github",
"async-trait",
"base64",
"openssh",
"serde",
"serde_json",
"shlex",
"tempfile",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "arc-types"
version = "0.2.0"
@ -463,6 +481,7 @@ dependencies = [
"arc-github",
"arc-llm",
"arc-mcp",
"arc-ssh",
"arc-util",
"assert_cmd",
"async-trait",

View file

@ -4080,6 +4080,8 @@ components:
$ref: "#/components/schemas/DaytonaConfiguration"
exe:
$ref: "#/components/schemas/ExeConfiguration"
ssh:
$ref: "#/components/schemas/SshConfiguration"
local:
$ref: "#/components/schemas/LocalSandboxConfiguration"
env:
@ -4106,6 +4108,23 @@ components:
type: string
description: VM image to use for the exe.dev sandbox.
SshConfiguration:
description: SSH sandbox configuration for user-provided hosts.
type: object
required:
- destination
- working_directory
properties:
destination:
type: string
description: SSH destination (e.g. user@host or an SSH alias).
working_directory:
type: string
description: Remote working directory.
config_file:
type: string
description: Optional path to a custom SSH config file.
DaytonaConfiguration:
description: Daytona-specific sandbox settings.
type: object

View file

@ -1333,6 +1333,7 @@ mod runs {
network: Some(arc_workflows::daytona_sandbox::DaytonaNetwork::Block),
}),
exe: None,
ssh: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
@ -1490,6 +1491,7 @@ mod workflows {
network: None,
}),
exe: None,
ssh: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
@ -1561,6 +1563,7 @@ mod workflows {
network: None,
}),
exe: None,
ssh: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
@ -1643,6 +1646,7 @@ mod workflows {
network: None,
}),
exe: None,
ssh: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
@ -1716,6 +1720,7 @@ mod workflows {
network: None,
}),
exe: None,
ssh: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
@ -3279,6 +3284,7 @@ mod settings {
network: Some(arc_workflows::daytona_sandbox::DaytonaNetwork::Block),
}),
exe: None,
ssh: None,
env: None,
}),
vars: None,

View file

@ -305,6 +305,7 @@ fn fully_populated_server_config() -> ServerConfig {
network: Some(DaytonaNetwork::Block),
}),
exe: Some(arc_exe::ExeConfig { image: None }),
ssh: None,
env: Some(Default::default()),
}),
vars: Some(Default::default()),

View file

@ -0,0 +1,26 @@
[package]
name = "arc-ssh"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "Generic SSH sandbox for Arc agent tool operations"
[lib]
doctest = false
[dependencies]
arc-agent = { path = "../arc-agent" }
arc-github = { path = "../arc-github" }
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
shlex = "1"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,101 @@
use async_trait::async_trait;
use openssh::{KnownHosts, SessionBuilder};
use crate::{shell_quote, SshOutput, SshRunner};
/// Real SSH implementation using the `openssh` crate (multiplexed connections).
pub struct OpensshRunner {
session: openssh::Session,
}
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(destination: &str, config_file: Option<&str>) -> Result<Self, String> {
let mut builder = SessionBuilder::default();
builder.known_hosts_check(KnownHosts::Accept);
if let Some(cfg) = config_file {
builder.config_file(cfg);
}
let session = builder
.connect(destination)
.await
.map_err(|e| format!("SSH connection to {destination} failed: {e}"))?;
Ok(Self { session })
}
}
#[async_trait]
impl SshRunner for OpensshRunner {
async fn run_command(&self, command: &str) -> Result<SshOutput, String> {
let output = self
.session
.shell(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.session.shell(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, shell_quote(path),);
let output = self
.session
.shell(&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 {}", shell_quote(path));
let output = self
.session
.shell(&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

@ -23,6 +23,7 @@ dotenvy.workspace = true
arc-agent = { path = "../arc-agent" }
arc-devcontainer = { path = "../arc-devcontainer" }
arc-exe = { path = "../arc-exe", optional = true }
arc-ssh = { path = "../arc-ssh" }
arc-mcp = { path = "../arc-mcp" }
arc-github = { path = "../arc-github" }
arc-util = { path = "../arc-util" }

View file

@ -150,6 +150,26 @@ pub async fn reconnect(record: &SandboxRecord) -> Result<Box<dyn arc_agent::sand
let sandbox = arc_exe::ExeSandbox::from_existing(Box::new(data_ssh));
Ok(Box::new(sandbox))
}
"ssh" => {
let destination = record
.data_host
.as_deref()
.context("SSH sandbox record missing data_host (destination)")?;
let ssh = arc_ssh::OpensshRunner::connect(destination, None)
.await
.map_err(|e| {
anyhow::anyhow!("Failed to connect to SSH sandbox '{destination}': {e}")
})?;
let config = arc_ssh::SshConfig {
destination: destination.to_string(),
working_directory: record.working_directory.clone(),
config_file: None,
};
let sandbox = arc_ssh::SshSandbox::from_existing(Box::new(ssh), config);
Ok(Box::new(sandbox))
}
other => bail!("Unknown sandbox provider: {other}"),
}
}

View file

@ -38,6 +38,8 @@ pub enum SandboxProvider {
/// Run tools inside an exe.dev VM
#[cfg(feature = "exedev")]
Exe,
/// Run tools on a user-provided SSH host
Ssh,
}
impl SandboxProvider {
@ -46,6 +48,7 @@ impl SandboxProvider {
Self::Daytona => true,
#[cfg(feature = "exedev")]
Self::Exe => true,
Self::Ssh => true,
_ => false,
}
}
@ -59,6 +62,7 @@ impl fmt::Display for SandboxProvider {
Self::Daytona => write!(f, "daytona"),
#[cfg(feature = "exedev")]
Self::Exe => write!(f, "exe"),
Self::Ssh => write!(f, "ssh"),
}
}
}
@ -73,6 +77,7 @@ impl FromStr for SandboxProvider {
"daytona" => Ok(Self::Daytona),
#[cfg(feature = "exedev")]
"exe" => Ok(Self::Exe),
"ssh" => Ok(Self::Ssh),
other => Err(format!("unknown sandbox provider: {other}")),
}
}
@ -309,6 +314,14 @@ mod tests {
SandboxProvider::Exe
);
}
assert_eq!(
"ssh".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Ssh
);
assert_eq!(
"SSH".parse::<SandboxProvider>().unwrap(),
SandboxProvider::Ssh
);
assert!("invalid".parse::<SandboxProvider>().is_err());
}
@ -319,6 +332,7 @@ mod tests {
assert_eq!(SandboxProvider::Daytona.to_string(), "daytona");
#[cfg(feature = "exedev")]
assert_eq!(SandboxProvider::Exe.to_string(), "exe");
assert_eq!(SandboxProvider::Ssh.to_string(), "ssh");
}
#[test]

View file

@ -225,6 +225,33 @@ fn resolve_exe_clone_params(cwd: &std::path::Path) -> Option<arc_exe::GitClonePa
Some(arc_exe::GitCloneParams { url, branch })
}
/// Resolve SSH sandbox config: TOML config > run defaults.
fn resolve_ssh_config(
run_cfg: Option<&WorkflowRunConfig>,
run_defaults: &RunDefaults,
) -> Option<arc_ssh::SshConfig> {
run_cfg
.and_then(|c| c.sandbox.as_ref())
.and_then(|e| e.ssh.clone())
.or_else(|| run_defaults.sandbox.as_ref().and_then(|s| s.ssh.clone()))
}
/// Resolve SSH sandbox git clone parameters from the current repo.
///
/// Returns `None` if no git repo is detected. Credential resolution is
/// handled by SshSandbox itself via its `github_app` field.
fn resolve_ssh_clone_params(cwd: &std::path::Path) -> Option<arc_ssh::GitCloneParams> {
let (detected_url, branch) = match crate::daytona_sandbox::detect_repo_info(cwd) {
Ok(info) => info,
Err(e) => {
tracing::warn!("No git repo detected for SSH clone: {e}");
return None;
}
};
let url = arc_github::ssh_url_to_https(&detected_url);
Some(arc_ssh::GitCloneParams { url, branch })
}
/// Resolve the fallback chain from config.
///
/// `apply_defaults` must be called on `run_cfg` before this — it merges
@ -625,6 +652,7 @@ pub async fn run_command(
let mut daytona_config = resolve_daytona_config(run_cfg.as_ref(), &run_defaults);
#[cfg(feature = "exedev")]
let exe_config = resolve_exe_config(run_cfg.as_ref(), &run_defaults);
let ssh_config = resolve_ssh_config(run_cfg.as_ref(), &run_defaults);
// Resolve devcontainer if enabled
let devcontainer_config = if run_cfg
@ -762,6 +790,23 @@ pub async fn run_command(
}));
Arc::new(env)
}
SandboxProvider::Ssh => {
let config = ssh_config
.clone()
.ok_or_else(|| anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config"))?;
let clone_params = resolve_ssh_clone_params(&original_cwd);
let mut env = arc_ssh::SshSandbox::new(
config,
clone_params,
Some(run_id.clone()),
github_app.clone(),
);
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.clone());
let emitter_cb = Arc::clone(&emitter);
@ -836,6 +881,17 @@ pub async fn run_command(
data_host,
}
}
SandboxProvider::Ssh => {
let data_host = ssh_config.as_ref().map(|c| c.destination.clone());
crate::sandbox_record::SandboxRecord {
provider: "ssh".to_string(),
working_directory: sandbox.working_directory().to_string(),
identifier: sandbox_info_opt,
host_working_directory: None,
container_mount_point: None,
data_host,
}
}
};
if let Err(e) = record.save(&run_dir.join("sandbox.json")) {
tracing::warn!(error = %e, "Failed to save sandbox record");
@ -895,7 +951,7 @@ pub async fn run_command(
}
Ok(None) => {
eprintln!(
"{} --ssh only works with --sandbox daytona or exe, skipping.",
"{} --ssh only works with --sandbox daytona, exe, or ssh, skipping.",
styles.yellow.apply_to("Warning:"),
);
}
@ -1611,6 +1667,23 @@ async fn run_from_branch(
}));
(Arc::new(env), None)
}
SandboxProvider::Ssh => {
let config = resolve_ssh_config(None, &run_defaults).ok_or_else(|| {
anyhow::anyhow!("--sandbox ssh requires [sandbox.ssh] config")
})?;
let clone_params = resolve_ssh_clone_params(&original_cwd);
let mut env = arc_ssh::SshSandbox::new(
config,
clone_params,
Some(run_id.clone()),
github_app.clone(),
);
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), None)
}
SandboxProvider::Daytona => {
bail!("--run-branch resume is not yet supported with --sandbox daytona");
}
@ -1911,6 +1984,7 @@ async fn run_preflight(
let daytona_config = resolve_daytona_config(run_cfg.as_ref(), run_defaults);
#[cfg(feature = "exedev")]
let exe_config = resolve_exe_config(run_cfg.as_ref(), run_defaults);
let ssh_config = resolve_ssh_config(run_cfg.as_ref(), run_defaults);
let sandbox_result: Result<Arc<dyn Sandbox>, String> = match sandbox_provider {
SandboxProvider::Docker => {
@ -1947,6 +2021,14 @@ async fn run_preflight(
}
Err(e) => Err(format!("exe.dev SSH connection failed: {e}")),
},
SandboxProvider::Ssh => match ssh_config {
Some(config) => {
let clone_params = resolve_ssh_clone_params(&original_cwd);
let env = arc_ssh::SshSandbox::new(config, clone_params, None, None);
Ok(Arc::new(env) as Arc<dyn Sandbox>)
}
None => Err("SSH sandbox requires [sandbox.ssh] config".to_string()),
},
SandboxProvider::Local => {
Ok(Arc::new(LocalSandbox::new(original_cwd.clone())) as Arc<dyn Sandbox>)
}
@ -2592,6 +2674,7 @@ mod tests {
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
vars: None,
@ -2622,6 +2705,7 @@ mod tests {
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
vars: None,
@ -2640,6 +2724,7 @@ mod tests {
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -2658,6 +2743,7 @@ mod tests {
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -2699,6 +2785,7 @@ mod tests {
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
vars: None,
@ -2728,6 +2815,7 @@ mod tests {
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -2757,6 +2845,7 @@ mod tests {
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
vars: None,
@ -2777,6 +2866,7 @@ mod tests {
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()

View file

@ -118,6 +118,7 @@ pub struct SandboxConfig {
pub daytona: Option<DaytonaConfig>,
#[cfg(feature = "exedev")]
pub exe: Option<arc_exe::ExeConfig>,
pub ssh: Option<arc_ssh::SshConfig>,
pub env: Option<HashMap<String, String>>,
}
@ -1107,6 +1108,7 @@ preserve = true
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1137,6 +1139,7 @@ provider = "docker"
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1170,6 +1173,7 @@ provider = "daytona"
}),
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1210,6 +1214,7 @@ auto_stop_interval = 60
}),
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1249,6 +1254,7 @@ env = "from_task"
}),
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1292,6 +1298,7 @@ cpu = 2
}),
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1334,6 +1341,7 @@ auto_stop_interval = 60
}),
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1559,6 +1567,7 @@ network = "block"
}),
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1597,6 +1606,7 @@ auto_stop_interval = 60
}),
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: None,
}),
..RunDefaults::default()
@ -1807,6 +1817,7 @@ SHARED = "from_task"
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: Some(HashMap::from([
("DEFAULT_KEY".into(), "default_val".into()),
("SHARED".into(), "from_default".into()),
@ -1843,6 +1854,7 @@ provider = "daytona"
daytona: None,
#[cfg(feature = "exedev")]
exe: None,
ssh: None,
env: Some(HashMap::from([("KEY".into(), "val".into())])),
}),
..RunDefaults::default()