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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-04 09:11:39 -05:00
parent 6dc359f867
commit 97d370b727
6 changed files with 107 additions and 1 deletions

View file

@ -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))
}

View file

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

View file

@ -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<Arc<crate::daytona_sandbox::DaytonaSandbox>> = None;
let sandbox: Arc<dyn Sandbox> = 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 {

View file

@ -59,6 +59,17 @@ impl DaytonaSandbox {
self.event_callback = Some(cb);
}
pub async fn create_ssh_access(
&self,
expires_in_minutes: Option<f64>,
) -> Result<daytona_sdk::api_types::SshAccessDto, String> {
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 {

View file

@ -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");
}
}
}
}

View file

@ -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"
);
}