diff --git a/crates/arc-agent/src/docker_sandbox.rs b/crates/arc-agent/src/docker_sandbox.rs index ff9d54657..c636e8404 100644 --- a/crates/arc-agent/src/docker_sandbox.rs +++ b/crates/arc-agent/src/docker_sandbox.rs @@ -956,4 +956,4 @@ mod tests { std::fs::remove_dir_all(&host_dir).ok(); } -} \ No newline at end of file +} diff --git a/crates/arc-exe/src/lib.rs b/crates/arc-exe/src/lib.rs index 91fc12372..07af21d1b 100644 --- a/crates/arc-exe/src/lib.rs +++ b/crates/arc-exe/src/lib.rs @@ -164,7 +164,9 @@ impl ExeSandbox { 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()) }) + Box::pin(async { + Err("from_existing sandbox cannot create new SSH connections".to_string()) + }) }), config: ExeConfig::default(), clone_params: None, diff --git a/crates/arc-workflows/src/cli/cp.rs b/crates/arc-workflows/src/cli/cp.rs index 9ed0a25d3..e75cd67a7 100644 --- a/crates/arc-workflows/src/cli/cp.rs +++ b/crates/arc-workflows/src/cli/cp.rs @@ -53,7 +53,9 @@ fn parse_direction(src: &str, dst: &str) -> Result { 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"), + (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. :)"), } } @@ -104,9 +106,7 @@ fn find_run_by_prefix(base: &Path, prefix: &str) -> Result { /// 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> { +pub async fn reconnect(record: &SandboxRecord) -> Result> { debug!( provider = %record.provider, identifier = record.identifier.as_deref().unwrap_or(""), @@ -115,8 +115,9 @@ async fn reconnect( match record.provider.as_str() { "local" => { - let sandbox = - arc_agent::local_sandbox::LocalSandbox::new(PathBuf::from(&record.working_directory)); + let sandbox = arc_agent::local_sandbox::LocalSandbox::new(PathBuf::from( + &record.working_directory, + )); Ok(Box::new(sandbox)) } "docker" => { @@ -153,15 +154,12 @@ async fn reconnect( .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 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, - ); + let sandbox = + crate::daytona_sandbox::DaytonaSandbox::from_existing(client, sdk_sandbox); Ok(Box::new(sandbox)) } "exe" => { @@ -172,7 +170,9 @@ async fn reconnect( let data_ssh = arc_exe::OpensshRunner::connect(data_host) .await - .map_err(|e| anyhow::anyhow!("Failed to connect to exe sandbox '{data_host}': {e}"))?; + .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)) @@ -194,8 +194,9 @@ pub async fn cp_command(args: CpArgs) -> Result<()> { 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?")?; + 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?; @@ -219,8 +220,9 @@ pub async fn cp_command(args: CpArgs) -> Result<()> { 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?")?; + 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?; @@ -278,7 +280,8 @@ async fn upload_recursive( 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())]; + 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) @@ -448,4 +451,4 @@ mod tests { "Should mention ambiguity" ); } -} \ No newline at end of file +} diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 488f104a7..89fa68628 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -519,7 +519,7 @@ pub async fn run_command( }; // Set up git worktree for local execution (must happen before cwd is captured) - let (worktree_work_dir, worktree_path, worktree_branch, worktree_base_sha) = if git_clean { + let (worktree_work_dir, _worktree_path, worktree_branch, worktree_base_sha) = if git_clean { match setup_worktree(&original_cwd, &logs_dir, &run_id) { Ok((wd, wt, branch, base)) => (Some(wd), Some(wt), Some(branch), Some(base)), Err(e) => { @@ -617,7 +617,11 @@ pub async fn run_command( { let sandbox_info_opt = { let info = sandbox.sandbox_info(); - if info.is_empty() { None } else { Some(info) } + if info.is_empty() { + None + } else { + Some(info) + } }; let record = match sandbox_provider { SandboxProvider::Local => crate::sandbox_record::SandboxRecord { @@ -933,11 +937,8 @@ pub async fn run_command( }; let run_duration_ms = run_start.elapsed().as_millis() as u64; - // Restore cwd and clean up worktree (best-effort) + // Restore cwd (worktree is kept for `arc cp` access; pruned separately) let _ = std::env::set_current_dir(&original_cwd); - if let Some(ref wt) = worktree_path { - let _ = crate::git::remove_worktree(&original_cwd, wt); - } { let (status, failure_reason) = match &engine_result { @@ -1447,11 +1448,8 @@ async fn run_from_branch( .await; let run_duration_ms = run_start.elapsed().as_millis() as u64; - // Clean up + // Restore cwd (worktree is kept for `arc cp` access; pruned separately) let _ = std::env::set_current_dir(&original_cwd); - if let Some(ref wt) = worktree_path { - let _ = crate::git::remove_worktree(&original_cwd, wt); - } if sandbox_provider == SandboxProvider::Exe { let _ = sandbox.cleanup().await; } @@ -2258,4 +2256,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 a6a2d9fc2..afde94821 100644 --- a/crates/arc-workflows/src/cli/runs.rs +++ b/crates/arc-workflows/src/cli/runs.rs @@ -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/sandbox_record.rs b/crates/arc-workflows/src/sandbox_record.rs index 15dab4431..167cf79f8 100644 --- a/crates/arc-workflows/src/sandbox_record.rs +++ b/crates/arc-workflows/src/sandbox_record.rs @@ -81,10 +81,7 @@ mod tests { loaded.host_working_directory.as_deref(), Some("/home/user/project") ); - assert_eq!( - loaded.container_mount_point.as_deref(), - Some("/workspace") - ); + assert_eq!(loaded.container_mount_point.as_deref(), Some("/workspace")); assert!(loaded.data_host.is_none()); } diff --git a/crates/arc-workflows/tests/cp_integration.rs b/crates/arc-workflows/tests/cp_integration.rs new file mode 100644 index 000000000..a4e8a6ec0 --- /dev/null +++ b/crates/arc-workflows/tests/cp_integration.rs @@ -0,0 +1,303 @@ +//! E2E tests for `arc cp` against local and Docker sandbox backends. +//! +//! Local tests run without `#[ignore]` (no external dependencies). +//! Docker tests require a Docker daemon and are marked `#[ignore]`. +//! Run Docker tests with: `cargo test --package arc-workflows --test cp_integration -- --ignored` + +use arc_workflows::cli::cp::reconnect; +use arc_workflows::sandbox_record::SandboxRecord; + +// --------------------------------------------------------------------------- +// Local sandbox +// --------------------------------------------------------------------------- + +fn local_record(working_directory: &std::path::Path) -> SandboxRecord { + SandboxRecord { + provider: "local".to_string(), + working_directory: working_directory.to_string_lossy().to_string(), + identifier: None, + host_working_directory: None, + container_mount_point: None, + data_host: None, + } +} + +#[tokio::test] +async fn local_cp_upload_download_round_trip() { + let sandbox_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + let record = local_record(sandbox_dir.path()); + let sandbox = reconnect(&record).await.expect("reconnect local"); + + // Upload a text file + let content = b"hello from local cp test\n"; + let local_src = scratch.path().join("upload.txt"); + std::fs::write(&local_src, content).unwrap(); + + sandbox + .upload_file_from_local(&local_src, "cp_test.txt") + .await + .expect("upload text"); + + // Verify it landed in the sandbox working directory + assert!(sandbox_dir.path().join("cp_test.txt").exists()); + + // Download it back + let local_dst = scratch.path().join("download.txt"); + sandbox + .download_file_to_local("cp_test.txt", &local_dst) + .await + .expect("download text"); + + assert_eq!(std::fs::read(&local_dst).unwrap(), content); +} + +#[tokio::test] +async fn local_cp_binary_round_trip() { + let sandbox_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + let record = local_record(sandbox_dir.path()); + let sandbox = reconnect(&record).await.expect("reconnect local"); + + // All 256 byte values + let binary: Vec = (0..=255).collect(); + let local_src = scratch.path().join("binary.bin"); + std::fs::write(&local_src, &binary).unwrap(); + + sandbox + .upload_file_from_local(&local_src, "binary.bin") + .await + .expect("upload binary"); + + let local_dst = scratch.path().join("binary_dl.bin"); + sandbox + .download_file_to_local("binary.bin", &local_dst) + .await + .expect("download binary"); + + assert_eq!(std::fs::read(&local_dst).unwrap(), binary); +} + +#[tokio::test] +async fn local_cp_creates_parent_dirs() { + let sandbox_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + let record = local_record(sandbox_dir.path()); + let sandbox = reconnect(&record).await.expect("reconnect local"); + + let content = b"nested file\n"; + let local_src = scratch.path().join("nested.txt"); + std::fs::write(&local_src, content).unwrap(); + + // Upload to a nested path that doesn't exist yet + sandbox + .upload_file_from_local(&local_src, "a/b/c/nested.txt") + .await + .expect("upload to nested path"); + + assert!(sandbox_dir.path().join("a/b/c/nested.txt").exists()); + + // Download to a nested local path that doesn't exist yet + let local_dst = scratch.path().join("x/y/z/nested.txt"); + sandbox + .download_file_to_local("a/b/c/nested.txt", &local_dst) + .await + .expect("download to nested path"); + + assert_eq!(std::fs::read(&local_dst).unwrap(), content); +} + +#[tokio::test] +async fn local_cp_record_save_load_round_trip() { + let sandbox_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + let record = local_record(sandbox_dir.path()); + let path = scratch.path().join("sandbox.json"); + record.save(&path).unwrap(); + + let loaded = SandboxRecord::load(&path).unwrap(); + assert_eq!(loaded.provider, "local"); + assert_eq!(loaded.working_directory, record.working_directory); + assert!(loaded.identifier.is_none()); + + // Reconnect from the loaded record still works + let sandbox = reconnect(&loaded).await.expect("reconnect from loaded"); + assert_eq!(sandbox.working_directory(), record.working_directory); +} + +// --------------------------------------------------------------------------- +// Docker sandbox +// --------------------------------------------------------------------------- + +fn docker_record(host_dir: &std::path::Path, mount_point: &str) -> SandboxRecord { + SandboxRecord { + provider: "docker".to_string(), + working_directory: mount_point.to_string(), + identifier: None, + host_working_directory: Some(host_dir.to_string_lossy().to_string()), + container_mount_point: Some(mount_point.to_string()), + data_host: None, + } +} + +#[tokio::test] +#[ignore] // requires Docker daemon +async fn docker_cp_upload_download_round_trip() { + let host_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + let record = docker_record(host_dir.path(), "/workspace"); + let sandbox = reconnect(&record).await.expect("reconnect docker"); + + // Upload a text file + let content = b"hello from docker cp test\n"; + let local_src = scratch.path().join("upload.txt"); + std::fs::write(&local_src, content).unwrap(); + + sandbox + .upload_file_from_local(&local_src, "cp_test.txt") + .await + .expect("upload text"); + + // Verify it landed on the host filesystem (bind mount path) + assert!(host_dir.path().join("cp_test.txt").exists()); + assert_eq!( + std::fs::read(host_dir.path().join("cp_test.txt")).unwrap(), + content + ); + + // Download it back + let local_dst = scratch.path().join("download.txt"); + sandbox + .download_file_to_local("cp_test.txt", &local_dst) + .await + .expect("download text"); + + assert_eq!(std::fs::read(&local_dst).unwrap(), content); +} + +#[tokio::test] +#[ignore] // requires Docker daemon +async fn docker_cp_binary_round_trip() { + let host_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + let record = docker_record(host_dir.path(), "/workspace"); + let sandbox = reconnect(&record).await.expect("reconnect docker"); + + let binary: Vec = (0..=255).collect(); + let local_src = scratch.path().join("binary.bin"); + std::fs::write(&local_src, &binary).unwrap(); + + sandbox + .upload_file_from_local(&local_src, "binary.bin") + .await + .expect("upload binary"); + + let local_dst = scratch.path().join("binary_dl.bin"); + sandbox + .download_file_to_local("binary.bin", &local_dst) + .await + .expect("download binary"); + + assert_eq!(std::fs::read(&local_dst).unwrap(), binary); +} + +#[tokio::test] +#[ignore] // requires Docker daemon +async fn docker_cp_creates_parent_dirs() { + let host_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + let record = docker_record(host_dir.path(), "/workspace"); + let sandbox = reconnect(&record).await.expect("reconnect docker"); + + let content = b"nested docker file\n"; + let local_src = scratch.path().join("nested.txt"); + std::fs::write(&local_src, content).unwrap(); + + sandbox + .upload_file_from_local(&local_src, "deep/nested/file.txt") + .await + .expect("upload to nested path"); + + assert!(host_dir.path().join("deep/nested/file.txt").exists()); + + let local_dst = scratch.path().join("p/q/file.txt"); + sandbox + .download_file_to_local("deep/nested/file.txt", &local_dst) + .await + .expect("download to nested path"); + + assert_eq!(std::fs::read(&local_dst).unwrap(), content); +} + +#[tokio::test] +#[ignore] // requires Docker daemon +async fn docker_cp_custom_mount_point() { + let host_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + // Use a non-default mount point + let record = docker_record(host_dir.path(), "/app"); + let sandbox = reconnect(&record).await.expect("reconnect docker"); + + let content = b"custom mount\n"; + let local_src = scratch.path().join("mount.txt"); + std::fs::write(&local_src, content).unwrap(); + + sandbox + .upload_file_from_local(&local_src, "mount.txt") + .await + .expect("upload with custom mount"); + + assert!(host_dir.path().join("mount.txt").exists()); + + let local_dst = scratch.path().join("mount_dl.txt"); + sandbox + .download_file_to_local("mount.txt", &local_dst) + .await + .expect("download with custom mount"); + + assert_eq!(std::fs::read(&local_dst).unwrap(), content); +} + +#[tokio::test] +#[ignore] // requires Docker daemon +async fn docker_cp_record_save_load_round_trip() { + let host_dir = tempfile::tempdir().unwrap(); + let scratch = tempfile::tempdir().unwrap(); + + let record = docker_record(host_dir.path(), "/workspace"); + let path = scratch.path().join("sandbox.json"); + record.save(&path).unwrap(); + + let loaded = SandboxRecord::load(&path).unwrap(); + assert_eq!(loaded.provider, "docker"); + assert_eq!( + loaded.host_working_directory.as_deref(), + Some(host_dir.path().to_str().unwrap()) + ); + assert_eq!(loaded.container_mount_point.as_deref(), Some("/workspace")); + + // Reconnect from the loaded record still works + let sandbox = reconnect(&loaded).await.expect("reconnect from loaded"); + + let content = b"from loaded record\n"; + let local_src = scratch.path().join("loaded.txt"); + std::fs::write(&local_src, content).unwrap(); + + sandbox + .upload_file_from_local(&local_src, "loaded.txt") + .await + .expect("upload via loaded record"); + + assert_eq!( + std::fs::read(host_dir.path().join("loaded.txt")).unwrap(), + content + ); +} diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index d599f9b55..a41a7485f 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -1743,3 +1743,104 @@ async fn daytona_toolbox_idle_diagnostic() { eprintln!("\n=== PASS: all idle durations survived ==="); env.cleanup().await.unwrap(); } + +/// E2E test for `arc cp` against a live Daytona sandbox. +/// +/// Creates a sandbox, saves a SandboxRecord, reconnects via `cp::reconnect`, +/// uploads a file, downloads it back, and verifies the round-trip. +#[tokio::test] +#[ignore] +async fn daytona_cp_upload_download_round_trip() { + use arc_workflows::cli::cp::reconnect; + use arc_workflows::sandbox_record::SandboxRecord; + + // 1. Create and initialize a real Daytona sandbox + let env = create_env().await; + env.initialize().await.unwrap(); + + let sandbox_name = env.sandbox_info(); + assert!( + !sandbox_name.is_empty(), + "sandbox_info() should return the Daytona sandbox name" + ); + + // 2. Build a SandboxRecord (same as `arc run` would persist) + let record = SandboxRecord { + provider: "daytona".to_string(), + working_directory: env.working_directory().to_string(), + identifier: Some(sandbox_name.clone()), + host_working_directory: None, + container_mount_point: None, + data_host: None, + }; + + // 3. Save to temp dir and reload (verify serialization round-trip) + let tmp = tempfile::tempdir().unwrap(); + let record_path = tmp.path().join("sandbox.json"); + record.save(&record_path).unwrap(); + let loaded = SandboxRecord::load(&record_path).unwrap(); + assert_eq!(loaded.provider, "daytona"); + assert_eq!(loaded.identifier.as_deref(), Some(sandbox_name.as_str())); + + // 4. Reconnect via the real cp::reconnect path + let reconnected = reconnect(&loaded).await.expect("reconnect should succeed"); + + // 5. Upload: write a local file, then upload it to the sandbox + let upload_content = b"hello from arc cp e2e test\n"; + let local_upload = tmp.path().join("upload.txt"); + std::fs::write(&local_upload, upload_content).unwrap(); + + reconnected + .upload_file_from_local(&local_upload, "cp_test_upload.txt") + .await + .expect("upload_file_from_local should succeed"); + + // 6. Verify the file exists in the sandbox via the original connection + assert!( + env.file_exists("cp_test_upload.txt").await.unwrap(), + "uploaded file should exist in the sandbox" + ); + let remote_content = env + .read_file("cp_test_upload.txt", None, None) + .await + .unwrap(); + assert!( + remote_content.contains("hello from arc cp e2e test"), + "expected uploaded content in sandbox, got: {remote_content}" + ); + + // 7. Download: retrieve the file back to local via the reconnected sandbox + let local_download = tmp.path().join("download.txt"); + reconnected + .download_file_to_local("cp_test_upload.txt", &local_download) + .await + .expect("download_file_to_local should succeed"); + + let downloaded = std::fs::read(&local_download).unwrap(); + assert_eq!(downloaded, upload_content); + + // 8. Upload a binary file to test non-UTF-8 content + let binary_content: Vec = (0..=255).collect(); + let local_binary = tmp.path().join("binary.bin"); + std::fs::write(&local_binary, &binary_content).unwrap(); + + reconnected + .upload_file_from_local(&local_binary, "cp_test_binary.bin") + .await + .expect("binary upload should succeed"); + + let local_binary_dl = tmp.path().join("binary_dl.bin"); + reconnected + .download_file_to_local("cp_test_binary.bin", &local_binary_dl) + .await + .expect("binary download should succeed"); + + let downloaded_binary = std::fs::read(&local_binary_dl).unwrap(); + assert_eq!( + downloaded_binary, binary_content, + "binary round-trip should be exact" + ); + + // 9. Cleanup + env.cleanup().await.unwrap(); +} diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index 7ad76f5ce..dd5eec2cb 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -8926,7 +8926,11 @@ 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> { + async fn upload_file_from_local( + &self, + _: &std::path::Path, + _: &str, + ) -> std::result::Result<(), String> { Err("not implemented".to_string()) } @@ -13256,4 +13260,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 +}