Add arc cp command for copying files to/from run sandboxes

Introduces sandbox reconnection via persisted SandboxRecord, adds
upload_file_from_local to the Sandbox trait, and migrates arc-sprites
shell quoting to shlex::try_quote.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-09 11:03:10 -04:00
parent fc90f5988b
commit aae1efab98
24 changed files with 1010 additions and 56 deletions

1
Cargo.lock generated
View file

@ -340,6 +340,7 @@ dependencies = [
"chrono",
"rand 0.8.5",
"serde",
"shlex",
"tempfile",
"tokio",
"tokio-util",

View file

@ -114,6 +114,22 @@ impl DockerSandbox {
}
}
/// Maps a container-space path to the corresponding host-space path
/// using the bind-mount configuration.
fn container_to_host_path(&self, remote_path: &str) -> Result<std::path::PathBuf, String> {
let container_path = self.resolve_container_path(remote_path);
if container_path.starts_with(&self.config.container_mount_point) {
let relative = &container_path[self.config.container_mount_point.len()..];
let relative = relative.strip_prefix('/').unwrap_or(relative);
Ok(std::path::PathBuf::from(&self.config.host_working_directory).join(relative))
} else {
Err(format!(
"Path {container_path} is outside the bind-mounted directory {}",
self.config.container_mount_point
))
}
}
/// Executes a command inside the container, returning `(stdout, stderr, exit_code)`.
async fn docker_exec(
&self,
@ -275,19 +291,7 @@ impl Sandbox for DockerSandbox {
remote_path: &str,
local_path: &std::path::Path,
) -> Result<(), String> {
// Docker bind-mounts host_working_directory -> container_mount_point.
// Resolve the container path to the corresponding host path.
let container_path = self.resolve_container_path(remote_path);
let host_path = if container_path.starts_with(&self.config.container_mount_point) {
let relative = &container_path[self.config.container_mount_point.len()..];
let relative = relative.strip_prefix('/').unwrap_or(relative);
std::path::PathBuf::from(&self.config.host_working_directory).join(relative)
} else {
return Err(format!(
"Path {container_path} is outside the bind-mounted directory {}",
self.config.container_mount_point
));
};
let host_path = self.container_to_host_path(remote_path)?;
if let Some(parent) = local_path.parent() {
tokio::fs::create_dir_all(parent)
@ -304,6 +308,28 @@ impl Sandbox for DockerSandbox {
Ok(())
}
async fn upload_file_from_local(
&self,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<(), String> {
let host_path = self.container_to_host_path(remote_path)?;
if let Some(parent) = host_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
}
tokio::fs::copy(local_path, &host_path).await.map_err(|e| {
format!(
"Failed to copy {} to {}: {e}",
local_path.display(),
host_path.display()
)
})?;
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
self.emit(SandboxEvent::Initializing {
provider: "docker".into(),
@ -930,4 +956,4 @@ mod tests {
std::fs::remove_dir_all(&host_dir).ok();
}
}
}

View file

@ -382,6 +382,27 @@ impl Sandbox for LocalSandbox {
Ok(())
}
async fn upload_file_from_local(
&self,
local_path: &Path,
remote_path: &str,
) -> Result<(), String> {
let full_path = self.resolve_path(remote_path);
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create parent dirs: {e}"))?;
}
tokio::fs::copy(local_path, &full_path).await.map_err(|e| {
format!(
"Failed to copy {} to {}: {e}",
local_path.display(),
full_path.display()
)
})?;
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
self.emit(SandboxEvent::Initializing {
provider: "local".into(),

View file

@ -69,6 +69,14 @@ macro_rules! delegate_sandbox {
self.$field.download_file_to_local(remote_path, local_path).await
}
async fn upload_file_from_local(
&self,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<(), String> {
self.$field.upload_file_from_local(local_path, remote_path).await
}
async fn initialize(&self) -> Result<(), String> {
self.$field.initialize().await
}
@ -345,6 +353,13 @@ pub trait Sandbox: Send + Sync {
remote_path: &str,
local_path: &Path,
) -> Result<(), String>;
/// Copy a file from the local filesystem into the sandbox.
/// Handles binary files correctly across all sandbox types.
async fn upload_file_from_local(
&self,
local_path: &Path,
remote_path: &str,
) -> Result<(), String>;
async fn initialize(&self) -> Result<(), String>;
async fn cleanup(&self) -> Result<(), String>;
fn working_directory(&self) -> &str;

View file

@ -189,6 +189,17 @@ impl Sandbox for MockSandbox {
Ok(())
}
async fn upload_file_from_local(
&self,
local_path: &std::path::Path,
_remote_path: &str,
) -> Result<(), String> {
if !local_path.exists() {
return Err(format!("File not found: {}", local_path.display()));
}
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
self.emit(crate::sandbox::SandboxEvent::Initializing {
provider: "mock".into(),
@ -351,6 +362,21 @@ impl Sandbox for MutableMockSandbox {
Ok(())
}
async fn upload_file_from_local(
&self,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<(), String> {
let content = tokio::fs::read_to_string(local_path)
.await
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
self.files
.lock()
.expect("files lock poisoned")
.insert(remote_path.to_string(), content);
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}

View file

@ -55,6 +55,8 @@ enum Command {
Validate(arc_workflows::cli::ValidateArgs),
/// Parse a DOT file and print its AST
Parse(arc_workflows::cli::ParseArgs),
/// Copy files to/from a run's sandbox
Cp(arc_workflows::cli::cp::CpArgs),
/// List and test LLM models
Model {
#[command(subcommand)]
@ -133,6 +135,7 @@ async fn main() -> Result<()> {
Command::Run(_) => "run",
Command::Validate(_) => "validate",
Command::Parse(_) => "parse",
Command::Cp(_) => "cp",
Command::Model { .. } => "model",
Command::Serve(_) => "serve",
Command::Doctor { .. } => "doctor",
@ -292,6 +295,9 @@ async fn main() -> Result<()> {
Command::Parse(args) => {
arc_workflows::cli::parse::parse_command(&args)?;
}
Command::Cp(args) => {
arc_workflows::cli::cp::cp_command(args).await?;
}
Command::Model { command } => {
let cli_config = cli_config::load_cli_config(None)?;
let resolved =

View file

@ -18,6 +18,30 @@ pub use openssh_runner::OpensshRunner;
const WORKING_DIRECTORY: &str = "/home/exedev";
const PROVIDER: &str = "exe";
/// No-op SSH runner used as a placeholder for the management plane
/// when reconnecting to an existing VM via `from_existing`.
struct NoopSshRunner;
#[async_trait]
impl SshRunner for NoopSshRunner {
async fn run_command(&self, _command: &str) -> Result<SshOutput, String> {
Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
}
async fn run_command_with_timeout(
&self,
_command: &str,
_timeout: std::time::Duration,
) -> Result<SshOutput, String> {
Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
}
async fn upload_file(&self, _path: &str, _content: &[u8]) -> Result<(), String> {
Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
}
async fn download_file(&self, _path: &str) -> Result<Vec<u8>, String> {
Err("NoopSshRunner: management plane not available on reconnected sandbox".to_string())
}
}
pub(crate) fn shell_quote(s: &str) -> String {
shlex::try_quote(s).map_or_else(
|_| format!("'{}'", s.replace('\'', "'\\''")),
@ -127,11 +151,43 @@ impl ExeSandbox {
}
}
/// Create an `ExeSandbox` from a pre-connected data-plane SSH runner.
/// Used for reconnection (e.g. `arc cp`) when the VM already exists.
pub fn from_existing(data_ssh: Box<dyn SshRunner>) -> Self {
let data_cell = tokio::sync::OnceCell::new();
let _ = data_cell.set(data_ssh);
Self {
mgmt_ssh: Box::new(NoopSshRunner),
data_ssh: data_cell,
vm_name: tokio::sync::OnceCell::new(),
data_host: tokio::sync::OnceCell::new(),
rg_available: tokio::sync::OnceCell::const_new(),
event_callback: None,
data_ssh_factory: Box::new(|_: &str| {
Box::pin(async { Err("from_existing sandbox cannot create new SSH connections".to_string()) })
}),
config: ExeConfig::default(),
clone_params: None,
run_id: None,
origin_url: tokio::sync::OnceCell::new(),
}
}
/// The display URL of the cloned origin remote, if a clone was performed.
pub fn origin_url(&self) -> Option<&str> {
self.origin_url.get().map(String::as_str)
}
/// The VM name, available after initialization.
pub fn vm_name(&self) -> Option<&str> {
self.vm_name.get().map(String::as_str)
}
/// The data-plane SSH host, available after initialization.
pub fn data_host(&self) -> Option<&str> {
self.data_host.get().map(String::as_str)
}
pub fn set_event_callback(&mut self, cb: SandboxEventCallback) {
self.event_callback = Some(cb);
}
@ -710,6 +766,29 @@ impl Sandbox for ExeSandbox {
Ok(())
}
async fn upload_file_from_local(
&self,
local_path: &Path,
remote_path: &str,
) -> Result<(), String> {
let ssh = self.data_ssh()?;
let resolved = if Path::new(remote_path).is_absolute() {
remote_path.to_string()
} else {
format!("{WORKING_DIRECTORY}/{remote_path}")
};
let bytes = tokio::fs::read(local_path)
.await
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
ssh.upload_file(&resolved, &bytes)
.await
.map_err(|e| format!("Failed to upload file {resolved}: {e}"))?;
Ok(())
}
fn working_directory(&self) -> &str {
WORKING_DIRECTORY
}

View file

@ -18,6 +18,7 @@ chrono.workspace = true
rand.workspace = true
tracing.workspace = true
base64.workspace = true
shlex = "1"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }

View file

@ -17,6 +17,13 @@ pub use cli_runner::CliSpriteRunner;
const WORKING_DIRECTORY: &str = "/home/sprite";
const PROVIDER: &str = "sprites";
fn shell_quote(s: &str) -> String {
shlex::try_quote(s).map_or_else(
|_| format!("'{}'", s.replace('\'', "'\\''")),
|q| q.to_string(),
)
}
/// Output from a sprite CLI command execution.
pub struct SpriteOutput {
pub stdout: String,
@ -52,12 +59,10 @@ pub struct SpritesSandbox {
preview_url: tokio::sync::OnceCell<String>,
rg_available: tokio::sync::OnceCell<bool>,
event_callback: Option<SandboxEventCallback>,
preserve_sprite: bool,
}
impl SpritesSandbox {
pub fn new(runner: Box<dyn SpriteRunner>, config: SpritesConfig) -> Self {
let preserve_sprite = config.sprite_name.is_some();
Self {
config,
runner,
@ -65,7 +70,6 @@ impl SpritesSandbox {
preview_url: tokio::sync::OnceCell::new(),
rg_available: tokio::sync::OnceCell::const_new(),
event_callback: None,
preserve_sprite,
}
}
@ -231,7 +235,7 @@ impl Sandbox for SpritesSandbox {
});
let start = Instant::now();
if !self.preserve_sprite {
if self.config.sprite_name.is_none() {
if let Some(name) = self.sprite_name.get() {
let args = self.build_sprite_args(&["destroy", "-s", name, "-force"]);
let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
@ -269,19 +273,19 @@ impl Sandbox for SpritesSandbox {
if let Some(vars) = env_vars {
for (key, value) in vars {
parts.push(format!(
"export {}='{}';",
key,
value.replace('\'', "'\\''")
"export {}={};",
shell_quote(key),
shell_quote(value)
));
}
}
if let Some(dir) = working_dir {
let resolved = self.resolve_path(dir);
parts.push(format!("cd '{}'", resolved.replace('\'', "'\\''")));
parts.push(format!("cd {}", shell_quote(&resolved)));
parts.push("&&".to_string());
} else {
parts.push(format!("cd '{WORKING_DIRECTORY}'"));
parts.push(format!("cd {}", shell_quote(WORKING_DIRECTORY)));
parts.push("&&".to_string());
}
@ -321,7 +325,7 @@ impl Sandbox for SpritesSandbox {
limit: Option<usize>,
) -> Result<String, String> {
let resolved = self.resolve_path(path);
let cmd = format!("cat '{}'", resolved.replace('\'', "'\\''"));
let cmd = format!("cat {}", shell_quote(&resolved));
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
@ -339,7 +343,7 @@ impl Sandbox for SpritesSandbox {
if let Some(parent) = Path::new(&resolved).parent() {
let parent_str = parent.to_string_lossy();
if parent_str != "/" {
let mkdir_cmd = format!("mkdir -p '{}'", parent_str.replace('\'', "'\\''"));
let mkdir_cmd = format!("mkdir -p {}", shell_quote(&parent_str));
self.exec_command(&mkdir_cmd, 30_000, None, None, None)
.await?;
}
@ -348,9 +352,9 @@ impl Sandbox for SpritesSandbox {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(content.as_bytes());
let cmd = format!(
"echo '{}' | base64 -d > '{}'",
"echo '{}' | base64 -d > {}",
encoded,
resolved.replace('\'', "'\\''"),
shell_quote(&resolved),
);
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
@ -363,7 +367,7 @@ impl Sandbox for SpritesSandbox {
async fn delete_file(&self, path: &str) -> Result<(), String> {
let resolved = self.resolve_path(path);
let cmd = format!("rm -f '{}'", resolved.replace('\'', "'\\''"));
let cmd = format!("rm -f {}", shell_quote(&resolved));
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
if result.exit_code != 0 {
@ -374,7 +378,7 @@ impl Sandbox for SpritesSandbox {
async fn file_exists(&self, path: &str) -> Result<bool, String> {
let resolved = self.resolve_path(path);
let cmd = format!("test -e '{}'", resolved.replace('\'', "'\\''"));
let cmd = format!("test -e {}", shell_quote(&resolved));
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
Ok(result.exit_code == 0)
@ -389,8 +393,8 @@ impl Sandbox for SpritesSandbox {
let max_depth = depth.unwrap_or(1);
let cmd = format!(
"find '{}' -mindepth 1 -maxdepth {} -printf '%y\\t%s\\t%P\\n'",
resolved.replace('\'', "'\\''"),
"find {} -mindepth 1 -maxdepth {} -printf '%y\\t%s\\t%P\\n'",
shell_quote(&resolved),
max_depth,
);
@ -452,15 +456,15 @@ impl Sandbox for SpritesSandbox {
cmd.push_str(" -i");
}
if let Some(ref glob_filter) = options.glob_filter {
cmd.push_str(&format!(" --glob '{glob_filter}'"));
cmd.push_str(&format!(" --glob {}", shell_quote(glob_filter)));
}
if let Some(max) = options.max_results {
cmd.push_str(&format!(" --max-count {max}"));
}
cmd.push_str(&format!(
" -- '{}' '{}'",
pattern.replace('\'', "'\\''"),
resolved
" -- {} {}",
shell_quote(pattern),
shell_quote(&resolved)
));
cmd
} else {
@ -469,15 +473,15 @@ impl Sandbox for SpritesSandbox {
cmd.push_str(" -i");
}
if let Some(ref glob_filter) = options.glob_filter {
cmd.push_str(&format!(" --include '{glob_filter}'"));
cmd.push_str(&format!(" --include {}", shell_quote(glob_filter)));
}
if let Some(max) = options.max_results {
cmd.push_str(&format!(" -m {max}"));
}
cmd.push_str(&format!(
" -- '{}' '{}'",
pattern.replace('\'', "'\\''"),
resolved
" -- {} {}",
shell_quote(pattern),
shell_quote(&resolved)
));
cmd
};
@ -503,9 +507,9 @@ impl Sandbox for SpritesSandbox {
.unwrap_or_else(|| WORKING_DIRECTORY.to_string());
let cmd = format!(
"find '{}' -name '{}' -type f | sort",
base.replace('\'', "'\\''"),
pattern.replace('\'', "'\\''"),
"find {} -name {} -type f | sort",
shell_quote(&base),
shell_quote(pattern),
);
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
@ -531,7 +535,7 @@ impl Sandbox for SpritesSandbox {
local_path: &Path,
) -> Result<(), String> {
let resolved = self.resolve_path(remote_path);
let cmd = format!("base64 '{}'", resolved.replace('\'', "'\\''"));
let cmd = format!("base64 {}", shell_quote(&resolved));
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
if result.exit_code != 0 {
@ -555,6 +559,40 @@ impl Sandbox for SpritesSandbox {
Ok(())
}
async fn upload_file_from_local(
&self,
local_path: &Path,
remote_path: &str,
) -> Result<(), String> {
let resolved = self.resolve_path(remote_path);
let bytes = tokio::fs::read(local_path)
.await
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
// Ensure parent directory exists
if let Some(parent) = Path::new(&resolved).parent() {
let mkdir_cmd = format!("mkdir -p {}", shell_quote(&parent.to_string_lossy()));
self.exec_command(&mkdir_cmd, 10_000, None, None, None)
.await?;
}
let cmd = format!(
"echo {} | base64 -d > {}",
shell_quote(&encoded),
shell_quote(&resolved)
);
let result = self.exec_command(&cmd, 30_000, None, None, None).await?;
if result.exit_code != 0 {
return Err(format!("Failed to write {resolved}: {}", result.stderr));
}
Ok(())
}
fn working_directory(&self) -> &str {
WORKING_DIRECTORY
}
@ -735,7 +773,7 @@ mod tests {
let recorded = commands.lock().unwrap();
let cmd = recorded[0].args.last().unwrap();
assert!(
cmd.contains("cd '/tmp/work'"),
cmd.contains("cd /tmp/work"),
"expected cd to working dir, got: {cmd}",
);
}
@ -758,7 +796,7 @@ mod tests {
let recorded = commands.lock().unwrap();
let cmd = recorded[0].args.last().unwrap();
assert!(
cmd.contains("export FOO='bar';"),
cmd.contains("export FOO=bar;"),
"expected env var export, got: {cmd}",
);
}
@ -820,7 +858,7 @@ mod tests {
let recorded = commands.lock().unwrap();
let cmd = recorded[0].args.last().unwrap();
assert!(
cmd.contains("cat '/etc/hosts'"),
cmd.contains("cat /etc/hosts"),
"expected absolute path in cat, got: {cmd}",
);
assert!(

View file

@ -586,6 +586,14 @@ mod tests {
Err("not implemented".to_string())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> std::result::Result<(), String> {
Err("not implemented".to_string())
}
async fn initialize(&self) -> std::result::Result<(), String> {
Ok(())
}

View file

@ -401,6 +401,13 @@ mod tests {
.map_err(|e| format!("Failed to write: {e}"))?;
Ok(())
}
async fn upload_file_from_local(
&self,
_local_path: &std::path::Path,
_remote_path: &str,
) -> Result<(), String> {
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}

View file

@ -878,6 +878,9 @@ mod tests {
async fn download_file_to_local(&self, _remote: &str, _local: &Path) -> Result<(), String> {
Ok(())
}
async fn upload_file_from_local(&self, _local: &Path, _remote: &str) -> Result<(), String> {
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}

View file

@ -0,0 +1,451 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use tracing::{debug, info, warn};
use crate::cli::runs::{default_logs_base, scan_runs};
use crate::sandbox_record::SandboxRecord;
#[derive(Args)]
pub struct CpArgs {
/// Source: <run-id>:<path> or local path
pub src: String,
/// Destination: <run-id>:<path> or local path
pub dst: String,
/// Recurse into directories
#[arg(short, long)]
pub recursive: bool,
}
/// Parsed copy direction.
enum CopyDirection {
/// Download from sandbox to local
Download {
run_prefix: String,
remote_path: String,
local_path: PathBuf,
},
/// Upload from local to sandbox
Upload {
local_path: PathBuf,
run_prefix: String,
remote_path: String,
},
}
/// Parse src/dst to determine direction.
///
/// The convention is: `<run-id>:<path>` refers to a sandbox path,
/// and a plain path (no colon) is local. We split on the first colon.
fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
let src_parts = split_run_path(src);
let dst_parts = split_run_path(dst);
match (src_parts, dst_parts) {
(Some((run_prefix, remote_path)), None) => Ok(CopyDirection::Download {
run_prefix: run_prefix.to_string(),
remote_path: remote_path.to_string(),
local_path: PathBuf::from(dst),
}),
(None, Some((run_prefix, remote_path))) => Ok(CopyDirection::Upload {
local_path: PathBuf::from(src),
run_prefix: run_prefix.to_string(),
remote_path: remote_path.to_string(),
}),
(Some(_), Some(_)) => bail!("Cannot copy between two sandboxes; one argument must be a local path"),
(None, None) => bail!("One argument must contain a run-id prefix (e.g. <run-id>:<path>)"),
}
}
/// Split `"run-id:path"` on the first colon.
/// Returns `None` if the string doesn't look like a run-id:path reference.
///
/// We distinguish local paths from run references by checking:
/// - Paths starting with `/`, `./`, or `../` are always local
/// - Otherwise, split on the first colon
fn split_run_path(s: &str) -> Option<(&str, &str)> {
if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") {
return None;
}
s.split_once(':')
}
/// Find a run directory by prefix match against run IDs.
fn find_run_by_prefix(base: &Path, prefix: &str) -> Result<PathBuf> {
let runs = scan_runs(base).context("Failed to scan runs")?;
let matches: Vec<_> = runs
.iter()
.filter(|r| r.run_id.starts_with(prefix))
.collect();
match matches.len() {
0 => {
warn!(run_id = %prefix, "No matching run found");
bail!("No run found matching prefix '{prefix}'")
}
1 => {
let run = &matches[0];
debug!(run_id = %prefix, matched = %run.run_id, "Resolved run by prefix");
Ok(run.path.clone())
}
n => {
let ids: Vec<&str> = matches.iter().map(|r| r.run_id.as_str()).collect();
bail!(
"Ambiguous prefix '{prefix}': {n} runs match: {}",
ids.join(", ")
)
}
}
}
/// Reconnect to a sandbox from a saved record.
///
/// Returns a sandbox that can perform file operations.
/// Note: for Docker and Local sandboxes, the container/directory may still
/// need to be alive. For Daytona and Exe, we reconnect via their APIs.
async fn reconnect(
record: &SandboxRecord,
) -> Result<Box<dyn arc_agent::sandbox::Sandbox>> {
debug!(
provider = %record.provider,
identifier = record.identifier.as_deref().unwrap_or(""),
"Reconnecting to sandbox"
);
match record.provider.as_str() {
"local" => {
let sandbox =
arc_agent::local_sandbox::LocalSandbox::new(PathBuf::from(&record.working_directory));
Ok(Box::new(sandbox))
}
"docker" => {
let host_dir = record
.host_working_directory
.as_deref()
.context("Docker sandbox record missing host_working_directory")?;
let mount_point = record
.container_mount_point
.as_deref()
.unwrap_or("/workspace");
// Docker uses bind mounts — file operations can go directly through
// the host filesystem without needing the container running.
// We create a DockerSandboxConfig with the bind-mount info and use
// a LocalSandbox pointed at the host directory (since we just need
// file copy operations, not container exec).
let config = arc_agent::docker_sandbox::DockerSandboxConfig {
host_working_directory: host_dir.to_string(),
container_mount_point: mount_point.to_string(),
..arc_agent::docker_sandbox::DockerSandboxConfig::default()
};
let sandbox = arc_agent::docker_sandbox::DockerSandbox::new(config)
.map_err(|e| anyhow::anyhow!("Failed to create Docker sandbox: {e}"))?;
Ok(Box::new(sandbox))
}
"daytona" => {
let name = record
.identifier
.as_deref()
.context("Daytona sandbox record missing identifier (sandbox name)")?;
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 sandbox = crate::daytona_sandbox::DaytonaSandbox::from_existing(
client,
sdk_sandbox,
);
Ok(Box::new(sandbox))
}
"exe" => {
let data_host = record
.data_host
.as_deref()
.context("Exe sandbox record missing data_host")?;
let data_ssh = arc_exe::OpensshRunner::connect(data_host)
.await
.map_err(|e| anyhow::anyhow!("Failed to connect to exe sandbox '{data_host}': {e}"))?;
let sandbox = arc_exe::ExeSandbox::from_existing(Box::new(data_ssh));
Ok(Box::new(sandbox))
}
other => bail!("Unknown sandbox provider: {other}"),
}
}
pub async fn cp_command(args: CpArgs) -> Result<()> {
let direction = parse_direction(&args.src, &args.dst)?;
let base = default_logs_base();
match direction {
CopyDirection::Download {
run_prefix,
remote_path,
local_path,
} => {
let run_dir = find_run_by_prefix(&base, &run_prefix)?;
let sandbox_json = run_dir.join("sandbox.json");
debug!(path = %sandbox_json.display(), "Loading sandbox record");
let record = SandboxRecord::load(&sandbox_json)
.context("Failed to load sandbox.json — was this run started with a recent version of arc?")?;
info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox");
let sandbox = reconnect(&record).await?;
if args.recursive {
download_recursive(&*sandbox, &remote_path, &local_path).await?;
} else {
debug!(path = %remote_path, "Downloading file from sandbox");
sandbox
.download_file_to_local(&remote_path, &local_path)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
info!(direction = "download", path = %remote_path, "Copy complete");
}
CopyDirection::Upload {
local_path,
run_prefix,
remote_path,
} => {
let run_dir = find_run_by_prefix(&base, &run_prefix)?;
let sandbox_json = run_dir.join("sandbox.json");
debug!(path = %sandbox_json.display(), "Loading sandbox record");
let record = SandboxRecord::load(&sandbox_json)
.context("Failed to load sandbox.json — was this run started with a recent version of arc?")?;
info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox");
let sandbox = reconnect(&record).await?;
if args.recursive {
upload_recursive(&*sandbox, &local_path, &remote_path).await?;
} else {
debug!(path = %remote_path, "Uploading file to sandbox");
sandbox
.upload_file_from_local(&local_path, &remote_path)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
}
info!(direction = "upload", path = %remote_path, "Copy complete");
}
}
Ok(())
}
/// Recursively download a directory from the sandbox.
async fn download_recursive(
sandbox: &dyn arc_agent::sandbox::Sandbox,
remote_path: &str,
local_path: &Path,
) -> Result<()> {
let entries = sandbox
.list_directory(remote_path, Some(100))
.await
.map_err(|e| anyhow::anyhow!("Failed to list directory {remote_path}: {e}"))?;
let mut file_count = 0usize;
for entry in &entries {
let remote_file = format!("{remote_path}/{}", entry.name);
let local_file = local_path.join(&entry.name);
if entry.is_dir {
// Directories are listed with their contents via depth traversal
continue;
}
debug!(path = %remote_file, "Downloading file from sandbox");
sandbox
.download_file_to_local(&remote_file, &local_file)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
file_count += 1;
}
debug!(count = file_count, "Recursive download complete");
Ok(())
}
/// Recursively upload a directory to the sandbox.
async fn upload_recursive(
sandbox: &dyn arc_agent::sandbox::Sandbox,
local_path: &Path,
remote_path: &str,
) -> Result<()> {
let mut file_count = 0usize;
let mut stack: Vec<(PathBuf, String)> = vec![(local_path.to_path_buf(), remote_path.to_string())];
while let Some((dir_path, dir_remote)) = stack.pop() {
let mut entries = tokio::fs::read_dir(&dir_path)
.await
.with_context(|| format!("Failed to read directory {}", dir_path.display()))?;
while let Some(entry) = entries.next_entry().await? {
let entry_path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
let remote_file = format!("{dir_remote}/{file_name}");
if entry_path.is_dir() {
stack.push((entry_path, remote_file));
} else {
debug!(path = %remote_file, "Uploading file to sandbox");
sandbox
.upload_file_from_local(&entry_path, &remote_file)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
file_count += 1;
}
}
}
debug!(count = file_count, "Recursive upload complete");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_direction_download() {
let dir = parse_direction("abc123:/some/file.txt", "./local.txt").unwrap();
match dir {
CopyDirection::Download {
run_prefix,
remote_path,
local_path,
} => {
assert_eq!(run_prefix, "abc123");
assert_eq!(remote_path, "/some/file.txt");
assert_eq!(local_path, PathBuf::from("./local.txt"));
}
_ => panic!("Expected Download"),
}
}
#[test]
fn parse_direction_upload() {
let dir = parse_direction("./local.txt", "abc123:/some/file.txt").unwrap();
match dir {
CopyDirection::Upload {
local_path,
run_prefix,
remote_path,
} => {
assert_eq!(local_path, PathBuf::from("./local.txt"));
assert_eq!(run_prefix, "abc123");
assert_eq!(remote_path, "/some/file.txt");
}
_ => panic!("Expected Upload"),
}
}
#[test]
fn parse_direction_absolute_local_path() {
let dir = parse_direction("abc123:src/main.rs", "/tmp/main.rs").unwrap();
match dir {
CopyDirection::Download {
run_prefix,
remote_path,
local_path,
} => {
assert_eq!(run_prefix, "abc123");
assert_eq!(remote_path, "src/main.rs");
assert_eq!(local_path, PathBuf::from("/tmp/main.rs"));
}
_ => panic!("Expected Download"),
}
}
#[test]
fn parse_direction_both_sandbox_errors() {
let result = parse_direction("abc:path", "def:path");
assert!(result.is_err());
}
#[test]
fn parse_direction_neither_sandbox_errors() {
let result = parse_direction("./file.txt", "/tmp/file.txt");
assert!(result.is_err());
}
#[test]
fn parse_direction_relative_upload() {
let dir = parse_direction("../local.txt", "abc123:remote.txt").unwrap();
match dir {
CopyDirection::Upload {
local_path,
run_prefix,
remote_path,
} => {
assert_eq!(local_path, PathBuf::from("../local.txt"));
assert_eq!(run_prefix, "abc123");
assert_eq!(remote_path, "remote.txt");
}
_ => panic!("Expected Upload"),
}
}
#[test]
fn find_run_by_prefix_no_match() {
let dir = tempfile::tempdir().unwrap();
let result = find_run_by_prefix(dir.path(), "nonexistent");
assert!(result.is_err());
}
#[test]
fn find_run_by_prefix_single_match() {
let dir = tempfile::tempdir().unwrap();
let run_dir = dir.path().join("20260101-ABC123");
std::fs::create_dir_all(&run_dir).unwrap();
std::fs::write(
run_dir.join("manifest.json"),
serde_json::to_string_pretty(&serde_json::json!({
"run_id": "abc123-full-id",
"workflow_name": "test",
"goal": "",
"start_time": "2026-01-01T12:00:00Z",
"node_count": 1,
"edge_count": 0
}))
.unwrap(),
)
.unwrap();
let result = find_run_by_prefix(dir.path(), "abc123").unwrap();
assert_eq!(result, run_dir);
}
#[test]
fn find_run_by_prefix_ambiguous() {
let dir = tempfile::tempdir().unwrap();
for (subdir, run_id) in [("d1", "abc-111"), ("d2", "abc-222")] {
let run_dir = dir.path().join(subdir);
std::fs::create_dir_all(&run_dir).unwrap();
std::fs::write(
run_dir.join("manifest.json"),
serde_json::to_string_pretty(&serde_json::json!({
"run_id": run_id,
"workflow_name": "test",
"goal": "",
"start_time": "2026-01-01T12:00:00Z",
"node_count": 1,
"edge_count": 0
}))
.unwrap(),
)
.unwrap();
}
let result = find_run_by_prefix(dir.path(), "abc");
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("Ambiguous"),
"Should mention ambiguity"
);
}
}

View file

@ -1,5 +1,6 @@
pub mod backend;
pub mod cli_backend;
pub mod cp;
pub mod parse;
pub mod progress;
pub mod run;

View file

@ -598,7 +598,7 @@ pub async fn run_command(
exe_arc
}
SandboxProvider::Local => {
let mut env = LocalSandbox::new(cwd);
let mut env = LocalSandbox::new(cwd.clone());
let emitter_cb = Arc::clone(&emitter);
env.set_event_callback(Arc::new(move |event| {
emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event });
@ -613,6 +613,55 @@ pub async fn run_command(
.await
.map_err(|e| anyhow::anyhow!("Failed to initialize sandbox: {e}"))?;
// Persist sandbox connection info for `arc cp`
{
let sandbox_info_opt = {
let info = sandbox.sandbox_info();
if info.is_empty() { None } else { Some(info) }
};
let record = match sandbox_provider {
SandboxProvider::Local => crate::sandbox_record::SandboxRecord {
provider: "local".to_string(),
working_directory: sandbox.working_directory().to_string(),
identifier: None,
host_working_directory: None,
container_mount_point: None,
data_host: None,
},
SandboxProvider::Docker => crate::sandbox_record::SandboxRecord {
provider: "docker".to_string(),
working_directory: sandbox.working_directory().to_string(),
identifier: sandbox_info_opt,
host_working_directory: Some(cwd.to_string_lossy().to_string()),
container_mount_point: Some(sandbox.working_directory().to_string()),
data_host: None,
},
SandboxProvider::Daytona => crate::sandbox_record::SandboxRecord {
provider: "daytona".to_string(),
working_directory: sandbox.working_directory().to_string(),
identifier: sandbox_info_opt,
host_working_directory: None,
container_mount_point: None,
data_host: None,
},
SandboxProvider::Exe => crate::sandbox_record::SandboxRecord {
provider: "exe".to_string(),
working_directory: sandbox.working_directory().to_string(),
identifier: exe_sandbox_ref
.as_ref()
.and_then(|e| e.vm_name().map(String::from)),
host_working_directory: None,
container_mount_point: None,
data_host: exe_sandbox_ref
.as_ref()
.and_then(|e| e.data_host().map(String::from)),
},
};
if let Err(e) = record.save(&logs_dir.join("sandbox.json")) {
tracing::warn!(error = %e, "Failed to save sandbox record");
}
}
// Wrap exe.dev sandbox with GitCredentialSandbox for push credential refresh
let sandbox: Arc<dyn Sandbox> = if sandbox_provider == SandboxProvider::Exe {
let origin_url = exe_sandbox_ref
@ -2209,4 +2258,4 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap();
assert_eq!(parsed["run_id"], "def-456");
}
}
}

View file

@ -183,7 +183,7 @@ fn parse_label_filters(label_args: &[String]) -> Vec<(String, String)> {
.collect()
}
fn default_logs_base() -> PathBuf {
pub(crate) fn default_logs_base() -> PathBuf {
dirs::home_dir()
.expect("could not determine home directory")
.join(".arc")
@ -660,4 +660,4 @@ mod tests {
let result = parse_label_filters(&args);
assert!(result.is_empty());
}
}
}

View file

@ -182,6 +182,24 @@ impl DaytonaSandbox {
}
}
/// Create a `DaytonaSandbox` from an already-existing Daytona SDK sandbox.
/// Used for reconnection (e.g. `arc cp`).
#[must_use]
pub fn from_existing(client: daytona_sdk::Client, sdk_sandbox: daytona_sdk::Sandbox) -> Self {
let sandbox_cell = tokio::sync::OnceCell::new();
let _ = sandbox_cell.set(sdk_sandbox);
Self {
config: DaytonaConfig::default(),
client,
github_app: None,
sandbox: sandbox_cell,
rg_available: tokio::sync::OnceCell::const_new(),
event_callback: None,
origin_url: tokio::sync::OnceCell::new(),
run_id: None,
}
}
pub fn set_event_callback(&mut self, cb: SandboxEventCallback) {
self.event_callback = Some(cb);
}
@ -410,6 +428,31 @@ impl Sandbox for DaytonaSandbox {
Ok(())
}
async fn upload_file_from_local(
&self,
local_path: &Path,
remote_path: &str,
) -> Result<(), String> {
let sandbox = self.sandbox()?;
let resolved = self.resolve_path(remote_path);
let bytes = tokio::fs::read(local_path)
.await
.map_err(|e| format!("Failed to read {}: {e}", local_path.display()))?;
let fs_svc = sandbox
.fs()
.await
.map_err(|e| format!("Failed to get fs service: {e}"))?;
fs_svc
.upload_file_bytes(&resolved, &bytes)
.await
.map_err(|e| format!("Failed to upload file {resolved}: {e}"))?;
Ok(())
}
async fn initialize(&self) -> Result<(), String> {
self.emit(SandboxEvent::Initializing {
provider: "daytona".into(),

View file

@ -107,6 +107,16 @@ impl Sandbox for GitCredentialSandbox {
.await
}
async fn upload_file_from_local(
&self,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<(), String> {
self.inner
.upload_file_from_local(local_path, remote_path)
.await
}
fn working_directory(&self) -> &str {
self.inner.working_directory()
}

View file

@ -647,6 +647,9 @@ mod tests {
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
unimplemented!()
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
unimplemented!()
}
async fn initialize(&self) -> Result<(), String> {
Ok(())
}

View file

@ -88,6 +88,15 @@ impl Sandbox for WorktreeSandbox {
.download_file_to_local(remote_path, local_path)
.await
}
async fn upload_file_from_local(
&self,
local_path: &std::path::Path,
remote_path: &str,
) -> Result<(), String> {
self.inner
.upload_file_from_local(local_path, remote_path)
.await
}
async fn initialize(&self) -> Result<(), String> {
self.inner.initialize().await
}

View file

@ -50,6 +50,7 @@ pub mod preamble;
pub mod pull_request;
pub mod retro;
pub mod retro_agent;
pub mod sandbox_record;
pub mod stylesheet;
pub mod transform;
pub mod validation;

View file

@ -0,0 +1,140 @@
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxRecord {
/// Provider type: "local", "docker", "daytona", "exe"
pub provider: String,
/// Working directory inside the sandbox
pub working_directory: String,
/// Provider-specific identifier (container_id / sandbox_name / vm_name)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
/// Docker: host path that is bind-mounted into the container
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_working_directory: Option<String>,
/// Docker: mount point inside the container
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_mount_point: Option<String>,
/// Exe: SSH destination for the data plane
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_host: Option<String>,
}
impl SandboxRecord {
pub fn save(&self, path: &Path) -> Result<()> {
crate::save_json(self, path, "sandbox_record")
}
pub fn load(path: &Path) -> Result<Self> {
crate::load_json(path, "sandbox_record")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn save_and_load_roundtrip_local() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sandbox.json");
let record = SandboxRecord {
provider: "local".to_string(),
working_directory: "/tmp/work".to_string(),
identifier: None,
host_working_directory: None,
container_mount_point: None,
data_host: None,
};
record.save(&path).unwrap();
let loaded = SandboxRecord::load(&path).unwrap();
assert_eq!(loaded.provider, "local");
assert_eq!(loaded.working_directory, "/tmp/work");
assert!(loaded.identifier.is_none());
}
#[test]
fn save_and_load_roundtrip_docker() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sandbox.json");
let record = SandboxRecord {
provider: "docker".to_string(),
working_directory: "/workspace".to_string(),
identifier: Some("abc123container".to_string()),
host_working_directory: Some("/home/user/project".to_string()),
container_mount_point: Some("/workspace".to_string()),
data_host: None,
};
record.save(&path).unwrap();
let loaded = SandboxRecord::load(&path).unwrap();
assert_eq!(loaded.provider, "docker");
assert_eq!(loaded.identifier.as_deref(), Some("abc123container"));
assert_eq!(
loaded.host_working_directory.as_deref(),
Some("/home/user/project")
);
assert_eq!(
loaded.container_mount_point.as_deref(),
Some("/workspace")
);
assert!(loaded.data_host.is_none());
}
#[test]
fn save_and_load_roundtrip_exe() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sandbox.json");
let record = SandboxRecord {
provider: "exe".to_string(),
working_directory: "/home/exedev".to_string(),
identifier: Some("my-vm".to_string()),
host_working_directory: None,
container_mount_point: None,
data_host: Some("my-vm.exe.xyz".to_string()),
};
record.save(&path).unwrap();
let loaded = SandboxRecord::load(&path).unwrap();
assert_eq!(loaded.provider, "exe");
assert_eq!(loaded.identifier.as_deref(), Some("my-vm"));
assert_eq!(loaded.data_host.as_deref(), Some("my-vm.exe.xyz"));
}
#[test]
fn optional_fields_omitted_when_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sandbox.json");
let record = SandboxRecord {
provider: "local".to_string(),
working_directory: "/work".to_string(),
identifier: None,
host_working_directory: None,
container_mount_point: None,
data_host: None,
};
record.save(&path).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(raw.get("identifier").is_none());
assert!(raw.get("host_working_directory").is_none());
assert!(raw.get("container_mount_point").is_none());
assert!(raw.get("data_host").is_none());
}
#[test]
fn load_nonexistent_file() {
let result = SandboxRecord::load(Path::new("/nonexistent/sandbox.json"));
assert!(result.is_err());
}
}

View file

@ -8926,6 +8926,10 @@ impl arc_agent::Sandbox for RemoteMockEnv {
Err("not implemented".to_string())
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> std::result::Result<(), String> {
Err("not implemented".to_string())
}
fn working_directory(&self) -> &str {
&self.working_dir
}
@ -9361,6 +9365,10 @@ impl arc_agent::Sandbox for CliTestEnv {
Err("not implemented".to_string())
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
Err("not implemented".to_string())
}
fn working_directory(&self) -> &str {
"/tmp/test"
}
@ -9638,6 +9646,9 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
async fn download_file_to_local(&self, _: &str, _: &std::path::Path) -> Result<(), String> {
Err("not implemented".to_string())
}
async fn upload_file_from_local(&self, _: &std::path::Path, _: &str) -> Result<(), String> {
Err("not implemented".to_string())
}
fn working_directory(&self) -> &str {
"/tmp"
}
@ -13245,4 +13256,4 @@ async fn wait_timer_e2e() {
};
let outcome = engine.run(&graph, &config).await.expect("run");
assert_eq!(outcome.status, StageStatus::Success);
}
}

View file

@ -2,10 +2,15 @@ version = 1
graph = "implement-and-simplify.dot"
[sandbox]
provider = "daytona"
provider = "local"
# provider = "daytona"
[sandbox.env]
CARGO_INCREMENTAL = "0"
# [sandbox.env]
# CARGO_INCREMENTAL = "0"
[sandbox.daytona.snapshot]
name = "daytona-large"
# [sandbox.daytona.snapshot]
# name = "daytona-large"
[sandbox.local]
worktree = true