Skip to content

Commit daac5cd

Browse files
committed
Merge pull request 'feat(orchestrator): inject per-agent GITEA_TOKEN into spawn env' (#741) from task/inject-per-agent-gitea-token into main
2 parents 729dd48 + a297a21 commit daac5cd

2 files changed

Lines changed: 140 additions & 51 deletions

File tree

crates/terraphim_orchestrator/src/lib.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,16 @@ fn build_flow_project_runtimes(
338338
/// Gitea owner/repo, and the project id itself into the child process's
339339
/// environment (`ADF_PROJECT_ID`, `ADF_WORKING_DIR`, `GITEA_OWNER`,
340340
/// `GITEA_REPO`). Legacy (project-less) agents use [`SpawnContext::global()`].
341+
///
342+
/// When an [`OutputPoster`] is available and has a per-agent Gitea token
343+
/// for `(project, agent_name)` (loaded from `agent_tokens.json`), the
344+
/// token is injected as `GITEA_TOKEN` so the agent's own `gtr` / API
345+
/// calls inside its task shell post under its own Gitea identity. Without
346+
/// this, `source ~/.profile` would overlay the shared root token.
341347
fn build_spawn_context_for_agent(
342348
config: &OrchestratorConfig,
343349
def: &AgentDefinition,
350+
output_poster: Option<&OutputPoster>,
344351
) -> SpawnContext {
345352
let Some(pid) = def.project.as_deref() else {
346353
return SpawnContext::global();
@@ -357,6 +364,11 @@ fn build_spawn_context_for_agent(
357364
.with_env("GITEA_OWNER", gitea.owner.clone())
358365
.with_env("GITEA_REPO", gitea.repo.clone());
359366
}
367+
if let Some(poster) = output_poster {
368+
if let Some(token) = poster.agent_token(pid, &def.name) {
369+
ctx = ctx.with_env("GITEA_TOKEN", token.to_string());
370+
}
371+
}
360372
ctx
361373
}
362374

@@ -1614,7 +1626,8 @@ impl AgentOrchestrator {
16141626
}
16151627
request = request.with_resource_limits(limits);
16161628

1617-
let spawn_ctx = build_spawn_context_for_agent(&self.config, def);
1629+
let spawn_ctx =
1630+
build_spawn_context_for_agent(&self.config, def, self.output_poster.as_ref());
16181631
let handle = self
16191632
.spawner
16201633
.spawn_with_fallback(&request, spawn_ctx)
@@ -1872,7 +1885,8 @@ impl AgentOrchestrator {
18721885
}
18731886
request = request.with_resource_limits(limits);
18741887

1875-
let base_ctx = build_spawn_context_for_agent(&self.config, &def);
1888+
let base_ctx =
1889+
build_spawn_context_for_agent(&self.config, &def, self.output_poster.as_ref());
18761890
let spawn_ctx = pr_dispatch::layer_pr_env(base_ctx, &req);
18771891

18781892
let handle = self

crates/terraphim_orchestrator/src/output_poster.rs

Lines changed: 124 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ struct ProjectTrackers {
2121
default_tracker: GiteaTracker,
2222
/// Per-agent trackers keyed by agent name (scoped to this project).
2323
agent_trackers: HashMap<String, GiteaTracker>,
24+
/// Per-agent raw token strings keyed by agent name (parallel to
25+
/// agent_trackers). Exposed via [`OutputPoster::agent_token`] so the
26+
/// spawn path can inject the token as a `GITEA_TOKEN` env override,
27+
/// giving each agent's own `gtr` calls their own identity.
28+
agent_tokens: HashMap<String, String>,
2429
}
2530

2631
/// Posts collected agent output to a Gitea issue comment.
@@ -141,6 +146,18 @@ impl OutputPoster {
141146
.is_some_and(|p| p.agent_trackers.contains_key(agent_name))
142147
}
143148

149+
/// Return the agent's own Gitea token for this project, if one is
150+
/// configured in `agent_tokens.json`. Used by the spawn path to inject
151+
/// `GITEA_TOKEN` into the child process so the agent's own `gtr` /
152+
/// API calls post under its own Gitea user identity (matching what
153+
/// [`OutputPoster`] does for the wrapped completion comment).
154+
pub fn agent_token(&self, project: &str, agent_name: &str) -> Option<&str> {
155+
self.projects
156+
.get(project)
157+
.and_then(|p| p.agent_tokens.get(agent_name))
158+
.map(|s| s.as_str())
159+
}
160+
144161
/// Post agent output as a comment on a Gitea issue in the given project.
145162
///
146163
/// Uses the agent's own Gitea token if configured, otherwise falls back
@@ -382,68 +399,126 @@ fn build_project_trackers(config: &GiteaOutputConfig) -> ProjectTrackers {
382399
let default_tracker =
383400
GiteaTracker::new(default_gitea_config).expect("Failed to create default GiteaTracker");
384401

385-
let agent_trackers = match &config.agent_tokens_path {
386-
Some(path) => match std::fs::read_to_string(path) {
387-
Ok(contents) => match serde_json::from_str::<HashMap<String, String>>(&contents) {
388-
Ok(tokens) => {
389-
tracing::info!(
390-
count = tokens.len(),
391-
path = %path.display(),
392-
owner = %config.owner,
393-
repo = %config.repo,
394-
"loaded per-agent Gitea tokens"
395-
);
396-
let mut trackers = HashMap::with_capacity(tokens.len());
397-
for (agent_name, token) in tokens {
398-
let agent_config = GiteaConfig {
399-
base_url: config.base_url.clone(),
400-
token,
401-
owner: config.owner.clone(),
402-
repo: config.repo.clone(),
403-
active_states: vec!["open".to_string()],
404-
terminal_states: vec!["closed".to_string()],
405-
use_robot_api: false,
406-
robot_path: PathBuf::from(ROBOT_PATH),
407-
claim_strategy: ClaimStrategy::PreferRobot,
408-
};
409-
match GiteaTracker::new(agent_config) {
410-
Ok(tracker) => {
411-
trackers.insert(agent_name, tracker);
412-
}
413-
Err(e) => {
414-
tracing::warn!(
415-
agent = %agent_name,
416-
error = %e,
417-
"failed to create agent tracker, will use project default"
418-
);
402+
let (agent_trackers, agent_tokens): (HashMap<String, GiteaTracker>, HashMap<String, String>) =
403+
match &config.agent_tokens_path {
404+
Some(path) => match std::fs::read_to_string(path) {
405+
Ok(contents) => match serde_json::from_str::<HashMap<String, String>>(&contents) {
406+
Ok(tokens) => {
407+
tracing::info!(
408+
count = tokens.len(),
409+
path = %path.display(),
410+
owner = %config.owner,
411+
repo = %config.repo,
412+
"loaded per-agent Gitea tokens"
413+
);
414+
let mut trackers = HashMap::with_capacity(tokens.len());
415+
let mut raw = HashMap::with_capacity(tokens.len());
416+
for (agent_name, token) in tokens {
417+
let agent_config = GiteaConfig {
418+
base_url: config.base_url.clone(),
419+
token: token.clone(),
420+
owner: config.owner.clone(),
421+
repo: config.repo.clone(),
422+
active_states: vec!["open".to_string()],
423+
terminal_states: vec!["closed".to_string()],
424+
use_robot_api: false,
425+
robot_path: PathBuf::from(ROBOT_PATH),
426+
claim_strategy: ClaimStrategy::PreferRobot,
427+
};
428+
match GiteaTracker::new(agent_config) {
429+
Ok(tracker) => {
430+
trackers.insert(agent_name.clone(), tracker);
431+
raw.insert(agent_name, token);
432+
}
433+
Err(e) => {
434+
tracing::warn!(
435+
agent = %agent_name,
436+
error = %e,
437+
"failed to create agent tracker, will use project default"
438+
);
439+
}
419440
}
420441
}
442+
(trackers, raw)
421443
}
422-
trackers
423-
}
444+
Err(e) => {
445+
tracing::warn!(
446+
path = %path.display(),
447+
error = %e,
448+
"failed to parse agent tokens JSON, all agents will use project default token"
449+
);
450+
(HashMap::new(), HashMap::new())
451+
}
452+
},
424453
Err(e) => {
425454
tracing::warn!(
426455
path = %path.display(),
427456
error = %e,
428-
"failed to parse agent tokens JSON, all agents will use project default token"
457+
"failed to read agent tokens file, all agents will use project default token"
429458
);
430-
HashMap::new()
459+
(HashMap::new(), HashMap::new())
431460
}
432461
},
433-
Err(e) => {
434-
tracing::warn!(
435-
path = %path.display(),
436-
error = %e,
437-
"failed to read agent tokens file, all agents will use project default token"
438-
);
439-
HashMap::new()
440-
}
441-
},
442-
None => HashMap::new(),
443-
};
462+
None => (HashMap::new(), HashMap::new()),
463+
};
444464

445465
ProjectTrackers {
446466
default_tracker,
447467
agent_trackers,
468+
agent_tokens,
469+
}
470+
}
471+
472+
#[cfg(test)]
473+
mod agent_token_tests {
474+
use super::*;
475+
use crate::config::GiteaOutputConfig;
476+
477+
#[test]
478+
fn agent_token_returns_configured_value_when_tokens_file_loaded() {
479+
use std::io::Write;
480+
let dir = tempfile::tempdir().unwrap();
481+
let tokens_path = dir.path().join("agent_tokens.json");
482+
let mut f = std::fs::File::create(&tokens_path).unwrap();
483+
writeln!(f, r#"{{"alpha":"tok-alpha","beta":"tok-beta"}}"#).unwrap();
484+
drop(f);
485+
486+
let config = GiteaOutputConfig {
487+
base_url: "https://example.com".to_string(),
488+
token: "root-token".to_string(),
489+
owner: "terraphim".to_string(),
490+
repo: "acme".to_string(),
491+
agent_tokens_path: Some(tokens_path),
492+
};
493+
let poster = OutputPoster::new(&config);
494+
495+
assert_eq!(
496+
poster.agent_token(crate::dispatcher::LEGACY_PROJECT_ID, "alpha"),
497+
Some("tok-alpha")
498+
);
499+
assert_eq!(
500+
poster.agent_token(crate::dispatcher::LEGACY_PROJECT_ID, "beta"),
501+
Some("tok-beta")
502+
);
503+
assert_eq!(
504+
poster.agent_token(crate::dispatcher::LEGACY_PROJECT_ID, "gamma"),
505+
None
506+
);
507+
assert_eq!(poster.agent_token("nonexistent", "alpha"), None);
508+
}
509+
510+
#[test]
511+
fn agent_token_returns_none_when_no_tokens_file() {
512+
let config = GiteaOutputConfig {
513+
base_url: "https://example.com".to_string(),
514+
token: "root-token".to_string(),
515+
owner: "terraphim".to_string(),
516+
repo: "acme".to_string(),
517+
agent_tokens_path: None,
518+
};
519+
let poster = OutputPoster::new(&config);
520+
assert!(poster
521+
.agent_token(crate::dispatcher::LEGACY_PROJECT_ID, "anything")
522+
.is_none());
448523
}
449524
}

0 commit comments

Comments
 (0)