//! First-spawn provisioning: seed the agent's proposed config repo and //! the hive-c0re-owned applied repo, and ensure the per-agent state / //! claude-credentials dirs (btrfs subvolume when available) exist. //! //! Seeding is the only write either repo gets from here. Later config //! changes arrive as forge PRs authored in a clone — nothing edits a //! working tree in place. 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, }; /// Initialize an agent's config repo, preferring the one the forge /// already has. `agent-configs/` is authored by whoever created /// the agent — at swarm level that is the controller, which writes the /// config before any hive is told to deploy it — so this **clones** /// when there is something to clone and seeds a template only when /// there is not. Two authors for one file is the failure mode being /// avoided: whichever wrote second would win, and neither knows about /// the other. /// /// The template ([`seed_template`]) is what this still falls back to when /// there's genuinely nothing to clone yet — `swarm-controller` creates /// `agent-configs/` before telling a hive to deploy, but hasn't /// necessarily pushed `main` to it yet, or the forge is unreachable, or /// this hive has no forge read token. /// /// **Seeding is the whole of hive-c0re's write.** Changes to the repo /// arrive as PRs from a clone, via the forge, like any other code /// change — nothing edits a working tree in place, here or in the /// agent's bind-mounted `config/` (which is a read-only copy). /// /// Touched by hive-c0re only on first spawn — never again — so a /// hive-c0re commit or working-tree reset can't surprise anyone /// holding a clone. 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()))?; if !crate::forge::clone_config_into_proposed(proposed_dir, name).await { seed_template(proposed_dir, name).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. ensure_applied_remote(proposed_dir, name).await } /// Author the initial config in place: `agent.nix` (the agent's own module) /// 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). `flake.nix` /// isn't meant to be edited, but it's tracked so it can be read. /// /// The fallback half of [`setup_proposed`]: reached only when the forge has /// no config for this agent to take. async fn seed_template(proposed_dir: &Path, name: &str) -> Result<()> { 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 } 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 config changes are fetched from the forge's `main` /// (see `actions::advance_applied_to_rev`). /// /// `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`, so `deployed/0` is the template baseline rather // than whatever the manager may have already committed on top. 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). 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(()) } /// Create the per-agent state dir if missing. Also creates the sibling /// `harness/` dir so the first harness startup can write its sqlite files /// immediately, and the sibling `work/` dir. 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 and work dirs are siblings of the agent-visible state dir. if let Some(parent) = notes_dir.parent() { for sibling in ["harness", crate::paths::WORK_SUBDIR] { let dir = parent.join(sibling); if !dir.exists() { std::fs::create_dir_all(&dir) .with_context(|| format!("create {}", 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/`, `work/`) 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 agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; let root = crate::paths::agent_state_dir(&agent); 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" }