Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
9 changed files with 261 additions and 211 deletions

View file

@ -496,31 +496,19 @@ 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 `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`:
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`:
- `/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/agents/` → `/agents/` (RW) — all top-level
agents' proposed repos (not just direct children).
- `/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.
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.
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.
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 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 |
| `manage_root_agent` | may lifecycle-manage the root/manager agent via `kill`/`start`/`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` | mounts every agent's state (rw) + config (ro) into this agent's container so it can manage/recover any agent |
| `manage_root_agent` | allows the `set_status` / lifecycle tools on the root 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,9 +49,8 @@ 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 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 doesn't expose a new MCP tool: it gates
// existing lifecycle tools via topology enforcement instead.
"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.
//!
//! 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`.
//! 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.
//!
//! Format, rationale, read/reconcile/inject/surface flow, and target
//! enforcement semantics: `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`.
@ -77,24 +77,30 @@ pub fn children_of_in(topo: &BTreeMap<String, Option<String>>, name: &str) -> Ve
.collect()
}
/// 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.
/// 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.
///
/// 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.
/// In normal operation this is just the manager, but any agent the
/// operator explicitly places outside the hierarchy is also included.
#[must_use]
pub fn all_agents() -> Vec<String> {
all_agents_in(&read())
pub fn top_level_agents() -> Vec<String> {
top_level_agents_in(&read())
}
/// Pure form of [`all_agents`] for unit tests.
/// Pure form of [`top_level_agents`] for unit tests.
#[must_use]
pub fn all_agents_in(topo: &BTreeMap<String, Option<String>>) -> Vec<String> {
topo.keys().cloned().collect()
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()
}
/// Resolve a magic recipient sentinel (currently just
@ -264,13 +270,16 @@ 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 — that happens when the container actually spawns
/// and the name moves into `agent_names`.
/// default parent here and NOT added to roles — 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)
}
@ -309,6 +318,95 @@ 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::*;
@ -580,32 +678,120 @@ mod tests {
}
#[test]
fn all_agents_in_returns_every_name() {
fn top_level_agents_in_returns_parentless_agents() {
let topo = topo_three_level();
let mut all = all_agents_in(&topo);
all.sort();
let mut expected = vec![crate::lifecycle::MANAGER_NAME, "alice", "bob", "carol"];
// 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"];
expected.sort_unstable();
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"]);
assert_eq!(top, expected);
}
#[test]
fn all_agents_in_empty_topo_returns_empty() {
fn top_level_agents_in_empty_topo_returns_empty() {
let topo = BTreeMap::new();
assert!(all_agents_in(&topo).is_empty());
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");
}
}

View file

@ -6,7 +6,6 @@ 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};
@ -324,28 +323,18 @@ async fn set_nspawn_flags(
bind_child_agent_dirs(child, &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);
// `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);
}
}
// 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 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.
/// 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.
async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
for (path, name) in [
(crate::topology::topology_path(), "topology.json"),
@ -69,6 +69,7 @@ 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?;
@ -221,6 +222,7 @@ 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,10 +94,6 @@ 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
@ -188,68 +184,6 @@ 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.
@ -541,48 +475,9 @@ fn submit_boot_tree(
#[cfg(test)]
mod tests {
use super::{BootAction, boot_action, manager_seed_caps, should_seed_manager_caps};
use super::{BootAction, boot_action};
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,20 +367,11 @@ impl ToolGroup {
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Capability {
/// 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.
/// 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.
ManageRootAgent,
/// Agent can read the full host journal via `GetHostJournal`.
/// hive-c0re checks this capability before running journalctl.
@ -399,7 +390,7 @@ impl Capability {
pub fn description(self) -> &'static str {
match self {
Self::ManageRootAgent => {
"manage any agent: every agent's state (rw) and config (ro) mounted for recovery"
"lifecycle-manage the root/manager agent on hive crash recovery"
}
Self::ReadHostJournal => "read host journald via get_host_journal MCP tool",
}