diff --git a/docs/agent-lifecycle/approvals.md b/docs/agent-lifecycle/approvals.md index 19afec2b..0ec41bb6 100644 --- a/docs/agent-lifecycle/approvals.md +++ b/docs/agent-lifecycle/approvals.md @@ -515,6 +515,13 @@ dirs. revoking this capability does not change any mount until that agent's container is rebuilt/restarted. +The root agent gets the capability by default, seeded on its auto-deploy +path (`workers::auto_update::ensure_root_agent`) so the recovery mounts +are there from its first container. That seed only fires while +`capabilities.json` has never been written: any grant or revoke through +the dashboard creates the file, so a revoked root-agent grant stays +revoked and is not re-applied on the next hive-c0re restart. + Each proposed repo (`/agents//config/`) is pre-configured with `applied` as a git remote pointing at `/applied//.git`. Useful incantations from inside an agent with diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index 03ee16b0..05c2a848 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -94,6 +94,10 @@ pub async fn ensure_root_agent(coord: &Arc) -> Result<()> { ); return Ok(()); } + // Before the create/start branch, not inside it: on a hive whose root + // container already exists this is the only run that can still seed the + // grant, and it has to land before her next rebuild bakes the binds. + seed_manager_capabilities(); let existing = lifecycle::list().await.unwrap_or_default(); let current_rev = current_flake_rev(&coord.hyperhive_flake); if existing @@ -184,6 +188,68 @@ fn seed_manager_tool_groups() { } } +/// The capability the root agent is seeded with, as the `snake_case` string +/// `capabilities.json` stores and `capabilities::has_cap` compares against. +/// Taken from the enum rather than written out, so the seed cannot drift into +/// a name `capabilities::prune_unknown` would silently drop. +fn manager_seed_caps() -> Vec { + vec![<&str>::from(hive_sh4re::permissions::Capability::ManageRootAgent).to_owned()] +} + +/// Whether the root agent's default capability grant should be written. +/// +/// `store_written` is "`capabilities.json` exists" — see +/// [`seed_manager_capabilities`] for why that, and not the absence of an +/// entry for the manager, is the condition. +fn should_seed_manager_caps(store_written: bool) -> bool { + !store_written +} + +/// Give ruth the `ManageRootAgent` capability on the path that deploys her. +/// +/// This is the capability-store half of the grant `roles.json` used to make: +/// `topology::reconcile_roles` seeded `can_manage_top_level_agents` onto +/// `MANAGER_NAME` on every meta sync, and that role is what put the other +/// agents' state/config dirs, `/applied` and `/meta` into her nspawn binds. +/// #4477 collapsed the role into the capability, so without a seed here she +/// loses those recovery mounts at her next container rebuild — silently, and +/// only then, because nspawn bakes bind flags at container start. +/// +/// **Seeded once, not re-ensured on every boot.** The role kept an empty-list +/// tombstone so an explicit revoke stuck; the capability store *deletes* an +/// entry that has been emptied rather than tombstoning it, so "the manager +/// has no entry" cannot tell a fresh hive apart from a deliberate revoke. +/// File existence can: every grant and revoke goes through +/// `meta::commit_capabilities` → `capabilities::set_caps` → `write`, which +/// writes the file even when the result is an empty `{}`. So while the file +/// is absent nobody has ever had a say, and once it exists this is inert +/// forever — including on the destroy+recreate path, matching +/// [`seed_manager_tool_groups`]'s refusal to reset an operator's choice. +/// +/// Written with plain `set_caps` rather than `meta::commit_capabilities`: the +/// role's own seed wrote the file and left it for `stage_generated_meta_files` +/// to `git add` into the next deploy commit, and taking `META_LOCK` on the +/// boot path would be a new ordering constraint for no gain. +fn seed_manager_capabilities() { + if !should_seed_manager_caps(crate::capabilities::capabilities_path().exists()) { + tracing::debug!( + "capabilities.json already written — leaving the manager's capabilities as-is" + ); + return; + } + let caps = manager_seed_caps(); + match crate::capabilities::set_caps(MANAGER_NAME, &caps) { + Ok(()) => tracing::info!( + caps = ?caps, + "seeded ruth's default capabilities (recovery bind mounts for every agent)" + ), + Err(e) => tracing::warn!( + error = ?e, + "failed to seed ruth's capabilities — she will come up without the recovery mounts" + ), + } +} + /// Sort `names` in-place so parents precede their children in the topology. /// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last, /// alphabetically within their tier. Stable within each depth tier. @@ -475,9 +541,48 @@ fn submit_boot_tree( #[cfg(test)] mod tests { - use super::{BootAction, boot_action}; + use super::{BootAction, boot_action, manager_seed_caps, should_seed_manager_caps}; use crate::power::Wanted; + // ----------------------------------------------------------------------- + // Root-agent capability seed. `capabilities_path()` resolves under + // `paths::meta_root()`, which is hardcoded to `/var/lib/hyperhive` with no + // test override, so — same as `capabilities.rs`'s own tests — these pin + // the pure decision (`should_seed_manager_caps`, `manager_seed_caps`) + // rather than round-tripping the real file. Together they are the + // replacement for the deleted `reconcile_roles_in_seeds_root_when_absent` + // and `reconcile_roles_in_does_not_reseed_after_explicit_revoke`. + + #[test] + fn the_manager_is_seeded_while_the_capability_store_has_never_been_written() { + assert!(should_seed_manager_caps(false)); + assert_eq!(manager_seed_caps(), vec!["manage_root_agent".to_owned()]); + } + + /// The revoke-sticks half, and the reason the condition is the file rather + /// than the manager's entry: the store deletes an emptied entry instead of + /// keeping a tombstone, but a revoke still writes the file — so once it + /// exists the seed must never fire again. + #[test] + fn a_written_capability_store_is_never_reseeded() { + assert!(!should_seed_manager_caps(true)); + } + + /// A seed the store would drop as unrecognised grants nothing while + /// logging success, so pin that what we write is a name `prune_unknown` + /// keeps — i.e. one of `Capability::ALL`'s `snake_case` spellings. + #[test] + fn the_seeded_name_is_a_capability_the_store_recognises() { + for cap in manager_seed_caps() { + assert!( + hive_sh4re::permissions::Capability::ALL + .iter() + .any(|known| <&str>::from(*known) == cap), + "{cap} is not a known capability" + ); + } + } + // The regression the `Unreadable` variant exists for: the arm used to // substitute `from_running(running)`, which is `Noop` against BOTH // observations — so no combination of inputs could reach a reconcile.