diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index a90e7cfa..4e28c1a5 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1064,23 +1064,6 @@ const HOST_META_ROOT: &str = "/var/lib/hyperhive/meta"; /// Shared directory accessible to all agents. All agents bind-mount this RW. const HOST_SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; -/// Append bind flags for `child`'s state, harness, and config dirs into -/// `binds`. All three are RW so the parent can read/write state and -/// submit config-change requests. Creates missing host-side directories -/// so nspawn doesn't refuse to start; missing dirs are non-fatal. -fn bind_child_agent_dirs(child: &str, binds: &mut String) { - use std::fmt::Write as _; - let state_dir = format!("{HOST_AGENTS_ROOT}/{child}/state"); - let harness_dir = format!("{HOST_AGENTS_ROOT}/{child}/harness"); - let config_dir = format!("{HOST_AGENTS_ROOT}/{child}/config"); - for dir in [&state_dir, &harness_dir, &config_dir] { - let _ = std::fs::create_dir_all(dir); - } - let _ = write!(binds, " --bind={state_dir}:/agents/{child}/state"); - let _ = write!(binds, " --bind={harness_dir}:/agents/{child}/harness"); - let _ = write!(binds, " --bind={config_dir}:/agents/{child}/config"); -} - fn set_nspawn_flags( container: &str, runtime_dir: &Path, @@ -1114,17 +1097,21 @@ fn set_nspawn_flags( shared = HOST_SHARED_ROOT, ); - // Own state, harness, and config dirs — same for every agent including - // root. Config is RO: an agent must not edit its own config; changes - // only ever flow through the approval queue. - { + // Per-agent state + harness dirs. Skipped for the manager — + // the `/agents` bind below already exposes both (along with + // every sub-agent's). For regular agents the harness dir is + // the sibling of notes_dir (same parent, "harness" subdir). + if container != MANAGER_CONTAINER { let _ = write!( binds, " --bind={notes}:/agents/{agent_name}/state", notes = notes_dir.display(), ); - if let Some(state_parent) = notes_dir.parent() { - let harness_dir = state_parent.join("harness"); + // Harness dir: sibling of notes_dir under the agent state root. + // systemd-nspawn refuses to start when the bind source is missing; + // ensure_state_dir already creates it, but be defensive here. + if let Some(parent) = notes_dir.parent() { + let harness_dir = parent.join("harness"); if !harness_dir.exists() { let _ = std::fs::create_dir_all(&harness_dir); } @@ -1134,41 +1121,34 @@ fn set_nspawn_flags( harness = harness_dir.display(), ); } - let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); - std::fs::create_dir_all(&own_config) - .with_context(|| format!("create {own_config}"))?; - let _ = write!(binds, " --bind-ro={own_config}:/agents/{agent_name}/config"); } - - // Topology-driven child mounts: every direct child of this agent gets - // its state, harness, and config dirs bind-mounted RW so the parent - // can read state and manage config. - let direct_children = crate::topology::children_of(agent_name); - for child in &direct_children { - 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); - } - } + if container == MANAGER_CONTAINER { // systemd-nspawn refuses to start a container whose bind // source doesn't exist. The meta repo is created by the // startup migration, but make sure the directory is there - // before the role holder comes up in case set_nspawn_flags - // fires first (e.g. cold start with no agents). + // before the manager comes up in case set_nspawn_flags fires + // first (e.g. cold start with no agents). std::fs::create_dir_all(HOST_META_ROOT) .with_context(|| format!("create {HOST_META_ROOT}"))?; + // Manager edits sub-agent proposed/ repos and its own. RW so it can + // git-commit. Sub-agents see only their own /run/hive socket and + // /root/.claude (no /agents or /applied). + // + // /applied is a separate RO mount of the hive-c0re-only applied + // repos so the manager can `git fetch /applied//.git + // refs/tags/*:refs/tags/applied/*` to mirror deployed/failed/ + // denied tags into its proposed clones and diff against + // what's actually deployed. RO bind makes destructive git + // plumbing inside the container unable to corrupt applied. + // + // /meta is a third RO mount exposing the system-wide deploy + // flake (`git log /meta --oneline` shows every deploy across + // every agent; `cat /meta/flake.lock` resolves which sha each + // agent is pinned at right now). + let _ = write!( + binds, + " --bind={HOST_AGENTS_ROOT}:{CONTAINER_MANAGER_AGENTS_MOUNT}", + ); let _ = write!( binds, " --bind-ro={HOST_APPLIED_ROOT}:{CONTAINER_MANAGER_APPLIED_MOUNT}", @@ -1178,6 +1158,24 @@ fn set_nspawn_flags( " --bind-ro={HOST_META_ROOT}:{mount}", mount = crate::meta::CONTAINER_MANAGER_META_MOUNT, ); + } else { + // Sub-agents get a READ-ONLY view of their own proposed + // config repo at /agents//config — agent.nix plus + // whatever extra files the manager split the config into. + // Lets an agent inspect exactly what defines it and request + // precise changes from the manager. RO is load-bearing: the + // agent must NOT edit its own config — changes only ever flow + // through the manager's RW proposed repo + the approval + // queue. The manager already has this dir RW via the /agents + // tree bind above. + let config_dir = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); + // nspawn refuses to start when a bind source is missing. + // `setup_proposed` seeds this dir before spawn reaches here, + // but create defensively so a missing repo degrades to an + // empty RO dir instead of a container that won't boot. + std::fs::create_dir_all(&config_dir).with_context(|| format!("create {config_dir}"))?; + let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config"); + } // Web-socket subdir: bind-mount `/run/hive-agent//` into the // container so the harness can bind `web.sock` there and the host-side diff --git a/hive-c0re/src/topology.rs b/hive-c0re/src/topology.rs index d02d30bb..898bb433 100644 --- a/hive-c0re/src/topology.rs +++ b/hive-c0re/src/topology.rs @@ -4,10 +4,6 @@ //! 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. -//! //! Format, rationale, read/reconcile/inject/surface flow, and target //! enforcement semantics: `docs/agent-hierarchy.md::Current state`. //! `` sentinel resolution (delivered by [`resolve_recipient`]): @@ -74,26 +70,6 @@ pub fn children_of_in( .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. -/// -/// 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 top_level_agents() -> Vec { - top_level_agents_in(&read()) -} - -/// Pure form of [`top_level_agents`] for unit tests. -#[must_use] -pub fn top_level_agents_in(topo: &BTreeMap>) -> Vec { - topo.iter() - .filter_map(|(name, parent)| if parent.is_none() { Some(name.clone()) } else { None }) - .collect() -} - /// Resolve a magic recipient sentinel (currently just /// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at /// send time. Returns an owned `String` so callers can plug it @@ -302,118 +278,6 @@ pub fn reconcile(agent_names: &[String]) -> std::io::Result { if changed { write(¤t)?; } - // Keep roles in sync with the agent set. - reconcile_roles(agent_names)?; - Ok(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::meta::meta_dir().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> { - 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>) -> 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>, name: &str, role: &str) -> bool { - roles - .get(name) - .is_some_and(|rs| rs.iter().any(|r| r == role)) -} - -/// Grant or revoke a role for `name`. Idempotent — no disk write when the -/// state is already correct. -/// -/// Empty role lists are kept in the map (never removed). An absent key means -/// "never seen" (seed on next `reconcile_roles`); an empty list means -/// "explicitly revoked" (do not re-seed). Callers that want to remove an -/// agent from the map entirely should use `reconcile_roles` (agent departure). -pub fn set_role(name: &str, role: &str, enabled: bool) -> Result<(), String> { - let mut roles = read_roles(); - let list = roles.entry(name.to_owned()).or_default(); - let held = list.iter().any(|r| r == role); - match (enabled, held) { - (true, false) => list.push(role.to_owned()), - (false, true) => list.retain(|r| r != role), - _ => return Ok(()), - } - // Intentionally do NOT remove empty entries — an empty list signals an - // explicit revoke and prevents reconcile_roles from re-seeding the role. - write_roles(&roles).map_err(|e| format!("write roles.json: {e}")) -} - -/// Reconcile `roles.json` against the current agent set: -/// - Seeds root's default `can_manage_top_level_agents` role on first -/// appearance (operator can revoke with `set_role`). -/// - Drops entries for agents that no longer exist. -/// -/// Returns true when the file changed. -pub fn reconcile_roles(agent_names: &[String]) -> std::io::Result { - 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) } @@ -642,102 +506,4 @@ mod tests { let topo = topo_three_level(); assert!(children_of_in(&topo, "nobody").is_empty()); } - - #[test] - fn top_level_agents_in_returns_parentless_agents() { - 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(); - assert_eq!(top, vec![crate::lifecycle::MANAGER_NAME, "orphan"]); - } - - #[test] - fn top_level_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> = 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> = 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 = vec![mgr.to_owned(), "alice".to_owned()]; - let mut roles: BTreeMap> = 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 = vec![mgr.to_owned(), "alice".to_owned()]; - let roles: BTreeMap> = 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"); - } }