mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
41f72116f2
commit
cf50625824
11 changed files with 634 additions and 60 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -353,6 +353,7 @@ dependencies = [
|
|||
"futures",
|
||||
"git2",
|
||||
"indicatif",
|
||||
"jsonwebtoken",
|
||||
"mockito",
|
||||
"nom",
|
||||
"predicates",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,20 @@ enum LlmCommand {
|
|||
Chat(arc_llm::cli::ChatArgs),
|
||||
}
|
||||
|
||||
fn build_github_app_credentials(
|
||||
config: &arc_api::server_config::ServerConfig,
|
||||
) -> Option<arc_workflows::github_app::GitHubAppCredentials> {
|
||||
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)?;
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ pub async fn run_command(
|
|||
args: RunArgs,
|
||||
run_defaults: RunDefaults,
|
||||
styles: &'static Styles,
|
||||
github_app: Option<crate::github_app::GitHubAppCredentials>,
|
||||
) -> 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<crate::github_app::GitHubAppCredentials>,
|
||||
) -> 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<dyn Sandbox>)
|
||||
}
|
||||
Err(e) => Err(format!("Daytona client creation failed: {e}")),
|
||||
|
|
|
|||
|
|
@ -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<GitHubAppCredentials>,
|
||||
sandbox: tokio::sync::OnceCell<daytona_sdk::Sandbox>,
|
||||
rg_available: tokio::sync::OnceCell<bool>,
|
||||
event_callback: Option<SandboxEventCallback>,
|
||||
|
|
@ -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<GitHubAppCredentials>,
|
||||
) -> 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>), String>
|
|||
Ok((url, branch))
|
||||
}
|
||||
|
||||
/// Get a GitHub authentication token via `gh auth token`.
|
||||
pub fn get_gh_token() -> Result<String, String> {
|
||||
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();
|
||||
|
|
|
|||
510
crates/arc-workflows/src/github_app.rs
Normal file
510
crates/arc-workflows/src/github_app.rs
Normal file
|
|
@ -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<String, String> {
|
||||
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<bool, String> {
|
||||
#[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<String, String> {
|
||||
#[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<String>, Option<String>), 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
24
docs/changelog/2026-03-06.mdx
Normal file
24
docs/changelog/2026-03-06.mdx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
title: "GitHub App tokens for Daytona git cloning"
|
||||
date: "2026-03-06"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
**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.
|
||||
</Warning>
|
||||
|
||||
## 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue