Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
eec908bdb7 fix tracker-tag lint and vale prose reds on #4624
Rewrite two bare issue references (#4472, #4477) as self-standing
prose per check-issue-refs.sh's requirement — no markdown exemption,
hash-number tags are dead weight to a public forge-mirror reader.

Apply iris's vale fix to docs/agent-lifecycle/approvals.md (passive
voice, two contractions, one auto- hyphenation).
2026-09-21 19:04:14 +02:00
atlas
1c53dc1935 seed the root agent's ManageRootAgent capability on its deploy path
`roles.json` had a seeding path: `topology::reconcile_roles` granted
`can_manage_top_level_agents` to MANAGER_NAME on every meta sync, and that
role is what put every agent's state/config dir, `/applied` and `/meta` into
the root agent's nspawn binds. Collapsing the role into the capability left
the capability store with no equivalent, so the root agent would have lost
its recovery mounts at its next container rebuild — silently, and only then,
since nspawn bakes bind flags at container start.

Seed it in `ensure_root_agent`, before the create/start branch so a hive
whose root container already exists still gets the grant. Seeded once rather
than re-ensured every boot: the role kept an empty-list tombstone so a revoke
stuck, and the capability store deletes an emptied entry instead, so "no
entry for the manager" cannot tell a fresh hive from a deliberate revoke.
File existence can — every grant and revoke writes capabilities.json, even
when the result is `{}` — so the seed fires only while the store has never
been written, and is inert forever after.

The target is `lifecycle::MANAGER_NAME`, the same const the deleted role seed
used and the one definition of "the root agent" in the tree; no name literal
at the seeding site. The written string comes from
`Capability::ManageRootAgent` via `IntoStaticStr` rather than being spelled
out, so it cannot drift into a name `prune_unknown` would drop.
2026-09-21 19:04:14 +02:00
atlas
4f6407fdea collapse the roles.json mount grant into the ManageRootAgent capability
The hive had two spellings of "this agent may act on agents that aren't
its children": the `ManageRootAgent` capability, which nothing checked,
and a `can_manage_top_level_agents` role in a third meta store,
`roles.json`, which owned the real grant — the bind mounts that put
another agent's state (rw) and config (ro) inside the holder's
container. The two drifted independently, and with the parent/child
hierarchy removed the role's set (`parent.is_none()`) silently became
every agent while nothing said so.

Collapse them. The mount grant now hangs off
`Capability::ManageRootAgent`, looked up through the one capability
path that already exists (`capabilities::has_cap` over
`capabilities.json`) rather than a second mechanism. `roles.json` and
everything that read, wrote or reconciled it is gone, along with its
`meta.rs` staging and commit-label wiring; nothing in the tree reads
that file any more.

The enum variant keeps its name deliberately. Renaming it would turn
every `manage_root_agent` already stored in `capabilities.json` into an
unrecognised name that `prune_unknown` drops without asking. Its
meaning, not its spelling, is what changed: "may manage any agent". The
doc comment and the description string now say that.

`top_level_agents()`/`top_level_agents_in()` are replaced by
`all_agents()`/`all_agents_in()`. Under "manage any agent" the mounted
set is every agent by definition, so the code states it instead of
deriving it from a predicate that no longer discriminates — and the
call-site comment explains that, because it otherwise reads as a
widening. The holder is no longer bound as its own virtual child: that
reproduced the own-state and own-config mounts exactly, so dropping it
loses nothing.
2026-09-21 19:04:14 +02:00
9 changed files with 211 additions and 261 deletions

View file

@ -496,19 +496,31 @@ forge into its own state dir, commit on a branch, open a PR**, and let
the operator review and approve it. By design, no second, mount-shaped
path reaches the same file without the review.
Agents holding the `can_manage_top_level_agents` topology role (see
`hive-c0re/src/agent_config/topology.rs`) get additional host-side
bind mounts via `set_nspawn_flags`:
Agents holding the `manage_root_agent` capability (granted per agent in
`capabilities.json`; see `hive-sh4re/src/permissions.rs`) get additional
host-side bind mounts via `set_nspawn_flags`:
- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — all top-level
agents' proposed repos (not just direct children).
- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's
proposed repo, not just direct children. The capability means "may
manage any agent", so the mounted set is every agent.
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
authoritative applied repo, including `.git`.
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide
deploy flake.
The root agent holds this role; a sub-manager that only manages a
subtree doesn't, and only has its direct children's config dirs.
An agent without the capability only has its direct children's config
dirs.
⚠️ nspawn bakes bind flags at container start, so granting or
revoking this capability doesn't change any mount until that agent's
container is rebuilt/restarted.
The root agent gets the capability by default, seeded on its autodeploy
path (`workers::auto_update::ensure_root_agent`) so the recovery mounts
are there from its first container. That seed only fires while
`capabilities.json` doesn't exist yet: any grant or revoke through
the dashboard creates the file, so a revoked root-agent grant stays
revoked and isn't re-applied on the next hive-c0re restart.
Each proposed repo (`/agents/<n>/config/`) is pre-configured
with `applied` as a git remote pointing at

View file

@ -362,7 +362,7 @@ that allows the underlying resource access.
| Capability | Effect |
|---|---|
| `manage_root_agent` | may lifecycle-manage the root/manager agent via `kill`/`start`/`restart` |
| `manage_root_agent` | may manage *any* agent: bind-mounts every agent's state (rw) + config (ro) into the holder's container, plus `/applied` and `/meta` (ro). Takes effect on the holder's next container rebuild/restart |
| `read_host_journal` | registers the `get_host_journal` MCP tool + serves `GET /journal-host` requests |
**Config storage** — per-agent capabilities live in

View file

@ -440,7 +440,7 @@ The current capabilities are:
| Name | Effect |
|------|--------|
| `manage_root_agent` | allows the `set_status` / lifecycle tools on the root agent |
| `manage_root_agent` | mounts every agent's state (rw) + config (ro) into this agent's container so it can manage/recover any agent |
| `read_host_journal` | unlocks `get_host_journal` to read journald from inside a container |
Each row is one agent. Columns are the capability names returned by

View file

@ -49,8 +49,9 @@ fn allowed_capability_tools() -> Vec<String> {
let t = token.trim().to_ascii_lowercase();
match t.as_str() {
"read_host_journal" => tools.push("get_host_journal".to_owned()),
// manage_root_agent doesn't expose a new MCP tool: it gates
// existing lifecycle tools via topology enforcement instead.
// manage_root_agent exposes no MCP tool at all: it is a
// host-side grant, enforced by hive-c0re when it computes the
// container's bind mounts. Nothing for the harness to register.
"manage_root_agent" => {}
unknown => {
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");

View file

@ -4,9 +4,9 @@
//! alongside the meta `flake.nix`, so topology changes thread through
//! the same git commit log as deploys.
//!
//! Agent roles are stored alongside in `roles.json` as a flat map of
//! `name → [role, ...]`. Roles gate additional bind-mount grants; see
//! `lifecycle::set_nspawn_flags` for the consumer.
//! Broader-than-your-own-children bind-mount grants are **not** stored
//! here: they hang off the `ManageRootAgent` capability in
//! `capabilities.json`. See `lifecycle::set_nspawn_flags`.
//!
//! Format, rationale, read/reconcile/inject/surface flow, and target
//! enforcement semantics: `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`.
@ -77,30 +77,24 @@ pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Ve
.collect()
}
/// Return every agent that has no parent in the topology. These are the
/// "top-level" agents a `can_manage_top_level_agents` role holder is
/// granted access to. No agent name is hardcoded — the set is derived
/// purely from topology structure.
/// Every agent the topology knows about, in name order. This is the set
/// a [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets
/// bind-mounted, and it is deliberately unfiltered: that capability means
/// "may manage any agent", so the set is all of them.
///
/// In normal operation this is just the manager, but any agent the
/// operator explicitly places outside the hierarchy is also included.
/// It replaces a `top_level_agents()` that selected `parent.is_none()`.
/// With the hierarchy removed every agent is parentless, so the old
/// predicate already matched everything — keeping it would have hidden an
/// all-agents grant behind a filter that no longer filters.
#[must_use]
pub fn top_level_agents() -> Vec<String> {
top_level_agents_in(&read())
pub fn all_agents() -> Vec<String> {
all_agents_in(&read())
}
/// Pure form of [`top_level_agents`] for unit tests.
/// Pure form of [`all_agents`] for unit tests.
#[must_use]
pub fn top_level_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
topo.iter()
.filter_map(|(name, parent)| {
if parent.is_none() {
Some(name.clone())
} else {
None
}
})
.collect()
pub fn all_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
topo.keys().cloned().collect()
}
/// Resolve a magic recipient sentinel (currently just
@ -270,16 +264,13 @@ pub fn apply_set_parent(
/// but no container yet (provisioned, not yet spawned). They are KEPT
/// (not dropped) so an explicit parent edge written before the first
/// spawn survives until the first apply-commit, but they are NOT seeded with a
/// default parent here and NOT added to roles — that happens when the
/// container actually spawns and the name moves into `agent_names`.
/// default parent here — that happens when the container actually spawns
/// and the name moves into `agent_names`.
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
let (next, changed) = apply_reconcile(&read(), agent_names, pending);
if changed {
write(&next)?;
}
// Keep roles in sync with the live agent set (pending agents get
// roles when they spawn).
reconcile_roles(agent_names)?;
Ok(changed)
}
@ -318,95 +309,6 @@ pub fn apply_reconcile(
(next, changed)
}
// ---------------------------------------------------------------------------
// Roles
// ---------------------------------------------------------------------------
/// Agents with this role have every parentless agent in the topology
/// (see `top_level_agents`) added as virtual children for bind-mount
/// and config-change purposes. Enables recovery: if a top-level agent
/// is down, a role holder can still read its state and update its config.
///
/// The manager receives this role by default on first `reconcile_roles`
/// call; operators can revoke it with `set_role`.
pub const ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS: &str = "can_manage_top_level_agents";
const ROLES_FILE: &str = "roles.json";
#[must_use]
pub fn roles_path() -> std::path::PathBuf {
crate::paths::meta_root().join(ROLES_FILE)
}
/// Read the roles map from disk. Returns an empty map when absent or
/// unparsable — same safe-degradation pattern as `topology::read`.
#[must_use]
pub fn read_roles() -> BTreeMap<String, Vec<String>> {
let Ok(raw) = std::fs::read_to_string(roles_path()) else {
return BTreeMap::new();
};
serde_json::from_str(&raw).unwrap_or_default()
}
/// Persist the roles map. Sorted output keeps git diffs minimal.
pub fn write_roles(roles: &BTreeMap<String, Vec<String>>) -> std::io::Result<()> {
let path = roles_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string_pretty(roles)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&path, format!("{text}\n"))
}
/// Return true when `name` holds `role`.
#[must_use]
pub fn has_role(name: &str, role: &str) -> bool {
has_role_in(&read_roles(), name, role)
}
/// Pure form of [`has_role`] for unit tests.
#[must_use]
pub fn has_role_in(roles: &BTreeMap<String, Vec<String>>, name: &str, role: &str) -> bool {
roles
.get(name)
.is_some_and(|rs| rs.iter().any(|r| r == role))
}
/// Reconcile `roles.json` against the current agent set:
/// - Seeds root's default `can_manage_top_level_agents` role on first
/// appearance.
/// - Drops entries for agents that no longer exist.
///
/// Returns true when the file changed.
pub fn reconcile_roles(agent_names: &[String]) -> std::io::Result<bool> {
let mut roles = read_roles();
let mut changed = false;
let root = crate::lifecycle::MANAGER_NAME;
if agent_names.iter().any(|n| n == root) && !roles.contains_key(root) {
roles.insert(
root.to_owned(),
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
);
changed = true;
}
let known: std::collections::HashSet<_> = agent_names.iter().collect();
roles.retain(|name, _| {
let keep = known.contains(name);
if !keep {
changed = true;
}
keep
});
if changed {
write_roles(&roles)?;
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
@ -678,120 +580,32 @@ mod tests {
}
#[test]
fn top_level_agents_in_returns_parentless_agents() {
fn all_agents_in_returns_every_name() {
let topo = topo_three_level();
// Only the manager has no parent (alice/bob/carol all have parents).
let top = top_level_agents_in(&topo);
assert_eq!(top, vec![crate::lifecycle::MANAGER_NAME]);
}
#[test]
fn top_level_agents_in_multi_root_returns_all_parentless() {
let mut topo = topo_three_level();
// Simulate a second parentless agent alongside the manager.
topo.insert("orphan".to_owned(), None);
let mut top = top_level_agents_in(&topo);
top.sort();
let mut expected = vec![crate::lifecycle::MANAGER_NAME, "orphan"];
let mut all = all_agents_in(&topo);
all.sort();
let mut expected = vec![crate::lifecycle::MANAGER_NAME, "alice", "bob", "carol"];
expected.sort_unstable();
assert_eq!(top, expected);
assert_eq!(all, expected);
}
/// The set behind the `ManageRootAgent` mount grant must not depend on
/// `parent`: a capability holder manages an agent whether or not that
/// agent sits under someone. This is the assertion the old
/// `top_level_agents_in` could not have made.
#[test]
fn all_agents_in_includes_parented_agents() {
let mut topo = BTreeMap::new();
topo.insert("alice".to_owned(), Some("bob".to_owned()));
topo.insert("bob".to_owned(), None);
let mut all = all_agents_in(&topo);
all.sort();
assert_eq!(all, vec!["alice", "bob"]);
}
#[test]
fn top_level_agents_in_empty_topo_returns_empty() {
fn all_agents_in_empty_topo_returns_empty() {
let topo = BTreeMap::new();
assert!(top_level_agents_in(&topo).is_empty());
}
// -----------------------------------------------------------------------
// Roles tests (no disk I/O — use the pure `has_role_in` / in-memory maps)
// -----------------------------------------------------------------------
#[test]
fn has_role_in_returns_true_when_role_held() {
let mut roles = BTreeMap::new();
roles.insert(
"alice".to_owned(),
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
);
assert!(has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
#[test]
fn has_role_in_returns_false_for_absent_agent() {
let roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
assert!(!has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
#[test]
fn has_role_in_returns_false_for_empty_list() {
let mut roles = BTreeMap::new();
roles.insert("alice".to_owned(), vec![]);
assert!(!has_role_in(
&roles,
"alice",
ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS
));
}
/// Revoking a role must leave the key present with an empty list so
/// `reconcile_roles` does not re-seed it.
#[test]
fn set_role_revoke_keeps_empty_entry_as_tombstone() {
let mgr = crate::lifecycle::MANAGER_NAME;
// Build an in-memory roles map as set_role would see it after granting.
let mut roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
roles.insert(
mgr.to_owned(),
vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()],
);
// Simulate the revoke path of set_role (in-memory, no disk).
let list = roles.entry(mgr.to_owned()).or_default();
let held = list.iter().any(|r| r == ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS);
assert!(held);
list.retain(|r| r != ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS);
// Key must still be present (tombstone), just with an empty list.
assert!(roles.contains_key(mgr), "empty entry must not be removed");
assert!(roles[mgr].is_empty());
}
/// `reconcile_roles` must not re-seed the manager when its entry exists
/// but is empty (operator explicitly revoked the role).
#[test]
fn reconcile_roles_in_does_not_reseed_after_explicit_revoke() {
let mgr = crate::lifecycle::MANAGER_NAME;
let agent_names = [mgr.to_owned(), "alice".to_owned()];
let mut roles: BTreeMap<String, Vec<String>> = BTreeMap::new();
// Tombstone: manager was seen before but all roles were revoked.
roles.insert(mgr.to_owned(), vec![]);
let mgr_present = agent_names.iter().any(|n| n == mgr);
let should_seed = mgr_present && !roles.contains_key(mgr);
// should_seed must be false because manager key is present (tombstone).
assert!(
!should_seed,
"reconcile_roles must not re-seed an explicit revoke"
);
}
/// `reconcile_roles` seeds the manager on first appearance (no prior entry).
#[test]
fn reconcile_roles_in_seeds_root_when_absent() {
let mgr = crate::lifecycle::MANAGER_NAME;
let agent_names = [mgr.to_owned(), "alice".to_owned()];
let roles: BTreeMap<String, Vec<String>> = BTreeMap::new(); // empty
let should_seed = agent_names.iter().any(|n| n == mgr) && !roles.contains_key(mgr);
assert!(should_seed, "reconcile_roles must seed manager when absent");
assert!(all_agents_in(&topo).is_empty());
}
}

View file

@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_priv_sock::{BindMount, CredentialMount};
use hive_sh4re::permissions::Capability;
use crate::coordinator::{AgentPaths, HiveEnv};
@ -323,18 +324,28 @@ async fn set_nspawn_flags(
bind_child_agent_dirs(child, &mut binds);
}
// `can_manage_top_level_agents` role: additionally mount every
// parentless agent in the topology as a virtual child. Enables
// recovery — a role holder can update those agents' configs even
// when they are down. Also grants RO access to /applied and /meta.
if crate::topology::has_role(
agent_name,
crate::topology::ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS,
) {
let top_level = crate::topology::top_level_agents();
for tl in &top_level {
if !direct_children.contains(tl) {
bind_child_agent_dirs(tl, &mut binds);
// `ManageRootAgent` capability: additionally mount *every* agent in
// the hive as a virtual child. Enables recovery — the holder can
// update another agent's config even when that agent is down. Also
// grants RO access to /applied and /meta.
//
// ⚠️ "every agent" reads as a widening next to the `children_of`
// mounts above, so: it is the definition of this capability, not an
// accident of how the set is computed. The grant used to hang off a
// `can_manage_top_level_agents` role and cover `top_level_agents()`
// — i.e. `parent.is_none()` — which was "everything outside the
// hierarchy". With that hierarchy removed, every agent is
// parentless, so that set *was* every agent anyway; the capability
// now says so out loud instead of deriving it from a field that no
// longer discriminates.
if crate::capabilities::has_cap(agent_name, Capability::ManageRootAgent) {
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
// `agent_state_dir/state` and `config_bind_source` is shared, so
// binding the holder as its own virtual child reproduced the two
// own-dir mounts pushed above, byte for byte.
for other in crate::topology::all_agents() {
if other != agent_name && !direct_children.contains(&other) {
bind_child_agent_dirs(&other, &mut binds);
}
}
// systemd-nspawn refuses to start a container whose bind

View file

@ -51,12 +51,12 @@ pub struct AgentSpec {
/// Stage every generated meta JSON file that exists: topology.json is
/// regenerated by `reconcile` whenever the agent set changed;
/// tool-groups/capabilities/resource-limits/roles are created lazily on
/// first write (`set_groups`/`set_caps`/`set_limits`/role assignment) —
/// absent means every agent is on defaults, no file needed. Without
/// staging, an existing-but-untracked file (e.g. roles.json) shows up as
/// untracked in the meta repo, which can confuse nix's dirty-tree fetch.
/// `git add` is a no-op when content is unchanged.
/// tool-groups/capabilities/resource-limits are created lazily on
/// first write (`set_groups`/`set_caps`/`set_limits`) — absent means
/// every agent is on defaults, no file needed. Without staging, an
/// existing-but-untracked file shows up as untracked in the meta repo,
/// which can confuse nix's dirty-tree fetch. `git add` is a no-op when
/// content is unchanged.
async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
for (path, name) in [
(crate::topology::topology_path(), "topology.json"),
@ -69,7 +69,6 @@ async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
crate::resource_limits::resource_limits_path(),
"resource-limits.json",
),
(crate::topology::roles_path(), "roles.json"),
] {
if path.exists() {
git(dir, &["add", name]).await?;
@ -222,7 +221,6 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
"capabilities.json" => Some("capabilities"),
"resource-limits.json" => Some("resource-limits"),
"tool-groups.json" => Some("tool-groups"),
"roles.json" => Some("roles"),
_ => None,
})
.collect();

View file

@ -94,6 +94,10 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> 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<String> {
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.
/// Collapsing the role into the capability means that 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.

View file

@ -367,11 +367,20 @@ impl ToolGroup {
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Capability {
/// Agent can lifecycle-manage the root agent (kill/start/restart)
/// on behalf of the hive when the root has crashed. Named capability
/// for the existing manager privilege — future topology enforcement
/// will gate this via the capability system instead of the hardcoded
/// `container == MANAGER_CONTAINER` check.
/// Agent may manage *any* agent in the hive, not just its own
/// children. Concretely, hive-c0re bind-mounts every agent's state
/// dir (RW) and config dir (RO) into the holder's container, plus
/// `/applied` and `/meta` read-only, so the holder can recover an
/// agent that is down. See `lifecycle::host_config::set_nspawn_flags`.
///
/// The name is historical: it was minted when the hive had a parent/
/// child topology and "manage the root agent" was the widest grant
/// there was. With the hierarchy removed there is no privileged root
/// to single out, so the same grant now means "manage any agent".
/// Kept under the old spelling deliberately — renaming it would turn
/// every `manage_root_agent` already stored in `capabilities.json`
/// into an unrecognised name that `capabilities::prune_unknown`
/// silently drops.
ManageRootAgent,
/// Agent can read the full host journal via `GetHostJournal`.
/// hive-c0re checks this capability before running journalctl.
@ -390,7 +399,7 @@ impl Capability {
pub fn description(self) -> &'static str {
match self {
Self::ManageRootAgent => {
"lifecycle-manage the root/manager agent on hive crash recovery"
"manage any agent: every agent's state (rw) and config (ro) mounted for recovery"
}
Self::ReadHostJournal => "read host journald via get_host_journal MCP tool",
}