diff --git a/crates/arc-agent/src/sandbox.rs b/crates/arc-agent/src/sandbox.rs index 1d7478bfa..23345d90d 100644 --- a/crates/arc-agent/src/sandbox.rs +++ b/crates/arc-agent/src/sandbox.rs @@ -92,6 +92,10 @@ macro_rules! delegate_sandbox { fn sandbox_info(&self) -> String { self.$field.sandbox_info() } + + async fn refresh_push_credentials(&self) -> Result<(), String> { + self.$field.refresh_push_credentials().await + } } }; } @@ -329,6 +333,12 @@ pub trait Sandbox: Send + Sync { fn sandbox_info(&self) -> String { String::new() } + + /// Refresh git push credentials (e.g. rotate an expiring GitHub App token). + /// Default is a no-op; Daytona overrides to update the remote URL with a fresh token. + async fn refresh_push_credentials(&self) -> Result<(), String> { + Ok(()) + } } #[cfg(test)] diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index 1b2a4bb8d..0c1146e75 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -141,6 +141,8 @@ pub struct DaytonaSandbox { sandbox: tokio::sync::OnceCell, rg_available: tokio::sync::OnceCell, event_callback: Option, + /// HTTPS origin URL stored after clone so we can refresh push credentials later. + origin_url: tokio::sync::OnceCell, } impl DaytonaSandbox { @@ -157,6 +159,7 @@ impl DaytonaSandbox { sandbox: tokio::sync::OnceCell::new(), rg_available: tokio::sync::OnceCell::const_new(), event_callback: None, + origin_url: tokio::sync::OnceCell::new(), } } @@ -521,6 +524,7 @@ impl Sandbox for DaytonaSandbox { } }; + let clone_token = password.clone(); let clone_result = git_svc .clone( &url, @@ -539,9 +543,40 @@ impl Sandbox for DaytonaSandbox { let clone_duration = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX); self.emit(SandboxEvent::GitCloneCompleted { - url, + url: url.clone(), duration_ms: clone_duration, }); + + // Store origin URL and set push credentials for later pushes + if let Some(token) = clone_token { + let _ = self.origin_url.set(url); + let process_svc = sandbox.process().await.ok(); + if let Some(ps) = process_svc { + let origin = self.origin_url.get().expect("just set"); + let auth_url = origin.replacen( + "https://", + &format!("https://x-access-token:{token}@"), + 1, + ); + let cmd = format!( + "git -c maintenance.auto=0 remote set-url origin '{}'", + auth_url.replace('\'', "'\\''"), + ); + let opts = daytona_sdk::ExecuteCommandOptions { + cwd: Some(WORKING_DIRECTORY.to_string()), + ..Default::default() + }; + let wrapped = wrap_bash_command(&cmd); + if let Ok(r) = ps.execute_command(&wrapped, opts).await { + if r.exit_code != 0 { + tracing::warn!( + exit_code = r.exit_code, + "Failed to set push credentials on origin" + ); + } + } + } + } } Err(e) if self.github_app.is_none() => { let err = format!( @@ -657,6 +692,42 @@ impl Sandbox for DaytonaSandbox { .unwrap_or_default() } + async fn refresh_push_credentials(&self) -> Result<(), String> { + let origin_url = match self.origin_url.get() { + Some(url) => url, + None => return Ok(()), // no authenticated origin — nothing to refresh + }; + let creds = match &self.github_app { + Some(c) => c, + None => return Ok(()), + }; + + let (owner, repo) = crate::github_app::parse_github_owner_repo(origin_url) + .map_err(|e| format!("Failed to parse origin URL for credential refresh: {e}"))?; + + let (_username, password) = + crate::github_app::resolve_clone_credentials(creds, &owner, &repo) + .await + .map_err(|e| format!("Failed to refresh GitHub App token: {e}"))?; + + if let Some(token) = password { + let auth_url = origin_url.replacen( + "https://", + &format!("https://x-access-token:{token}@"), + 1, + ); + let cmd = format!( + "git -c maintenance.auto=0 remote set-url origin '{}'", + auth_url.replace('\'', "'\\''"), + ); + self.exec_command(&cmd, 10_000, None, None, None) + .await + .map_err(|e| format!("Failed to set refreshed push credentials: {e}"))?; + } + + Ok(()) + } + async fn read_file( &self, path: &str, diff --git a/crates/arc-workflows/src/engine.rs b/crates/arc-workflows/src/engine.rs index 7adb0ecc0..492451d9d 100644 --- a/crates/arc-workflows/src/engine.rs +++ b/crates/arc-workflows/src/engine.rs @@ -646,6 +646,25 @@ pub async fn git_checkpoint_remote( } } +/// Push the run branch to origin inside a remote sandbox (best-effort). +async fn git_push_remote(sandbox: &dyn Sandbox, branch: &str) { + if let Err(e) = sandbox.refresh_push_credentials().await { + tracing::warn!(error = %e, "Failed to refresh push credentials"); + } + let cmd = format!("{GIT_REMOTE} push origin {branch}"); + match sandbox.exec_command(&cmd, 60_000, None, None, None).await { + Ok(r) if r.exit_code == 0 => { + tracing::info!(branch, "Pushed run branch to origin"); + } + Ok(r) => { + tracing::warn!(branch, exit_code = r.exit_code, "Failed to push run branch"); + } + Err(e) => { + tracing::warn!(branch, error = %e, "Failed to push run branch"); + } + } +} + /// Run a git diff inside a remote sandbox. async fn git_diff_remote(sandbox: &dyn Sandbox, base: &str) -> Option { let cmd = format!("{GIT_REMOTE} diff {base} HEAD"); @@ -1854,6 +1873,13 @@ impl WorkflowRunEngine { git_commit_sha: sha.clone(), }); + // Push run branch to origin after remote checkpoint + if matches!(mode, GitCheckpointMode::Remote(_)) { + if let Some(ref branch) = config.run_branch { + git_push_remote(&*self.services.sandbox, branch).await; + } + } + // Save diff.patch for this stage let prev = last_git_sha .as_deref() diff --git a/crates/arc-workflows/src/github_app.rs b/crates/arc-workflows/src/github_app.rs index 27e32feac..432d42dfa 100644 --- a/crates/arc-workflows/src/github_app.rs +++ b/crates/arc-workflows/src/github_app.rs @@ -187,7 +187,7 @@ pub async fn create_installation_access_token( ); let body = serde_json::json!({ "repositories": [repo], - "permissions": { "contents": "read" } + "permissions": { "contents": "write" } }); let token_resp = client @@ -436,7 +436,7 @@ mod tests { .mock("POST", "/app/installations/123/access_tokens") .match_header("Authorization", "Bearer test-jwt") .match_body(mockito::Matcher::JsonString( - r#"{"repositories":["repo"],"permissions":{"contents":"read"}}"#.to_string(), + r#"{"repositories":["repo"],"permissions":{"contents":"write"}}"#.to_string(), )) .with_status(201) .with_body(r#"{"token": "ghs_xxx"}"#) diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index cf1e1e627..991b61458 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -1312,3 +1312,127 @@ async fn daytona_iat_not_installed_gives_clear_error() { "error should mention 'not installed', got: {err}" ); } + +// --------------------------------------------------------------------------- +// Push run branch to origin after each checkpoint (Remote mode + GitHub App) +// --------------------------------------------------------------------------- + +/// E2E: After each remote checkpoint, the run branch is pushed to origin. +/// Verifies the branch appears on the remote via `git ls-remote`. +#[tokio::test] +#[ignore] +async fn daytona_git_push_run_branch_to_origin() { + let creds = load_github_app_credentials(); + let env = create_env_with_github_app(Some(creds)).await; + env.initialize().await.unwrap(); + let env: Arc = Arc::new(env); + + // Install git if not available + let git_check = env + .exec_command("git --version", 10_000, None, None, None) + .await; + if git_check.as_ref().map_or(true, |r| r.exit_code != 0) { + let install = env + .exec_command( + "apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1", + 120_000, + None, + None, + None, + ) + .await + .expect("apt-get install git should not error"); + assert_eq!( + install.exit_code, 0, + "git install failed: {}", + install.stderr + ); + } + + // Set up git in the sandbox + let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await; + + // Pipeline: start -> work -> exit + let mut graph = Graph::new("DaytonaGitPush"); + graph.attrs.insert( + "goal".to_string(), + AttrValue::String("Test push run branch to origin".to_string()), + ); + + let mut start = Node::new("start"); + start + .attrs + .insert("shape".to_string(), AttrValue::String("Mdiamond".to_string())); + graph.nodes.insert("start".to_string(), start); + + let mut exit = Node::new("exit"); + exit.attrs + .insert("shape".to_string(), AttrValue::String("Msquare".to_string())); + graph.nodes.insert("exit".to_string(), exit); + + let mut work = Node::new("work"); + work.attrs + .insert("label".to_string(), AttrValue::String("Work".to_string())); + graph.nodes.insert("work".to_string(), work); + + graph.edges.push(Edge::new("start", "work")); + graph.edges.push(Edge::new("work", "exit")); + + let dir = tempfile::tempdir().unwrap(); + + let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler)); + registry.register("start", Box::new(StartHandler)); + registry.register("exit", Box::new(ExitHandler)); + + let engine = WorkflowRunEngine::new(registry, Arc::new(EventEmitter::new()), env.clone()); + let config = RunConfig { + logs_root: dir.path().to_path_buf(), + cancel_token: None, + dry_run: false, + run_id: run_id.clone(), + git_checkpoint: Some(GitCheckpointMode::Remote(dir.path().to_path_buf())), + base_sha: Some(base_sha), + run_branch: Some(branch_name.clone()), + meta_branch: None, + labels: std::collections::HashMap::new(), + }; + + let outcome = engine + .run(&graph, &config) + .await + .expect("pipeline should succeed"); + assert_eq!(outcome.status, StageStatus::Success); + + // Verify the run branch was pushed to origin + let ls_remote_cmd = format!("git ls-remote --heads origin {branch_name}"); + let ls_result = env + .exec_command(&ls_remote_cmd, 30_000, None, None, None) + .await + .expect("git ls-remote should succeed"); + assert_eq!( + ls_result.exit_code, 0, + "git ls-remote failed: {}", + ls_result.stdout + ); + assert!( + ls_result.stdout.contains(&branch_name), + "run branch should exist on origin after push, got: {}", + ls_result.stdout.trim() + ); + + // Clean up the remote branch + let delete_cmd = format!("git push origin --delete {branch_name}"); + let delete_result = env + .exec_command(&delete_cmd, 30_000, None, None, None) + .await; + if let Ok(r) = &delete_result { + if r.exit_code != 0 { + eprintln!( + "Warning: failed to delete remote branch {branch_name}: {}", + r.stdout + ); + } + } + + env.cleanup().await.unwrap(); +}