From 4fe05e8a718a07698927a1eaa2fac57de4a72718 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 10 Mar 2026 12:28:17 -0400 Subject: [PATCH] Add `arc ssh` command for SSH access to Daytona sandboxes Standalone subcommand that creates SSH credentials for a run's Daytona sandbox and connects directly (or prints the command with --print). Also parameterizes create_ssh_access TTL instead of hardcoding 60 min. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/arc-cli/src/main.rs | 6 + lib/crates/arc-workflows/src/cli/mod.rs | 1 + lib/crates/arc-workflows/src/cli/ssh.rs | 154 ++++++++++++++++++ .../arc-workflows/src/daytona_sandbox.rs | 6 +- .../tests/daytona_integration.rs | 4 +- 5 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 lib/crates/arc-workflows/src/cli/ssh.rs diff --git a/lib/crates/arc-cli/src/main.rs b/lib/crates/arc-cli/src/main.rs index d51ef27b9..c1a020359 100644 --- a/lib/crates/arc-cli/src/main.rs +++ b/lib/crates/arc-cli/src/main.rs @@ -62,6 +62,8 @@ enum Command { Cp(arc_workflows::cli::cp::CpArgs), /// Get a preview URL for a port on a run's sandbox Preview(arc_workflows::cli::preview::PreviewArgs), + /// SSH into a run's Daytona sandbox + Ssh(arc_workflows::cli::ssh::SshArgs), /// List and test LLM models Model { #[command(subcommand)] @@ -180,6 +182,7 @@ async fn main_inner() -> Result<()> { Command::Parse(_) => "parse", Command::Cp(_) => "cp", Command::Preview(_) => "preview", + Command::Ssh(_) => "ssh", Command::Model { .. } => "model", #[cfg(feature = "server")] Command::Serve(_) => "serve", @@ -355,6 +358,9 @@ async fn main_inner() -> Result<()> { Command::Preview(args) => { arc_workflows::cli::preview::preview_command(args).await?; } + Command::Ssh(args) => { + arc_workflows::cli::ssh::ssh_command(args).await?; + } Command::Model { command } => { let cli_config = cli_config::load_cli_config(None)?; let resolved = diff --git a/lib/crates/arc-workflows/src/cli/mod.rs b/lib/crates/arc-workflows/src/cli/mod.rs index 8c2fcc4cd..39598dcfc 100644 --- a/lib/crates/arc-workflows/src/cli/mod.rs +++ b/lib/crates/arc-workflows/src/cli/mod.rs @@ -9,6 +9,7 @@ pub mod project_config; pub mod run; pub mod run_config; pub mod runs; +pub mod ssh; pub mod validate; use std::path::Path; diff --git a/lib/crates/arc-workflows/src/cli/ssh.rs b/lib/crates/arc-workflows/src/cli/ssh.rs new file mode 100644 index 000000000..655228807 --- /dev/null +++ b/lib/crates/arc-workflows/src/cli/ssh.rs @@ -0,0 +1,154 @@ +use anyhow::{bail, Context, Result}; +use clap::Args; +use tracing::info; + +use crate::cli::runs::{default_runs_base, find_run_by_prefix}; +use crate::sandbox_record::SandboxRecord; + +#[derive(Args)] +pub struct SshArgs { + /// Run ID or prefix + pub run: String, + /// SSH access expiry in minutes (default 60) + #[arg(long, default_value = "60")] + pub ttl: f64, + /// Print the SSH command instead of connecting + #[arg(long)] + pub print: bool, +} + +fn validate_provider(record: &SandboxRecord) -> Result<()> { + if record.provider != "daytona" { + bail!( + "SSH access is only supported for Daytona sandboxes (this run uses '{}')", + record.provider + ); + } + Ok(()) +} + +fn format_output(ssh_command: &str) -> String { + format!("{ssh_command}\n") +} + +pub async fn ssh_command(args: SshArgs) -> Result<()> { + let base = default_runs_base(); + let run_dir = find_run_by_prefix(&base, &args.run)?; + let sandbox_json = run_dir.join("sandbox.json"); + let record = SandboxRecord::load(&sandbox_json).context( + "Failed to load sandbox.json — was this run started with a recent version of arc?", + )?; + + validate_provider(&record)?; + + let name = record + .identifier + .as_deref() + .context("Daytona sandbox record missing identifier (sandbox name)")?; + + info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access"); + + let client = daytona_sdk::Client::new() + .await + .map_err(|e| anyhow::anyhow!("Failed to create Daytona client: {e}"))?; + + let sdk_sandbox = client + .get(name) + .await + .map_err(|e| anyhow::anyhow!("Failed to reconnect to Daytona sandbox '{name}': {e}"))?; + + let daytona = crate::daytona_sandbox::DaytonaSandbox::from_existing(client, sdk_sandbox); + + let ssh_cmd = daytona + .create_ssh_access(Some(args.ttl)) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + if args.print { + let output = format_output(&ssh_cmd); + print!("{output}"); + } else { + exec_ssh(&ssh_cmd)?; + } + + Ok(()) +} + +#[cfg(unix)] +fn exec_ssh(ssh_cmd: &str) -> Result<()> { + use std::os::unix::process::CommandExt; + + let parts: Vec<&str> = ssh_cmd.split_whitespace().collect(); + if parts.is_empty() { + bail!("Empty SSH command returned from Daytona"); + } + let err = std::process::Command::new(parts[0]) + .args(&parts[1..]) + .exec(); + // exec() only returns on error + Err(anyhow::anyhow!("Failed to exec SSH: {err}")) +} + +#[cfg(not(unix))] +fn exec_ssh(ssh_cmd: &str) -> Result<()> { + bail!("Direct SSH connection is only supported on Unix systems; use --print instead"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_provider_rejects_local() { + let record = SandboxRecord { + provider: "local".to_string(), + working_directory: "/tmp".to_string(), + identifier: None, + host_working_directory: None, + container_mount_point: None, + data_host: None, + }; + let err = validate_provider(&record).unwrap_err(); + assert!( + err.to_string() + .contains("SSH access is only supported for Daytona sandboxes"), + "got: {err}" + ); + } + + #[test] + fn validate_provider_rejects_docker() { + let record = SandboxRecord { + provider: "docker".to_string(), + working_directory: "/workspace".to_string(), + identifier: None, + host_working_directory: None, + container_mount_point: None, + data_host: None, + }; + let err = validate_provider(&record).unwrap_err(); + assert!( + err.to_string().contains("this run uses 'docker'"), + "got: {err}" + ); + } + + #[test] + fn validate_provider_accepts_daytona() { + let record = SandboxRecord { + provider: "daytona".to_string(), + working_directory: "/home/daytona/workspace".to_string(), + identifier: Some("sandbox-abc".to_string()), + host_working_directory: None, + container_mount_point: None, + data_host: None, + }; + validate_provider(&record).unwrap(); + } + + #[test] + fn format_output_produces_ssh_command_with_newline() { + let output = format_output("ssh -p 2222 daytona@sandbox-123.daytona.work"); + assert_eq!(output, "ssh -p 2222 daytona@sandbox-123.daytona.work\n"); + } +} diff --git a/lib/crates/arc-workflows/src/daytona_sandbox.rs b/lib/crates/arc-workflows/src/daytona_sandbox.rs index 583450016..6b57f07a0 100644 --- a/lib/crates/arc-workflows/src/daytona_sandbox.rs +++ b/lib/crates/arc-workflows/src/daytona_sandbox.rs @@ -216,10 +216,10 @@ impl DaytonaSandbox { } /// Create SSH access and return the connection command string. - pub async fn create_ssh_access(&self) -> Result { + pub async fn create_ssh_access(&self, ttl_minutes: Option) -> Result { let sandbox = self.sandbox()?; let dto = sandbox - .create_ssh_access(Some(60.0)) + .create_ssh_access(ttl_minutes) .await .map_err(|e| format!("Failed to create SSH access: {e}"))?; Ok(dto.ssh_command) @@ -800,7 +800,7 @@ impl Sandbox for DaytonaSandbox { } async fn ssh_access_command(&self) -> Result, String> { - self.create_ssh_access().await.map(Some) + self.create_ssh_access(Some(60.0)).await.map(Some) } fn origin_url(&self) -> Option<&str> { diff --git a/lib/crates/arc-workflows/tests/daytona_integration.rs b/lib/crates/arc-workflows/tests/daytona_integration.rs index cac7e9f94..eb0852bca 100644 --- a/lib/crates/arc-workflows/tests/daytona_integration.rs +++ b/lib/crates/arc-workflows/tests/daytona_integration.rs @@ -1345,7 +1345,7 @@ async fn daytona_ssh_access() { let env = create_env().await; env.initialize().await.unwrap(); - let ssh_command = env.create_ssh_access().await.unwrap(); + let ssh_command = env.create_ssh_access(Some(60.0)).await.unwrap(); assert!(!ssh_command.is_empty(), "ssh_command should not be empty"); assert!( ssh_command.contains("ssh"), @@ -1360,7 +1360,7 @@ async fn daytona_ssh_access() { async fn daytona_ssh_access_before_init_fails() { let env = create_env().await; - let result = env.create_ssh_access().await; + let result = env.create_ssh_access(Some(60.0)).await; assert!(result.is_err(), "should fail before initialize()"); assert!( result.unwrap_err().contains("not initialized"),