From aae1efab98b776cc2fe826ea8a396d535d5bf2c4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 9 Mar 2026 11:03:10 -0400 Subject: [PATCH] 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 --- Cargo.lock | 1 + crates/arc-agent/src/docker_sandbox.rs | 54 ++- crates/arc-agent/src/local_sandbox.rs | 21 + crates/arc-agent/src/sandbox.rs | 15 + crates/arc-agent/src/test_support.rs | 26 + crates/arc-cli/src/main.rs | 6 + crates/arc-exe/src/lib.rs | 79 +++ crates/arc-sprites/Cargo.toml | 1 + crates/arc-sprites/src/lib.rs | 102 ++-- crates/arc-workflows/src/artifact.rs | 8 + crates/arc-workflows/src/asset_snapshot.rs | 7 + crates/arc-workflows/src/cli/cli_backend.rs | 3 + crates/arc-workflows/src/cli/cp.rs | 451 ++++++++++++++++++ crates/arc-workflows/src/cli/mod.rs | 1 + crates/arc-workflows/src/cli/run.rs | 53 +- crates/arc-workflows/src/cli/runs.rs | 4 +- crates/arc-workflows/src/daytona_sandbox.rs | 43 ++ .../src/git_credential_sandbox.rs | 10 + crates/arc-workflows/src/handler/command.rs | 3 + crates/arc-workflows/src/handler/parallel.rs | 9 + crates/arc-workflows/src/lib.rs | 1 + crates/arc-workflows/src/sandbox_record.rs | 140 ++++++ crates/arc-workflows/tests/integration.rs | 13 +- tmp/implement-and-simplify.toml | 15 +- 24 files changed, 1010 insertions(+), 56 deletions(-) create mode 100644 crates/arc-workflows/src/cli/cp.rs create mode 100644 crates/arc-workflows/src/sandbox_record.rs diff --git a/Cargo.lock b/Cargo.lock index b45176ea9..d915a0bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,7 @@ dependencies = [ "chrono", "rand 0.8.5", "serde", + "shlex", "tempfile", "tokio", "tokio-util", diff --git a/crates/arc-agent/src/docker_sandbox.rs b/crates/arc-agent/src/docker_sandbox.rs index 5e5e7076d..ff9d54657 100644 --- a/crates/arc-agent/src/docker_sandbox.rs +++ b/crates/arc-agent/src/docker_sandbox.rs @@ -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 { + 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(); } -} +} \ No newline at end of file diff --git a/crates/arc-agent/src/local_sandbox.rs b/crates/arc-agent/src/local_sandbox.rs index b24a9f40d..9798ea302 100644 --- a/crates/arc-agent/src/local_sandbox.rs +++ b/crates/arc-agent/src/local_sandbox.rs @@ -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(), diff --git a/crates/arc-agent/src/sandbox.rs b/crates/arc-agent/src/sandbox.rs index c4e93c03b..5a29a9e78 100644 --- a/crates/arc-agent/src/sandbox.rs +++ b/crates/arc-agent/src/sandbox.rs @@ -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; diff --git a/crates/arc-agent/src/test_support.rs b/crates/arc-agent/src/test_support.rs index b8836b98e..30a149309 100644 --- a/crates/arc-agent/src/test_support.rs +++ b/crates/arc-agent/src/test_support.rs @@ -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(()) } diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index 9ab6cd45d..e756cdd63 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -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 = diff --git a/crates/arc-exe/src/lib.rs b/crates/arc-exe/src/lib.rs index 18451bd83..91fc12372 100644 --- a/crates/arc-exe/src/lib.rs +++ b/crates/arc-exe/src/lib.rs @@ -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 { + 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 { + 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, 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) -> 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 } diff --git a/crates/arc-sprites/Cargo.toml b/crates/arc-sprites/Cargo.toml index a8c25d61f..cbe6b0f3f 100644 --- a/crates/arc-sprites/Cargo.toml +++ b/crates/arc-sprites/Cargo.toml @@ -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"] } diff --git a/crates/arc-sprites/src/lib.rs b/crates/arc-sprites/src/lib.rs index 587949396..f57170af3 100644 --- a/crates/arc-sprites/src/lib.rs +++ b/crates/arc-sprites/src/lib.rs @@ -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, rg_available: tokio::sync::OnceCell, event_callback: Option, - preserve_sprite: bool, } impl SpritesSandbox { pub fn new(runner: Box, 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, ) -> Result { 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 { 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!( diff --git a/crates/arc-workflows/src/artifact.rs b/crates/arc-workflows/src/artifact.rs index d1f911a1a..cad321309 100644 --- a/crates/arc-workflows/src/artifact.rs +++ b/crates/arc-workflows/src/artifact.rs @@ -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(()) } diff --git a/crates/arc-workflows/src/asset_snapshot.rs b/crates/arc-workflows/src/asset_snapshot.rs index a00d4749e..24b0a7f55 100644 --- a/crates/arc-workflows/src/asset_snapshot.rs +++ b/crates/arc-workflows/src/asset_snapshot.rs @@ -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(()) } diff --git a/crates/arc-workflows/src/cli/cli_backend.rs b/crates/arc-workflows/src/cli/cli_backend.rs index da4662cd5..aaf144107 100644 --- a/crates/arc-workflows/src/cli/cli_backend.rs +++ b/crates/arc-workflows/src/cli/cli_backend.rs @@ -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(()) } diff --git a/crates/arc-workflows/src/cli/cp.rs b/crates/arc-workflows/src/cli/cp.rs new file mode 100644 index 000000000..9ed0a25d3 --- /dev/null +++ b/crates/arc-workflows/src/cli/cp.rs @@ -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: : or local path + pub src: String, + /// Destination: : 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: `:` 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 { + 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. :)"), + } +} + +/// 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 { + 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> { + 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" + ); + } +} \ No newline at end of file diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index 67f314a03..d7e31efda 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -1,5 +1,6 @@ pub mod backend; pub mod cli_backend; +pub mod cp; pub mod parse; pub mod progress; pub mod run; diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index c27025eeb..488f104a7 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -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 = 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"); } -} +} \ No newline at end of file diff --git a/crates/arc-workflows/src/cli/runs.rs b/crates/arc-workflows/src/cli/runs.rs index e3e6f7145..a6a2d9fc2 100644 --- a/crates/arc-workflows/src/cli/runs.rs +++ b/crates/arc-workflows/src/cli/runs.rs @@ -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()); } -} +} \ No newline at end of file diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index a95fbd690..67ee0c078 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -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(), diff --git a/crates/arc-workflows/src/git_credential_sandbox.rs b/crates/arc-workflows/src/git_credential_sandbox.rs index 78eb261df..a87509d6c 100644 --- a/crates/arc-workflows/src/git_credential_sandbox.rs +++ b/crates/arc-workflows/src/git_credential_sandbox.rs @@ -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() } diff --git a/crates/arc-workflows/src/handler/command.rs b/crates/arc-workflows/src/handler/command.rs index aace2595a..431fc25c4 100644 --- a/crates/arc-workflows/src/handler/command.rs +++ b/crates/arc-workflows/src/handler/command.rs @@ -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(()) } diff --git a/crates/arc-workflows/src/handler/parallel.rs b/crates/arc-workflows/src/handler/parallel.rs index 7e78bd141..1b2bb34bf 100644 --- a/crates/arc-workflows/src/handler/parallel.rs +++ b/crates/arc-workflows/src/handler/parallel.rs @@ -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 } diff --git a/crates/arc-workflows/src/lib.rs b/crates/arc-workflows/src/lib.rs index c62751175..195f7cf9d 100644 --- a/crates/arc-workflows/src/lib.rs +++ b/crates/arc-workflows/src/lib.rs @@ -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; diff --git a/crates/arc-workflows/src/sandbox_record.rs b/crates/arc-workflows/src/sandbox_record.rs new file mode 100644 index 000000000..15dab4431 --- /dev/null +++ b/crates/arc-workflows/src/sandbox_record.rs @@ -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, + /// Docker: host path that is bind-mounted into the container + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_working_directory: Option, + /// Docker: mount point inside the container + #[serde(default, skip_serializing_if = "Option::is_none")] + pub container_mount_point: Option, + /// Exe: SSH destination for the data plane + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_host: Option, +} + +impl SandboxRecord { + pub fn save(&self, path: &Path) -> Result<()> { + crate::save_json(self, path, "sandbox_record") + } + + pub fn load(path: &Path) -> Result { + 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()); + } +} diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index a48e66d49..7ad76f5ce 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -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); -} +} \ No newline at end of file diff --git a/tmp/implement-and-simplify.toml b/tmp/implement-and-simplify.toml index 6b88d905f..b4cc2ebe2 100644 --- a/tmp/implement-and-simplify.toml +++ b/tmp/implement-and-simplify.toml @@ -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" \ No newline at end of file +# [sandbox.daytona.snapshot] +# name = "daytona-large" + + +[sandbox.local] +worktree = true \ No newline at end of file