@@ -305,7 +305,11 @@ impl PreparedEnvironment {
305305 /// Resolve the environment plugin and prepare a BARE node for the whole run.
306306 /// Blocking (the [`EnvironmentClient`] surface is blocking) — call via
307307 /// [`Self::prepare_off_runtime`] from an async context.
308- pub ( crate ) fn prepare ( project_root : & Path , environment : & ResolvedEnvironment ) -> Result < Self > {
308+ pub ( crate ) fn prepare (
309+ project_root : & Path ,
310+ environment : & ResolvedEnvironment ,
311+ github_repo : Option < & str > ,
312+ ) -> Result < Self > {
309313 let environment_id = environment. id . as_str ( ) ;
310314 let client = EnvironmentClient :: resolve ( project_root, environment_id) . map_err ( |err| {
311315 anyhow ! (
@@ -314,7 +318,7 @@ impl PreparedEnvironment {
314318 )
315319 } ) ?;
316320 let backend_label = format ! ( "environment:{}" , client. plugin_name( ) ) ;
317- Self :: prepare_with_backend ( Arc :: new ( client) , backend_label, environment)
321+ Self :: prepare_with_backend ( Arc :: new ( client) , backend_label, environment, github_repo )
318322 }
319323
320324 /// Prepare against an already-resolved backend (production wraps an
@@ -325,6 +329,7 @@ impl PreparedEnvironment {
325329 backend : Arc < dyn EnvironmentExecBackend > ,
326330 backend_label : String ,
327331 environment : & ResolvedEnvironment ,
332+ github_repo : Option < & str > ,
328333 ) -> Result < Self > {
329334 let mut spec = EnvironmentSpec {
330335 kind : environment. id . clone ( ) ,
@@ -337,6 +342,9 @@ impl PreparedEnvironment {
337342 if let Some ( overrides) = environment. spec_overrides . clone ( ) {
338343 apply_spec_overrides ( & mut spec, overrides) ;
339344 }
345+ // Repo-scope the node's GitHub App token: merged AFTER the routing-rule
346+ // overrides so it lands on (and preserves) any metadata they set.
347+ merge_github_repo_metadata ( & mut spec, github_repo) ;
340348 let handle =
341349 backend. prepare ( spec) . map_err ( |err| anyhow ! ( "environment prepare failed for {backend_label}: {err:#}" ) ) ?;
342350 Ok ( Self {
@@ -360,7 +368,11 @@ impl PreparedEnvironment {
360368 /// runtime cannot be created from within another runtime's worker), then the
361369 /// idle runtime — whose own worker threads keep the driver parked and live —
362370 /// is moved back to the async caller and stored on the struct.
363- pub ( crate ) async fn prepare_off_runtime ( project_root : & Path , environment : & ResolvedEnvironment ) -> Result < Self > {
371+ pub ( crate ) async fn prepare_off_runtime (
372+ project_root : & Path ,
373+ environment : & ResolvedEnvironment ,
374+ github_repo : Option < String > ,
375+ ) -> Result < Self > {
364376 let project_root = project_root. to_path_buf ( ) ;
365377 let environment = environment. clone ( ) ;
366378 let ( tx, rx) = tokio:: sync:: oneshot:: channel ( ) ;
@@ -373,7 +385,8 @@ impl PreparedEnvironment {
373385 . context ( "building dedicated runtime for the environment host" ) ?;
374386 // Lease + prepare INSIDE this runtime so the host's I/O driver
375387 // binds here (not to a per-call throwaway that would die on return).
376- let mut prepared = runtime. block_on ( async { Self :: prepare ( & project_root, & environment) } ) ?;
388+ let mut prepared =
389+ runtime. block_on ( async { Self :: prepare ( & project_root, & environment, github_repo. as_deref ( ) ) } ) ?;
377390 prepared. runtime = Some ( runtime) ;
378391 Ok :: < _ , anyhow:: Error > ( prepared)
379392 } ) ( ) ;
@@ -487,6 +500,27 @@ impl HeldEnvironment for PreparedEnvironment {
487500 }
488501}
489502
503+ /// Merge the run's target repo into the [`EnvironmentSpec`]'s `metadata` as
504+ /// `github_repo`, so the environment plugin resolves the GitHub App INSTALLATION
505+ /// for THAT repo (`GET /repos/{owner}/{repo}/installation`) and scopes the minted
506+ /// installation token to it — instead of falling back to the first of possibly
507+ /// several org installations (which mints a token for the WRONG org and 403s on
508+ /// push). The repo is the subject's `git_repo` custom field (the same value the
509+ /// harness renders as `{{git_repo}}`). Any existing `metadata` object is
510+ /// PRESERVED (`github_repo` is merged in, not clobbered); a `None`/empty repo
511+ /// leaves `metadata` untouched (a bare non-coding run must not invent a repo).
512+ fn merge_github_repo_metadata ( spec : & mut EnvironmentSpec , github_repo : Option < & str > ) {
513+ let Some ( repo) = github_repo. map ( str:: trim) . filter ( |repo| !repo. is_empty ( ) ) else {
514+ return ;
515+ } ;
516+ if !matches ! ( spec. metadata, Value :: Object ( _) ) {
517+ spec. metadata = Value :: Object ( serde_json:: Map :: new ( ) ) ;
518+ }
519+ if let Value :: Object ( map) = & mut spec. metadata {
520+ map. insert ( "github_repo" . to_string ( ) , Value :: String ( repo. to_string ( ) ) ) ;
521+ }
522+ }
523+
490524/// Merge a routing rule's opaque `spec` overrides into the compiled
491525/// [`EnvironmentSpec`]: the wire-typed keys (`image`, `resources`, `env`) land
492526/// on their typed fields; everything else is carried opaquely on `metadata`. The
@@ -1074,24 +1108,27 @@ impl BrokeredEnvironment {
10741108 ///
10751109 /// The blocking socket dial runs on a blocking thread so it never stalls the
10761110 /// async runtime's worker.
1077- pub ( crate ) async fn acquire_from_env ( ) -> Option < Result < Self > > {
1111+ pub ( crate ) async fn acquire_from_env ( github_repo : Option < String > ) -> Option < Result < Self > > {
10781112 let env = broker_env ( ) ?;
1079- let joined = tokio:: task:: spawn_blocking ( move || Self :: acquire ( env) ) . await ;
1113+ let joined = tokio:: task:: spawn_blocking ( move || Self :: acquire ( env, github_repo . as_deref ( ) ) ) . await ;
10801114 Some ( joined. map_err ( |err| anyhow ! ( "environment broker acquire task panicked: {err}" ) ) . and_then ( |res| res) )
10811115 }
10821116
10831117 /// Dial the broker socket and perform the `acquire` handshake.
1084- fn acquire ( env : BrokerEnv ) -> Result < Self > {
1118+ fn acquire ( env : BrokerEnv , github_repo : Option < & str > ) -> Result < Self > {
10851119 // Bare spec (no repos), carrying `metadata.animus_run_id` so the plugin
1086- // names the node deterministically per run.
1087- let spec = EnvironmentSpec {
1120+ // names the node deterministically per run — plus `github_repo` (when the
1121+ // subject carries one) so the plugin repo-scopes the minted GitHub App
1122+ // installation token to the run's target repo.
1123+ let mut spec = EnvironmentSpec {
10881124 kind : env. environment_id . clone ( ) ,
10891125 repos : Vec :: new ( ) ,
10901126 image : None ,
10911127 resources : None ,
10921128 env : BTreeMap :: new ( ) ,
10931129 metadata : serde_json:: json!( { "animus_run_id" : env. run_id } ) ,
10941130 } ;
1131+ merge_github_repo_metadata ( & mut spec, github_repo) ;
10951132 let request = AcquireRequest {
10961133 op : "acquire" ,
10971134 token : & env. token ,
@@ -1605,6 +1642,52 @@ environment_routing:
16051642
16061643 /// The prepared node is BARE (no repos) but a routing rule's opaque `spec`
16071644 /// overrides (image / env / metadata) still ride the prepared EnvironmentSpec.
1645+ #[ test]
1646+ fn merge_github_repo_metadata_scopes_the_token_and_preserves_existing_metadata ( ) {
1647+ let mut spec = EnvironmentSpec {
1648+ kind : "railway" . to_string ( ) ,
1649+ repos : Vec :: new ( ) ,
1650+ image : None ,
1651+ resources : None ,
1652+ env : BTreeMap :: new ( ) ,
1653+ metadata : Value :: Null ,
1654+ } ;
1655+
1656+ // Null metadata -> a fresh object carrying only github_repo.
1657+ merge_github_repo_metadata ( & mut spec, Some ( "launchapp-dev/animus-cli" ) ) ;
1658+ assert_eq ! (
1659+ spec. metadata. pointer( "/github_repo" ) . and_then( Value :: as_str) ,
1660+ Some ( "launchapp-dev/animus-cli" ) ,
1661+ "github_repo lands on metadata so the plugin repo-scopes the token"
1662+ ) ;
1663+
1664+ // Existing metadata keys (e.g. the broker's animus_run_id) are PRESERVED.
1665+ spec. metadata = serde_json:: json!( { "animus_run_id" : "RUN-1" , "network" : "none" } ) ;
1666+ merge_github_repo_metadata ( & mut spec, Some ( " launchapp-dev/animus-cli " ) ) ;
1667+ assert_eq ! ( spec. metadata. pointer( "/animus_run_id" ) . and_then( Value :: as_str) , Some ( "RUN-1" ) ) ;
1668+ assert_eq ! ( spec. metadata. pointer( "/network" ) . and_then( Value :: as_str) , Some ( "none" ) ) ;
1669+ assert_eq ! (
1670+ spec. metadata. pointer( "/github_repo" ) . and_then( Value :: as_str) ,
1671+ Some ( "launchapp-dev/animus-cli" ) ,
1672+ "the repo is trimmed and merged in without clobbering sibling keys"
1673+ ) ;
1674+
1675+ // None / empty repo (a non-coding run) leaves metadata untouched — never
1676+ // invents a repo.
1677+ let mut bare = EnvironmentSpec {
1678+ kind : "railway" . to_string ( ) ,
1679+ repos : Vec :: new ( ) ,
1680+ image : None ,
1681+ resources : None ,
1682+ env : BTreeMap :: new ( ) ,
1683+ metadata : Value :: Null ,
1684+ } ;
1685+ merge_github_repo_metadata ( & mut bare, None ) ;
1686+ assert ! ( bare. metadata. is_null( ) , "no repo -> metadata stays untouched" ) ;
1687+ merge_github_repo_metadata ( & mut bare, Some ( " " ) ) ;
1688+ assert ! ( bare. metadata. is_null( ) , "blank repo -> metadata stays untouched" ) ;
1689+ }
1690+
16081691 #[ test]
16091692 fn prepared_spec_is_bare_but_carries_rule_spec_overrides ( ) {
16101693 let _lock = crate :: test_env:: scoped_state_serializer ( ) ;
@@ -1641,6 +1724,7 @@ environment_routing:
16411724 backend. clone ( ) ,
16421725 "environment:container-env" . to_string ( ) ,
16431726 & resolved,
1727+ None ,
16441728 )
16451729 . expect ( "prepare succeeds" ) ;
16461730 assert_eq ! ( prepared. id( ) , "container-env" ) ;
@@ -2076,6 +2160,7 @@ environment_routing:
20762160 backend. clone ( ) ,
20772161 "environment:container" . to_string ( ) ,
20782162 & bare_env ( "container" ) ,
2163+ None ,
20792164 ) {
20802165 Ok ( _) => panic ! ( "prepare failure must surface as an error" ) ,
20812166 Err ( err) => err,
@@ -2094,6 +2179,7 @@ environment_routing:
20942179 backend. clone ( ) ,
20952180 "environment:container" . to_string ( ) ,
20962181 & bare_env ( "container" ) ,
2182+ None ,
20972183 )
20982184 . expect ( "prepare succeeds" ) ;
20992185 assert_eq ! ( backend. prepare_calls. load( Ordering :: SeqCst ) , 1 , "prepared exactly once" ) ;
@@ -2292,6 +2378,9 @@ environment_routing:
22922378 // on metadata for deterministic per-run node naming.
22932379 assert ! ( v[ "spec" ] . get( "repos" ) . is_none( ) , "bare node carries no repos: {v}" ) ;
22942380 assert_eq ! ( v[ "spec" ] [ "metadata" ] [ "animus_run_id" ] , "run-1" ) ;
2381+ // The target repo rides metadata so the plugin repo-scopes the token,
2382+ // alongside (not clobbering) the run id.
2383+ assert_eq ! ( v[ "spec" ] [ "metadata" ] [ "github_repo" ] , "launchapp-dev/animus-cli" ) ;
22952384 writeln ! ( out, r#"{{"ok":true,"workspace_root":"/workspace","handle_id":"h-1"}}"# ) . unwrap ( ) ;
22962385 out. flush ( ) . unwrap ( ) ;
22972386 } ) ;
@@ -2301,7 +2390,7 @@ environment_routing:
23012390 run_id : "run-1" . to_string ( ) ,
23022391 environment_id : "railway" . to_string ( ) ,
23032392 } ;
2304- let brokered = BrokeredEnvironment :: acquire ( env) . expect ( "acquire succeeds" ) ;
2393+ let brokered = BrokeredEnvironment :: acquire ( env, Some ( "launchapp-dev/animus-cli" ) ) . expect ( "acquire succeeds" ) ;
23052394 assert_eq ! ( brokered. id( ) , "railway" ) ;
23062395 assert_eq ! ( brokered. handle_id, "h-1" ) ;
23072396 assert_eq ! ( brokered. workspace_root, "/workspace" ) ;
@@ -2321,7 +2410,7 @@ environment_routing:
23212410 run_id : "run-1" . to_string ( ) ,
23222411 environment_id : "railway" . to_string ( ) ,
23232412 } ;
2324- let err = BrokeredEnvironment :: acquire ( env) . expect_err ( "ok:false must fail" ) ;
2413+ let err = BrokeredEnvironment :: acquire ( env, None ) . expect_err ( "ok:false must fail" ) ;
23252414 assert ! ( format!( "{err:#}" ) . contains( "no capacity" ) , "error surfaces the broker reason: {err:#}" ) ;
23262415 server. join ( ) . unwrap ( ) ;
23272416 }
0 commit comments