From f0ddbe49d0a2a74824e998b2b8029982c2d6959d Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 13 Sep 2026 16:14:11 +0200 Subject: [PATCH 1/3] hive-c0re: pull each meta-update cascade agent's own config-repo main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara (hyperhive#4271): "whatever is on main is trusted and should be pulled. dont make it periodic, just add it to the meta update when choosing the agent." A meta-input bump previously rebuilt every affected agent against whatever applied/ was already locked to. Normally current, but silently stale forever if a past deploy failed and nothing since retried it — the manual meta-input trigger never special-cased that either, since nothing wired it to check the config repo's live main at all. run_meta_lock now relocks each cascade agent's own input alongside the originally-requested ones, against the input's declared source (the forge URL) rather than the local applied/ mirror prepare_deploy uses for a reviewed deploy — there's no PR to review on this path, so nothing to gate. One combined lock_update call for the whole cascade: simpler than per-agent isolation, at the cost of one broken/unreachable agent repo failing the whole cascade relock rather than just that agent (the separate top-level input bump above it is unaffected). Known gap, not fixed here: applied/'s own main/deployed/* tags never advance from this path, only a real MergeConfigPr deploy does that — so the audit trail stays as accurate as today, it just stops being what actually got built. --- hive-c0re/src/job_queue/exec.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 740b3c15..067684a5 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -548,6 +548,23 @@ async fn run_meta_lock( Some(list) => list, None => meta_update_cascade_agents(inputs).await, }; + // Pull each cascade agent's own input too — an agent's config-repo main + // is trusted, so a meta-input bump is a reasonable place to also catch + // it up. Without this, a meta-input bump rebuilds every affected agent + // against whatever `applied/` + // already happened to be locked to — normally current, but silently + // stale forever if a past deploy failed and nothing since retried it. + // Relocks against each agent's *declared* input (the forge URL, not the + // local `applied/` mirror `prepare_deploy` uses for a reviewed + // deploy) — this path has no PR to review, so there's nothing to gate. + // One combined call, not per-agent: simpler, at the cost of one + // unreachable/broken agent repo failing the whole cascade relock rather + // than just that agent. + if !cascade.is_empty() { + let agent_inputs: Vec = + cascade.iter().map(|name| format!("agent-{name}")).collect(); + crate::meta::lock_update(&agent_inputs).await?; + } // Grow one rebuild subgraph per affected agent into *this* meta-update // DAG (rooted on this `MetaLock`, so they build against the post-bump // lock), rather than fanning out child DAGs. `relock = false` — the From c45d679a322aa4ae829d7e50a7938639d606c1c7 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 13 Sep 2026 16:24:34 +0200 Subject: [PATCH 2/3] hive-c0re: fast-forward applied//main too, not just the one-shot relock argus + mara (PR #4339 review): the previous commit's per-agent lock relock is a one-shot effect on the single rebuild the cascade triggers - applied/ never moves, so the next relock=true rebuild trigger (the boot sweep, most notably) re-locks against applied/ and reverts straight back to whatever it was stuck on. The fix didn't outlive the transaction it ran in. New forge::fast_forward_applied_main(name), sibling to the existing reseed-only fetch_config_main_into_applied: for an applied repo that already has a .git and just needs to catch up, force-set rather than fast-forward-gated since there's no PR to review on this path either. Called per cascade agent right after the relock, best-effort so one unreachable agent repo doesn't block the others. Once applied//main has actually moved, lock_update_for_rebuild's override (always reads current applied//main, no ?rev pin) naturally stays in sync on any later relock=true rebuild instead of reverting. --- hive-c0re/src/forge/mod.rs | 4 +-- hive-c0re/src/forge/repos.rs | 60 +++++++++++++++++++++++++++++++++ hive-c0re/src/job_queue/exec.rs | 34 ++++++++++++++----- 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index dfcec6c9..89ca413d 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -18,8 +18,8 @@ pub use pr_merge::{ pub use reconcile::{reconcile_config_apply, reconcile_config_status}; pub use repos::{ clone_config_into_proposed, create_agent_repo, ensure_config_repo, ensure_knowledge_repo, - ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fetch_config_main_into_applied, - meta_read_access, push_config, push_meta, shared_docs_access, + ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main, + fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access, }; pub use users::{core_token, ensure_user_for, provision_user_token}; diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index 74a7252e..8a00d8c4 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -593,6 +593,66 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool { true } +/// Fast-forward `applied//main` to the config repo's current live +/// main. Unlike [`fetch_config_main_into_applied`] (reseed-only — bails +/// if `applied` already has a `.git`), this is for an `applied` repo that +/// already exists and just needs to catch up: force-sets the local ref to +/// match live main rather than requiring a fast-forward or any review +/// gate — there is no PR to review on this path, so nothing to check +/// ancestry against. Contrast the approval-deploy flow's CAS'd, +/// ancestry-gated advance for a reviewed PR merge, which this is not. +/// +/// Best-effort, same shape as [`fetch_config_main_into_applied`]: returns +/// `false` (not an error) when the forge is absent, the core token isn't +/// minted, `applied` has no `.git` yet (nothing to fast-forward — reseed +/// instead), or any git step fails. +pub async fn fast_forward_applied_main(name: &str) -> bool { + if !is_present().await { + return false; + } + let Some(token) = core_token() else { + return false; + }; + let applied = crate::paths::applied_dir(name); + if !applied.join(".git").exists() { + return false; + } + let url = forge_git_url(&format!("{CONFIG_ORG}/{name}")); + let auth = core_auth_header(&token); + let out = crate::lifecycle::git_command_authed(&auth) + .current_dir(&applied) + .args(["fetch", "--no-tags", &url, "refs/heads/main"]) + .output() + .await; + match out { + Ok(o) if o.status.success() => {} + Ok(o) => { + tracing::warn!( + %name, + stderr = %String::from_utf8_lossy(&o.stderr).trim(), + "forge: applied fast-forward fetch failed" + ); + return false; + } + Err(e) => { + tracing::warn!(%name, error = ?e, "forge: applied fast-forward fetch failed"); + return false; + } + } + if let Err(e) = + crate::lifecycle::git_update_ref(&applied, "refs/heads/main", "FETCH_HEAD").await + { + tracing::warn!(%name, error = ?e, "forge: applied fast-forward update-ref failed"); + return false; + } + if let Err(e) = crate::lifecycle::git_read_tree_reset(&applied, "refs/heads/main").await { + tracing::warn!(%name, error = ?e, "forge: applied fast-forward read-tree failed"); + return false; + } + tracing::info!(%name, "forge: fast-forwarded applied/main to agent-configs main"); + true +} + /// Seed an agent's `proposed` repo from `agent-configs/` on the forge. /// /// When the swarm creates an agent it writes that agent's config to the forge diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 067684a5..3b4c099a 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -551,19 +551,35 @@ async fn run_meta_lock( // Pull each cascade agent's own input too — an agent's config-repo main // is trusted, so a meta-input bump is a reasonable place to also catch // it up. Without this, a meta-input bump rebuilds every affected agent - // against whatever `applied/` - // already happened to be locked to — normally current, but silently - // stale forever if a past deploy failed and nothing since retried it. - // Relocks against each agent's *declared* input (the forge URL, not the - // local `applied/` mirror `prepare_deploy` uses for a reviewed - // deploy) — this path has no PR to review, so there's nothing to gate. - // One combined call, not per-agent: simpler, at the cost of one - // unreachable/broken agent repo failing the whole cascade relock rather - // than just that agent. + // against whatever `applied/` already happened to be locked to — + // normally current, but silently stale forever if a past deploy failed + // and nothing since retried it. Relocks against each agent's *declared* + // input (the forge URL, not the local `applied/` mirror + // `prepare_deploy` uses for a reviewed deploy) — this path has no PR to + // review, so there's nothing to gate. One combined call, not per-agent: + // simpler, at the cost of one unreachable/broken agent repo failing the + // whole cascade relock rather than just that agent. if !cascade.is_empty() { let agent_inputs: Vec = cascade.iter().map(|name| format!("agent-{name}")).collect(); crate::meta::lock_update(&agent_inputs).await?; + // The relock above is a one-shot effect on *this* rebuild only — + // `applied/` itself hasn't moved, so the next `relock = true` + // rebuild trigger (the boot sweep, most notably) re-locks against + // `applied/` and reverts straight back to whatever it was + // stuck on. Fast-forward each cascade agent's own applied mirror + // too so a later rebuild through a different trigger doesn't undo + // this one. Best-effort per agent (unlike the relock above): one + // agent's forge repo being briefly unreachable here shouldn't be + // fatal to the cascade that already relocked its input above. + for name in &cascade { + if !crate::forge::fast_forward_applied_main(name).await { + tracing::warn!( + %name, + "meta-update cascade: applied/main fast-forward skipped or failed" + ); + } + } } // Grow one rebuild subgraph per affected agent into *this* meta-update // DAG (rooted on this `MetaLock`, so they build against the post-bump From 95898338fc044a546e402a0e46d6b486c31e8212 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 13 Sep 2026 16:41:57 +0200 Subject: [PATCH 3/3] hive-c0re: validate cascade agent names in meta_update_cascade_agents' fanout path too extract validate_agent_names() and use it for both the parsed agent- inputs and run_meta_lock's pre-computed fanout list, so a malformed name can't reach the new fast_forward_applied_main / lock_update filesystem+git+forge-URL operations regardless of which of the two sources it came from --- hive-c0re/src/job_queue/exec.rs | 100 ++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 6 deletions(-) diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 3b4c099a..92bd448b 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -545,7 +545,7 @@ async fn run_meta_lock( // Lock file changed — meta-inputs panel re-renders. crate::dashboard::emit_meta_inputs_snapshot(coord); let cascade = match fanout { - Some(list) => list, + Some(list) => validate_agent_names(list), None => meta_update_cascade_agents(inputs).await, }; // Pull each cascade agent's own input too — an agent's config-repo main @@ -891,15 +891,20 @@ async fn run_deploy_tail( /// `inputs` or any input under `hyperhive` → every container; /// otherwise just the agents named by `agent-` inputs. /// Topology-sorted so parents rebuild before their children. +/// +/// `inputs` is the caller-supplied flake-input-name list +/// (`RequestUpdateMetaInputs`'s `inputs` field, operator-approved but not +/// otherwise validated) — the `agent-` branch parses agent names +/// straight out of it, so each is validated through [`hive_types::Ident`] +/// before it ever reaches a filesystem path or a forge URL built from a +/// cascade agent's name. The `touched_hyperhive` branch's names need no +/// such filter: they come from `lifecycle::list()`, already real +/// container names by construction, not parsed out of caller input. pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec { let touched_hyperhive = inputs .iter() .any(|i| i == "hyperhive" || i.starts_with("hyperhive/")); - let touched_agents: Vec = inputs - .iter() - .filter_map(|i| i.strip_prefix("agent-")) - .map(|rest| rest.split('/').next().unwrap_or(rest).to_owned()) - .collect(); + let touched_agents = parse_agent_input_names(inputs); let mut names = if touched_hyperhive || inputs.is_empty() { crate::lifecycle::list() .await @@ -917,3 +922,86 @@ pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec { crate::auto_update::topology_sort(&mut names, &topo); names } + +/// Parse `agent-` input strings into validated agent names. Pure and +/// synchronous (no `lifecycle::list()` call) so the validation — the part +/// that actually matters for safety, since these names end up in +/// filesystem paths and forge URLs — is unit-testable without touching +/// live container state. Delegates the actual filtering to +/// [`validate_agent_names`] after stripping the `agent-` prefix. +fn parse_agent_input_names(inputs: &[String]) -> Vec { + let candidates = inputs + .iter() + .filter_map(|i| i.strip_prefix("agent-")) + .map(|rest| rest.split('/').next().unwrap_or(rest).to_owned()); + validate_agent_names(candidates) +} + +/// Drop any name that isn't a well-formed [`hive_types::Ident`] — the same +/// validating parser every other agent-name wire field in this codebase +/// goes through, so a name that reaches +/// [`fast_forward_applied_main`](crate::forge::fast_forward_applied_main) or +/// a `git+http://.../agent-configs/.git` URL built off it has already +/// passed the same bar as one that arrived over a socket. Not just a +/// helper for [`parse_agent_input_names`]: `run_meta_lock`'s `fanout` +/// parameter carries pre-computed agent names too, and while today's only +/// caller of that branch (the boot sweep, `sweep = true`) returns before +/// ever reaching the filesystem/git code below, nothing in the type +/// enforces that pairing — so this same filter also guards the +/// `Some(list)` arm, closing the gap for good rather than relying on it +/// staying incidental. +fn validate_agent_names(names: impl IntoIterator) -> Vec { + names + .into_iter() + .filter(|name| { + let valid = hive_types::Ident::parse(name).is_ok(); + if !valid { + tracing::warn!(%name, "meta-update cascade: dropping malformed agent name"); + } + valid + }) + .collect() +} + +#[cfg(test)] +mod cascade_agent_tests { + use super::{parse_agent_input_names, validate_agent_names}; + + #[test] + fn well_formed_agent_inputs_pass_through() { + let inputs = vec!["agent-iris".to_owned(), "agent-atlas".to_owned()]; + assert_eq!(parse_agent_input_names(&inputs), vec!["iris", "atlas"]); + } + + #[test] + fn path_traversal_in_an_agent_input_is_dropped() { + let inputs = vec!["agent-../../etc".to_owned(), "agent-iris".to_owned()]; + assert_eq!(parse_agent_input_names(&inputs), vec!["iris"]); + } + + #[test] + fn non_agent_inputs_are_ignored_not_misparsed() { + let inputs = vec!["hyperhive".to_owned(), "nixpkgs".to_owned()]; + assert!(parse_agent_input_names(&inputs).is_empty()); + } + + #[test] + fn oversized_or_uppercase_names_are_dropped() { + let too_long = format!("agent-{}", "a".repeat(64)); + let inputs = vec![too_long, "agent-Iris".to_owned(), "agent-iris".to_owned()]; + assert_eq!(parse_agent_input_names(&inputs), vec!["iris"]); + } + + #[test] + fn fanout_supplied_names_are_validated_too() { + // `run_meta_lock`'s `Some(list)` arm feeds `fanout` straight into + // this filter — pin that a bogus name in that pre-computed list + // gets dropped exactly like a parsed `agent-` input would. + let names = vec![ + "iris".to_owned(), + "../../etc".to_owned(), + "atlas".to_owned(), + ]; + assert_eq!(validate_agent_names(names), vec!["iris", "atlas"]); + } +}