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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-10 12:28:17 -04:00
parent f27a5e187a
commit 4fe05e8a71
5 changed files with 166 additions and 5 deletions

View file

@ -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 =

View file

@ -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;

View file

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

View file

@ -216,10 +216,10 @@ impl DaytonaSandbox {
}
/// Create SSH access and return the connection command string.
pub async fn create_ssh_access(&self) -> Result<String, String> {
pub async fn create_ssh_access(&self, ttl_minutes: Option<f64>) -> Result<String, String> {
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<Option<String>, String> {
self.create_ssh_access().await.map(Some)
self.create_ssh_access(Some(60.0)).await.map(Some)
}
fn origin_url(&self) -> Option<&str> {

View file

@ -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"),