fix(github): refresh installation tokens during workflows

Split PATs from installation access tokens so static configuration cannot accidentally store expiring ghs_* credentials. Workflow command and API agent stages now resolve GITHUB_TOKEN lazily from a refreshable source, while CLI agent stages surface their launch-time refresh limitation.
This commit is contained in:
Bryan Helmkamp 2026-05-06 06:59:34 -04:00
parent d2e6f09780
commit f1c247bc0f
No known key found for this signature in database
31 changed files with 1167 additions and 238 deletions

View file

@ -32,7 +32,7 @@ tokio-util = "0.7"
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4"
jsonschema = { version = "0.42", default-features = false }
chrono = { version = "0.4", features = ["clock"] }
chrono = { version = "0.4", features = ["clock", "serde"] }
dashmap = "6"
bollard = "0.18"
tar = "0.4"

View file

@ -44,7 +44,10 @@ pub use sandbox::{
SandboxEvent, SandboxEventCallback, WorktreeEvent, WorktreeEventCallback, WorktreeOptions,
WorktreeSandbox, format_lines_numbered, shell_quote,
};
pub use session::{CompletionCoordinator, Session, SessionControlHandle, SteeringItem};
pub use session::{
CompletionCoordinator, Session, SessionControlHandle, StaticEnvProvider, SteeringItem,
ToolEnvProvider,
};
pub use skills::Skill;
pub use subagent::{
SubAgent, SubAgentEventCallback, SubAgentManager, SubAgentResult, SubAgentStatus,

View file

@ -94,7 +94,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;

View file

@ -228,6 +228,20 @@ impl SessionControlHandle {
}
}
#[async_trait::async_trait]
pub trait ToolEnvProvider: Send + Sync {
async fn resolve(&self) -> anyhow::Result<HashMap<String, String>>;
}
pub struct StaticEnvProvider(pub HashMap<String, String>);
#[async_trait::async_trait]
impl ToolEnvProvider for StaticEnvProvider {
async fn resolve(&self) -> anyhow::Result<HashMap<String, String>> {
Ok(self.0.clone())
}
}
pub struct Session {
id: String,
config: SessionOptions,
@ -248,7 +262,7 @@ pub struct Session {
skills: Vec<Skill>,
system_prompt: String,
file_tracker: FileTracker,
tool_env: Option<HashMap<String, String>>,
tool_env_provider: Option<Arc<dyn ToolEnvProvider>>,
subagent_manager: Option<Arc<AsyncMutex<SubAgentManager>>>,
completion_coordinator: Option<Arc<dyn CompletionCoordinator>>,
}
@ -282,7 +296,7 @@ impl Session {
skills: Vec::new(),
system_prompt: String::new(),
file_tracker: FileTracker::default(),
tool_env: None,
tool_env_provider: None,
subagent_manager,
completion_coordinator: None,
}
@ -315,8 +329,12 @@ impl Session {
))
}
pub fn set_tool_env_provider(&mut self, provider: Arc<dyn ToolEnvProvider>) {
self.tool_env_provider = Some(provider);
}
pub fn set_tool_env(&mut self, env: HashMap<String, String>) {
self.tool_env = Some(env);
self.set_tool_env_provider(Arc::new(StaticEnvProvider(env)));
}
#[must_use]
@ -1356,7 +1374,7 @@ impl Session {
&self.config,
&self.event_emitter,
&self.id,
self.tool_env.as_ref(),
self.tool_env_provider.as_ref(),
)
.await;
composite_watcher.abort();
@ -1545,6 +1563,7 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use anyhow::Context as _;
use fabro_llm::error::{ProviderErrorDetail, ProviderErrorKind};
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::types::{
@ -1708,6 +1727,72 @@ mod tests {
}
}
struct SequenceToolEnvProvider {
values: Mutex<VecDeque<HashMap<String, String>>>,
}
#[async_trait::async_trait]
impl ToolEnvProvider for SequenceToolEnvProvider {
async fn resolve(&self) -> anyhow::Result<HashMap<String, String>> {
self.values
.lock()
.unwrap()
.pop_front()
.context("env script exhausted")
}
}
#[tokio::test]
async fn session_passes_tool_env_provider_to_each_tool_round() {
let seen_tokens = Arc::new(Mutex::new(Vec::new()));
let seen_tokens_for_tool = Arc::clone(&seen_tokens);
let record_env_tool = RegisteredTool {
definition: ToolDefinition {
name: "record_env".into(),
description: "Records resolved env".into(),
parameters: serde_json::json!({"type": "object"}),
},
executor: Arc::new(move |_args, ctx| {
let seen_tokens = Arc::clone(&seen_tokens_for_tool);
Box::pin(async move {
let env = ctx
.resolve_tool_env()
.await
.map_err(|err| format!("{err:#}"))?
.unwrap_or_default();
seen_tokens.lock().unwrap().push(
env.get("GITHUB_TOKEN")
.cloned()
.unwrap_or_else(|| "<missing>".to_string()),
);
Ok("recorded".to_string())
})
}),
};
let mut registry = ToolRegistry::new();
registry.register(record_env_tool);
let responses = vec![
tool_call_response("record_env", "call_1", serde_json::json!({})),
tool_call_response("record_env", "call_2", serde_json::json!({})),
text_response("Done!"),
];
let mut session = make_session_with_tools(responses, registry).await;
session.set_tool_env_provider(Arc::new(SequenceToolEnvProvider {
values: Mutex::new(VecDeque::from([
HashMap::from([("GITHUB_TOKEN".to_string(), "t1".to_string())]),
HashMap::from([("GITHUB_TOKEN".to_string(), "t2".to_string())]),
])),
}));
session.process_input("Use tools").await.unwrap();
assert_eq!(seen_tokens.lock().unwrap().as_slice(), [
"t1".to_string(),
"t2".to_string()
]);
}
#[tokio::test]
async fn max_tool_rounds_enforced() {
let mut registry = ToolRegistry::new();

View file

@ -581,7 +581,7 @@ name: trimmed
let ctx = ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
};
let result = (tool.executor)(args, ctx).await;
assert_eq!(
@ -600,7 +600,7 @@ name: trimmed
let ctx = ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
};
let result = (tool.executor)(args, ctx).await;
assert!(result.is_err());
@ -617,7 +617,7 @@ name: trimmed
let ctx = ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
};
let result = (tool.executor)(args, ctx).await;
assert!(result.is_err());

View file

@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::sync::Arc;
use fabro_llm::types::{ToolCall, ToolResult};
@ -9,6 +8,7 @@ use tracing::debug;
use crate::config::{SessionOptions, ToolHookCallback, ToolHookDecision};
use crate::event::Emitter;
use crate::sandbox::Sandbox;
use crate::session::ToolEnvProvider;
use crate::tool_registry::{RegisteredTool, ToolContext, ToolRegistry};
use crate::truncation::truncate_tool_output;
use crate::types::AgentEvent;
@ -29,7 +29,7 @@ pub async fn execute_tool_calls(
config: &SessionOptions,
emitter: &Emitter,
session_id: &str,
tool_env: Option<&HashMap<String, String>>,
tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
) -> Vec<ToolResult> {
if parallel && tool_calls.len() > 1 {
execute_tool_calls_parallel(
@ -41,7 +41,7 @@ pub async fn execute_tool_calls(
config,
emitter,
session_id,
tool_env,
tool_env_provider,
)
.await
} else {
@ -54,7 +54,7 @@ pub async fn execute_tool_calls(
config,
emitter,
session_id,
tool_env,
tool_env_provider,
)
.await
}
@ -73,7 +73,7 @@ async fn execute_tool_calls_sequential(
config: &SessionOptions,
emitter: &Emitter,
session_id: &str,
tool_env: Option<&HashMap<String, String>>,
tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
) -> Vec<ToolResult> {
let mut results = Vec::new();
for tc in tool_calls {
@ -91,7 +91,7 @@ async fn execute_tool_calls_sequential(
config,
emitter,
session_id,
tool_env,
tool_env_provider,
)
.await;
results.push(result);
@ -112,9 +112,9 @@ async fn execute_tool_calls_parallel(
config: &SessionOptions,
emitter: &Emitter,
session_id: &str,
tool_env: Option<&HashMap<String, String>>,
tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
) -> Vec<ToolResult> {
let tool_env = tool_env.cloned();
let tool_env_provider = tool_env_provider.cloned();
let futures: Vec<_> = tool_calls
.iter()
.map(|tc| {
@ -125,7 +125,7 @@ async fn execute_tool_calls_parallel(
let tc = tc.clone();
let session_id = session_id.to_owned();
let tool_hooks = tool_hooks.cloned();
let tool_env = tool_env.clone();
let tool_env_provider = tool_env_provider.clone();
// Look up the tool before spawning since ToolRegistry is not Send.
let registered_tool = registry.get(&tc.name).cloned();
async move {
@ -138,7 +138,7 @@ async fn execute_tool_calls_parallel(
&config,
&emitter,
&session_id,
tool_env.as_ref(),
tool_env_provider.as_ref(),
)
.await
}
@ -162,7 +162,7 @@ pub async fn execute_and_emit_one_tool(
config: &SessionOptions,
emitter: &Emitter,
session_id: &str,
tool_env: Option<&HashMap<String, String>>,
tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
) -> ToolResult {
execute_and_emit_one_tool_with_lookup(
tc,
@ -173,7 +173,7 @@ pub async fn execute_and_emit_one_tool(
config,
emitter,
session_id,
tool_env,
tool_env_provider,
)
.await
}
@ -193,7 +193,7 @@ async fn execute_and_emit_one_tool_with_lookup(
config: &SessionOptions,
emitter: &Emitter,
session_id: &str,
tool_env: Option<&HashMap<String, String>>,
tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
) -> ToolResult {
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallStarted {
tool_name: tc.name.clone(),
@ -226,7 +226,7 @@ async fn execute_and_emit_one_tool_with_lookup(
}
}
let result = execute_one_tool(tc, registered_tool, env, cancel_token, tool_env).await;
let result = execute_one_tool(tc, registered_tool, env, cancel_token, tool_env_provider).await;
emitter.emit(session_id.to_owned(), AgentEvent::ToolCallOutputDelta {
delta: result.content.to_string(),
@ -270,7 +270,7 @@ async fn execute_one_tool(
registered_tool: Option<&RegisteredTool>,
env: Arc<dyn Sandbox>,
cancel_token: CancellationToken,
tool_env: Option<&HashMap<String, String>>,
tool_env_provider: Option<&Arc<dyn ToolEnvProvider>>,
) -> ToolResult {
match registered_tool {
Some(tool) => {
@ -283,7 +283,7 @@ async fn execute_one_tool(
let ctx = ToolContext {
env,
cancel: cancel_token,
tool_env: tool_env.cloned(),
tool_env_provider: tool_env_provider.cloned(),
};
match (tool.executor)(tc.arguments.clone(), ctx).await {
Ok(output) => ToolResult::success(&tc.id, serde_json::json!(output)),
@ -347,6 +347,7 @@ pub fn validate_tool_args(
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Mutex;
use fabro_llm::types::{ToolCall, ToolDefinition};

View file

@ -7,11 +7,21 @@ use fabro_llm::types::ToolDefinition;
use tokio_util::sync::CancellationToken;
use crate::sandbox::Sandbox;
use crate::session::ToolEnvProvider;
pub struct ToolContext {
pub env: Arc<dyn Sandbox>,
pub cancel: CancellationToken,
pub tool_env: Option<HashMap<String, String>>,
pub env: Arc<dyn Sandbox>,
pub cancel: CancellationToken,
pub tool_env_provider: Option<Arc<dyn ToolEnvProvider>>,
}
impl ToolContext {
pub async fn resolve_tool_env(&self) -> anyhow::Result<Option<HashMap<String, String>>> {
match &self.tool_env_provider {
Some(provider) => Ok(Some(provider.resolve().await?)),
None => Ok(None),
}
}
}
pub type ToolExecutor = Arc<
@ -179,7 +189,7 @@ mod tests {
let ctx = ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
};
let result = (tool.executor)(serde_json::json!({}), ctx).await;
assert_eq!(result.unwrap(), "ok");

View file

@ -240,11 +240,9 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
.unwrap_or(default_timeout)
.min(max_timeout);
let tool_env = ctx.resolve_tool_env().await.map_err(|e| format!("{e:#}"))?;
tracing::debug!(
env_var_count = ctx
.tool_env
.as_ref()
.map_or(0, std::collections::HashMap::len),
env_var_count = tool_env.as_ref().map_or(0, std::collections::HashMap::len),
"Injecting sandbox env vars into tool execution"
);
let result = ctx
@ -253,7 +251,7 @@ pub fn make_shell_tool_with_config(config: &SessionOptions) -> RegisteredTool {
command,
timeout_ms,
None,
ctx.tool_env.as_ref(),
tool_env.as_ref(),
Some(ctx.cancel),
)
.await
@ -609,13 +607,14 @@ pub(crate) fn make_web_fetch_tool(summarizer: Option<WebFetchSummarizer>) -> Reg
"curl -sL --max-time {timeout_secs} -H 'User-Agent: fabro-agent/0.1' {escaped_url}"
);
let tool_env = ctx.resolve_tool_env().await.map_err(|e| format!("{e:#}"))?;
let result = ctx
.env
.exec_command(
&command,
timeout_ms,
None,
ctx.tool_env.as_ref(),
tool_env.as_ref(),
Some(ctx.cancel),
)
.await
@ -698,7 +697,7 @@ mod tests {
let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
})
.await;
assert_eq!(result.unwrap(), " 1 | hello\n 2 | world");
@ -722,7 +721,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -737,9 +736,9 @@ mod tests {
let result = (tool.executor)(
serde_json::json!({"file_path": "/out.txt", "content": "hello"}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: None,
},
)
.await;
@ -767,9 +766,9 @@ mod tests {
"new_string": "goodbye"
}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: None,
},
)
.await;
@ -797,7 +796,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -822,7 +821,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -849,9 +848,9 @@ mod tests {
"replace_all": true
}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: None,
},
)
.await;
@ -877,7 +876,7 @@ mod tests {
let result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
})
.await;
let output = result.unwrap();
@ -893,9 +892,9 @@ mod tests {
let _result = (tool.executor)(
serde_json::json!({"command": "sleep 1", "timeout_ms": 5000}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: None,
},
)
.await;
@ -918,7 +917,7 @@ mod tests {
let result = (tool.executor)(serde_json::json!({"command": "false"}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
})
.await;
let output = result.unwrap();
@ -942,7 +941,7 @@ mod tests {
let result = (tool.executor)(serde_json::json!({"command": "sleep 100"}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
})
.await;
let output = result.unwrap();
@ -959,9 +958,9 @@ mod tests {
let _result = (tool.executor)(
serde_json::json!({"command": "echo $MY_KEY"}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: Some(tool_env.clone()),
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: Some(Arc::new(crate::StaticEnvProvider(tool_env.clone()))),
},
)
.await;
@ -969,15 +968,124 @@ mod tests {
assert_eq!(captured, Some(tool_env));
}
struct SequenceToolEnvProvider {
values: std::sync::Mutex<Vec<HashMap<String, String>>>,
}
#[async_trait::async_trait]
impl crate::ToolEnvProvider for SequenceToolEnvProvider {
async fn resolve(&self) -> anyhow::Result<HashMap<String, String>> {
Ok(self.values.lock().unwrap().remove(0))
}
}
struct FailingToolEnvProvider;
#[async_trait::async_trait]
impl crate::ToolEnvProvider for FailingToolEnvProvider {
async fn resolve(&self) -> anyhow::Result<HashMap<String, String>> {
Err(anyhow::anyhow!("GITHUB_TOKEN refresh failed"))
}
}
#[tokio::test]
async fn shell_resolves_tool_env_for_each_call() {
let tool = make_shell_tool();
let env = Arc::new(MockSandbox::default());
let provider = Arc::new(SequenceToolEnvProvider {
values: std::sync::Mutex::new(vec![
HashMap::from([("GITHUB_TOKEN".to_string(), "t1".to_string())]),
HashMap::from([("GITHUB_TOKEN".to_string(), "t2".to_string())]),
]),
});
let _result = (tool.executor)(
serde_json::json!({"command": "echo $GITHUB_TOKEN"}),
ToolContext {
env: env.clone(),
cancel: CancellationToken::new(),
tool_env_provider: Some(provider.clone()),
},
)
.await;
assert_eq!(
env.captured_env_vars.lock().unwrap().clone(),
Some(HashMap::from([(
"GITHUB_TOKEN".to_string(),
"t1".to_string()
)]))
);
let _result = (tool.executor)(
serde_json::json!({"command": "echo $GITHUB_TOKEN"}),
ToolContext {
env: env.clone(),
cancel: CancellationToken::new(),
tool_env_provider: Some(provider),
},
)
.await;
assert_eq!(
env.captured_env_vars.lock().unwrap().clone(),
Some(HashMap::from([(
"GITHUB_TOKEN".to_string(),
"t2".to_string()
)]))
);
}
#[tokio::test]
async fn shell_returns_provider_error_for_env_resolution_failure() {
let tool = make_shell_tool();
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox::default());
let result = (tool.executor)(
serde_json::json!({"command": "echo $GITHUB_TOKEN"}),
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env_provider: Some(Arc::new(FailingToolEnvProvider)),
},
)
.await
.unwrap_err();
assert!(
result.contains("GITHUB_TOKEN refresh failed"),
"got: {result}"
);
}
#[tokio::test]
async fn read_file_does_not_resolve_failing_tool_env_provider() {
let tool = make_read_file_tool();
let mut files = HashMap::new();
files.insert("/test.txt".into(), " 1 | hello".into());
let env: Arc<dyn Sandbox> = Arc::new(MockSandbox {
files,
apply_read_offset_limit: true,
..Default::default()
});
let result = (tool.executor)(serde_json::json!({"file_path": "/test.txt"}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env_provider: Some(Arc::new(FailingToolEnvProvider)),
})
.await;
assert_eq!(result.unwrap(), " 1 | hello");
}
#[tokio::test]
async fn shell_passes_none_env_when_tool_env_is_none() {
let tool = make_shell_tool();
let env = Arc::new(MockSandbox::default());
let env_clone: Arc<dyn Sandbox> = env.clone();
let _result = (tool.executor)(serde_json::json!({"command": "echo hello"}), ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: None,
})
.await;
let captured = env.captured_env_vars.lock().unwrap().clone();
@ -1003,9 +1111,9 @@ mod tests {
let _result = (tool.executor)(
serde_json::json!({"url": "https://example.com"}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: Some(tool_env.clone()),
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: Some(Arc::new(crate::StaticEnvProvider(tool_env.clone()))),
},
)
.await;
@ -1026,7 +1134,7 @@ mod tests {
let result = (tool.executor)(serde_json::json!({"pattern": "fn"}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
})
.await;
let output = result.unwrap();
@ -1044,7 +1152,7 @@ mod tests {
let result = (tool.executor)(serde_json::json!({"pattern": "src/**/*.rs"}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
})
.await;
let output = result.unwrap();
@ -1059,7 +1167,7 @@ mod tests {
let result = (tool.executor)(serde_json::json!({"query": "test"}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
})
.await;
let err = result.unwrap_err();
@ -1076,7 +1184,7 @@ mod tests {
let result = (tool.executor)(serde_json::json!({}), ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
})
.await;
let err = result.unwrap_err();
@ -1126,9 +1234,9 @@ mod tests {
let result = (tool.executor)(
serde_json::json!({"url": "https://example.com"}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: None,
},
)
.await;
@ -1165,7 +1273,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -1184,9 +1292,9 @@ mod tests {
let _result = (tool.executor)(
serde_json::json!({"url": "https://example.com", "timeout_ms": 15000}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: None,
},
)
.await;
@ -1206,9 +1314,9 @@ mod tests {
let _result = (tool.executor)(
serde_json::json!({"url": "https://example.com", "timeout_ms": 120_000}),
ToolContext {
env: env_clone,
cancel: CancellationToken::new(),
tool_env: None,
env: env_clone,
cancel: CancellationToken::new(),
tool_env_provider: None,
},
)
.await;
@ -1239,7 +1347,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -1266,7 +1374,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -1314,7 +1422,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -1345,7 +1453,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -1414,7 +1522,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -1468,7 +1576,7 @@ mod tests {
ToolContext {
env,
cancel: CancellationToken::new(),
tool_env: None,
tool_env_provider: None,
},
)
.await;
@ -1492,9 +1600,9 @@ mod tests {
// read_file tool should mark the file as agent-read
let tool = make_read_file_tool();
(tool.executor)(serde_json::json!({"file_path": "a.ts"}), ToolContext {
env: Arc::clone(&env),
cancel: CancellationToken::new(),
tool_env: None,
env: Arc::clone(&env),
cancel: CancellationToken::new(),
tool_env_provider: None,
})
.await
.unwrap();
@ -1521,9 +1629,9 @@ mod tests {
// grep tool should mark matched files as agent-read
let tool = make_grep_tool();
(tool.executor)(serde_json::json!({"pattern": "content"}), ToolContext {
env: Arc::clone(&env),
cancel: CancellationToken::new(),
tool_env: None,
env: Arc::clone(&env),
cancel: CancellationToken::new(),
tool_env_provider: None,
})
.await
.unwrap();

View file

@ -463,6 +463,7 @@ impl InstallInputSource for InteractiveInstallInputSource {
} else {
spawn_blocking(|| prompt_password("GitHub Personal Access Token")).await??
};
fabro_github::validate_static_github_token(&token)?;
Ok(GitHubInstallSelection::Token { token })
}
1 => {
@ -620,6 +621,7 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
let token = fabro_github::gh_auth_token()
.await
.context("Run `gh auth login` and rerun `fabro install`.")?;
fabro_github::validate_static_github_token(&token)?;
Ok(GitHubInstallSelection::Token { token })
}
Some(InstallGitHubStrategyArg::App) => Ok(GitHubInstallSelection::App {
@ -705,6 +707,7 @@ async fn choose_install_github_selection(
let token = fabro_github::gh_auth_token()
.await
.context("Run `gh auth login` and rerun `fabro install github`.")?;
fabro_github::validate_static_github_token(&token)?;
Ok(GitHubInstallSelection::Token { token })
}
Some(InstallGitHubStrategyArg::App) => Ok(GitHubInstallSelection::App {

View file

@ -17,7 +17,10 @@ pub(crate) fn build_github_credentials(
GithubIntegrationStrategy::Token => {
let token = lookup_github_token(vault);
match token {
Some(t) => Ok(Some(GitHubCredentials::Token(t))),
Some(t) => {
fabro_github::validate_static_github_token(&t)?;
Ok(Some(GitHubCredentials::Pat(t)))
}
None => Err(anyhow!(
"GITHUB_TOKEN not configured — run fabro install or set GITHUB_TOKEN"
)),

View file

@ -1,6 +1,7 @@
use anyhow::{Context as _, anyhow, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use chrono::{DateTime, Utc};
use fabro_redact::DisplaySafeUrl;
use fabro_static::EnvVars;
use fabro_types::PullRequestGithubDetail;
@ -136,12 +137,60 @@ impl GitHubAppCredentials {
format!("https://github.com/organizations/{owner}/settings/apps/{slug}/installations")
})
}
pub async fn mint_installation_token(
&self,
client: &impl HttpClient,
owner: &str,
repo: &str,
base_url: &str,
permissions: serde_json::Value,
install_url: Option<&str>,
) -> anyhow::Result<InstallationToken> {
let jwt = sign_app_jwt(&self.app_id, &self.private_key_pem)?;
let default_install_url = self.installation_url(owner);
let install_url = install_url.or(default_install_url.as_deref());
mint_installation_token_with_jwt(
client,
&jwt,
owner,
repo,
base_url,
permissions,
install_url,
)
.await
}
}
#[derive(Clone, Debug)]
pub struct InstallationToken {
pub token: String,
pub expires_at: DateTime<Utc>,
}
impl InstallationToken {
pub fn near_expiry(&self, threshold: std::time::Duration) -> bool {
let threshold = chrono::Duration::from_std(threshold).unwrap_or(chrono::Duration::MAX);
self.expires_at <= Utc::now() + threshold
}
pub fn valid_token(&self) -> anyhow::Result<&str> {
if self.expires_at <= Utc::now() {
bail!(
"GitHub installation access token expired at {}",
self.expires_at
);
}
Ok(&self.token)
}
}
#[derive(Clone, Debug)]
pub enum GitHubCredentials {
App(GitHubAppCredentials),
Token(String),
Pat(String),
Installation(InstallationToken),
}
impl GitHubCredentials {
@ -166,24 +215,35 @@ impl GitHubCredentials {
) -> anyhow::Result<String> {
match self {
Self::App(creds) => {
let jwt = sign_app_jwt(&creds.app_id, &creds.private_key_pem)?;
let install_url = creds.installation_url(owner);
create_installation_access_token_with_permissions_and_install_url(
client,
&jwt,
owner,
repo,
base_url,
permissions,
install_url.as_deref(),
)
.await
creds
.mint_installation_token(
client,
owner,
repo,
base_url,
permissions,
install_url.as_deref(),
)
.await
.map(|token| token.token)
}
Self::Token(token) => Ok(token.clone()),
Self::Pat(token) => Ok(token.clone()),
Self::Installation(token) => token.valid_token().map(str::to_owned),
}
}
}
pub fn validate_static_github_token(token: &str) -> anyhow::Result<()> {
if token.starts_with("ghs_") {
bail!(
"GitHub installation access token (ghs_*) cannot be configured as a static token \
because it expires quickly; use a PAT or GitHub App credentials instead"
);
}
Ok(())
}
pub async fn gh_auth_token() -> anyhow::Result<String> {
let output = Command::new("gh")
.args(["auth", "token"])
@ -401,6 +461,20 @@ pub async fn create_installation_access_token_with_permissions_and_install_url(
permissions: serde_json::Value,
install_url: Option<&str>,
) -> anyhow::Result<String> {
mint_installation_token_with_jwt(client, jwt, owner, repo, base_url, permissions, install_url)
.await
.map(|token| token.token)
}
async fn mint_installation_token_with_jwt(
client: &impl HttpClient,
jwt: &str,
owner: &str,
repo: &str,
base_url: &str,
permissions: serde_json::Value,
install_url: Option<&str>,
) -> anyhow::Result<InstallationToken> {
#[derive(Deserialize)]
struct Installation {
id: u64,
@ -408,7 +482,8 @@ pub async fn create_installation_access_token_with_permissions_and_install_url(
#[derive(Deserialize)]
struct AccessToken {
token: String,
token: String,
expires_at: DateTime<Utc>,
}
// Step 1: Find the installation for this repo
@ -506,7 +581,10 @@ pub async fn create_installation_access_token_with_permissions_and_install_url(
.json()
.context("Failed to parse access token response")?;
Ok(access_token.token)
Ok(InstallationToken {
token: access_token.token,
expires_at: access_token.expires_at,
})
}
/// Request a scoped Installation Access Token with `contents: write`.
@ -963,7 +1041,8 @@ pub async fn resolve_clone_credentials(
repo: &str,
) -> anyhow::Result<(Option<String>, Option<String>)> {
let token = match ctx.creds {
GitHubCredentials::Token(token) => token.clone(),
GitHubCredentials::Pat(token) => token.clone(),
GitHubCredentials::Installation(token) => token.valid_token()?.to_string(),
GitHubCredentials::App(_) => {
let client = ctx.http_client()?;
ctx.creds
@ -1635,6 +1714,50 @@ mod tests {
// create_installation_access_token — success
// -----------------------------------------------------------------------
#[tokio::test]
async fn app_credentials_mint_installation_token_preserves_expiry() {
let mock = MockHttpClient::new()
.on(
HttpMethod::Get,
"/repos/owner/repo/installation",
200,
r#"{"id": 123}"#,
)
.on(
HttpMethod::Post,
"/app/installations/123/access_tokens",
201,
r#"{"token": "ghs_xxx", "expires_at": "2026-01-01T12:00:00Z"}"#,
)
.with_req_body(r#"{"permissions":{"contents":"write"},"repositories":["repo"]}"#);
let creds = GitHubAppCredentials {
app_id: "test".to_string(),
private_key_pem: test_rsa_key().to_string(),
slug: None,
};
let token = creds
.mint_installation_token(
&mock,
"owner",
"repo",
"",
serde_json::json!({ "contents": "write" }),
None,
)
.await
.unwrap();
assert_eq!(token.token, "ghs_xxx");
assert_eq!(
token.expires_at,
"2026-01-01T12:00:00Z"
.parse::<chrono::DateTime<chrono::Utc>>()
.unwrap()
);
}
#[tokio::test]
async fn create_iat_success() {
let mock = MockHttpClient::new()
@ -1649,7 +1772,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/123/access_tokens",
201,
r#"{"token": "ghs_xxx"}"#,
r#"{"token": "ghs_xxx", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.with_req_header("Authorization", "Bearer test-jwt")
.with_req_body(r#"{"permissions":{"contents":"write"},"repositories":["repo"]}"#);
@ -1771,7 +1894,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/456/access_tokens",
201,
r#"{"token": "ghs_pr_token"}"#,
r#"{"token": "ghs_pr_token", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.with_req_header("Authorization", "Bearer test-jwt")
.with_req_body(
@ -1801,7 +1924,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(
HttpMethod::Get,
@ -1840,7 +1963,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(
HttpMethod::Get,
@ -1879,7 +2002,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(
HttpMethod::Get,
@ -1916,7 +2039,7 @@ mod tests {
)
.with_req_header("Authorization", "Bearer ghu_test");
let creds = GitHubCredentials::Token("ghu_test".to_string());
let creds = GitHubCredentials::Pat("ghu_test".to_string());
let result = branch_exists_with_client(
&mock,
&GitHubContext::new(&creds, ""),
@ -2047,7 +2170,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(
HttpMethod::Get,
@ -2098,7 +2221,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(HttpMethod::Get, "/repos/owner/repo/pulls/999", 404, "");
@ -2141,7 +2264,7 @@ mod tests {
)
.with_req_header("Authorization", "Bearer ghu_test");
let creds = GitHubCredentials::Token("ghu_test".to_string());
let creds = GitHubCredentials::Pat("ghu_test".to_string());
let detail = get_pull_request_with_client(
&mock,
&GitHubContext::new(&creds, ""),
@ -2157,7 +2280,7 @@ mod tests {
#[tokio::test]
async fn resolve_clone_credentials_returns_token_for_token_credentials() {
let creds = GitHubCredentials::Token("ghu_test".to_string());
let creds = GitHubCredentials::Pat("ghu_test".to_string());
let credentials =
resolve_clone_credentials(&GitHubContext::new(&creds, ""), "owner", "repo")
@ -2173,6 +2296,34 @@ mod tests {
);
}
#[test]
fn installation_token_valid_token_rejects_expired_tokens() {
let expired = InstallationToken {
token: "ghs_expired".to_string(),
expires_at: chrono::Utc::now() - chrono::Duration::seconds(1),
};
assert!(expired.valid_token().is_err());
let fresh = InstallationToken {
token: "ghs_fresh".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
};
assert_eq!(fresh.valid_token().unwrap(), "ghs_fresh");
assert!(!fresh.near_expiry(std::time::Duration::from_mins(15)));
}
#[test]
fn validate_static_github_token_rejects_installation_tokens() {
validate_static_github_token("ghp_personal").unwrap();
validate_static_github_token("gho_oauth").unwrap();
validate_static_github_token("ghu_user").unwrap();
let err = validate_static_github_token("ghs_installation")
.unwrap_err()
.to_string();
assert!(err.contains("installation access token"), "got: {err}");
}
// -----------------------------------------------------------------------
// merge_pull_request
// -----------------------------------------------------------------------
@ -2190,7 +2341,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(
HttpMethod::Put,
@ -2230,7 +2381,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(HttpMethod::Put, "/repos/owner/repo/pulls/42/merge", 405, "");
@ -2266,7 +2417,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(HttpMethod::Put, "/repos/owner/repo/pulls/42/merge", 409, "");
@ -2306,7 +2457,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(
HttpMethod::Patch,
@ -2339,7 +2490,7 @@ mod tests {
HttpMethod::Post,
"/app/installations/1/access_tokens",
201,
r#"{"token": "ghs_test"}"#,
r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
)
.on(HttpMethod::Patch, "/repos/owner/repo/pulls/999", 404, "");

View file

@ -424,9 +424,9 @@ impl HookExecutorImpl {
for tc in &tool_calls {
let tool = registry.get(&tc.name).cloned();
let ctx = ToolContext {
env: sandbox.clone(),
cancel: cancel.child_token(),
tool_env: None,
env: sandbox.clone(),
cancel: cancel.child_token(),
tool_env_provider: None,
};
let result = match tool {
Some(t) => match (t.executor)(tc.arguments.clone(), ctx).await {

View file

@ -230,7 +230,23 @@ async fn check_github_app(state: &AppState) -> CheckResult {
let settings = state.server_settings();
if settings.server.integrations.github.strategy == GithubIntegrationStrategy::Token {
let token = match state.github_credentials(&settings.server.integrations.github) {
Ok(Some(fabro_github::GitHubCredentials::Token(token))) => token,
Ok(Some(fabro_github::GitHubCredentials::Pat(token))) => token,
Ok(Some(fabro_github::GitHubCredentials::Installation(token))) => {
match token.valid_token() {
Ok(token) => token.to_string(),
Err(err) => {
return CheckResult {
name: "GitHub Token".to_string(),
status: CheckStatus::Error,
summary: "token expired".to_string(),
details: vec![CheckDetail::new(err.to_string())],
remediation: Some(
"Run fabro install or update GITHUB_TOKEN".to_string(),
),
};
}
}
}
Ok(Some(_)) => unreachable!("token strategy should not return app credentials"),
Ok(None) => {
return CheckResult {

View file

@ -1234,8 +1234,12 @@ async fn mint_github_token(
origin_url: &str,
permissions: &HashMap<String, String>,
) -> Result<String> {
if let fabro_github::GitHubCredentials::Token(token) = creds {
return Ok(token.clone());
match creds {
fabro_github::GitHubCredentials::Pat(token) => return Ok(token.clone()),
fabro_github::GitHubCredentials::Installation(token) => {
return token.valid_token().map(str::to_owned);
}
fabro_github::GitHubCredentials::App(_) => {}
}
let https_url = fabro_github::ssh_url_to_https(origin_url);

View file

@ -256,10 +256,21 @@ fn resolve_webhook_preconditions(
)));
}
};
let Some(fabro_github::GitHubCredentials::App(github_app)) = github_app else {
return Ok(WebhookPreconditions::Skip(
"GITHUB_APP_PRIVATE_KEY is not available".to_string(),
));
let github_app = match github_app {
Some(fabro_github::GitHubCredentials::App(github_app)) => github_app,
Some(
fabro_github::GitHubCredentials::Pat(_)
| fabro_github::GitHubCredentials::Installation(_),
) => {
return Ok(WebhookPreconditions::Skip(
"GitHub webhooks require GitHub App credentials".to_string(),
));
}
None => {
return Ok(WebhookPreconditions::Skip(
"GITHUB_APP_PRIVATE_KEY is not available".to_string(),
));
}
};
Ok(WebhookPreconditions::Ready {
app_id,

View file

@ -798,7 +798,11 @@ impl AppState {
.filter(|token| !token.is_empty())
.map(str::to_string);
match token {
Some(token) => Ok(Some(fabro_github::GitHubCredentials::Token(token))),
Some(token) => {
fabro_github::validate_static_github_token(&token)
.map_err(|err| err.to_string())?;
Ok(Some(fabro_github::GitHubCredentials::Pat(token)))
}
None => Err(
"GITHUB_TOKEN not configured — run fabro install or set GITHUB_TOKEN"
.to_string(),

View file

@ -366,7 +366,16 @@ async fn get_github_repo(
}
GithubIntegrationStrategy::Token => {
let token = match state.github_credentials(github_settings) {
Ok(Some(fabro_github::GitHubCredentials::Token(token))) => token,
Ok(Some(fabro_github::GitHubCredentials::Pat(token))) => token,
Ok(Some(fabro_github::GitHubCredentials::Installation(token))) => {
match token.valid_token() {
Ok(token) => token.to_string(),
Err(err) => {
return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err.to_string())
.into_response();
}
}
}
Ok(Some(_)) => unreachable!("token strategy should not return app credentials"),
Ok(None) => {
return ApiError::new(

View file

@ -78,7 +78,8 @@ impl GitHubTracker {
.await
.context("creating GitHub App installation token")
}
GitHubCredentials::Token(token) => Ok(token.clone()),
GitHubCredentials::Pat(token) => Ok(token.clone()),
GitHubCredentials::Installation(token) => token.valid_token().map(str::to_owned),
}
}
@ -725,7 +726,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -765,7 +767,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -818,7 +821,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -853,7 +857,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -888,7 +893,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -929,7 +935,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -975,7 +982,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -1026,7 +1034,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -1099,7 +1108,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server
@ -1182,7 +1192,8 @@ mod tests {
.mock_async(|when, then| {
when.method("POST")
.path("/app/installations/1/access_tokens");
then.status(201).body(r#"{"token": "ghs_test"}"#);
then.status(201)
.body(r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#);
})
.await;
server

View file

@ -27,6 +27,7 @@ pub enum RunNoticeCode {
GitDiffFailed,
GitPushFailed,
GithubTokenFailed,
GithubTokenRefreshLimited,
ParallelBaseCheckpointFailed,
PullRequestFailed,
SandboxCleanupFailed,

View file

@ -0,0 +1,277 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use fabro_github::{GitHubAppCredentials, InstallationToken};
use tokio::sync::Mutex;
use tracing::warn;
const REFRESH_THRESHOLD: Duration = Duration::from_mins(15);
#[async_trait::async_trait]
pub trait IatMinter: Send + Sync {
async fn mint(&self) -> anyhow::Result<InstallationToken>;
}
pub struct AppIatMinter {
creds: GitHubAppCredentials,
http: fabro_http::HttpClient,
owner: String,
repo: String,
api_base: String,
install_url: Option<String>,
permissions: serde_json::Value,
}
impl AppIatMinter {
#[must_use]
pub fn new(
creds: GitHubAppCredentials,
http: fabro_http::HttpClient,
owner: String,
repo: String,
api_base: String,
install_url: Option<String>,
permissions: serde_json::Value,
) -> Self {
Self {
creds,
http,
owner,
repo,
api_base,
install_url,
permissions,
}
}
}
#[async_trait::async_trait]
impl IatMinter for AppIatMinter {
async fn mint(&self) -> anyhow::Result<InstallationToken> {
self.creds
.mint_installation_token(
&self.http,
&self.owner,
&self.repo,
&self.api_base,
self.permissions.clone(),
self.install_url.as_deref(),
)
.await
}
}
pub struct GitHubTokenSource {
state: SourceState,
}
enum SourceState {
Pat(String),
StaticIat(InstallationToken),
Mintable {
minter: Arc<dyn IatMinter>,
cache: Mutex<Option<InstallationToken>>,
},
}
impl GitHubTokenSource {
#[must_use]
pub fn pat(token: String) -> Self {
Self {
state: SourceState::Pat(token),
}
}
#[must_use]
pub fn static_iat(token: InstallationToken) -> Self {
Self {
state: SourceState::StaticIat(token),
}
}
#[must_use]
pub fn mintable(minter: Arc<dyn IatMinter>) -> Self {
Self {
state: SourceState::Mintable {
minter,
cache: Mutex::new(None),
},
}
}
#[must_use]
pub fn is_refreshable(&self) -> bool {
matches!(self.state, SourceState::Mintable { .. })
}
pub async fn current_token(&self) -> anyhow::Result<String> {
match &self.state {
SourceState::Pat(token) => Ok(token.clone()),
SourceState::StaticIat(token) => token.valid_token().map(str::to_owned),
SourceState::Mintable { minter, cache } => {
let mut cache = cache.lock().await;
let should_refresh = cache
.as_ref()
.is_none_or(|token| token.near_expiry(REFRESH_THRESHOLD));
if should_refresh {
match minter.mint().await {
Ok(token) => {
*cache = Some(token);
}
Err(err) => {
if let Some(token) = cache.as_ref() {
if token.valid_token().is_ok() {
warn!(
error = %err,
"GitHub installation token refresh failed; using cached token"
);
return token.valid_token().map(str::to_owned);
}
}
return Err(err)
.context("failed to mint GitHub installation access token");
}
}
}
cache
.as_ref()
.expect("mintable token source should have a token after refresh")
.valid_token()
.map(str::to_owned)
}
}
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::anyhow;
use super::*;
enum MintAction {
Token(&'static str, chrono::DateTime<chrono::Utc>),
Error(&'static str),
}
struct MockMinter {
calls: AtomicUsize,
script: Mutex<VecDeque<MintAction>>,
}
impl MockMinter {
fn new(script: Vec<MintAction>) -> Self {
Self {
calls: AtomicUsize::new(0),
script: Mutex::new(script.into()),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl IatMinter for MockMinter {
async fn mint(&self) -> anyhow::Result<InstallationToken> {
self.calls.fetch_add(1, Ordering::SeqCst);
match self.script.lock().await.pop_front().expect("mint script") {
MintAction::Token(token, expires_at) => Ok(InstallationToken {
token: token.to_string(),
expires_at,
}),
MintAction::Error(message) => Err(anyhow!(message)),
}
}
}
#[tokio::test]
async fn pat_returns_same_token_without_minting() {
let source = GitHubTokenSource::pat("ghp_pat".to_string());
assert_eq!(source.current_token().await.unwrap(), "ghp_pat");
assert_eq!(source.current_token().await.unwrap(), "ghp_pat");
assert!(!source.is_refreshable());
}
#[tokio::test]
async fn static_iat_returns_valid_token_and_rejects_expired_token() {
let valid = GitHubTokenSource::static_iat(InstallationToken {
token: "ghs_valid".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
});
assert_eq!(valid.current_token().await.unwrap(), "ghs_valid");
assert!(!valid.is_refreshable());
let expired = GitHubTokenSource::static_iat(InstallationToken {
token: "ghs_expired".to_string(),
expires_at: chrono::Utc::now() - chrono::Duration::seconds(1),
});
assert!(expired.current_token().await.is_err());
}
#[tokio::test]
async fn mintable_reuses_cached_token_until_refresh_threshold() {
let minter = Arc::new(MockMinter::new(vec![MintAction::Token(
"ghs_cached",
chrono::Utc::now() + chrono::Duration::minutes(30),
)]));
let source = GitHubTokenSource::mintable(minter.clone());
assert!(source.is_refreshable());
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
assert_eq!(minter.calls(), 1);
}
#[tokio::test]
async fn mintable_refreshes_cached_token_near_expiry() {
let minter = Arc::new(MockMinter::new(vec![
MintAction::Token(
"ghs_first",
chrono::Utc::now() + chrono::Duration::minutes(10),
),
MintAction::Token(
"ghs_second",
chrono::Utc::now() + chrono::Duration::minutes(30),
),
]));
let source = GitHubTokenSource::mintable(minter.clone());
assert_eq!(source.current_token().await.unwrap(), "ghs_first");
assert_eq!(source.current_token().await.unwrap(), "ghs_second");
assert_eq!(minter.calls(), 2);
}
#[tokio::test]
async fn mintable_uses_valid_cached_token_when_refresh_fails() {
let minter = Arc::new(MockMinter::new(vec![
MintAction::Token(
"ghs_cached",
chrono::Utc::now() + chrono::Duration::minutes(10),
),
MintAction::Error("mint failed"),
]));
let source = GitHubTokenSource::mintable(minter.clone());
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
assert_eq!(minter.calls(), 2);
}
#[tokio::test]
async fn mintable_errors_when_no_cached_token_can_cover_mint_failure() {
let minter = Arc::new(MockMinter::new(vec![MintAction::Error("mint failed")]));
let source = GitHubTokenSource::mintable(minter);
let err = format!("{:#}", source.current_token().await.unwrap_err());
assert!(err.contains("mint failed"), "got: {err}");
}
}

View file

@ -104,11 +104,11 @@ impl Handler for CommandHandler {
);
let timeout_ms = node.timeout().map_or(600_000, crate::millis_u64);
let env_vars = if services.env.is_empty() {
None
} else {
Some(&services.env)
};
let env = services
.env_for_stage()
.await
.map_err(|err| Error::handler_with_anyhow("Failed to resolve stage env", &err))?;
let env_vars = if env.is_empty() { None } else { Some(&env) };
let cancel_token = services.run.cancel_token().child_token();
let stage_id = stage_scope.stage_id();
let recorder = CommandLogRecorder::create(run_dir, &stage_id).await?;
@ -984,6 +984,21 @@ mod tests {
services
}
struct RefreshingMinter {
calls: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl crate::github_token_source::IatMinter for RefreshingMinter {
async fn mint(&self) -> anyhow::Result<fabro_github::InstallationToken> {
let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
Ok(fabro_github::InstallationToken {
token: format!("ghs_{call}"),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(10),
})
}
}
#[tokio::test]
async fn executes_script_via_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
@ -1087,7 +1102,7 @@ mod tests {
let mut services = make_spy_services(spy.clone());
services
.env
.base_env
.insert("MY_VAR".to_string(), "my_value".to_string());
handler
@ -1102,6 +1117,61 @@ mod tests {
);
}
#[tokio::test]
async fn refreshes_github_token_for_each_command_stage_when_near_expiry() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
termination: CommandTermination::Exited,
duration_ms: 5,
}));
let minter = std::sync::Arc::new(RefreshingMinter {
calls: std::sync::atomic::AtomicUsize::new(0),
});
let mut services = make_spy_services(spy.clone());
services.github_token = Some(std::sync::Arc::new(
crate::github_token_source::GitHubTokenSource::mintable(minter.clone()),
));
let handler = CommandHandler;
let mut node = Node::new("script_node");
node.attrs
.insert("script".to_string(), AttrValue::String("true".to_string()));
let context = Context::new();
let graph = Graph::new("test");
let run_dir = tempfile::tempdir().unwrap();
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(
spy.captured_env_vars
.lock()
.unwrap()
.as_ref()
.and_then(|env| env.get("GITHUB_TOKEN"))
.map(String::as_str),
Some("ghs_1")
);
handler
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
assert_eq!(
spy.captured_env_vars
.lock()
.unwrap()
.as_ref()
.and_then(|env| env.get("GITHUB_TOKEN"))
.map(String::as_str),
Some("ghs_2")
);
assert_eq!(minter.calls.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn passes_run_cancellation_to_sandbox() {
let spy = std::sync::Arc::new(SpySandbox::new(fabro_agent::sandbox::ExecResult {

View file

@ -5,7 +5,8 @@ use async_trait::async_trait;
use fabro_agent::subagent::{SessionFactory, SubAgentManager};
use fabro_agent::{
AgentEvent, AgentProfile, AnthropicProfile, CompletionCoordinator, GeminiProfile,
OpenAiProfile, Sandbox, Session, SessionControlHandle, SessionOptions, Turn,
OpenAiProfile, Sandbox, Session, SessionControlHandle, SessionOptions, StaticEnvProvider,
ToolEnvProvider, Turn,
};
use fabro_auth::{CredentialSource, EnvCredentialSource};
use fabro_graphviz::graph::Node;
@ -250,7 +251,7 @@ pub struct AgentApiBackend {
provider: Provider,
fallback_chain: Vec<FallbackTarget>,
sessions: Mutex<HashMap<String, Session>>,
env: HashMap<String, String>,
tool_env: Option<Arc<dyn ToolEnvProvider>>,
mcp_servers: Vec<McpServerSettings>,
source: Arc<dyn CredentialSource>,
steering_hub: Arc<SteeringHub>,
@ -270,7 +271,7 @@ impl AgentApiBackend {
provider,
fallback_chain,
sessions: Mutex::new(HashMap::new()),
env: HashMap::new(),
tool_env: None,
mcp_servers: Vec::new(),
source,
steering_hub,
@ -295,7 +296,13 @@ impl AgentApiBackend {
#[must_use]
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env = env;
self.tool_env = Some(Arc::new(StaticEnvProvider(env)));
self
}
#[must_use]
pub fn with_tool_env_provider(mut self, provider: Arc<dyn ToolEnvProvider>) -> Self {
self.tool_env = Some(provider);
self
}
@ -322,7 +329,7 @@ impl AgentApiBackend {
node,
sandbox,
self.source.as_ref(),
&self.env,
self.tool_env.as_ref(),
tool_hooks,
self.mcp_servers.clone(),
)
@ -335,7 +342,7 @@ impl AgentApiBackend {
node: &Node,
sandbox: &Arc<dyn Sandbox>,
source: &dyn CredentialSource,
env: &HashMap<String, String>,
tool_env: Option<&Arc<dyn ToolEnvProvider>>,
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
mcp_servers: Vec<McpServerSettings>,
) -> Result<Session, Error> {
@ -363,7 +370,7 @@ impl AgentApiBackend {
let factory_client = client.clone();
let factory_model = model.to_string();
let factory_env = Arc::clone(sandbox);
let factory_tool_env = env.clone();
let factory_tool_env = tool_env.cloned();
let factory: SessionFactory = Arc::new(move || {
let child_profile: Arc<dyn AgentProfile> = match provider {
Provider::OpenAi => Arc::new(OpenAiProfile::new(&factory_model)),
@ -384,8 +391,8 @@ impl AgentApiBackend {
SessionOptions::default(),
None,
);
if !factory_tool_env.is_empty() {
session.set_tool_env(factory_tool_env.clone());
if let Some(provider) = &factory_tool_env {
session.set_tool_env_provider(Arc::clone(provider));
}
session
});
@ -400,8 +407,8 @@ impl AgentApiBackend {
config,
Some(manager_for_callback.clone()),
);
if !env.is_empty() {
session.set_tool_env(env.clone());
if let Some(provider) = tool_env {
session.set_tool_env_provider(Arc::clone(provider));
}
// Wire subagent event callback to parent session's emitter
@ -793,7 +800,7 @@ impl CodergenBackend for AgentApiBackend {
node,
sandbox,
self.source.as_ref(),
&self.env,
self.tool_env.as_ref(),
tool_hooks.clone(),
self.mcp_servers.clone(),
)

View file

@ -1,8 +1,13 @@
//! CLI agent stages resolve workflow tool env once when launching the external
//! CLI process. Long-running CLI stages do not observe later GitHub
//! installation token refreshes until a future credential-helper integration
//! moves token lookup inside the child process.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_agent::{Sandbox, shell_quote};
use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider, shell_quote};
use fabro_auth::{CliAgentKind, CredentialResolver, CredentialUsage, ResolvedCredential};
use fabro_graphviz::graph::Node;
use fabro_llm::types::TokenCounts;
@ -36,7 +41,7 @@ fn cli_failure_detail(stdout: &str, stderr: &str, command: &str) -> String {
use super::super::agent::{CodergenBackend, CodergenResult};
use crate::context::Context;
use crate::error::Error;
use crate::event::{Emitter, Event, StageScope};
use crate::event::{Emitter, Event, RunNoticeCode, RunNoticeLevel, StageScope};
use crate::outcome::billed_model_usage_from_llm;
/// Maps a provider to its corresponding CLI tool metadata.
@ -395,11 +400,12 @@ pub fn parse_cli_response(provider: Provider, output: &str) -> Option<CliRespons
/// CLI backend that invokes external CLI tools (claude, codex, gemini) via
/// `exec_command()`.
pub struct AgentCliBackend {
model: String,
provider: Provider,
env: HashMap<String, String>,
model: String,
provider: Provider,
tool_env: Option<Arc<dyn ToolEnvProvider>>,
github_token_refresh_managed: bool,
poll_interval: std::time::Duration,
resolver: Option<CredentialResolver>,
resolver: Option<CredentialResolver>,
}
impl AgentCliBackend {
@ -408,7 +414,8 @@ impl AgentCliBackend {
Self {
model,
provider,
env: HashMap::new(),
tool_env: None,
github_token_refresh_managed: false,
poll_interval: std::time::Duration::from_secs(5),
resolver: Some(resolver),
}
@ -419,7 +426,8 @@ impl AgentCliBackend {
Self {
model,
provider,
env: HashMap::new(),
tool_env: None,
github_token_refresh_managed: false,
poll_interval: std::time::Duration::from_secs(5),
resolver: None,
}
@ -427,7 +435,18 @@ impl AgentCliBackend {
#[must_use]
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env = env;
self.tool_env = Some(Arc::new(StaticEnvProvider(env)));
self
}
#[must_use]
pub fn with_tool_env_provider(
mut self,
provider: Arc<dyn ToolEnvProvider>,
github_token_refresh_managed: bool,
) -> Self {
self.tool_env = Some(provider);
self.github_token_refresh_managed = github_token_refresh_managed;
self
}
@ -585,8 +604,21 @@ impl CodergenBackend for AgentCliBackend {
}
env
};
for (name, val) in &self.env {
launch_env.insert(name.clone(), val.clone());
if let Some(provider) = &self.tool_env {
if self.github_token_refresh_managed {
emitter.notice(
RunNoticeLevel::Info,
RunNoticeCode::GithubTokenRefreshLimited,
"CLI agent stages receive GitHub tokens at process launch; stages running \
beyond token expiry may need to be retried.",
);
}
let tool_env = provider.resolve().await.map_err(|err| {
Error::handler_with_anyhow("Failed to resolve CLI agent env", &err)
})?;
for (name, val) in tool_env {
launch_env.insert(name, val);
}
}
// Write env file so the inner shell that runs the CLI command picks up

View file

@ -227,7 +227,8 @@ impl Handler for SubWorkflowHandler {
let parent_run = Arc::clone(&services.run);
let registry = Arc::clone(&services.registry);
let env = services.env.clone();
let base_env = services.base_env.clone();
let github_token = services.github_token.clone();
let inputs = services.inputs.clone();
let dry_run = services.dry_run;
let workflow_bundle = services.workflow_bundle.clone();
@ -265,7 +266,8 @@ impl Handler for SubWorkflowHandler {
run: child_run,
registry,
git_state: std::sync::RwLock::new(None),
env,
base_env,
github_token,
inputs,
dry_run,
workflow_path: child_workflow_path,

View file

@ -303,7 +303,8 @@ impl Handler for ParallelHandler {
for setup in branch_setups {
let parent_run = Arc::clone(&services.run);
let registry = Arc::clone(&services.registry);
let env = services.env.clone();
let base_env = services.base_env.clone();
let github_token = services.github_token.clone();
let inputs = services.inputs.clone();
let dry_run = services.dry_run;
let workflow_path = services.workflow_path.clone();
@ -371,7 +372,8 @@ impl Handler for ParallelHandler {
run: parent_run.with_sandbox(Arc::clone(&setup.sandbox)),
registry: Arc::clone(&registry),
git_state: std::sync::RwLock::new(None),
env: env.clone(),
base_env: base_env.clone(),
github_token: github_token.clone(),
inputs: inputs.clone(),
dry_run,
workflow_path,

View file

@ -279,6 +279,7 @@ pub mod error;
pub mod event;
pub mod file_resolver;
pub mod git;
pub mod github_token_source;
pub(crate) mod graph;
pub mod handler;
mod hook_context;

View file

@ -27,17 +27,16 @@ use tokio::time::timeout as tokio_timeout;
use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec};
use crate::devcontainer_bridge::{devcontainer_to_snapshot_config, run_devcontainer_lifecycle};
use crate::error::Error;
use crate::event::{Emitter, Event, RunNoticeCode, RunNoticeLevel};
use crate::event::{Event, RunNoticeCode, RunNoticeLevel};
use crate::git::RUN_BRANCH_PREFIX;
use crate::github_token_source::{AppIatMinter, GitHubTokenSource};
use crate::handler::llm::{AgentApiBackend, AgentCliBackend, BackendRouter};
use crate::handler::{HandlerRegistry, default_registry};
use crate::run_metadata::{
RunMetadataRuntime, build_metadata_writer, metadata_branch_name, mint_token,
};
use crate::run_metadata::{RunMetadataRuntime, build_metadata_writer, metadata_branch_name};
use crate::run_options::{GitCheckpointOptions, RunOptions};
use crate::sandbox_git::GIT_REMOTE;
use crate::sandbox_git_runtime::SandboxGitRuntime;
use crate::services::{EngineServices, RunServices};
use crate::services::{EngineServices, RunServices, WorkflowToolEnvProvider};
use crate::steering_hub::SteeringHub;
struct WorktreePlan {
@ -47,6 +46,8 @@ struct WorktreePlan {
skip_branch_creation: bool,
}
type BuiltSandboxEnv = (HashMap<String, String>, Option<Arc<GitHubTokenSource>>);
async fn resolve_worktree_base_sha(
sandbox: &dyn Sandbox,
plan: &WorktreePlan,
@ -223,52 +224,62 @@ fn git_setup_intent(run_options: &RunOptions) -> GitSetupIntent {
}
}
async fn mint_github_token(
creds: &fabro_github::GitHubCredentials,
origin_url: &str,
permissions: &HashMap<String, String>,
) -> Result<String, Error> {
mint_token(creds, origin_url, permissions)
.await
.map_err(|err| Error::engine_with_anyhow("Failed to mint GitHub token", &err))
}
async fn build_sandbox_env(
fn build_sandbox_env(
spec: &SandboxEnvSpec,
github_app: Option<&fabro_github::GitHubCredentials>,
emitter: &Emitter,
) -> Result<HashMap<String, String>, Error> {
) -> Result<BuiltSandboxEnv, Error> {
let mut env = spec.devcontainer_env.clone();
env.extend(spec.toml_env.clone());
if let Some(permissions) = spec.github_permissions.as_ref() {
if !permissions.is_empty() {
if let (Some(creds), Some(origin_url)) = (github_app, spec.origin_url.as_deref()) {
match mint_github_token(creds, origin_url, permissions).await {
Ok(token) => {
env.insert("GITHUB_TOKEN".to_string(), token);
}
Err(e) => {
tracing::warn!(error = %e, "Failed to mint GitHub token");
emitter.notice(
RunNoticeLevel::Warn,
RunNoticeCode::GithubTokenFailed,
format!("Failed to mint GitHub token: {e}"),
);
}
}
}
}
}
let Some(permissions) = spec.github_permissions.as_ref().filter(|p| !p.is_empty()) else {
return Ok((env, None));
};
let Some(creds) = github_app else {
return Ok((env, None));
};
Ok(env)
let source = match creds {
fabro_github::GitHubCredentials::Pat(token) => {
Some(Arc::new(GitHubTokenSource::pat(token.clone())))
}
fabro_github::GitHubCredentials::Installation(token) => {
Some(Arc::new(GitHubTokenSource::static_iat(token.clone())))
}
fabro_github::GitHubCredentials::App(app) => {
let Some(origin_url) = spec.origin_url.as_deref() else {
return Ok((env, None));
};
let https_url = fabro_github::ssh_url_to_https(origin_url);
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)
.map_err(|err| Error::engine_with_anyhow("Failed to parse GitHub origin", &err))?;
let permissions = serde_json::to_value(permissions).map_err(|err| {
Error::engine_with_source("Failed to serialize GitHub permissions", &err)
})?;
let http = fabro_http::http_client()
.map_err(|err| Error::engine_with_source("Failed to build HTTP client", &err))?;
let install_url = app.installation_url(&owner);
let minter = AppIatMinter::new(
app.clone(),
http,
owner,
repo,
fabro_github::github_api_base_url(),
install_url,
permissions,
);
Some(Arc::new(GitHubTokenSource::mintable(Arc::new(minter))))
}
};
Ok((env, source))
}
async fn build_registry(
spec: &LlmSpec,
interviewer: Arc<dyn fabro_interview::Interviewer>,
steering_hub: Arc<SteeringHub>,
sandbox_env: &HashMap<String, String>,
tool_env_provider: Arc<WorkflowToolEnvProvider>,
github_token_refresh_managed: bool,
graph: &graph::Graph,
llm_source: Arc<dyn CredentialSource>,
cli_resolver: Option<CredentialResolver>,
@ -306,14 +317,15 @@ async fn build_registry(
Ok((build_no_backend(), false))
}
Ok(_result) => {
let env = sandbox_env.clone();
let model = spec.model.clone();
let provider = spec.provider;
let fallback_chain = spec.fallback_chain.clone();
let mcp_servers = spec.mcp_servers.clone();
let llm_source_for_api = Arc::clone(&llm_source);
let steering_hub_for_api = Arc::clone(&steering_hub);
let tool_env_provider_for_backend = Arc::clone(&tool_env_provider);
let registry = Arc::new(default_registry(interviewer, move || {
let tool_env_provider = Arc::clone(&tool_env_provider_for_backend);
let api = AgentApiBackend::new(
model.clone(),
provider,
@ -321,7 +333,7 @@ async fn build_registry(
Arc::clone(&llm_source_for_api),
Arc::clone(&steering_hub_for_api),
)
.with_env(env.clone())
.with_tool_env_provider(tool_env_provider.clone())
.with_mcp_servers(mcp_servers.clone());
let cli = cli_resolver
.clone()
@ -329,7 +341,7 @@ async fn build_registry(
|| AgentCliBackend::new_from_env(model.clone(), provider),
|resolver| AgentCliBackend::new(model.clone(), provider, resolver),
)
.with_env(env.clone());
.with_tool_env_provider(tool_env_provider, github_token_refresh_managed);
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
}));
Ok((registry, false))
@ -580,12 +592,17 @@ pub async fn initialize(
clone_branch: sandbox_record.clone_branch.clone(),
});
let env = build_sandbox_env(
let (base_env, github_token) = build_sandbox_env(
&options.sandbox_env,
options.run_options.github_app.as_ref(),
&options.emitter,
)
.await?;
)?;
let tool_env_provider = Arc::new(WorkflowToolEnvProvider {
base_env: base_env.clone(),
github_token: github_token.clone(),
});
let github_token_refresh_managed = github_token
.as_deref()
.is_some_and(GitHubTokenSource::is_refreshable);
let (registry, effective_dry_run) = if let Some(registry) = options.registry_override.clone() {
// A caller-supplied registry owns execution behavior for its handlers.
(registry, options.dry_run)
@ -594,7 +611,8 @@ pub async fn initialize(
&options.llm,
Arc::clone(&options.interviewer),
Arc::clone(&options.steering_hub),
&env,
Arc::clone(&tool_env_provider),
github_token_refresh_managed,
&graph,
Arc::clone(&llm_source),
cli_resolver,
@ -757,7 +775,8 @@ pub async fn initialize(
run: Arc::clone(&run_services),
registry,
git_state: std::sync::RwLock::new(None),
env,
base_env,
github_token,
inputs: options.run_options.settings.run.inputs.clone(),
dry_run: options.dry_run,
workflow_path: options.workflow_path.clone(),
@ -1117,7 +1136,11 @@ mod tests {
assert_eq!(initialized.source, source);
assert!(initialized.engine.run.hook_runner.is_none());
assert_eq!(
initialized.engine.env.get("TEST_KEY").map(String::as_str),
initialized
.engine
.base_env
.get("TEST_KEY")
.map(String::as_str),
Some("value")
);
assert!(initialized.engine.dry_run);
@ -1161,6 +1184,10 @@ mod tests {
let vault = Arc::new(AsyncRwLock::new(vault));
let test_emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
let tool_env_provider = Arc::new(WorkflowToolEnvProvider {
base_env: HashMap::new(),
github_token: None,
});
let (_registry, effective_dry_run) = build_registry(
&LlmSpec {
model: "claude-opus-4-6".to_string(),
@ -1171,7 +1198,8 @@ mod tests {
},
Arc::new(AutoApproveInterviewer::engine()),
Arc::new(crate::steering_hub::SteeringHub::new(test_emitter)),
&HashMap::new(),
tool_env_provider,
false,
&graph,
Arc::new(VaultCredentialSource::new(Arc::clone(&vault))),
Some(CredentialResolver::new(vault)),

View file

@ -1947,7 +1947,7 @@ mod tests {
},
));
let creds = fabro_github::GitHubCredentials::Token("test-token".to_string());
let creds = fabro_github::GitHubCredentials::Pat("test-token".to_string());
let store = test_store();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();

View file

@ -646,8 +646,11 @@ mod tests {
run_id: fabro_types::fixtures::RUN_1,
labels: HashMap::new(),
workflow_slug: Some("metadata".to_string()),
github_app: Some(fabro_github::GitHubCredentials::Token(
"ghs_token".to_string(),
github_app: Some(fabro_github::GitHubCredentials::Installation(
fabro_github::InstallationToken {
token: "ghs_token".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
},
)),
pre_run_git: Some(GitContext {
origin_url: origin_url.to_string(),

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
#[cfg(test)]
use std::time::Duration;
use fabro_agent::Sandbox;
use fabro_agent::{Sandbox, ToolEnvProvider};
use fabro_auth::CredentialSource;
#[cfg(test)]
use fabro_auth::ResolvedCredentials;
@ -15,6 +15,7 @@ use tokio_util::sync::CancellationToken;
use crate::ManifestPath;
use crate::event::Emitter;
use crate::github_token_source::GitHubTokenSource;
use crate::handler::HandlerRegistry;
use crate::run_metadata::{RunMetadataRuntime, RunMetadataWriterHandle};
use crate::runtime_store::RunStoreHandle;
@ -135,9 +136,10 @@ pub struct EngineServices {
/// Git state for the current run. Set via `set_git_state` at the start of
/// `execute` and read by parallel/fan-in handlers.
pub(crate) git_state: std::sync::RwLock<Option<Arc<GitState>>>,
/// Environment variables from `[sandbox.env]` config, injected into command
/// nodes.
pub env: HashMap<String, String>,
/// Environment variables from devcontainer and `[sandbox.env]` config.
pub base_env: HashMap<String, String>,
/// GitHub token source used to inject `GITHUB_TOKEN` at the point of use.
pub github_token: Option<Arc<GitHubTokenSource>>,
/// Typed values from `[run.inputs]`, available to prompt templates.
pub inputs: HashMap<String, toml::Value>,
/// When true, handlers should skip real execution and return simulated
@ -150,6 +152,10 @@ pub struct EngineServices {
}
impl EngineServices {
pub async fn env_for_stage(&self) -> anyhow::Result<HashMap<String, String>> {
resolve_workflow_env(&self.base_env, self.github_token.as_ref()).await
}
/// Read the current git state (if any).
pub fn git_state(&self) -> Option<Arc<GitState>> {
self.git_state.read().unwrap().clone()
@ -227,7 +233,8 @@ impl EngineServices {
),
registry: Arc::new(HandlerRegistry::new(Box::new(start::StartHandler))),
git_state: std::sync::RwLock::new(None),
env: HashMap::new(),
base_env: HashMap::new(),
github_token: None,
inputs: HashMap::new(),
dry_run: false,
workflow_path: None,
@ -236,9 +243,40 @@ impl EngineServices {
}
}
pub struct WorkflowToolEnvProvider {
pub base_env: HashMap<String, String>,
pub github_token: Option<Arc<GitHubTokenSource>>,
}
#[async_trait::async_trait]
impl ToolEnvProvider for WorkflowToolEnvProvider {
async fn resolve(&self) -> anyhow::Result<HashMap<String, String>> {
resolve_workflow_env(&self.base_env, self.github_token.as_ref()).await
}
}
async fn resolve_workflow_env(
base_env: &HashMap<String, String>,
github_token: Option<&Arc<GitHubTokenSource>>,
) -> anyhow::Result<HashMap<String, String>> {
let mut env = base_env.clone();
if let Some(source) = github_token {
env.insert("GITHUB_TOKEN".to_string(), source.current_token().await?);
}
Ok(env)
}
#[cfg(test)]
mod tests {
use super::EngineServices;
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::anyhow;
use fabro_agent::ToolEnvProvider as _;
use fabro_github::InstallationToken;
use super::{EngineServices, WorkflowToolEnvProvider};
use crate::github_token_source::{GitHubTokenSource, IatMinter};
#[tokio::test]
async fn test_default_uses_stub_credential_source() {
@ -253,4 +291,52 @@ mod tests {
.is_empty()
);
}
#[tokio::test]
async fn workflow_tool_env_provider_returns_base_env_without_github_token() {
let provider = WorkflowToolEnvProvider {
base_env: HashMap::from([("FOO".to_string(), "bar".to_string())]),
github_token: None,
};
let env = provider.resolve().await.unwrap();
assert_eq!(env.get("FOO").map(String::as_str), Some("bar"));
assert!(!env.contains_key("GITHUB_TOKEN"));
}
#[tokio::test]
async fn workflow_tool_env_provider_merges_current_github_token() {
let provider = WorkflowToolEnvProvider {
base_env: HashMap::from([("FOO".to_string(), "bar".to_string())]),
github_token: Some(Arc::new(GitHubTokenSource::pat("ghp_pat".to_string()))),
};
let env = provider.resolve().await.unwrap();
assert_eq!(env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_pat"));
}
struct FailingMinter;
#[async_trait::async_trait]
impl IatMinter for FailingMinter {
async fn mint(&self) -> anyhow::Result<InstallationToken> {
Err(anyhow!("GITHUB_TOKEN refresh failed"))
}
}
#[tokio::test]
async fn workflow_tool_env_provider_propagates_token_refresh_errors() {
let provider = WorkflowToolEnvProvider {
base_env: HashMap::new(),
github_token: Some(Arc::new(GitHubTokenSource::mintable(Arc::new(
FailingMinter,
)))),
};
let err = format!("{:#}", provider.resolve().await.unwrap_err());
assert!(err.contains("GITHUB_TOKEN refresh failed"), "got: {err}");
}
}

View file

@ -166,7 +166,8 @@ async fn initialized(
),
registry: Arc::new(registry),
git_state: std::sync::RwLock::new(None),
env: options.env,
base_env: options.env,
github_token: None,
inputs: run_options.settings.run.inputs.clone(),
dry_run: run_options.dry_run_enabled(),
workflow_path: None,