//! Per-container host-side config: the nspawn conf rewrite (bind mounts, //! network isolation, forwarded credentials), the systemd resource-limits //! drop-in, and the `write_dropins` verb that re-applies both. use std::path::Path; use anyhow::{Context, Result}; use hive_sh4re::priv_proto::{BindMount, CredentialMount}; use crate::coordinator::{AgentPaths, HiveEnv}; use super::{ AGENT_PREFIX, CONTAINER_RUNTIME_MOUNT, CONTAINER_SHARED_MOUNT, agent_uid_gid, bridge_gateway_ip, container_claude_mount, container_name, validate, }; /// Re-apply the per-container host-side config: nspawn flags (bind /// mounts etc.), the systemd resource-limits drop-in, and a daemon /// reload so both take effect on the next unit (re)start. Idempotent — /// the job queue's `WriteDropin` node, also folded into every `Swap` /// (rebuild is the reconcile verb). pub async fn write_dropins(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> { validate(name)?; let container = container_name(name); set_nspawn_flags( &container, &paths.agent_dir, &paths.claude_dir, &paths.notes_dir, ) .await?; set_resource_limits(&container, &hive.agent_cpu_quota, &hive.agent_memory_max).await?; systemd_daemon_reload().await } /// Write a systemd drop-in for `container@.service` that applies /// our default resource caps. Goes under `/run/systemd/system/...` so it's /// ephemeral (regenerated on every spawn / rebuild). async fn set_resource_limits(container: &str, cpu_quota: &str, memory_max: &str) -> Result<()> { crate::priv_client::write_resource_limits(container, memory_max, cpu_quota).await } async fn systemd_daemon_reload() -> Result<()> { crate::priv_client::daemon_reload().await } /// Idempotently rewrite the lines in `/etc/nixos-containers/.conf` /// that hive-c0re owns: `PRIVATE_NETWORK` (forced 0 so the agent's web UI port /// is reachable on the host) and `EXTRA_NSPAWN_FLAGS` (the runtime-dir bind). /// The start script expands `$EXTRA_NSPAWN_FLAGS` unquoted into the /// `systemd-nspawn` command. /// Where in the container's filesystem the manager sees its agents tree. /// Matches the `/agents` path that pre-Phase-8 hosts declared via /// `containers.root.bindMounts."/agents"`. pub const CONTAINER_MANAGER_AGENTS_MOUNT: &str = "/agents"; /// Where the manager sees the applied trees of every agent, read-only. /// Manager runs `git fetch /applied//.git refs/tags/*:refs/tags/applied/*` /// to learn what hive-c0re deployed (or rejected, or failed to /// build); the RO bind makes accidental writes impossible from /// inside the container. pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; /// Append bind flags for `child`'s state, harness, and config dirs into /// `binds`, all read-write. The RW on `state` is deliberate (recovery), /// not an oversight; see docs/persistence.md ("Parent access to child /// state") for the rationale. 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 Vec) { let child_root = crate::paths::agent_state_dir(child); for sub in ["state", "harness", "config"] { let host = child_root.join(sub); let _ = std::fs::create_dir_all(&host); binds.push(BindMount { host_path: host.to_string_lossy().into_owned(), container_path: format!("/agents/{child}/{sub}"), read_only: false, }); } } /// Hive-wide secrets forwarded into every agent container via nspawn /// `--load-credential=:`. Currently just the OTEL /// auth-header secret, when `services.hyperhive.otel.headersCredential` /// is set (surfaced as `HYPERHIVE_OTEL_HEADERS_CREDENTIAL` on hive-c0re's /// unit env — the same host option meta.rs reads to inject /// `hyperhive.otel.headersCredential`). The inner harness unit reads it /// via `LoadCredential=otel-headers` (inherit). The secret never lands in /// a bind mount, the nix store, or the generated config. /// /// A configured-but-missing file is skipped with a warning rather than /// forwarded (nspawn would refuse to start the container otherwise): a /// host-level secret typo shouldn't take down every agent's start; OTEL /// just exports without the auth header until the file appears. fn hive_load_credentials() -> Vec { let mut out = Vec::new(); let Ok(path) = std::env::var("HYPERHIVE_OTEL_HEADERS_CREDENTIAL") else { return out; }; if path.is_empty() { return out; } if std::path::Path::new(&path).is_file() { out.push(CredentialMount { name: "otel-headers".to_owned(), host_path: path, }); } else { tracing::warn!( %path, "HYPERHIVE_OTEL_HEADERS_CREDENTIAL is set but the file is missing; \ skipping --load-credential (OTEL will export without the auth header)" ); } out } #[allow( clippy::too_many_lines, reason = "one contiguous nspawn-flag assembly block; the length is the flag \ surface itself, splitting it would just hide the shape" )] async fn set_nspawn_flags( container: &str, runtime_dir: &Path, claude_dir: &Path, notes_dir: &Path, ) -> Result<()> { // Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist. let shared_root = crate::paths::shared_root(); std::fs::create_dir_all(&shared_root) .with_context(|| format!("create {}", shared_root.display()))?; // Make /shared writable by every agent. Containers share host uids (no // PrivateUsers), but each agent is a distinct unix user, so a root-owned // 0755 dir leaves them unable to write — the documented "read/write for // all agents" contract was broken. A setgid group would need a // pinned GID declared in every container plus all agent users joined to // it (cross-container coordination + a rebuild cascade); instead we use // the /tmp model — sticky world-writable (1777). The sticky bit lets any // agent create files while protecting each agent's entries from deletion // by the others, and matches /shared's documented "free-for-all, may be // deleted/lost" semantics without touching any per-agent config. { use std::os::unix::fs::PermissionsExt as _; let perms = std::fs::Permissions::from_mode(0o1777); std::fs::set_permissions(&shared_root, perms) .with_context(|| format!("chmod 1777 {}", shared_root.display()))?; } // Ensure /knowledge dir exists. It may be empty until forge seeds it; // nspawn refuses to start if the bind source is missing entirely. std::fs::create_dir_all(crate::knowledge::LOCAL_DIR) .with_context(|| format!("create {}", crate::knowledge::LOCAL_DIR))?; // Logical agent name — strip the `h-` prefix. // For the manager: `h-ruth` → `ruth`. For sub-agents: `h-iris` → `iris`. let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container); // Claude credentials land at `/home//.claude` so the // `claude` CLI (which reads `$HOME/.claude`) finds them. The // harness service's environment sets `HOME` to the same path // (`agent-base.nix` / `manager.nix`), so no `--setenv` plumbing // is needed here — the bind alone is enough. let claude_mount = container_claude_mount(agent_name); // Hive-wide secrets forwarded into the container's credential store // (currently just the OTEL auth-header). Same for every agent. let load_creds = hive_load_credentials(); let mut binds: Vec = vec![ BindMount { host_path: runtime_dir.to_string_lossy().into_owned(), container_path: CONTAINER_RUNTIME_MOUNT.to_owned(), read_only: false, }, BindMount { host_path: claude_dir.to_string_lossy().into_owned(), container_path: claude_mount, read_only: false, }, BindMount { host_path: shared_root.to_string_lossy().into_owned(), container_path: CONTAINER_SHARED_MOUNT.to_owned(), read_only: false, }, BindMount { host_path: crate::knowledge::LOCAL_DIR.to_owned(), container_path: crate::knowledge::CONTAINER_MOUNT.to_owned(), read_only: true, }, ]; // Own state, harness, and config dirs — same for every agent including // the manager. Config is RO: an agent must not edit its own config; changes // only ever flow through the approval queue. binds.push(BindMount { host_path: notes_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/state"), read_only: false, }); if let Some(state_parent) = notes_dir.parent() { let harness_dir = state_parent.join("harness"); if !harness_dir.exists() { let _ = std::fs::create_dir_all(&harness_dir); } binds.push(BindMount { host_path: harness_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/harness"), read_only: false, }); } let own_config = crate::paths::agent_state_dir(agent_name).join("config"); std::fs::create_dir_all(&own_config) .with_context(|| format!("create {}", own_config.display()))?; binds.push(BindMount { host_path: own_config.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/config"), read_only: true, }); // Topology-driven child mounts: every direct child of this agent gets // its state, harness, and config dirs bind-mounted RW (parent reads + // writes child state for recovery, and manages config). See // `bind_child_agent_dirs`. 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); } } // 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). let meta_root = crate::paths::meta_root(); std::fs::create_dir_all(&meta_root) .with_context(|| format!("create {}", meta_root.display()))?; binds.push(BindMount { host_path: crate::paths::applied_root().to_string_lossy().into_owned(), container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(), read_only: true, }); binds.push(BindMount { host_path: meta_root.to_string_lossy().into_owned(), container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(), read_only: true, }); } // Web-socket subdir: bind-mount `/run/hive-agent//` into the // container so the harness can bind `web.sock` there and the host-side // gateway sees it. Subdir bind (not socket file) keeps the inode // visible after the harness unlinks a stale socket on rebind. // Applies to manager and sub-agents alike. let socket_dir = crate::agent_sockets::agent_dir_for(agent_name); std::fs::create_dir_all(&socket_dir) .with_context(|| format!("create {}", socket_dir.display()))?; // Chown to the agent user so the non-root harness can bind(2) here. // Falls back to 0777 on first spawn when uid lookup returns None // (container /etc/passwd not yet rendered). if let Some((uid, gid)) = agent_uid_gid(agent_name) { if let Err(e) = crate::priv_client::chown_socket_dir(agent_name, uid, gid).await { tracing::warn!(%agent_name, error = ?e, "chown socket dir failed"); } } else if let Err(e) = crate::priv_client::chmod_socket_dir(agent_name, 0o777).await { tracing::warn!(%agent_name, error = ?e, "chmod socket dir failed"); } binds.push(BindMount { host_path: socket_dir.to_string_lossy().into_owned(), container_path: socket_dir.to_string_lossy().into_owned(), read_only: false, }); // Network isolation: when HIVE_NETWORK_ISOLATION=1 is set (by the // hive-network.nix module's `isolateContainers` option), flip the // container to a private network namespace with a veth pair attached // to the host bridge. Applies to all containers including the manager // (all hive-c0re<->agent comms go through bind-mounted UDS, not TCP). let isolation = { let isolate = std::env::var("HIVE_NETWORK_ISOLATION").ok().as_deref() == Some("1"); let bridge = std::env::var("HIVE_NETWORK_BRIDGE").unwrap_or_default(); let subnet = std::env::var("HIVE_NETWORK_SUBNET").unwrap_or_default(); if isolate && !bridge.is_empty() && !subnet.is_empty() { let Some(gateway_ip) = bridge_gateway_ip(&subnet) else { tracing::warn!( %agent_name, %subnet, "HIVE_NETWORK_SUBNET is set but the bridge gateway IP is unparseable; \ skipping PRIVATE_NETWORK write to avoid an isolated container with no \ default route or resolver" ); return crate::priv_client::write_nspawn_flags( container, &binds, None, &load_creds, ) .await; }; tracing::info!( %agent_name, %gateway_ip, %bridge, "network isolation: PRIVATE_NETWORK=1 (DHCP)" ); Some(hive_sh4re::priv_proto::NetworkIsolation { bridge, gateway_ip }) } else { None } }; // Delegate the actual conf-file rewrite to hive-priv (runs as root). crate::priv_client::write_nspawn_flags(container, &binds, isolation, &load_creds).await }