Skip to content

Commit d01ac04

Browse files
authored
Repo-scope the environment GitHub App token via metadata.github_repo (#25)
Thread the run's target repo (the subject's git_repo custom field, the same value the harness renders as {{git_repo}}) into the EnvironmentSpec metadata as github_repo so the ephemeral-node env plugin resolves the GitHub App installation for that repo and scopes the minted token to it, instead of falling back to the first of possibly several org installations (a push-403 class bug). Covers both environment paths: the runner-owned prepare (PreparedEnvironment) and the daemon broker acquire (BrokeredEnvironment), sourced once per run in workflow_execute and once per phase in the brokered plugin entry. Existing metadata is preserved; an absent git_repo leaves metadata untouched. Bump 0.4.36 -> 0.4.37.
1 parent 6d7ef64 commit d01ac04

5 files changed

Lines changed: 162 additions & 14 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "animus-workflow-runner-default"
3-
version = "0.4.36"
3+
version = "0.4.37"
44
edition = "2021"
55
description = "Reference workflow_runner plugin for Animus v0.5 (stdio JSON-RPC + direct-execute CLI; replaces in-tree workflow-runner-v2)"
66
license = "Elastic-2.0"

src/phase_command.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,48 @@ async fn fetch_subject_record(project_root: &str, kind: &str, native_id: &str) -
473473
None
474474
}
475475

476+
/// Best-effort fetch of the bound subject's `git_repo` custom field — the SAME
477+
/// value a command phase renders as `{{git_repo}}` (case-insensitive, mirroring
478+
/// [`build_command_template_vars`]'s lowercased custom-field keys). Used to
479+
/// repo-scope the per-run environment node's GitHub App installation token (see
480+
/// [`crate::phase_environment`]). Reuses the same bounded `subject/get` fetch the
481+
/// command path uses; a fetch failure / missing field degrades to `None` (a bare
482+
/// node, no repo scoping).
483+
pub(crate) async fn subject_git_repo(project_root: &str, kind: &str, subject_id: &str) -> Option<String> {
484+
// The brokered per-phase entry supplies no explicit kind and a possibly
485+
// kind-QUALIFIED id (`task:TASK-1`); the owned path supplies the real kind
486+
// and a BARE native id. Derive `(kind, native_id)` for the `subject/get`
487+
// fetch from whichever form arrived.
488+
let (kind, native_id) = resolve_kind_and_native(kind, subject_id);
489+
let record = tokio::time::timeout(
490+
std::time::Duration::from_secs(SUBJECT_FETCH_TIMEOUT_SECS),
491+
fetch_subject_record(project_root, kind, native_id),
492+
)
493+
.await
494+
.ok()
495+
.flatten();
496+
subject_custom_fields(record.as_ref())
497+
.into_iter()
498+
.find(|(key, value)| key.eq_ignore_ascii_case("git_repo") && !value.trim().is_empty())
499+
.map(|(_, value)| value)
500+
}
501+
502+
/// Derive the `(kind, native_id)` pair the `subject/get` fetch needs from a
503+
/// caller-supplied kind (possibly empty) plus a subject id that may be BARE
504+
/// (`TASK-1`) or kind-QUALIFIED (`task:TASK-1`). An explicit kind wins; when it
505+
/// is empty the prefix of a qualified id supplies it. A bare id with no kind
506+
/// yields an empty kind (the fetch then degrades to `None`).
507+
fn resolve_kind_and_native<'a>(kind: &'a str, subject_id: &'a str) -> (&'a str, &'a str) {
508+
let kind = kind.trim();
509+
let id = subject_id.trim();
510+
match id.split_once(':') {
511+
Some((prefix, native)) if !prefix.is_empty() && !native.is_empty() => {
512+
(if kind.is_empty() { prefix } else { kind }, native)
513+
}
514+
_ => (kind, id),
515+
}
516+
}
517+
476518
/// Extract the bound subject's CUSTOM fields from a raw `subject/get` record as
477519
/// string values, preferring the explicit `data` object then the backend
478520
/// `attributes` bag (the same precedence [`build_command_context_json`] uses).

src/phase_environment.rs

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

src/plugin.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,8 +454,16 @@ pub async fn handle_workflow_run_phase(request: WorkflowPhaseRunRequest) -> Resu
454454
// teardown — the daemon owns the lifecycle). Absent the broker env vars this
455455
// stays `None` (the byte-for-byte local path). A broker acquire failure fails
456456
// the phase and NEVER falls back to an owned prepare.
457+
// The run's target repo (the subject's `git_repo` custom field — the SAME
458+
// value a command phase renders as `{{git_repo}}`) rides the acquire spec's
459+
// `metadata.github_repo` so the environment plugin repo-scopes the minted
460+
// GitHub App installation token to THAT repo. The broker single-flights the
461+
// node per run, so this matters on the run's FIRST phase acquire; a bare run
462+
// without `git_repo` leaves the metadata untouched.
463+
let subject_git_repo =
464+
crate::phase_command::subject_git_repo(&project_root, "", &request.subject_id).await;
457465
let brokered_environment: Option<std::sync::Arc<crate::phase_environment::BrokeredEnvironment>> =
458-
match crate::phase_environment::BrokeredEnvironment::acquire_from_env().await {
466+
match crate::phase_environment::BrokeredEnvironment::acquire_from_env(subject_git_repo).await {
459467
Some(result) => Some(std::sync::Arc::new(result.with_context(|| {
460468
format!("failed to acquire brokered environment for phase '{}'", request.phase_id)
461469
})?)),

src/workflow_execute.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,16 @@ pub async fn execute_workflow_with_hub(
339339
// Preparation runs OFF the async runtime (the `EnvironmentClient` surface is
340340
// blocking); a prepare failure fails the run up front rather than executing
341341
// locally.
342+
//
343+
// The run's target repo (the subject's `git_repo` custom field — the SAME
344+
// value a command phase renders as `{{git_repo}}`) rides the prepared spec's
345+
// `metadata.github_repo` so the environment plugin repo-scopes the GitHub App
346+
// installation token to THAT repo (correct installation, least privilege). A
347+
// bare non-coding run without `git_repo` leaves the metadata untouched.
348+
let subject_git_repo =
349+
crate::phase_command::subject_git_repo(&params.project_root, &subject_kind_str, &subject_id_str).await;
342350
let brokered_environment: Option<std::sync::Arc<crate::phase_environment::BrokeredEnvironment>> =
343-
match crate::phase_environment::BrokeredEnvironment::acquire_from_env().await {
351+
match crate::phase_environment::BrokeredEnvironment::acquire_from_env(subject_git_repo.clone()).await {
344352
Some(result) => Some(std::sync::Arc::new(
345353
result
346354
.with_context(|| format!("failed to acquire brokered environment for workflow {}", workflow.id))?,
@@ -360,6 +368,7 @@ pub async fn execute_workflow_with_hub(
360368
let prepared = crate::phase_environment::PreparedEnvironment::prepare_off_runtime(
361369
Path::new(&params.project_root),
362370
&environment,
371+
subject_git_repo.clone(),
363372
)
364373
.await
365374
.with_context(|| {

0 commit comments

Comments
 (0)