@@ -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