From 43f5fb0edba2d644a91ca32b9847ea9b7cfabbd3 Mon Sep 17 00:00:00 2001 From: "arc-1e68f1[bot]" <265161896+arc-1e68f1[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:24:30 -0400 Subject: [PATCH] Inject GitHub App IAT into Sandbox as GITHUB_TOKEN (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds GitHub App Installation Access Token (IAT) injection into sandboxes, allowing `gh` CLI and other GitHub-authenticated tools to work seamlessly inside workflow sandboxes. Workflow authors can declare required GitHub permissions in `workflow.toml` under a `[github]` section (e.g., `permissions = { contents = "write", pull_requests = "read" }`), with project-wide defaults available in `fabro.toml`. Workflow-level config fully replaces project-level defaults, consistent with existing `[pull_request]` behavior. The implementation introduces a `GitHubConfig` struct wired through `WorkflowRunConfig`, `RunDefaults`, and `ProjectConfig`, with proper `apply_defaults` (inherit if unset) and `merge_overlay` (replace if present) semantics. At runtime, a new `mint_github_token()` helper signs a JWT, resolves the repo's owner/repo from the origin URL, and requests a scoped IAT which is injected as `GITHUB_TOKEN` into the sandbox environment. The previously private `create_installation_access_token_with_permissions` in `fabro-github` is made public to support this. A preflight check also mints a token during validation to surface credential or permission issues early. Comprehensive tests cover TOML parsing with and without `[github]`, default inheritance, workflow-over-default precedence, and overlay merge semantics for `RunDefaults`. ### Fabro Details
Ran 7 stages in 27m 15s for $5.88 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 0s | – | 0 | | preflight_compile | 0s | – | 0 | | preflight_lint | 0s | – | 0 | | implement | 0s | $3.79 | 0 | | simplify | 0s | $2.09 | 0 | | verify | 0s | – | 0 | | **Total** | **27m 15s** | **$5.88** | **0** |
Ran ImplementAndSimplify.fabro (10 nodes and 13 edges) ```dot digraph ImplementAndSimplify { graph [ goal="Implement and simplify", model_stylesheet=" * { backend: api; model: claude-opus-4-6;} " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."] simplify [label="Simplify", prompt="@prompts/simplify.md"] verify [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=success"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=success"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=success"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify -> verify verify -> exit [condition="outcome=success"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro Co-authored-by: Claude --- docs/api-reference/fabro-api.yaml | 12 ++ lib/crates/fabro-api/src/demo/mod.rs | 6 + .../fabro-api/tests/openapi_conformance.rs | 1 + lib/crates/fabro-github/src/lib.rs | 2 +- .../fabro-workflows/src/cli/project_config.rs | 34 ++++- lib/crates/fabro-workflows/src/cli/run.rs | 118 ++++++++++++++++- .../fabro-workflows/src/cli/run_config.rs | 122 ++++++++++++++++++ 7 files changed, 290 insertions(+), 5 deletions(-) diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index f97dd2a13..37baea0b0 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -4306,6 +4306,18 @@ components: additionalProperties: $ref: "#/components/schemas/McpServerEntry" description: Default MCP server configurations. + github: + $ref: "#/components/schemas/GitHubConfiguration" + + GitHubConfiguration: + description: GitHub App token injection configuration. + type: object + properties: + permissions: + type: object + additionalProperties: + type: string + description: GitHub API permissions to request (e.g. contents = write). McpServerEntry: description: MCP server connection entry. diff --git a/lib/crates/fabro-api/src/demo/mod.rs b/lib/crates/fabro-api/src/demo/mod.rs index 445c220fd..295b3380a 100644 --- a/lib/crates/fabro-api/src/demo/mod.rs +++ b/lib/crates/fabro-api/src/demo/mod.rs @@ -1350,6 +1350,7 @@ mod runs { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }) .unwrap() } @@ -1520,6 +1521,7 @@ mod workflows { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }), graph: r#"digraph fix_build { graph [ @@ -1592,6 +1594,7 @@ mod workflows { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }), graph: r#"digraph implement { graph [ @@ -1676,6 +1679,7 @@ mod workflows { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }), graph: r#"digraph sync { graph [ @@ -1749,6 +1753,7 @@ mod workflows { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }), graph: r#"digraph expand { graph [ @@ -3310,6 +3315,7 @@ mod settings { assets: None, hooks: vec![], mcp_servers: Default::default(), + github: None, }, }) .unwrap() diff --git a/lib/crates/fabro-api/tests/openapi_conformance.rs b/lib/crates/fabro-api/tests/openapi_conformance.rs index 548c92968..3bd36e124 100644 --- a/lib/crates/fabro-api/tests/openapi_conformance.rs +++ b/lib/crates/fabro-api/tests/openapi_conformance.rs @@ -375,6 +375,7 @@ fn fully_populated_server_config() -> ServerConfig { }, ], mcp_servers: Default::default(), + github: None, }, } } diff --git a/lib/crates/fabro-github/src/lib.rs b/lib/crates/fabro-github/src/lib.rs index af6481af4..dc15c5abf 100644 --- a/lib/crates/fabro-github/src/lib.rs +++ b/lib/crates/fabro-github/src/lib.rs @@ -111,7 +111,7 @@ pub fn sign_app_jwt(app_id: &str, private_key_pem: &str) -> Result, #[serde(default)] pub mcp_servers: HashMap, + pub github: Option, } impl ProjectConfig { @@ -49,6 +50,7 @@ impl ProjectConfig { assets: self.assets, hooks: self.hooks, mcp_servers: self.mcp_servers, + github: self.github, } } } @@ -911,4 +913,32 @@ provider = "daytona" None ); } + + #[test] + fn parse_project_config_with_github() { + let toml = r#" +version = 1 + +[github] +permissions = { contents = "read" } +"#; + let config = parse_project_config(toml).unwrap(); + let github = config.github.unwrap(); + assert_eq!(github.permissions["contents"], "read"); + } + + #[test] + fn into_run_defaults_preserves_github() { + let toml = r#" +version = 1 + +[github] +permissions = { contents = "read", issues = "write" } +"#; + let config = parse_project_config(toml).unwrap(); + let defaults = config.into_run_defaults(); + let github = defaults.github.unwrap(); + assert_eq!(github.permissions["contents"], "read"); + assert_eq!(github.permissions["issues"], "write"); + } } diff --git a/lib/crates/fabro-workflows/src/cli/run.rs b/lib/crates/fabro-workflows/src/cli/run.rs index 8051c91ea..e0ab146b8 100644 --- a/lib/crates/fabro-workflows/src/cli/run.rs +++ b/lib/crates/fabro-workflows/src/cli/run.rs @@ -272,6 +272,35 @@ fn resolve_fallback_chain( } } +/// Mint a GitHub App Installation Access Token with the given permissions. +/// +/// Signs a JWT, resolves `owner/repo` from `origin_url`, and requests a +/// scoped token. Returns the token string on success. +async fn mint_github_token( + creds: &fabro_github::GitHubAppCredentials, + origin_url: &str, + permissions: &HashMap, +) -> anyhow::Result { + let https_url = fabro_github::ssh_url_to_https(origin_url); + let (owner, repo) = + fabro_github::parse_github_owner_repo(&https_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let jwt = fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let client = reqwest::Client::new(); + let perms_json = serde_json::to_value(permissions)?; + let token = fabro_github::create_installation_access_token_with_permissions( + &client, + &jwt, + &owner, + &repo, + fabro_github::GITHUB_API_BASE_URL, + perms_json, + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(token) +} + /// Accumulates token usage and cost across all workflow stages. #[derive(Default)] struct CostAccumulator { @@ -1120,6 +1149,34 @@ pub async fn run_command( } env }; + + // Mint a GitHub App IAT and inject as GITHUB_TOKEN if [github] permissions are declared + let mut sandbox_env = sandbox_env; + let github_permissions = run_cfg + .as_ref() + .and_then(|c| c.github.as_ref()) + .or(run_defaults.github.as_ref()); + if let Some(gh_cfg) = github_permissions { + if !gh_cfg.permissions.is_empty() { + if let (Some(ref creds), Some(ref url)) = (&github_app, &origin_url) { + match mint_github_token(creds, url, &gh_cfg.permissions).await { + Ok(token) => { + debug!("Minted GitHub IAT for sandbox GITHUB_TOKEN"); + sandbox_env.insert("GITHUB_TOKEN".to_string(), token); + } + Err(e) => { + eprintln!( + "{} Failed to mint GitHub token: {e}", + styles.yellow.apply_to("Warning:"), + ); + } + } + } else { + debug!("Skipping GitHub token: no GitHub App credentials or origin URL"); + } + } + } + let mcp_servers: Vec = { let servers = run_cfg .as_ref() @@ -2095,7 +2152,7 @@ async fn run_preflight( let env = crate::daytona_sandbox::DaytonaSandbox::new( daytona_client, config, - github_app, + github_app.clone(), None, None, ); @@ -2264,7 +2321,57 @@ async fn run_preflight( } }; - // 5. Render report + // 5. GitHub token preflight + let github_permissions = run_cfg + .as_ref() + .and_then(|c| c.github.as_ref()) + .or(run_defaults.github.as_ref()); + if let Some(gh_cfg) = github_permissions { + if !gh_cfg.permissions.is_empty() { + let perm_details: Vec = gh_cfg + .permissions + .iter() + .map(|(k, v)| CheckDetail::new(format!("{k}: {v}"))) + .collect(); + match (&github_app, origin_url) { + (Some(creds), Some(url)) => { + match mint_github_token(creds, url, &gh_cfg.permissions).await { + Ok(_) => { + checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Pass, + summary: "minted".into(), + details: perm_details, + remediation: None, + }); + } + Err(e) => { + checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: perm_details, + remediation: Some(format!("Failed to mint GitHub token: {e}")), + }); + } + } + } + _ => { + checks.push(CheckResult { + name: "GitHub Token".into(), + status: CheckStatus::Warning, + summary: "skipped".into(), + details: vec![], + remediation: Some( + "No GitHub App credentials or origin URL available".to_string(), + ), + }); + } + } + } + } + + // 6. Render report spinner.finish_and_clear(); let report = CheckReport { @@ -2607,6 +2714,7 @@ mod tests { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }; let (model, provider) = resolve_model_provider( Some("gpt-5.2"), @@ -2651,6 +2759,7 @@ mod tests { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -2730,6 +2839,7 @@ mod tests { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }; let (model, provider) = resolve_model_provider(None, None, Some(&cfg), &defaults, &graph); assert_eq!(model, "toml-model"); @@ -2762,6 +2872,7 @@ mod tests { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }; let defaults = RunDefaults::default(); assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults)); @@ -2793,6 +2904,7 @@ mod tests { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }; let defaults = RunDefaults { sandbox: Some(run_config::SandboxConfig { @@ -2873,6 +2985,7 @@ mod tests { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }; let defaults = RunDefaults::default(); assert_eq!( @@ -2933,6 +3046,7 @@ mod tests { pull_request: None, assets: None, mcp_servers: Default::default(), + github: None, }; let defaults = RunDefaults { sandbox: Some(run_config::SandboxConfig { diff --git a/lib/crates/fabro-workflows/src/cli/run_config.rs b/lib/crates/fabro-workflows/src/cli/run_config.rs index 79375cde4..9b9ddc1e9 100644 --- a/lib/crates/fabro-workflows/src/cli/run_config.rs +++ b/lib/crates/fabro-workflows/src/cli/run_config.rs @@ -40,6 +40,12 @@ pub struct AssetsConfig { pub include: Vec, } +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct GitHubConfig { + #[serde(default)] + pub permissions: HashMap, +} + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct WorkflowRunConfig { @@ -61,6 +67,7 @@ pub struct WorkflowRunConfig { pub assets: Option, #[serde(default)] pub mcp_servers: HashMap, + pub github: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -147,6 +154,7 @@ pub struct RunDefaults { pub hooks: Vec, #[serde(default)] pub mcp_servers: HashMap, + pub github: Option, } impl WorkflowRunConfig { @@ -285,6 +293,10 @@ impl WorkflowRunConfig { merged.extend(std::mem::take(&mut self.mcp_servers)); self.mcp_servers = merged; } + + if self.github.is_none() { + self.github = defaults.github.clone(); + } } } @@ -416,6 +428,10 @@ impl RunDefaults { merged.extend(overlay.mcp_servers); self.mcp_servers = merged; } + + if overlay.github.is_some() { + self.github = overlay.github; + } } } @@ -2426,4 +2442,110 @@ command = "echo from-workflow" let config = parse_run_config(toml).unwrap(); assert_eq!(config.graph, "workflow.fabro"); } + + #[test] + fn parse_toml_with_github_permissions() { + let toml = r#" +version = 1 +goal = "test" +graph = "w.fabro" + +[github] +permissions = { contents = "write", pull_requests = "read" } +"#; + let config = parse_run_config(toml).unwrap(); + let github = config.github.unwrap(); + assert_eq!(github.permissions["contents"], "write"); + assert_eq!(github.permissions["pull_requests"], "read"); + } + + #[test] + fn parse_toml_without_github_defaults_none() { + let toml = r#" +version = 1 +goal = "test" +graph = "w.fabro" +"#; + let config = parse_run_config(toml).unwrap(); + assert!(config.github.is_none()); + } + + #[test] + fn apply_defaults_github_inherited() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.fabro" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + github: Some(GitHubConfig { + permissions: HashMap::from([("contents".into(), "read".into())]), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let github = cfg.github.unwrap(); + assert_eq!(github.permissions["contents"], "read"); + } + + #[test] + fn apply_defaults_github_task_wins() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.fabro" + +[github] +permissions = { contents = "write" } +"#, + ) + .unwrap(); + let defaults = RunDefaults { + github: Some(GitHubConfig { + permissions: HashMap::from([("contents".into(), "read".into())]), + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + let github = cfg.github.unwrap(); + assert_eq!(github.permissions["contents"], "write"); + } + + #[test] + fn merge_overlay_github_replaces() { + let mut base = RunDefaults { + github: Some(GitHubConfig { + permissions: HashMap::from([("contents".into(), "read".into())]), + }), + ..RunDefaults::default() + }; + let overlay = RunDefaults { + github: Some(GitHubConfig { + permissions: HashMap::from([("issues".into(), "write".into())]), + }), + ..RunDefaults::default() + }; + base.merge_overlay(overlay); + let github = base.github.unwrap(); + assert!(!github.permissions.contains_key("contents")); + assert_eq!(github.permissions["issues"], "write"); + } + + #[test] + fn merge_overlay_github_none_keeps_base() { + let mut base = RunDefaults { + github: Some(GitHubConfig { + permissions: HashMap::from([("contents".into(), "read".into())]), + }), + ..RunDefaults::default() + }; + let overlay = RunDefaults::default(); + base.merge_overlay(overlay); + let github = base.github.unwrap(); + assert_eq!(github.permissions["contents"], "read"); + } }