From cf50625824ba92e18a29775171e424ddb0e95dff Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 5 Mar 2026 22:31:40 -0500 Subject: [PATCH] Switch Daytona git cloning from gh CLI to GitHub App Installation Access Tokens Replace `gh auth token` with GitHub App IATs scoped to `contents: read` for Daytona sandbox git cloning. Public repos are auto-detected and cloned without credentials. Private repos get short-lived, repo-scoped tokens. Clear error messages for each failure mode (app not installed, suspended, no repo access, auth failure). Falls back gracefully when no GitHub App is configured. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + crates/arc-cli/src/main.rs | 24 +- crates/arc-workflows/Cargo.toml | 1 + crates/arc-workflows/src/cli/run.rs | 7 +- crates/arc-workflows/src/daytona_sandbox.rs | 117 ++-- crates/arc-workflows/src/github_app.rs | 510 ++++++++++++++++++ crates/arc-workflows/src/lib.rs | 1 + .../tests/daytona_integration.rs | 4 +- docs/changelog/2026-03-06.mdx | 24 + docs/docs.json | 1 + docs/execution/environments.mdx | 4 +- 11 files changed, 634 insertions(+), 60 deletions(-) create mode 100644 crates/arc-workflows/src/github_app.rs create mode 100644 docs/changelog/2026-03-06.mdx diff --git a/Cargo.lock b/Cargo.lock index a9dcad129..4b2961547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "futures", "git2", "indicatif", + "jsonwebtoken", "mockito", "nom", "predicates", diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index 46c0396e3..67726342d 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -79,6 +79,20 @@ enum LlmCommand { Chat(arc_llm::cli::ChatArgs), } +fn build_github_app_credentials( + config: &arc_api::server_config::ServerConfig, +) -> Option { + let app_id = config.git.app_id.as_ref()?; + let key_b64 = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?; + let pem_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &key_b64) + .ok()?; + let private_key_pem = String::from_utf8(pem_bytes).ok()?; + Some(arc_workflows::github_app::GitHubAppCredentials { + app_id: app_id.clone(), + private_key_pem, + }) +} + #[tokio::main] async fn main() -> Result<()> { let _ = rustls::crypto::ring::default_provider().install_default(); @@ -143,8 +157,14 @@ async fn main() -> Result<()> { let styles: &'static arc_util::terminal::Styles = Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr())); let server_config = arc_api::server_config::load_server_config(None)?; - arc_workflows::cli::run::run_command(args, server_config.run_defaults, styles) - .await?; + let github_app = build_github_app_credentials(&server_config); + arc_workflows::cli::run::run_command( + args, + server_config.run_defaults, + styles, + github_app, + ) + .await?; } RunCommand::List(args) => { arc_workflows::cli::runs::list_command(&args)?; diff --git a/crates/arc-workflows/Cargo.toml b/crates/arc-workflows/Cargo.toml index c2a997722..3403300f6 100644 --- a/crates/arc-workflows/Cargo.toml +++ b/crates/arc-workflows/Cargo.toml @@ -45,6 +45,7 @@ indicatif.workspace = true tokio-util.workspace = true tracing.workspace = true reqwest.workspace = true +jsonwebtoken.workspace = true [dev-dependencies] mockito = "1" tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 6b959ea08..500e28ae5 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -191,6 +191,7 @@ pub async fn run_command( args: RunArgs, run_defaults: RunDefaults, styles: &'static Styles, + github_app: Option, ) -> anyhow::Result<()> { // Handle --run-branch resume: read everything from git metadata if let Some(branch) = args.run_branch.clone() { @@ -289,6 +290,7 @@ pub async fn run_command( git_clean, sandbox_provider, styles, + github_app, ) .await; } @@ -457,7 +459,7 @@ pub async fn run_command( .await .map_err(|e| anyhow::anyhow!("Failed to create Daytona client: {e}"))?; let config = daytona_config.clone().unwrap_or_default(); - let mut env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config); + let mut env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config, github_app.clone()); let emitter_cb = Arc::clone(&emitter); env.set_event_callback(Arc::new(move |event| { emitter_cb.emit(&crate::event::WorkflowRunEvent::Sandbox { event }); @@ -1157,6 +1159,7 @@ async fn run_preflight( git_clean: bool, sandbox_provider: SandboxProvider, styles: &'static Styles, + github_app: Option, ) -> anyhow::Result<()> { use arc_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckStatus}; @@ -1209,7 +1212,7 @@ async fn run_preflight( SandboxProvider::Daytona => match daytona_sdk::Client::new().await { Ok(daytona_client) => { let config = daytona_config.unwrap_or_default(); - let env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config); + let env = crate::daytona_sandbox::DaytonaSandbox::new(daytona_client, config, github_app); Ok(Arc::new(env) as Arc) } Err(e) => Err(format!("Daytona client creation failed: {e}")), diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index 58117fa4a..3148c0df3 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -11,6 +11,8 @@ use rand::Rng; use serde::de::{self, MapAccess, Visitor}; use serde::Deserialize; +use crate::github_app::GitHubAppCredentials; + const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; const DEFAULT_IMAGE: &str = "ubuntu:22.04"; @@ -117,6 +119,7 @@ pub struct DaytonaSnapshotConfig { pub struct DaytonaSandbox { config: DaytonaConfig, client: daytona_sdk::Client, + github_app: Option, sandbox: tokio::sync::OnceCell, rg_available: tokio::sync::OnceCell, event_callback: Option, @@ -124,10 +127,15 @@ pub struct DaytonaSandbox { impl DaytonaSandbox { #[must_use] - pub fn new(client: daytona_sdk::Client, config: DaytonaConfig) -> Self { + pub fn new( + client: daytona_sdk::Client, + config: DaytonaConfig, + github_app: Option, + ) -> Self { Self { config, client, + github_app, sandbox: tokio::sync::OnceCell::new(), rg_available: tokio::sync::OnceCell::const_new(), event_callback: None, @@ -334,28 +342,6 @@ pub fn detect_repo_info(path: &Path) -> Result<(String, Option), String> Ok((url, branch)) } -/// Get a GitHub authentication token via `gh auth token`. -pub fn get_gh_token() -> Result { - let output = std::process::Command::new("gh") - .args(["auth", "token"]) - .output() - .map_err(|e| format!("Failed to run 'gh auth token': {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "gh auth token failed (exit code {}): {stderr}", - output.status.code().unwrap_or(-1) - )); - } - - let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if token.is_empty() { - return Err("gh auth token returned empty string".to_string()); - } - Ok(token) -} - #[async_trait] impl Sandbox for DaytonaSandbox { async fn download_file_to_local( @@ -461,24 +447,38 @@ impl Sandbox for DaytonaSandbox { }); let clone_start = Instant::now(); - let token = get_gh_token() - .map_err(|e| format!("Failed to get GitHub token for Daytona clone: {e}")); - let token = match token { - Ok(t) => t, - Err(e) => { - self.emit(SandboxEvent::GitCloneFailed { - url: url.clone(), - error: e.clone(), - }); - let duration_ms = - u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::InitializeFailed { - provider: "daytona".into(), - error: e.clone(), - duration_ms, - }); - return Err(e); + // Resolve clone credentials via GitHub App or fall back to no auth + let (username, password) = match &self.github_app { + Some(creds) => { + let (owner, repo) = + crate::github_app::parse_github_owner_repo(&url).map_err(|e| { + let err = format!("Failed to parse GitHub URL for clone: {e}"); + self.emit(SandboxEvent::GitCloneFailed { + url: url.clone(), + error: err.clone(), + }); + err + })?; + crate::github_app::resolve_clone_credentials(creds, &owner, &repo) + .await + .map_err(|e| { + let err = + format!("Failed to get GitHub App credentials for clone: {e}"); + self.emit(SandboxEvent::GitCloneFailed { + url: url.clone(), + error: err.clone(), + }); + let duration_ms = u64::try_from(init_start.elapsed().as_millis()) + .unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: "daytona".into(), + error: err.clone(), + duration_ms, + }); + err + })? } + None => (None, None), }; let git_svc = sandbox @@ -503,19 +503,20 @@ impl Sandbox for DaytonaSandbox { } }; - match git_svc + let clone_result = git_svc .clone( &url, WORKING_DIRECTORY, daytona_sdk::GitCloneOptions { branch, - username: Some("x-access-token".to_string()), - password: Some(token), + username, + password, ..Default::default() }, ) - .await - { + .await; + + match clone_result { Ok(()) => { let clone_duration = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX); @@ -524,6 +525,25 @@ impl Sandbox for DaytonaSandbox { duration_ms: clone_duration, }); } + Err(e) if self.github_app.is_none() => { + let err = format!( + "Git clone failed: {e}. If this is a private repository, \ + configure a GitHub App with `arc setup` and install it \ + for your organization." + ); + self.emit(SandboxEvent::GitCloneFailed { + url, + error: err.clone(), + }); + let duration_ms = + u64::try_from(init_start.elapsed().as_millis()).unwrap_or(u64::MAX); + self.emit(SandboxEvent::InitializeFailed { + provider: "daytona".into(), + error: err.clone(), + duration_ms, + }); + return Err(err); + } Err(e) => { let err = format!("Failed to clone repo into Daytona sandbox: {e}"); self.emit(SandboxEvent::GitCloneFailed { @@ -1013,13 +1033,6 @@ mod tests { assert!(branch.is_some()); } - #[test] - #[ignore] // requires `gh` CLI installed and authenticated - fn gh_auth_token_returns_nonempty_string() { - let token = get_gh_token().unwrap(); - assert!(!token.is_empty()); - } - #[test] fn network_block_from_string() { let config: DaytonaConfig = toml::from_str(r#"network = "block""#).unwrap(); diff --git a/crates/arc-workflows/src/github_app.rs b/crates/arc-workflows/src/github_app.rs new file mode 100644 index 000000000..e0798bdb1 --- /dev/null +++ b/crates/arc-workflows/src/github_app.rs @@ -0,0 +1,510 @@ +use serde::Deserialize; + +const GITHUB_API_BASE_URL: &str = "https://api.github.com"; + +/// Credentials for authenticating as a GitHub App. +#[derive(Clone, Debug)] +pub struct GitHubAppCredentials { + pub app_id: String, + pub private_key_pem: String, +} + +/// Parse `owner` and `repo` from a GitHub HTTPS URL. +/// +/// Accepts URLs like: +/// - `https://github.com/owner/repo.git` +/// - `https://github.com/owner/repo` +/// - `https://github.com/owner/repo/` +pub fn parse_github_owner_repo(url: &str) -> Result<(String, String), String> { + let path = url + .strip_prefix("https://github.com/") + .ok_or_else(|| format!("Not a GitHub HTTPS URL: {url}"))?; + + let path = path.trim_end_matches('/'); + let path = path.strip_suffix(".git").unwrap_or(path); + + let mut parts = path.splitn(3, '/'); + let owner = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| format!("Missing owner in GitHub URL: {url}"))?; + let repo = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| format!("Missing repo in GitHub URL: {url}"))?; + + Ok((owner.to_string(), repo.to_string())) +} + +/// Create a signed JWT for GitHub App authentication (RS256). +/// +/// The JWT is valid for 10 minutes with a 60-second clock skew allowance. +pub fn sign_app_jwt(app_id: &str, private_key_pem: &str) -> Result { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use serde::Serialize; + + #[derive(Serialize)] + struct Claims { + iss: String, + iat: i64, + exp: i64, + } + + let now = chrono::Utc::now().timestamp(); + let claims = Claims { + iss: app_id.to_string(), + iat: now - 60, + exp: now + 600, + }; + + let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()) + .map_err(|e| format!("Invalid RSA private key: {e}"))?; + + encode(&Header::new(Algorithm::RS256), &claims, &key) + .map_err(|e| format!("Failed to sign JWT: {e}")) +} + +/// Check whether a GitHub repository is public using the App JWT. +pub async fn is_repo_public( + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, +) -> Result { + #[derive(Deserialize)] + struct RepoResponse { + private: bool, + } + + let url = format!("{base_url}/repos/{owner}/{repo}"); + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "arc") + .send() + .await + .map_err(|e| format!("Failed to check repo visibility: {e}"))?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(false); + } + + let body: RepoResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse repo response: {e}"))?; + + Ok(!body.private) +} + +/// Request a scoped Installation Access Token for a specific repository. +/// +/// Uses the App JWT to find the installation for `owner/repo`, then requests +/// a token scoped to `contents: read` on that single repository. +pub async fn create_installation_access_token( + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, +) -> Result { + #[derive(Deserialize)] + struct Installation { + id: u64, + } + + #[derive(Deserialize)] + struct AccessToken { + token: String, + } + + let client = reqwest::Client::new(); + + // Step 1: Find the installation for this repo + let install_url = format!("{base_url}/repos/{owner}/{repo}/installation"); + let install_resp = client + .get(&install_url) + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "arc") + .send() + .await + .map_err(|e| format!("Failed to look up GitHub App installation: {e}"))?; + + let status = install_resp.status(); + match status.as_u16() { + 200 => {} + 404 => { + return Err(format!( + "GitHub App is not installed for {owner}. \ + Install it at https://github.com/organizations/{owner}/settings/installations" + )); + } + 403 => { + return Err( + "GitHub App installation is suspended. \ + Re-enable it in your organization's GitHub App settings." + .to_string(), + ); + } + 401 => { + return Err( + "GitHub App authentication failed. \ + Check that app_id and GITHUB_APP_PRIVATE_KEY are correct." + .to_string(), + ); + } + _ => { + return Err(format!( + "Unexpected status {status} looking up GitHub App installation" + )); + } + } + + let installation: Installation = install_resp + .json() + .await + .map_err(|e| format!("Failed to parse installation response: {e}"))?; + + // Step 2: Create a scoped access token + let token_url = format!( + "{base_url}/app/installations/{}/access_tokens", + installation.id + ); + let body = serde_json::json!({ + "repositories": [repo], + "permissions": { "contents": "read" } + }); + + let token_resp = client + .post(&token_url) + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "arc") + .json(&body) + .send() + .await + .map_err(|e| format!("Failed to create installation access token: {e}"))?; + + let token_status = token_resp.status(); + match token_status.as_u16() { + 201 => {} + 422 => { + return Err(format!( + "GitHub App does not have access to repository {repo}. \ + Update the installation's repository permissions to include it." + )); + } + 401 => { + return Err( + "GitHub App authentication failed. \ + Check that app_id and GITHUB_APP_PRIVATE_KEY are correct." + .to_string(), + ); + } + _ => { + return Err(format!( + "Unexpected status {token_status} creating installation access token" + )); + } + } + + let access_token: AccessToken = token_resp + .json() + .await + .map_err(|e| format!("Failed to parse access token response: {e}"))?; + + Ok(access_token.token) +} + +/// Resolve git clone credentials for a GitHub repository. +/// +/// Returns `(username, password)` for authenticated cloning, or `(None, None)` +/// for public repositories. +pub async fn resolve_clone_credentials( + creds: &GitHubAppCredentials, + owner: &str, + repo: &str, +) -> Result<(Option, Option), String> { + let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?; + + if is_repo_public(&jwt, owner, repo, GITHUB_API_BASE_URL).await? { + return Ok((None, None)); + } + + let token = + create_installation_access_token(&jwt, owner, repo, GITHUB_API_BASE_URL).await?; + Ok(( + Some("x-access-token".to_string()), + Some(token), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // parse_github_owner_repo + // ----------------------------------------------------------------------- + + #[test] + fn parse_https_with_git_suffix() { + let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo.git").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn parse_https_without_git_suffix() { + let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn parse_https_with_trailing_slash() { + let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo/").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn parse_non_github_url_errors() { + let result = parse_github_owner_repo("https://gitlab.com/owner/repo"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Not a GitHub HTTPS URL")); + } + + #[test] + fn parse_missing_repo_errors() { + let result = parse_github_owner_repo("https://github.com/owner"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing repo")); + } + + #[test] + fn parse_empty_string_errors() { + let result = parse_github_owner_repo(""); + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // sign_app_jwt + // ----------------------------------------------------------------------- + + fn test_rsa_key() -> String { + use std::process::Command; + let output = Command::new("openssl") + .args(["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048"]) + .output() + .expect("openssl should be available"); + assert!(output.status.success(), "openssl keygen failed"); + String::from_utf8(output.stdout).unwrap() + } + + #[test] + fn jwt_is_three_part_string() { + let pem = test_rsa_key(); + let jwt = sign_app_jwt("12345", &pem).unwrap(); + assert_eq!(jwt.split('.').count(), 3); + } + + #[test] + fn jwt_has_rs256_header() { + let pem = test_rsa_key(); + let jwt = sign_app_jwt("12345", &pem).unwrap(); + let header_b64 = jwt.split('.').next().unwrap(); + let header_json = + base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, header_b64) + .unwrap(); + let header: serde_json::Value = serde_json::from_slice(&header_json).unwrap(); + assert_eq!(header["alg"], "RS256"); + } + + #[test] + fn jwt_has_correct_claims() { + let pem = test_rsa_key(); + let jwt = sign_app_jwt("99999", &pem).unwrap(); + let payload_b64 = jwt.split('.').nth(1).unwrap(); + let payload_json = + base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload_b64) + .unwrap(); + let claims: serde_json::Value = serde_json::from_slice(&payload_json).unwrap(); + assert_eq!(claims["iss"], "99999"); + + let now = chrono::Utc::now().timestamp(); + let iat = claims["iat"].as_i64().unwrap(); + let exp = claims["exp"].as_i64().unwrap(); + // iat should be ~60s before now + assert!((now - 60 - iat).abs() < 5); + // exp should be ~10min after now + assert!((now + 600 - exp).abs() < 5); + } + + #[test] + fn jwt_invalid_pem_errors() { + let result = sign_app_jwt("12345", "not-a-pem"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid RSA private key")); + } + + // ----------------------------------------------------------------------- + // is_repo_public (mockito) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn is_repo_public_returns_true_for_public() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/repos/owner/repo") + .match_header("Authorization", "Bearer test-jwt") + .with_status(200) + .with_body(r#"{"private": false}"#) + .create_async() + .await; + + let result = is_repo_public("test-jwt", "owner", "repo", &server.url()).await; + assert_eq!(result.unwrap(), true); + mock.assert_async().await; + } + + #[tokio::test] + async fn is_repo_public_returns_false_for_private() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/repos/owner/repo") + .with_status(200) + .with_body(r#"{"private": true}"#) + .create_async() + .await; + + let result = is_repo_public("test-jwt", "owner", "repo", &server.url()).await; + assert_eq!(result.unwrap(), false); + mock.assert_async().await; + } + + #[tokio::test] + async fn is_repo_public_returns_false_for_404() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/repos/owner/repo") + .with_status(404) + .create_async() + .await; + + let result = is_repo_public("test-jwt", "owner", "repo", &server.url()).await; + assert_eq!(result.unwrap(), false); + mock.assert_async().await; + } + + // ----------------------------------------------------------------------- + // create_installation_access_token — success + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn create_iat_success() { + let mut server = mockito::Server::new_async().await; + + let install_mock = server + .mock("GET", "/repos/owner/repo/installation") + .match_header("Authorization", "Bearer test-jwt") + .with_status(200) + .with_body(r#"{"id": 123}"#) + .create_async() + .await; + + let token_mock = server + .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(), + )) + .with_status(201) + .with_body(r#"{"token": "ghs_xxx"}"#) + .create_async() + .await; + + let token = + create_installation_access_token("test-jwt", "owner", "repo", &server.url()) + .await + .unwrap(); + assert_eq!(token, "ghs_xxx"); + + install_mock.assert_async().await; + token_mock.assert_async().await; + } + + // ----------------------------------------------------------------------- + // create_installation_access_token — failure modes + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn create_iat_not_installed() { + let mut server = mockito::Server::new_async().await; + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(404) + .create_async() + .await; + + let err = create_installation_access_token("jwt", "owner", "repo", &server.url()) + .await + .unwrap_err(); + assert!(err.contains("not installed"), "got: {err}"); + assert!(err.contains("owner"), "got: {err}"); + } + + #[tokio::test] + async fn create_iat_suspended() { + let mut server = mockito::Server::new_async().await; + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(403) + .create_async() + .await; + + let err = create_installation_access_token("jwt", "owner", "repo", &server.url()) + .await + .unwrap_err(); + assert!(err.contains("suspended"), "got: {err}"); + } + + #[tokio::test] + async fn create_iat_no_repo_access() { + let mut server = mockito::Server::new_async().await; + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(200) + .with_body(r#"{"id": 123}"#) + .create_async() + .await; + server + .mock("POST", "/app/installations/123/access_tokens") + .with_status(422) + .create_async() + .await; + + let err = create_installation_access_token("jwt", "owner", "repo", &server.url()) + .await + .unwrap_err(); + assert!(err.contains("does not have access"), "got: {err}"); + assert!(err.contains("repo"), "got: {err}"); + } + + #[tokio::test] + async fn create_iat_auth_failed() { + let mut server = mockito::Server::new_async().await; + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(401) + .create_async() + .await; + + let err = create_installation_access_token("jwt", "owner", "repo", &server.url()) + .await + .unwrap_err(); + assert!(err.contains("authentication failed"), "got: {err}"); + } +} diff --git a/crates/arc-workflows/src/lib.rs b/crates/arc-workflows/src/lib.rs index 607c0aa1c..e1d0efe8a 100644 --- a/crates/arc-workflows/src/lib.rs +++ b/crates/arc-workflows/src/lib.rs @@ -6,6 +6,7 @@ pub mod condition; pub mod context; pub mod daytona_sandbox; pub mod engine; +pub mod github_app; pub mod error; pub mod event; pub mod git; diff --git a/crates/arc-workflows/tests/daytona_integration.rs b/crates/arc-workflows/tests/daytona_integration.rs index 1cf8dfa9a..aec0752ff 100644 --- a/crates/arc-workflows/tests/daytona_integration.rs +++ b/crates/arc-workflows/tests/daytona_integration.rs @@ -27,7 +27,7 @@ async fn create_env() -> DaytonaSandbox { let client = daytona_sdk::Client::new() .await .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?"); - DaytonaSandbox::new(client, DaytonaConfig::default()) + DaytonaSandbox::new(client, DaytonaConfig::default(), None) } #[tokio::test] @@ -138,7 +138,7 @@ async fn daytona_snapshot_sandbox() { ..DaytonaConfig::default() }; - let env = DaytonaSandbox::new(client, config); + let env = DaytonaSandbox::new(client, config, None); env.initialize().await.unwrap(); // Verify rg is available (installed by snapshot) diff --git a/docs/changelog/2026-03-06.mdx b/docs/changelog/2026-03-06.mdx new file mode 100644 index 000000000..d2543f82e --- /dev/null +++ b/docs/changelog/2026-03-06.mdx @@ -0,0 +1,24 @@ +--- +title: "GitHub App tokens for Daytona git cloning" +date: "2026-03-06" +--- + + +**Daytona sandboxes no longer use `gh` CLI for git cloning.** The `gh` CLI is no longer required in Daytona environments. Arc now uses GitHub App Installation Access Tokens instead. + +To migrate: Remove any `gh` CLI dependency from your Daytona environments. If you ran `arc setup`, your `app_id` and `GITHUB_APP_PRIVATE_KEY` are already configured — no further action needed. + + +## GitHub App Installation Access Tokens for Daytona + +Daytona sandboxes now authenticate git clones using GitHub App Installation Access Tokens (IATs) instead of the user's `gh` CLI token. Each token is scoped to `contents: read` on the specific repository being cloned. + +Arc automatically detects whether a repository is public or private. Public repositories are cloned without credentials. Private repositories get a short-lived, minimally-scoped IAT generated from the GitHub App configured during `arc setup`. + +When credentials are missing or misconfigured, Arc provides specific error messages for each failure mode: +- App not installed for the organization +- Installation suspended +- Repository not accessible to the app +- Authentication failure (bad key or app ID) + +If no GitHub App is configured (`app_id` or `GITHUB_APP_PRIVATE_KEY` missing), Arc clones without credentials and surfaces a clear message if the clone fails for private repositories. diff --git a/docs/docs.json b/docs/docs.json index 86abb7b7c..392291b33 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -230,6 +230,7 @@ "group": "Changelog", "icon": "clock-rotate-left", "pages": [ + "changelog/2026-03-06", "changelog/2026-03-05", "changelog/2026-03-04", "changelog/2026-03-03", diff --git a/docs/execution/environments.mdx b/docs/execution/environments.mdx index 9c3b5b14e..94125ef21 100644 --- a/docs/execution/environments.mdx +++ b/docs/execution/environments.mdx @@ -108,13 +108,13 @@ The Daytona sandbox runs all tool operations inside a cloud-hosted VM managed by ### Prerequisites - A `DAYTONA_API_KEY` environment variable -- The `gh` CLI authenticated (for git clone authentication) +- A GitHub App configured via `arc setup` (for private repository cloning) ### How it works - **Sandbox lifecycle** — On `initialize()`, Arc creates a Daytona sandbox (from an image or a snapshot), clones the current git repository into it, and waits until it's ready. On `cleanup()`, the sandbox is deleted. - **Working directory** — Fixed at `/home/daytona/workspace`. The current repository is cloned there automatically. -- **Git clone** — Arc detects the local `origin` remote URL and current branch, converts SSH URLs to HTTPS, obtains a GitHub token via `gh auth token`, and clones into the sandbox. If no git repo is detected, the working directory is created empty. +- **Git clone** — Arc detects the local `origin` remote URL and current branch, converts SSH URLs to HTTPS, and clones into the sandbox. For private repositories, Arc uses a GitHub App Installation Access Token scoped to `contents: read` on the specific repository. Public repositories are cloned without credentials. If no git repo is detected, the working directory is created empty. - **Commands** — Executed via the Daytona process API. Commands are base64-encoded and piped through `sh` to support pipes, environment variables, and other shell features. - **Ephemeral** — Sandboxes are created with `ephemeral: true` and a unique timestamped name (e.g. `arc-20260305-142301-a3f2`).