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