diff --git a/CLAUDE.md b/CLAUDE.md index 1246944..7331550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,20 @@ read them à la carte. In-flight or recent context that hasn't earned a section yet. Prune freely. +- **Just landed:** sub-agents get a read-only view of their own + config repo. `set_nspawn_flags` now adds + `--bind-ro={proposed_dir}:/agents//config` for every + sub-agent container (manager unchanged — it already has the whole + `/agents` tree RW). The agent can read `agent.nix` + whatever + extra files the manager split the config into, so it can request + precise changes from the manager instead of guessing. RO is + load-bearing: config edits only ever flow through the manager's + proposed repo + the approval queue. `setup_proposed` seeds the + dir before spawn reaches `set_nspawn_flags`; a defensive + `create_dir_all` keeps a missing repo from becoming a + won't-boot container. Takes effect on next rebuild/restart of + each existing sub-agent. `agent.md` system prompt + `docs/ + persistence.md` updated. - **Just landed:** `request_apply_commit` fetch fix. The old `git_fetch_to_tag` built a refspec `:refs/tags/proposal/` and ran `git fetch :...` — but `git fetch` resolves diff --git a/docs/persistence.md b/docs/persistence.md index eea0a98..41bb0ac 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -84,7 +84,11 @@ Survives destroy/recreate, gone on `--purge`. Under `/var/lib/hyperhive/agents//`: -- `config/` — the proposed nix repo (manager-editable). +- `config/` — the proposed nix repo (manager-editable). Bind-mounted + **read-only** to `/agents//config` inside the sub-agent's own + container so the agent can inspect what defines it and request + precise changes from the manager; RW into the manager via the + `/agents` tree bind. - `claude/` — claude OAuth credentials, bind-mounted RW to `/root/.claude` inside the container. - `state/` — durable notes, the events.sqlite db, and the diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 0b14eae..cd4b14b 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -13,6 +13,8 @@ Tools (hyperhive surface): Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. +Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files the manager has split the config into. Read it to see exactly what defines you (declared packages, env vars, MCP servers) before asking the manager for a change, so you can point at the precise file and line. You cannot write here; all changes flow through the manager. + Durable knowledge: write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets. Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts. diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 648f273..c48300c 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -768,6 +768,8 @@ fn set_nspawn_flags( claude_dir: &Path, notes_dir: &Path, ) -> Result<()> { + use std::fmt::Write as _; + // 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}"))?; @@ -775,6 +777,11 @@ fn set_nspawn_flags( let path = format!("/etc/nixos-containers/{container}.conf"); let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; + // Logical agent name (container name minus the sub-agent prefix). + // For the manager the strip is a no-op — harmless, manager paths + // below are gated on `container == MANAGER_NAME` anyway. + let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container); + // Compute the in-container state mount point. Sub-agents get // /agents//state; the manager keeps the legacy /state path. // Claude credentials always land at /root/.claude for all agents so @@ -783,7 +790,6 @@ fn set_nspawn_flags( let notes_mount = if container == MANAGER_NAME { CONTAINER_NOTES_MOUNT.to_owned() } else { - let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container); format!("/agents/{agent_name}/state") }; let claude_mount = CONTAINER_CLAUDE_MOUNT; @@ -796,7 +802,6 @@ fn set_nspawn_flags( shared = HOST_SHARED_ROOT, ); if container == MANAGER_NAME { - use std::fmt::Write as _; // 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 @@ -832,6 +837,24 @@ fn set_nspawn_flags( " --bind-ro={HOST_META_ROOT}:{mount}", mount = crate::meta::CONTAINER_MANAGER_META_MOUNT, ); + } else { + // Sub-agents get a READ-ONLY view of their own proposed + // config repo at /agents//config — agent.nix plus + // whatever extra files the manager split the config into. + // Lets an agent inspect exactly what defines it and request + // precise changes from the manager. RO is load-bearing: the + // agent must NOT edit its own config — changes only ever flow + // through the manager's RW proposed repo + the approval + // queue. The manager already has this dir RW via the /agents + // tree bind above. + let config_dir = format!("{HOST_AGENTS_ROOT}/{agent_name}/config"); + // nspawn refuses to start when a bind source is missing. + // `setup_proposed` seeds this dir before spawn reaches here, + // but create defensively so a missing repo degrades to an + // empty RO dir instead of a container that won't boot. + std::fs::create_dir_all(&config_dir) + .with_context(|| format!("create {config_dir}"))?; + let _ = write!(binds, " --bind-ro={config_dir}:/agents/{agent_name}/config"); } let bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\""); let mut lines: Vec = original