Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/lifecycle/setup.rs
atlas bed7ae6d5a docs: a config change is a PR from a clone, not an edit in place
Seven places described an authoring flow that does not exist: the
manager editing and committing a child's config in the bind-mounted
config dir. mara, on the snapshot issue: "the pr workflow is the main
one, anything else is legacy … the config dir in agent container is
read only copy to see own config … changes need to go through local
clone and via forge".

Two of the seven are the request_init_config MCP tool description and
its args struct, which are rendered into the system prompt of every
agent holding the approvals tool group. A wrong tool description is not
a stale comment; it is an instruction the whole hive reads as fact,
which is why the claim kept being repeated back.

The init tool creates the repo and seeds it. Nothing else. Tailoring
the seeded template is not a separate mechanism - it is the ordinary
config-change flow, a PR reviewed like any other.

Prose only; the code already implements the PR flow (an approval's
commit_ref is a PR number). Where a doc justified the parent's
read-write mount on the child's config dir with the authoring model,
the justification is removed rather than replaced: that mount is a
defect tracked on its own issue, and it should not read as intentional
while it waits.
2026-08-04 22:40:22 +02:00

256 lines
12 KiB
Rust

//! 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. Seeds two tracked files:
/// `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.
///
/// **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()))?;
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/<n>/.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
}
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 reviewed
/// forge PR head at merge time (see `actions::run_deploy_apply`).
///
/// `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).
/// 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/<name>`) 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 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.<name>.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"
}