From 3ee87d394c9119512c1c5e92b23c993371190abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Mon, 6 Jul 2026 21:05:52 +0200 Subject: [PATCH] refactor(hive-c0re): split lifecycle into submodules mod.rs keeps the container verbs + priv_run plumbing; git helpers, repo/dir setup, and host drop-in config move to their own files --- hive-c0re/src/lifecycle/git.rs | 207 ++++++ hive-c0re/src/lifecycle/host_config.rs | 367 ++++++++++ hive-c0re/src/lifecycle/mod.rs | 965 +------------------------ hive-c0re/src/lifecycle/setup.rs | 251 +++++++ hive-c0re/src/lifecycle/tests.rs | 155 ++++ 5 files changed, 998 insertions(+), 947 deletions(-) create mode 100644 hive-c0re/src/lifecycle/git.rs create mode 100644 hive-c0re/src/lifecycle/host_config.rs create mode 100644 hive-c0re/src/lifecycle/setup.rs create mode 100644 hive-c0re/src/lifecycle/tests.rs diff --git a/hive-c0re/src/lifecycle/git.rs b/hive-c0re/src/lifecycle/git.rs new file mode 100644 index 00000000..66567779 --- /dev/null +++ b/hive-c0re/src/lifecycle/git.rs @@ -0,0 +1,207 @@ +//! Git shellout helpers for the per-agent proposed/applied repos: run +//! `git` with the hive-c0re identity, resolve/plant refs and tags, and +//! fetch proposal commits into the applied repo. + +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use tokio::process::Command; + +const GIT_NAME: &str = "c0re"; +const GIT_EMAIL: &str = "c0re@hyperhive.local"; + +/// Return the SHA of the root (oldest, no-parent) commit in a repo. +/// Used to seed the applied repo at the template baseline rather than at +/// `main`, so the first `ApplyCommit` diff shows the manager's real changes. +pub(super) async fn git_root_commit(dir: &Path) -> Result { + let out = git_command() + .current_dir(dir) + .args(["rev-list", "--max-parents=0", "HEAD"]) + .output() + .await + .with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?; + if !out.status.success() { + anyhow::bail!( + "git rev-list --max-parents=0 failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> { + git( + dir, + &[ + "-c", + &format!("user.name={GIT_NAME}"), + "-c", + &format!("user.email={GIT_EMAIL}"), + "commit", + "-m", + message, + ], + ) + .await +} + +/// Spawn `git` honoring the `HYPERHIVE_GIT` env var (absolute path baked in +/// by the NixOS module), falling back to bare `git` (PATH lookup) otherwise. +#[must_use] +pub fn git_command() -> Command { + let exe = std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "git".into()); + Command::new(exe) +} + +pub async fn git(dir: &Path, args: &[&str]) -> Result<()> { + let out = git_command() + .current_dir(dir) + .args(args) + .output() + .await + .with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; + if !out.status.success() { + bail!( + "git {} failed ({}): {}", + args.join(" "), + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +/// Fetch the commit `sha` from the `src` git repo into `dst` and pin +/// it as `refs/tags/`. Used at `request_apply_commit` time so +/// hive-c0re captures an immutable handle on the manager's commit; +/// subsequent amendments / force-pushes in `src` no longer affect +/// what gets built. Returns the resolved full sha. +/// +/// `sha` must be a commit sha (short or full) — the caller +/// (`submit_apply_commit`) shape-checks it first. We resolve it +/// LOCALLY against `src` rather than asking the remote to resolve +/// it: `git fetch :` treats the left side as a +/// remote *ref name*, and a bare sha is not one ("couldn't find +/// remote ref ..."). Fetching by sha would need a full 40-hex sha +/// plus `uploadpack.allow*SHA1InWant` on the remote, which the +/// proposed repos don't set. hive-c0re has direct read access to +/// `src`, so a local `rev-parse` + a branch-glob fetch sidesteps +/// the whole sha-want negotiation. +pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result { + let src_str = src.display().to_string(); + // Resolve the (short-or-full) sha to a full sha against the + // source repo. The `^{commit}` peel + non-zero exit on a missing + // object means a typo'd / stale sha fails loudly right here. + let full = git_rev_parse(src, &format!("{sha}^{{commit}}")) + .await + .with_context(|| format!("commit '{sha}' not found in proposed repo {src_str}"))?; + // Bring src's objects into dst. Fetching every head pulls the + // wanted commit's history (always reachable from a branch in the + // manager's flow) into dst's object db without sha-want. + git( + dst, + &[ + "fetch", + "--no-tags", + &src_str, + "+refs/heads/*:refs/remotes/proposal-src/*", + ], + ) + .await?; + // Pin the exact commit as the proposal tag. The objects are now + // local so this resolves without touching the remote. + git(dst, &["tag", tag, &full]).await.with_context(|| { + format!("tag {tag} at {full}: commit not reachable from any branch in proposed repo") + })?; + Ok(full) +} + +/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha. +pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result { + let out = git_command() + .current_dir(dir) + .args(["rev-parse", refname]) + .output() + .await + .with_context(|| format!("git rev-parse {refname} in {}", dir.display()))?; + if !out.status.success() { + bail!( + "git rev-parse {refname} failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// Plant a lightweight tag at `target`. Errors if the tag already +/// exists — we want loud failures on id reuse, not silent +/// overwrites. +pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> { + git(dir, &["tag", name, target]).await +} + +/// Plant an annotated tag with `body` as the message. Used for +/// `failed/` (body = build error) and `denied/` (body = +/// operator note). Multi-line bodies handled via stdin so we don't +/// have to escape anything. +pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> { + use tokio::io::AsyncWriteExt; + // Annotated tags are git objects, so they need a tagger identity + // (same constraint as a commit). Pass the hive-c0re identity + // inline rather than relying on a global git config — applied + // repos are hive-c0re-owned and the host's user might not have + // user.email set. + let mut child = git_command() + .current_dir(dir) + .args([ + "-c", + &format!("user.name={GIT_NAME}"), + "-c", + &format!("user.email={GIT_EMAIL}"), + "tag", + "-a", + name, + target, + "-F", + "-", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .with_context(|| format!("spawn git tag -a {name} in {}", dir.display()))?; + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(body.as_bytes()) + .await + .context("write tag body to git stdin")?; + // Drop closes stdin so git can finish reading. + drop(stdin); + } + let out = child.wait_with_output().await.context("wait git tag -a")?; + if !out.status.success() { + bail!( + "git tag -a {name} failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +/// Replace working tree + index with the tree at `target` without +/// moving HEAD. `applied/main` stays pointing at the last known-good +/// `deployed/*` while we let `nixos-container update` evaluate the +/// candidate. On build failure callers reset back to HEAD; on +/// success they fast-forward main to `target`. +pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> { + git(dir, &["read-tree", "--reset", "-u", target]).await +} + +/// Hard-set a ref to `target`. Used to fast-forward `refs/heads/main` +/// to the just-deployed proposal commit. Uses `update-ref`, not +/// `branch -f`, so it works regardless of where HEAD currently sits. +pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> { + git(dir, &["update-ref", refname, target]).await +} diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs new file mode 100644 index 00000000..f7ae69b9 --- /dev/null +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -0,0 +1,367 @@ +//! 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_network_ip, 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"; + +/// The on-host root that gets bind-mounted to `/agents` inside the manager. +/// Hard-coded to match `AGENT_STATE_ROOT` in coordinator.rs (kept duplicated +/// here so lifecycle stays usable as a leaf module). +pub(super) const HOST_AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; + +/// On-host applied repo root, mirrored RO into the manager. Matches +/// `APPLIED_STATE_ROOT` in coordinator.rs. +const HOST_APPLIED_ROOT: &str = "/var/lib/hyperhive/applied"; + +/// On-host meta repo root, mirrored RO into the manager. Matches +/// `meta::meta_dir()` but duplicated here so lifecycle stays a leaf. +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 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 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); + } + binds.push(BindMount { + host_path: state_dir, + container_path: format!("/agents/{child}/state"), + read_only: false, + }); + binds.push(BindMount { + host_path: harness_dir, + container_path: format!("/agents/{child}/harness"), + read_only: false, + }); + binds.push(BindMount { + host_path: config_dir, + container_path: format!("/agents/{child}/config"), + 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. + std::fs::create_dir_all(HOST_SHARED_ROOT) + .with_context(|| format!("create {HOST_SHARED_ROOT}"))?; + // 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(HOST_SHARED_ROOT, perms) + .with_context(|| format!("chmod 1777 {HOST_SHARED_ROOT}"))?; + } + // 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: HOST_SHARED_ROOT.to_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 = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); + std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?; + binds.push(BindMount { + host_path: own_config, + 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). + std::fs::create_dir_all(HOST_META_ROOT) + .with_context(|| format!("create {HOST_META_ROOT}"))?; + binds.push(BindMount { + host_path: HOST_APPLIED_ROOT.to_owned(), + container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(), + read_only: true, + }); + binds.push(BindMount { + host_path: HOST_META_ROOT.to_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(agent_ip) = agent_network_ip(agent_name, &subnet) else { + tracing::warn!( + %agent_name, %subnet, + "HIVE_NETWORK_SUBNET is set but could not derive a valid IP for agent \ + (bad CIDR? prefix too narrow?); skipping PRIVATE_NETWORK write to \ + avoid misconfigured isolation" + ); + return crate::priv_client::write_nspawn_flags( + container, + &binds, + None, + &load_creds, + ) + .await; + }; + 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, %agent_ip, %gateway_ip, %bridge, + "network isolation: PRIVATE_NETWORK=1" + ); + Some(hive_sh4re::priv_proto::NetworkIsolation { + agent_ip, + 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 +} diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 4102a839..7097aed4 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -1,9 +1,26 @@ //! `nixos-container` lifecycle + per-agent config flake generation. +mod git; +mod host_config; +mod setup; +#[cfg(test)] +mod tests; + +pub use git::{ + git, git_command, git_fetch_to_tag, git_read_tree_reset, git_rev_parse, git_tag, + git_tag_annotated, git_update_ref, +}; +pub use host_config::{ + CONTAINER_MANAGER_AGENTS_MOUNT, CONTAINER_MANAGER_APPLIED_MOUNT, write_dropins, +}; +pub use setup::{ + ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix, + setup_applied, setup_proposed, +}; + use std::path::Path; use anyhow::{Context, Result, bail}; -use hive_sh4re::priv_proto::{BindMount, CredentialMount}; use tokio::process::Command; use crate::coordinator::{AgentPaths, HiveEnv}; @@ -43,9 +60,6 @@ pub fn container_claude_mount(name: &str) -> String { /// willing to lose (other agents may delete them). pub const CONTAINER_SHARED_MOUNT: &str = "/shared"; -const GIT_NAME: &str = "c0re"; -const GIT_EMAIL: &str = "c0re@hyperhive.local"; - /// Sub-agent web UI port range. Deterministic from the agent's name (FNV-1a /// hash mod range size), so the dashboard can compute the same port without /// asking hive-c0re. @@ -290,25 +304,6 @@ pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> priv_run("create", name).await } -/// 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 -} - /// Rebuild-path preamble shared by the job queue's `Prebuild` node and /// `rebuild_no_meta`: fail fast on a port collision, then make sure /// the applied repo + state dirs exist. Container untouched. @@ -772,774 +767,6 @@ pub async fn list() -> Result> { .collect()) } -/// Initialize the manager-editable proposed repo. Seeds two tracked -/// files: `agent.nix` (the module the manager edits) and `flake.nix` -/// (the boilerplate that lets the meta flake import this repo as an -/// input — meta locks at a specific sha and reads -/// `nixosModules.default`, so `flake.nix` must be in the commit). The -/// manager shouldn't edit `flake.nix` (the prompt says so) but it's -/// visible so they can introspect. -/// -/// Touched by hive-c0re only on first spawn — never again — so the -/// manager can't be surprised by hive-c0re commits or working-tree -/// resets. -pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { - let fresh = !proposed_dir.join(".git").exists(); - if fresh { - std::fs::create_dir_all(proposed_dir) - .with_context(|| format!("create {}", proposed_dir.display()))?; - let agent_path = proposed_dir.join("agent.nix"); - if !agent_path.exists() { - std::fs::write(&agent_path, initial_agent_nix(name)) - .with_context(|| format!("write {}", agent_path.display()))?; - } - let flake_path = proposed_dir.join("flake.nix"); - if !flake_path.exists() { - std::fs::write(&flake_path, initial_flake_nix()) - .with_context(|| format!("write {}", flake_path.display()))?; - } - git(proposed_dir, &["init", "--initial-branch=main"]).await?; - git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?; - git_commit(proposed_dir, "hive-c0re init").await?; - } - // Idempotently wire the `applied` remote — purely for the - // manager's ergonomics. The URL is the path inside the manager - // container (`/applied//.git`), where the RO bind in - // `set_nspawn_flags` makes it real. hive-c0re itself never - // dereferences this remote; the host-side fetch in - // `request_apply_commit` uses absolute host paths. - ensure_applied_remote(proposed_dir, name).await -} - -async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> { - let want = format!("/applied/{name}/.git"); - let existing = git_command() - .current_dir(proposed_dir) - .args(["remote", "get-url", "applied"]) - .output() - .await - .with_context(|| format!("git remote get-url applied in {}", proposed_dir.display()))?; - if existing.status.success() { - let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned(); - if current == want { - return Ok(()); - } - // URL drifted (path scheme changed, etc.) — re-point it. - return git(proposed_dir, &["remote", "set-url", "applied", &want]).await; - } - git(proposed_dir, &["remote", "add", "applied", &want]).await -} - -/// Set up the applied repo. First-spawn only: init the repo, pull -/// proposed's initial commit in via `git fetch`, tag it `deployed/0`. -/// This is the *only* time hive-c0re reads from `proposed` for an -/// agent — subsequent proposals are fetched at `request_apply_commit` -/// time and tagged `proposal/` (see `actions::approve` for the -/// tag state machine). -/// -/// `proposed_dir` is `None` on rebuild paths where the repo already -/// exists — we just verify it's the right shape and bail otherwise. -/// Unlike the pre-overhaul code path, `flake.nix` is no longer -/// regenerated at the host level: it's tracked in proposed (seeded by -/// `setup_proposed`) and rides along on every fetch. -pub async fn setup_applied( - applied_dir: &Path, - proposed_dir: Option<&Path>, - name: &str, -) -> Result<()> { - std::fs::create_dir_all(applied_dir) - .with_context(|| format!("create {}", applied_dir.display()))?; - - if !applied_dir.join(".git").exists() { - let Some(proposed) = proposed_dir else { - bail!( - "applied repo at {} is missing its .git directory; \ - cannot rebuild without a proposed source to seed from. \ - destroy --purge and re-spawn this agent.", - applied_dir.display() - ); - }; - git(applied_dir, &["init", "--initial-branch=main"]).await?; - let proposed_str = proposed.display().to_string(); - // Seed the applied repo at the root (template) commit of proposed, - // not at `main`. This ensures `deployed/0` is the template baseline - // so the first ApplyCommit diff shows the manager's real changes - // rather than an empty diff (which happens when the manager has - // already committed their config and proposed/main == proposal/). - let root_sha = git_root_commit(proposed).await?; - git( - applied_dir, - // --update-head-ok lets us fetch into refs/heads/main while - // HEAD still points there. git's default safeguard refuses - // to avoid index/working-tree desync, but the working tree - // is empty (we just `init`'d) and we read-tree-reset right - // after, so the safeguard is moot here. - &[ - "fetch", - "--no-tags", - "--update-head-ok", - &proposed_str, - &format!("{root_sha}:refs/heads/main"), - ], - ) - .await?; - git_read_tree_reset(applied_dir, "refs/heads/main").await?; - git_tag(applied_dir, "deployed/0", "refs/heads/main").await?; - } else if git_rev_parse(applied_dir, "refs/tags/deployed/0") - .await - .is_err() - { - // Pre-overhaul applied repo — no deployed/* tag scheme, - // flake.nix may be untracked, agent.nix possibly authored by - // hive-c0re directly. The startup auto-migration fixes this - // in place; if it didn't run (or got skipped), surface a - // clear error. - bail!( - "applied repo at {} predates the meta-flake layout. \ - Restart hive-c0re to let the auto-migration run, or \ - destroy --purge {name} and re-spawn.", - applied_dir.display() - ); - } - Ok(()) -} - -/// Create the per-agent Claude credentials dir if missing. Mode 0755 — hive-core -/// needs read+execute to list the directory so `claude_has_session` can detect a -/// valid session; credential files inside (`.credentials.json` etc.) are 0600 so -/// secrets stay private regardless of the directory mode. Idempotent: existing -/// dirs are left untouched (an agent's OAuth tokens survive `destroy`/recreate). -/// Public for the `InitConfig` approval path in `actions.rs` which seeds -/// dirs without calling the full `spawn`. -pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { - use std::io; - if !claude_dir.exists() { - std::fs::create_dir_all(claude_dir) - .with_context(|| format!("create {}", claude_dir.display()))?; - } - // 0755: hive-core (different user from the agent) needs read+execute to - // list the directory so `claude_has_session` can detect a valid session. - // The credential files inside (`.credentials.json` etc.) are 0600 so the - // secrets themselves stay private regardless of the directory mode. - // - // Best-effort: on the first container boot, `hive-agent-user-migrate` - // chowns this dir to the agent user. After that, hive-core (a different - // user) cannot chmod it (EPERM) — that's fine because the mode set during - // initial creation (0755) is preserved through the chown. Any other error - // (ENOENT, I/O error) is unexpected and propagated. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - match std::fs::set_permissions(claude_dir, std::fs::Permissions::from_mode(0o755)) { - Ok(()) => {} - Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { - tracing::debug!( - path = %claude_dir.display(), - "ensure_claude_dir: chmod 755 skipped (dir likely owned by agent user after migration)" - ); - } - Err(e) => { - return Err(e).with_context(|| format!("chmod 755 {}", claude_dir.display())); - } - } - } - Ok(()) -} - -/// Public for the `InitConfig` approval path in `actions.rs` which seeds -/// dirs without calling the full `spawn`. Also creates the sibling `harness/` -/// dir so the first harness startup can write its sqlite files immediately. -pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { - if !notes_dir.exists() { - std::fs::create_dir_all(notes_dir) - .with_context(|| format!("create {}", notes_dir.display()))?; - } - // Harness dir is a sibling of the agent-visible state dir. - if let Some(parent) = notes_dir.parent() { - let harness_dir = parent.join("harness"); - if !harness_dir.exists() { - std::fs::create_dir_all(&harness_dir) - .with_context(|| format!("create {}", harness_dir.display()))?; - } - } - Ok(()) -} - -/// Ensure agent `name`'s persistent state root -/// (`/var/lib/hyperhive/agents/`) is a btrfs subvolume — when the host -/// filesystem supports it — BEFORE the per-agent subdirs (`state/`, `claude/`, -/// `harness/`) are created by `ensure_state_dir` / `ensure_claude_dir`. -/// -/// Progressive enhancement: if the root already exists -/// (any agent provisioned before this landed, plain dir or subvol) it's left -/// exactly as-is — no auto-migration — and the priv round-trip is skipped. On -/// a non-btrfs host the priv op no-ops and the root is later created as a -/// plain dir by `ensure_*_dir`, identical to the old behaviour. Only a -/// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation -/// is privileged, so it's delegated to hive-priv. -pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> { - let root = Path::new(HOST_AGENTS_ROOT).join(name); - if root.exists() { - return Ok(()); - } - crate::priv_client::ensure_agent_subvolume(name) - .await - .with_context(|| format!("ensure btrfs subvolume for agent {name}")) -} - -fn initial_agent_nix(name: &str) -> String { - format!( - "{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n", - ) -} - -/// Module-only flake exposed by every agent's repo. Consumed by the -/// hive-c0re-owned meta flake at `/var/lib/hyperhive/meta/` as a flake -/// input. The wrapper is intentionally permissive: -/// -/// - Manager edits `inputs.* = …` to add other flakes (e.g. an MCP -/// server's own flake) — the lock for those lands in the agent's -/// own `flake.lock` and rolls up into meta's lock transitively. -/// - The outputs block forwards every input (minus `self`) into -/// `agent.nix` as the `flakeInputs` module argument, so the -/// manager just references `flakeInputs..packages.${pkgs.system}.default` -/// without further plumbing. -/// -/// Identity injection (`HIVE_PORT` / `HIVE_LABEL` / dashboard port / -/// git committer) still lives in the meta flake's wrapper. -pub fn initial_flake_nix() -> &'static str { - "{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n" -} - -/// Return the SHA of the root (oldest, no-parent) commit in a repo. -/// Used to seed the applied repo at the template baseline rather than at -/// `main`, so the first `ApplyCommit` diff shows the manager's real changes. -async fn git_root_commit(dir: &Path) -> Result { - let out = git_command() - .current_dir(dir) - .args(["rev-list", "--max-parents=0", "HEAD"]) - .output() - .await - .with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?; - if !out.status.success() { - anyhow::bail!( - "git rev-list --max-parents=0 failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) -} - -async fn git_commit(dir: &Path, message: &str) -> Result<()> { - git( - dir, - &[ - "-c", - &format!("user.name={GIT_NAME}"), - "-c", - &format!("user.email={GIT_EMAIL}"), - "commit", - "-m", - message, - ], - ) - .await -} - -/// Spawn `git` honoring the `HYPERHIVE_GIT` env var (absolute path baked in -/// by the NixOS module), falling back to bare `git` (PATH lookup) otherwise. -#[must_use] -pub fn git_command() -> Command { - let exe = std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "git".into()); - Command::new(exe) -} - -pub async fn git(dir: &Path, args: &[&str]) -> Result<()> { - let out = git_command() - .current_dir(dir) - .args(args) - .output() - .await - .with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?; - if !out.status.success() { - bail!( - "git {} failed ({}): {}", - args.join(" "), - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(()) -} - -/// Fetch the commit `sha` from the `src` git repo into `dst` and pin -/// it as `refs/tags/`. Used at `request_apply_commit` time so -/// hive-c0re captures an immutable handle on the manager's commit; -/// subsequent amendments / force-pushes in `src` no longer affect -/// what gets built. Returns the resolved full sha. -/// -/// `sha` must be a commit sha (short or full) — the caller -/// (`submit_apply_commit`) shape-checks it first. We resolve it -/// LOCALLY against `src` rather than asking the remote to resolve -/// it: `git fetch :` treats the left side as a -/// remote *ref name*, and a bare sha is not one ("couldn't find -/// remote ref ..."). Fetching by sha would need a full 40-hex sha -/// plus `uploadpack.allow*SHA1InWant` on the remote, which the -/// proposed repos don't set. hive-c0re has direct read access to -/// `src`, so a local `rev-parse` + a branch-glob fetch sidesteps -/// the whole sha-want negotiation. -pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result { - let src_str = src.display().to_string(); - // Resolve the (short-or-full) sha to a full sha against the - // source repo. The `^{commit}` peel + non-zero exit on a missing - // object means a typo'd / stale sha fails loudly right here. - let full = git_rev_parse(src, &format!("{sha}^{{commit}}")) - .await - .with_context(|| format!("commit '{sha}' not found in proposed repo {src_str}"))?; - // Bring src's objects into dst. Fetching every head pulls the - // wanted commit's history (always reachable from a branch in the - // manager's flow) into dst's object db without sha-want. - git( - dst, - &[ - "fetch", - "--no-tags", - &src_str, - "+refs/heads/*:refs/remotes/proposal-src/*", - ], - ) - .await?; - // Pin the exact commit as the proposal tag. The objects are now - // local so this resolves without touching the remote. - git(dst, &["tag", tag, &full]).await.with_context(|| { - format!("tag {tag} at {full}: commit not reachable from any branch in proposed repo") - })?; - Ok(full) -} - -/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha. -pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result { - let out = git_command() - .current_dir(dir) - .args(["rev-parse", refname]) - .output() - .await - .with_context(|| format!("git rev-parse {refname} in {}", dir.display()))?; - if !out.status.success() { - bail!( - "git rev-parse {refname} failed ({}): {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned()) -} - -/// Plant a lightweight tag at `target`. Errors if the tag already -/// exists — we want loud failures on id reuse, not silent -/// overwrites. -pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> { - git(dir, &["tag", name, target]).await -} - -/// Plant an annotated tag with `body` as the message. Used for -/// `failed/` (body = build error) and `denied/` (body = -/// operator note). Multi-line bodies handled via stdin so we don't -/// have to escape anything. -pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> { - use tokio::io::AsyncWriteExt; - // Annotated tags are git objects, so they need a tagger identity - // (same constraint as a commit). Pass the hive-c0re identity - // inline rather than relying on a global git config — applied - // repos are hive-c0re-owned and the host's user might not have - // user.email set. - let mut child = git_command() - .current_dir(dir) - .args([ - "-c", - &format!("user.name={GIT_NAME}"), - "-c", - &format!("user.email={GIT_EMAIL}"), - "tag", - "-a", - name, - target, - "-F", - "-", - ]) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .with_context(|| format!("spawn git tag -a {name} in {}", dir.display()))?; - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(body.as_bytes()) - .await - .context("write tag body to git stdin")?; - // Drop closes stdin so git can finish reading. - drop(stdin); - } - let out = child.wait_with_output().await.context("wait git tag -a")?; - if !out.status.success() { - bail!( - "git tag -a {name} failed ({}): {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - Ok(()) -} - -/// Replace working tree + index with the tree at `target` without -/// moving HEAD. `applied/main` stays pointing at the last known-good -/// `deployed/*` while we let `nixos-container update` evaluate the -/// candidate. On build failure callers reset back to HEAD; on -/// success they fast-forward main to `target`. -pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> { - git(dir, &["read-tree", "--reset", "-u", target]).await -} - -/// Hard-set a ref to `target`. Used to fast-forward `refs/heads/main` -/// to the just-deployed proposal commit. Uses `update-ref`, not -/// `branch -f`, so it works regardless of where HEAD currently sits. -pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> { - git(dir, &["update-ref", refname, target]).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"; - -/// The on-host root that gets bind-mounted to `/agents` inside the manager. -/// Hard-coded to match `AGENT_STATE_ROOT` in coordinator.rs (kept duplicated -/// here so lifecycle stays usable as a leaf module). -const HOST_AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; - -/// On-host applied repo root, mirrored RO into the manager. Matches -/// `APPLIED_STATE_ROOT` in coordinator.rs. -const HOST_APPLIED_ROOT: &str = "/var/lib/hyperhive/applied"; - -/// On-host meta repo root, mirrored RO into the manager. Matches -/// `meta::meta_dir()` but duplicated here so lifecycle stays a leaf. -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 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 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); - } - binds.push(BindMount { - host_path: state_dir, - container_path: format!("/agents/{child}/state"), - read_only: false, - }); - binds.push(BindMount { - host_path: harness_dir, - container_path: format!("/agents/{child}/harness"), - read_only: false, - }); - binds.push(BindMount { - host_path: config_dir, - container_path: format!("/agents/{child}/config"), - 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. - std::fs::create_dir_all(HOST_SHARED_ROOT) - .with_context(|| format!("create {HOST_SHARED_ROOT}"))?; - // 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(HOST_SHARED_ROOT, perms) - .with_context(|| format!("chmod 1777 {HOST_SHARED_ROOT}"))?; - } - // 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: HOST_SHARED_ROOT.to_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 = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); - std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?; - binds.push(BindMount { - host_path: own_config, - 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). - std::fs::create_dir_all(HOST_META_ROOT) - .with_context(|| format!("create {HOST_META_ROOT}"))?; - binds.push(BindMount { - host_path: HOST_APPLIED_ROOT.to_owned(), - container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(), - read_only: true, - }); - binds.push(BindMount { - host_path: HOST_META_ROOT.to_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(agent_ip) = agent_network_ip(agent_name, &subnet) else { - tracing::warn!( - %agent_name, %subnet, - "HIVE_NETWORK_SUBNET is set but could not derive a valid IP for agent \ - (bad CIDR? prefix too narrow?); skipping PRIVATE_NETWORK write to \ - avoid misconfigured isolation" - ); - return crate::priv_client::write_nspawn_flags( - container, - &binds, - None, - &load_creds, - ) - .await; - }; - 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, %agent_ip, %gateway_ip, %bridge, - "network isolation: PRIVATE_NETWORK=1" - ); - Some(hive_sh4re::priv_proto::NetworkIsolation { - agent_ip, - 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 -} - /// Build the per-line callback for `create_container_streaming` / /// `update_container_streaming`. Both ops share identical dispatch logic /// (stdout → info + `append_stdout`, stderr → warn + `append_stderr`); this @@ -1690,159 +917,3 @@ async fn container_journal_tail(container: &str) -> String { _ => String::new(), } } - -#[cfg(test)] -mod tests { - use super::*; - - /// Regression test: `setup_proposed` must seed both agent.nix and flake.nix - /// in the initial commit. Before commit 5b5a93e flake.nix was missing from - /// the scaffold, requiring manual creation (seen with the damocles agent). - #[tokio::test] - async fn setup_proposed_seeds_flake_nix() { - let dir = tempfile::tempdir().expect("tempdir"); - let proposed = dir.path().join("proposed"); - setup_proposed(&proposed, "test-agent") - .await - .expect("setup_proposed"); - - // Both files must exist on disk. - assert!(proposed.join("agent.nix").exists(), "agent.nix missing"); - assert!(proposed.join("flake.nix").exists(), "flake.nix missing"); - - // flake.nix must export nixosModules.default (the meta-flake contract). - let flake = std::fs::read_to_string(proposed.join("flake.nix")).unwrap(); - assert!( - flake.contains("nixosModules.default"), - "flake.nix does not export nixosModules.default" - ); - - // Both files must be tracked in the initial git commit. - let out = git_command() - .current_dir(&proposed) - .args(["show", "--name-only", "--format=", "HEAD"]) - .output() - .await - .expect("git show"); - let tracked = String::from_utf8_lossy(&out.stdout); - assert!(tracked.contains("agent.nix"), "agent.nix not committed"); - assert!(tracked.contains("flake.nix"), "flake.nix not committed"); - } - - #[test] - fn agent_network_ip_is_in_subnet() { - // Default subnet 10.42.0.0/24 — agents get .2 to .254. - let ip = agent_network_ip("alice", "10.42.0.0/24").expect("should produce an IP"); - let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); - assert_eq!(&octets[..3], &[10, 42, 0], "wrong /24 prefix"); - assert!( - octets[3] >= 2 && octets[3] <= 254, - "host byte {}", - octets[3] - ); - } - - #[test] - fn agent_network_ip_stable() { - // Same name + subnet must always produce the same IP. - let a = agent_network_ip("damocles", "10.42.0.0/24"); - let b = agent_network_ip("damocles", "10.42.0.0/24"); - assert_eq!(a, b); - } - - #[test] - fn agent_network_ip_different_agents() { - // Different agent names very likely produce different IPs (not guaranteed, - // but for these two names the hashes don't collide). - let alice = agent_network_ip("alice", "10.42.0.0/24").unwrap(); - let bob = agent_network_ip("bob", "10.42.0.0/24").unwrap(); - assert_ne!(alice, bob, "alice and bob collide — rename one"); - } - - #[test] - fn agent_network_ip_different_subnet() { - let ip = agent_network_ip("alice", "192.168.5.0/24").expect("should produce an IP"); - let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); - assert_eq!(&octets[..3], &[192, 168, 5]); - } - - #[test] - fn bridge_gateway_ip_extracts_verbatim_address() { - // HIVE_NETWORK_SUBNET carries the bridge IP verbatim, not the - // canonical network — the gateway is the address before the `/`. - assert_eq!( - bridge_gateway_ip("10.42.0.1/24").as_deref(), - Some("10.42.0.1") - ); - // Non-`.1` operator override: the gateway is wherever the bridge is. - assert_eq!( - bridge_gateway_ip("10.42.0.254/24").as_deref(), - Some("10.42.0.254") - ); - assert_eq!( - bridge_gateway_ip("172.30.0.1/16").as_deref(), - Some("172.30.0.1") - ); - } - - #[test] - fn bridge_gateway_ip_rejects_bad_input() { - assert!(bridge_gateway_ip("notanip/24").is_none()); - assert!(bridge_gateway_ip("10.42.0.1").is_none()); // no prefix - assert!(bridge_gateway_ip("10.42.0.1/33").is_none()); // prefix > 32 - assert!(bridge_gateway_ip("10.42.0.999/24").is_none()); // octet > 255 - assert!(bridge_gateway_ip("10.42.0/24").is_none()); // 3 octets - } - - #[test] - fn agent_network_ip_rejects_bad_input() { - assert!(agent_network_ip("alice", "notanip/24").is_none()); - assert!(agent_network_ip("alice", "10.0.0.0/33").is_none()); // prefix > 32 - assert!(agent_network_ip("alice", "10.0.0.0/31").is_none()); // too small - assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix - } - - #[test] - fn agent_network_ip_normalizes_bridge_ip_subnet() { - // HIVE_NETWORK_SUBNET carries the bridge IP (10.42.0.1/24), not - // canonical network (10.42.0.0/24). Both must produce the same result - // after host-bit masking. - let from_bridge = agent_network_ip("alice", "10.42.0.1/24"); - let from_canonical = agent_network_ip("alice", "10.42.0.0/24"); - assert_eq!( - from_bridge, from_canonical, - "bridge-IP and canonical-network form should normalize to the same result" - ); - // Result must still be in .2-.254. - let ip = from_bridge.unwrap(); - let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); - assert!((2..=254).contains(&last), "host byte {last}"); - } - - /// `setup_proposed` is idempotent: calling it on an existing repo is a - /// no-op (the fresh guard skips all writes). - #[tokio::test] - async fn setup_proposed_idempotent() { - let dir = tempfile::tempdir().expect("tempdir"); - let proposed = dir.path().join("proposed"); - setup_proposed(&proposed, "test-agent") - .await - .expect("first call"); - // Second call must not error even though .git already exists. - setup_proposed(&proposed, "test-agent") - .await - .expect("second call"); - // Still one commit. - let out = git_command() - .current_dir(&proposed) - .args(["rev-list", "--count", "HEAD"]) - .output() - .await - .expect("git rev-list"); - let count = String::from_utf8_lossy(&out.stdout).trim().to_owned(); - assert_eq!( - count, "1", - "expected exactly one commit after idempotent call" - ); - } -} diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs new file mode 100644 index 00000000..a97898ba --- /dev/null +++ b/hive-c0re/src/lifecycle/setup.rs @@ -0,0 +1,251 @@ +//! First-spawn provisioning: seed the manager-editable proposed repo and +//! the hive-c0re-owned applied repo, and ensure the per-agent state / +//! claude-credentials dirs (btrfs subvolume when available) exist. + +use std::path::Path; + +use anyhow::{Context, Result, bail}; + +use super::git::{ + git, git_command, git_commit, git_read_tree_reset, git_rev_parse, git_root_commit, git_tag, +}; +use super::host_config::HOST_AGENTS_ROOT; + +/// Initialize the manager-editable proposed repo. Seeds two tracked +/// files: `agent.nix` (the module the manager edits) and `flake.nix` +/// (the boilerplate that lets the meta flake import this repo as an +/// input — meta locks at a specific sha and reads +/// `nixosModules.default`, so `flake.nix` must be in the commit). The +/// manager shouldn't edit `flake.nix` (the prompt says so) but it's +/// visible so they can introspect. +/// +/// Touched by hive-c0re only on first spawn — never again — so the +/// manager can't be surprised by hive-c0re commits or working-tree +/// resets. +pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { + let fresh = !proposed_dir.join(".git").exists(); + if fresh { + std::fs::create_dir_all(proposed_dir) + .with_context(|| format!("create {}", proposed_dir.display()))?; + let agent_path = proposed_dir.join("agent.nix"); + if !agent_path.exists() { + std::fs::write(&agent_path, initial_agent_nix(name)) + .with_context(|| format!("write {}", agent_path.display()))?; + } + let flake_path = proposed_dir.join("flake.nix"); + if !flake_path.exists() { + std::fs::write(&flake_path, initial_flake_nix()) + .with_context(|| format!("write {}", flake_path.display()))?; + } + git(proposed_dir, &["init", "--initial-branch=main"]).await?; + git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?; + git_commit(proposed_dir, "hive-c0re init").await?; + } + // Idempotently wire the `applied` remote — purely for the + // manager's ergonomics. The URL is the path inside the manager + // container (`/applied//.git`), where the RO bind in + // `set_nspawn_flags` makes it real. hive-c0re itself never + // dereferences this remote; the host-side fetch in + // `request_apply_commit` uses absolute host paths. + ensure_applied_remote(proposed_dir, name).await +} + +async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> { + let want = format!("/applied/{name}/.git"); + let existing = git_command() + .current_dir(proposed_dir) + .args(["remote", "get-url", "applied"]) + .output() + .await + .with_context(|| format!("git remote get-url applied in {}", proposed_dir.display()))?; + if existing.status.success() { + let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned(); + if current == want { + return Ok(()); + } + // URL drifted (path scheme changed, etc.) — re-point it. + return git(proposed_dir, &["remote", "set-url", "applied", &want]).await; + } + git(proposed_dir, &["remote", "add", "applied", &want]).await +} + +/// Set up the applied repo. First-spawn only: init the repo, pull +/// proposed's initial commit in via `git fetch`, tag it `deployed/0`. +/// This is the *only* time hive-c0re reads from `proposed` for an +/// agent — subsequent proposals are fetched at `request_apply_commit` +/// time and tagged `proposal/` (see `actions::approve` for the +/// tag state machine). +/// +/// `proposed_dir` is `None` on rebuild paths where the repo already +/// exists — we just verify it's the right shape and bail otherwise. +/// Unlike the pre-overhaul code path, `flake.nix` is no longer +/// regenerated at the host level: it's tracked in proposed (seeded by +/// `setup_proposed`) and rides along on every fetch. +pub async fn setup_applied( + applied_dir: &Path, + proposed_dir: Option<&Path>, + name: &str, +) -> Result<()> { + std::fs::create_dir_all(applied_dir) + .with_context(|| format!("create {}", applied_dir.display()))?; + + if !applied_dir.join(".git").exists() { + let Some(proposed) = proposed_dir else { + bail!( + "applied repo at {} is missing its .git directory; \ + cannot rebuild without a proposed source to seed from. \ + destroy --purge and re-spawn this agent.", + applied_dir.display() + ); + }; + git(applied_dir, &["init", "--initial-branch=main"]).await?; + let proposed_str = proposed.display().to_string(); + // Seed the applied repo at the root (template) commit of proposed, + // not at `main`. This ensures `deployed/0` is the template baseline + // so the first ApplyCommit diff shows the manager's real changes + // rather than an empty diff (which happens when the manager has + // already committed their config and proposed/main == proposal/). + let root_sha = git_root_commit(proposed).await?; + git( + applied_dir, + // --update-head-ok lets us fetch into refs/heads/main while + // HEAD still points there. git's default safeguard refuses + // to avoid index/working-tree desync, but the working tree + // is empty (we just `init`'d) and we read-tree-reset right + // after, so the safeguard is moot here. + &[ + "fetch", + "--no-tags", + "--update-head-ok", + &proposed_str, + &format!("{root_sha}:refs/heads/main"), + ], + ) + .await?; + git_read_tree_reset(applied_dir, "refs/heads/main").await?; + git_tag(applied_dir, "deployed/0", "refs/heads/main").await?; + } else if git_rev_parse(applied_dir, "refs/tags/deployed/0") + .await + .is_err() + { + // Pre-overhaul applied repo — no deployed/* tag scheme, + // flake.nix may be untracked, agent.nix possibly authored by + // hive-c0re directly. The startup auto-migration fixes this + // in place; if it didn't run (or got skipped), surface a + // clear error. + bail!( + "applied repo at {} predates the meta-flake layout. \ + Restart hive-c0re to let the auto-migration run, or \ + destroy --purge {name} and re-spawn.", + applied_dir.display() + ); + } + Ok(()) +} + +/// Create the per-agent Claude credentials dir if missing. Mode 0755 — hive-core +/// needs read+execute to list the directory so `claude_has_session` can detect a +/// valid session; credential files inside (`.credentials.json` etc.) are 0600 so +/// secrets stay private regardless of the directory mode. Idempotent: existing +/// dirs are left untouched (an agent's OAuth tokens survive `destroy`/recreate). +/// Public for the `InitConfig` approval path in `actions.rs` which seeds +/// dirs without calling the full `spawn`. +pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { + use std::io; + if !claude_dir.exists() { + std::fs::create_dir_all(claude_dir) + .with_context(|| format!("create {}", claude_dir.display()))?; + } + // 0755: hive-core (different user from the agent) needs read+execute to + // list the directory so `claude_has_session` can detect a valid session. + // The credential files inside (`.credentials.json` etc.) are 0600 so the + // secrets themselves stay private regardless of the directory mode. + // + // Best-effort: on the first container boot, `hive-agent-user-migrate` + // chowns this dir to the agent user. After that, hive-core (a different + // user) cannot chmod it (EPERM) — that's fine because the mode set during + // initial creation (0755) is preserved through the chown. Any other error + // (ENOENT, I/O error) is unexpected and propagated. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + match std::fs::set_permissions(claude_dir, std::fs::Permissions::from_mode(0o755)) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + tracing::debug!( + path = %claude_dir.display(), + "ensure_claude_dir: chmod 755 skipped (dir likely owned by agent user after migration)" + ); + } + Err(e) => { + return Err(e).with_context(|| format!("chmod 755 {}", claude_dir.display())); + } + } + } + Ok(()) +} + +/// Public for the `InitConfig` approval path in `actions.rs` which seeds +/// dirs without calling the full `spawn`. Also creates the sibling `harness/` +/// dir so the first harness startup can write its sqlite files immediately. +pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { + if !notes_dir.exists() { + std::fs::create_dir_all(notes_dir) + .with_context(|| format!("create {}", notes_dir.display()))?; + } + // Harness dir is a sibling of the agent-visible state dir. + if let Some(parent) = notes_dir.parent() { + let harness_dir = parent.join("harness"); + if !harness_dir.exists() { + std::fs::create_dir_all(&harness_dir) + .with_context(|| format!("create {}", harness_dir.display()))?; + } + } + Ok(()) +} + +/// Ensure agent `name`'s persistent state root +/// (`/var/lib/hyperhive/agents/`) is a btrfs subvolume — when the host +/// filesystem supports it — BEFORE the per-agent subdirs (`state/`, `claude/`, +/// `harness/`) are created by `ensure_state_dir` / `ensure_claude_dir`. +/// +/// Progressive enhancement: if the root already exists +/// (any agent provisioned before this landed, plain dir or subvol) it's left +/// exactly as-is — no auto-migration — and the priv round-trip is skipped. On +/// a non-btrfs host the priv op no-ops and the root is later created as a +/// plain dir by `ensure_*_dir`, identical to the old behaviour. Only a +/// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation +/// is privileged, so it's delegated to hive-priv. +pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> { + let root = Path::new(HOST_AGENTS_ROOT).join(name); + if root.exists() { + return Ok(()); + } + crate::priv_client::ensure_agent_subvolume(name) + .await + .with_context(|| format!("ensure btrfs subvolume for agent {name}")) +} + +fn initial_agent_nix(name: &str) -> String { + format!( + "{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n", + ) +} + +/// Module-only flake exposed by every agent's repo. Consumed by the +/// hive-c0re-owned meta flake at `/var/lib/hyperhive/meta/` as a flake +/// input. The wrapper is intentionally permissive: +/// +/// - Manager edits `inputs.* = …` to add other flakes (e.g. an MCP +/// server's own flake) — the lock for those lands in the agent's +/// own `flake.lock` and rolls up into meta's lock transitively. +/// - The outputs block forwards every input (minus `self`) into +/// `agent.nix` as the `flakeInputs` module argument, so the +/// manager just references `flakeInputs..packages.${pkgs.system}.default` +/// without further plumbing. +/// +/// Identity injection (`HIVE_PORT` / `HIVE_LABEL` / dashboard port / +/// git committer) still lives in the meta flake's wrapper. +pub fn initial_flake_nix() -> &'static str { + "{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n" +} diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs new file mode 100644 index 00000000..af77f5bd --- /dev/null +++ b/hive-c0re/src/lifecycle/tests.rs @@ -0,0 +1,155 @@ +//! Unit tests for the lifecycle module (moved verbatim from the old +//! single-file `lifecycle.rs` `#[cfg(test)]` block). + +use super::*; + +/// Regression test: `setup_proposed` must seed both agent.nix and flake.nix +/// in the initial commit. Before commit 5b5a93e flake.nix was missing from +/// the scaffold, requiring manual creation (seen with the damocles agent). +#[tokio::test] +async fn setup_proposed_seeds_flake_nix() { + let dir = tempfile::tempdir().expect("tempdir"); + let proposed = dir.path().join("proposed"); + setup_proposed(&proposed, "test-agent") + .await + .expect("setup_proposed"); + + // Both files must exist on disk. + assert!(proposed.join("agent.nix").exists(), "agent.nix missing"); + assert!(proposed.join("flake.nix").exists(), "flake.nix missing"); + + // flake.nix must export nixosModules.default (the meta-flake contract). + let flake = std::fs::read_to_string(proposed.join("flake.nix")).unwrap(); + assert!( + flake.contains("nixosModules.default"), + "flake.nix does not export nixosModules.default" + ); + + // Both files must be tracked in the initial git commit. + let out = git_command() + .current_dir(&proposed) + .args(["show", "--name-only", "--format=", "HEAD"]) + .output() + .await + .expect("git show"); + let tracked = String::from_utf8_lossy(&out.stdout); + assert!(tracked.contains("agent.nix"), "agent.nix not committed"); + assert!(tracked.contains("flake.nix"), "flake.nix not committed"); +} + +#[test] +fn agent_network_ip_is_in_subnet() { + // Default subnet 10.42.0.0/24 — agents get .2 to .254. + let ip = agent_network_ip("alice", "10.42.0.0/24").expect("should produce an IP"); + let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); + assert_eq!(&octets[..3], &[10, 42, 0], "wrong /24 prefix"); + assert!( + octets[3] >= 2 && octets[3] <= 254, + "host byte {}", + octets[3] + ); +} + +#[test] +fn agent_network_ip_stable() { + // Same name + subnet must always produce the same IP. + let a = agent_network_ip("damocles", "10.42.0.0/24"); + let b = agent_network_ip("damocles", "10.42.0.0/24"); + assert_eq!(a, b); +} + +#[test] +fn agent_network_ip_different_agents() { + // Different agent names very likely produce different IPs (not guaranteed, + // but for these two names the hashes don't collide). + let alice = agent_network_ip("alice", "10.42.0.0/24").unwrap(); + let bob = agent_network_ip("bob", "10.42.0.0/24").unwrap(); + assert_ne!(alice, bob, "alice and bob collide — rename one"); +} + +#[test] +fn agent_network_ip_different_subnet() { + let ip = agent_network_ip("alice", "192.168.5.0/24").expect("should produce an IP"); + let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); + assert_eq!(&octets[..3], &[192, 168, 5]); +} + +#[test] +fn bridge_gateway_ip_extracts_verbatim_address() { + // HIVE_NETWORK_SUBNET carries the bridge IP verbatim, not the + // canonical network — the gateway is the address before the `/`. + assert_eq!( + bridge_gateway_ip("10.42.0.1/24").as_deref(), + Some("10.42.0.1") + ); + // Non-`.1` operator override: the gateway is wherever the bridge is. + assert_eq!( + bridge_gateway_ip("10.42.0.254/24").as_deref(), + Some("10.42.0.254") + ); + assert_eq!( + bridge_gateway_ip("172.30.0.1/16").as_deref(), + Some("172.30.0.1") + ); +} + +#[test] +fn bridge_gateway_ip_rejects_bad_input() { + assert!(bridge_gateway_ip("notanip/24").is_none()); + assert!(bridge_gateway_ip("10.42.0.1").is_none()); // no prefix + assert!(bridge_gateway_ip("10.42.0.1/33").is_none()); // prefix > 32 + assert!(bridge_gateway_ip("10.42.0.999/24").is_none()); // octet > 255 + assert!(bridge_gateway_ip("10.42.0/24").is_none()); // 3 octets +} + +#[test] +fn agent_network_ip_rejects_bad_input() { + assert!(agent_network_ip("alice", "notanip/24").is_none()); + assert!(agent_network_ip("alice", "10.0.0.0/33").is_none()); // prefix > 32 + assert!(agent_network_ip("alice", "10.0.0.0/31").is_none()); // too small + assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix +} + +#[test] +fn agent_network_ip_normalizes_bridge_ip_subnet() { + // HIVE_NETWORK_SUBNET carries the bridge IP (10.42.0.1/24), not + // canonical network (10.42.0.0/24). Both must produce the same result + // after host-bit masking. + let from_bridge = agent_network_ip("alice", "10.42.0.1/24"); + let from_canonical = agent_network_ip("alice", "10.42.0.0/24"); + assert_eq!( + from_bridge, from_canonical, + "bridge-IP and canonical-network form should normalize to the same result" + ); + // Result must still be in .2-.254. + let ip = from_bridge.unwrap(); + let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); + assert!((2..=254).contains(&last), "host byte {last}"); +} + +/// `setup_proposed` is idempotent: calling it on an existing repo is a +/// no-op (the fresh guard skips all writes). +#[tokio::test] +async fn setup_proposed_idempotent() { + let dir = tempfile::tempdir().expect("tempdir"); + let proposed = dir.path().join("proposed"); + setup_proposed(&proposed, "test-agent") + .await + .expect("first call"); + // Second call must not error even though .git already exists. + setup_proposed(&proposed, "test-agent") + .await + .expect("second call"); + // Still one commit. + let out = git_command() + .current_dir(&proposed) + .args(["rev-list", "--count", "HEAD"]) + .output() + .await + .expect("git rev-list"); + let count = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + assert_eq!( + count, "1", + "expected exactly one commit after idempotent call" + ); +}