diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 89ca413d..dfcec6c9 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, fast_forward_applied_main, - fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access, + ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, 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 8a00d8c4..74a7252e 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -593,66 +593,6 @@ 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 92bd448b..740b3c15 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -545,42 +545,9 @@ 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) => validate_agent_names(list), + 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?; - // 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 // lock), rather than fanning out child DAGs. `relock = false` — the @@ -891,20 +858,15 @@ 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 = parse_agent_input_names(inputs); + 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 mut names = if touched_hyperhive || inputs.is_empty() { crate::lifecycle::list() .await @@ -922,86 +884,3 @@ 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"]); - } -}