Add arc cp e2e tests and preserve worktrees for post-run access

- E2e tests for local, Docker, and Daytona sandbox backends
- Make cp::reconnect pub for integration test access
- Stop deleting worktrees after run completion so arc cp can
  access local/Docker sandbox files (prune step to come later)
- Rustfmt cleanups

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-09 11:19:31 -04:00
parent aae1efab98
commit e9ff7f3274
9 changed files with 449 additions and 41 deletions

View file

@ -956,4 +956,4 @@ mod tests {
std::fs::remove_dir_all(&host_dir).ok();
}
}
}

View file

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

View file

@ -53,7 +53,9 @@ fn parse_direction(src: &str, dst: &str) -> Result<CopyDirection> {
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. <run-id>:<path>)"),
}
}
@ -104,9 +106,7 @@ fn find_run_by_prefix(base: &Path, prefix: &str) -> Result<PathBuf> {
/// 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>> {
pub async fn reconnect(record: &SandboxRecord) -> Result<Box<dyn arc_agent::sandbox::Sandbox>> {
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"
);
}
}
}

View file

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

View file

@ -660,4 +660,4 @@ mod tests {
let result = parse_label_filters(&args);
assert!(result.is_empty());
}
}
}

View file

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

View file

@ -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<u8> = (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<u8> = (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
);
}

View file

@ -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<u8> = (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();
}

View file

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