From 97d370b72794d5feb91fc31bb920c8d54eb2158c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 4 Mar 2026 09:11:39 -0500 Subject: [PATCH] Add --ssh flag to arc run for Daytona SSH access Creates SSH access after sandbox init and displays the connection command in the progress output, aligned under the sandbox detail line. Co-Authored-By: Claude Opus 4.6 --- crates/arc-workflows/src/cli/mod.rs | 7 ++++ crates/arc-workflows/src/cli/progress.rs | 18 +++++++++ crates/arc-workflows/src/cli/run.rs | 29 ++++++++++++++- crates/arc-workflows/src/daytona_sandbox.rs | 11 ++++++ crates/arc-workflows/src/event.rs | 6 +++ .../tests/daytona_integration.rs | 37 +++++++++++++++++++ 6 files changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index c316b6cb7..6117e3b2b 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -127,6 +127,10 @@ pub struct RunArgs { /// Skip retro generation after the run #[arg(long)] pub no_retro: bool, + + /// Create SSH access to the Daytona sandbox and print the connection command + #[arg(long)] + pub ssh: bool, } #[derive(Args)] @@ -562,6 +566,9 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String } => { format!("[ASSETS_CAPTURED] node={node_id} files_copied={files_copied} total_bytes={} files_skipped={files_skipped}", HumanBytes(*total_bytes)) } + WorkflowRunEvent::SshAccessReady { ssh_command } => { + format!("[SSH_ACCESS_READY] {ssh_command}") + } }; format!("{}", styles.dim.apply_to(body)) } diff --git a/crates/arc-workflows/src/cli/progress.rs b/crates/arc-workflows/src/cli/progress.rs index 7467262f4..67825060a 100644 --- a/crates/arc-workflows/src/cli/progress.rs +++ b/crates/arc-workflows/src/cli/progress.rs @@ -315,6 +315,9 @@ impl ProgressUI { WorkflowRunEvent::Agent { stage, event } => { self.on_agent_event(stage, event); } + WorkflowRunEvent::SshAccessReady { ssh_command } => { + self.on_ssh_access_ready(ssh_command); + } _ => {} } } @@ -378,6 +381,21 @@ impl ProgressUI { } } + // ── SSH access ────────────────────────────────────────────────────── + + fn on_ssh_access_ready(&mut self, ssh_command: &str) { + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let bar = tty.multi.add(ProgressBar::new_spinner()); + bar.set_style(style_sandbox_detail()); + bar.finish_with_message(ssh_command.to_string()); + } + ProgressRenderer::Plain => { + eprintln!(" {ssh_command}"); + } + } + } + // ── Setup ─────────────────────────────────────────────────────────── fn on_setup_started(&mut self, command_count: usize) { diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 5219199fa..71c931577 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -412,6 +412,7 @@ pub async fn run_command( // Wrap emitter in Arc now so we can share it with exec env callbacks let emitter = Arc::new(emitter); + let mut daytona_sandbox_ref: Option> = None; let sandbox: Arc = match sandbox_provider { SandboxProvider::Docker => { let config = DockerSandboxConfig { @@ -436,7 +437,9 @@ pub async fn run_command( env.set_event_callback(Arc::new(move |event| { emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); })); - Arc::new(env) + let daytona_arc = Arc::new(env); + daytona_sandbox_ref = Some(Arc::clone(&daytona_arc)); + daytona_arc } SandboxProvider::Local => { let mut env = LocalSandbox::new(cwd); @@ -482,6 +485,30 @@ pub async fn run_command( (None, None, None) }; + // Create SSH access if requested + if args.ssh { + if let Some(ref daytona) = daytona_sandbox_ref { + match daytona.create_ssh_access(Some(60.0)).await { + Ok(ssh_info) => { + emitter.emit(&crate::event::WorkflowRunEvent::SshAccessReady { + ssh_command: ssh_info.ssh_command, + }); + } + Err(e) => { + eprintln!( + "{} Failed to create SSH access: {e}", + styles.yellow.apply_to("Warning:"), + ); + } + } + } else { + eprintln!( + "{} --ssh only works with --sandbox daytona, skipping.", + styles.yellow.apply_to("Warning:"), + ); + } + } + // Run setup commands inside the sandbox (once, not per-stage) if !setup_commands.is_empty() { emitter.emit(&crate::event::WorkflowRunEvent::SetupStarted { diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index 622553d09..e53a32378 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -59,6 +59,17 @@ impl DaytonaSandbox { self.event_callback = Some(cb); } + pub async fn create_ssh_access( + &self, + expires_in_minutes: Option, + ) -> Result { + let sandbox = self.sandbox()?; + sandbox + .create_ssh_access(expires_in_minutes) + .await + .map_err(|e| format!("Failed to create SSH access: {e}")) + } + fn emit(&self, event: SandboxEvent) { event.trace(); if let Some(ref cb) = self.event_callback { diff --git a/crates/arc-workflows/src/event.rs b/crates/arc-workflows/src/event.rs index 06b332d2b..7375a7508 100644 --- a/crates/arc-workflows/src/event.rs +++ b/crates/arc-workflows/src/event.rs @@ -187,6 +187,9 @@ pub enum WorkflowRunEvent { total_bytes: u64, files_skipped: usize, }, + SshAccessReady { + ssh_command: String, + }, } impl WorkflowRunEvent { @@ -444,6 +447,9 @@ impl WorkflowRunEvent { files_copied, total_bytes, files_skipped, "Assets captured" ); } + Self::SshAccessReady { ssh_command } => { + info!(ssh_command, "SSH access ready"); + } } } } diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index b45570c6b..b1f8d8311 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -1145,3 +1145,40 @@ async fn daytona_asset_collection() { env.cleanup().await.unwrap(); } + +#[tokio::test] +#[ignore] +async fn daytona_ssh_access() { + let env = create_env().await; + env.initialize().await.unwrap(); + + let ssh_info = env.create_ssh_access(Some(60.0)).await.unwrap(); + assert!( + !ssh_info.ssh_command.is_empty(), + "ssh_command should not be empty" + ); + assert!( + ssh_info.ssh_command.contains("ssh"), + "ssh_command should contain 'ssh': {}", + ssh_info.ssh_command + ); + assert!( + !ssh_info.token.is_empty(), + "token should not be empty" + ); + + env.cleanup().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn daytona_ssh_access_before_init_fails() { + let env = create_env().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"), + "error should mention not initialized" + ); +}