mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Inject GitHub App IAT into Sandbox as GITHUB_TOKEN (#7)
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
<details>
<summary>Ran 7 stages in 27m 15s for $5.88</summary>
| 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** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>
```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
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
parent
527b6252ef
commit
43f5fb0edb
7 changed files with 290 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -375,6 +375,7 @@ fn fully_populated_server_config() -> ServerConfig {
|
|||
},
|
||||
],
|
||||
mcp_servers: Default::default(),
|
||||
github: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ pub fn sign_app_jwt(app_id: &str, private_key_pem: &str) -> Result<String, Strin
|
|||
///
|
||||
/// Uses the App JWT to find the installation for `owner/repo`, then requests
|
||||
/// a token scoped to the given `permissions` on that single repository.
|
||||
async fn create_installation_access_token_with_permissions(
|
||||
pub async fn create_installation_access_token_with_permissions(
|
||||
client: &reqwest::Client,
|
||||
jwt: &str,
|
||||
owner: &str,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ use anyhow::{bail, Context};
|
|||
use serde::Deserialize;
|
||||
|
||||
use super::run_config::{
|
||||
AssetsConfig, CheckpointConfig, LlmConfig, McpServerEntry, PullRequestConfig, RunDefaults,
|
||||
SandboxConfig, SetupConfig,
|
||||
AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, McpServerEntry, PullRequestConfig,
|
||||
RunDefaults, SandboxConfig, SetupConfig,
|
||||
};
|
||||
use crate::hook::HookDefinition;
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ pub struct ProjectConfig {
|
|||
pub hooks: Vec<HookDefinition>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerEntry>,
|
||||
pub github: Option<GitHubConfig>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String>,
|
||||
) -> anyhow::Result<String> {
|
||||
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<fabro_mcp::config::McpServerConfig> = {
|
||||
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<CheckDetail> = 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 {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ pub struct AssetsConfig {
|
|||
pub include: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
pub struct GitHubConfig {
|
||||
#[serde(default)]
|
||||
pub permissions: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkflowRunConfig {
|
||||
|
|
@ -61,6 +67,7 @@ pub struct WorkflowRunConfig {
|
|||
pub assets: Option<AssetsConfig>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerEntry>,
|
||||
pub github: Option<GitHubConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
|
|
@ -147,6 +154,7 @@ pub struct RunDefaults {
|
|||
pub hooks: Vec<crate::hook::HookDefinition>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerEntry>,
|
||||
pub github: Option<GitHubConfig>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue