lifecycle: bind each sub-agent's config repo read-only at /agents/<name>/config

This commit is contained in:
damocles 2026-05-20 10:00:28 +02:00
parent 56e7eb6e73
commit 1529c2d777
4 changed files with 46 additions and 3 deletions

View file

@ -183,6 +183,20 @@ read them à la carte.
In-flight or recent context that hasn't earned a section yet. In-flight or recent context that hasn't earned a section yet.
Prune freely. 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/<name>/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 - **Just landed:** `request_apply_commit` fetch fix. The old
`git_fetch_to_tag` built a refspec `<sha>:refs/tags/proposal/<id>` `git_fetch_to_tag` built a refspec `<sha>:refs/tags/proposal/<id>`
and ran `git fetch <proposed> <sha>:...` — but `git fetch` resolves and ran `git fetch <proposed> <sha>:...` — but `git fetch` resolves

View file

@ -84,7 +84,11 @@ Survives destroy/recreate, gone on `--purge`.
Under `/var/lib/hyperhive/agents/<name>/`: Under `/var/lib/hyperhive/agents/<name>/`:
- `config/` — the proposed nix repo (manager-editable). - `config/` — the proposed nix repo (manager-editable). Bind-mounted
**read-only** to `/agents/<name>/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 - `claude/` — claude OAuth credentials, bind-mounted RW to
`/root/.claude` inside the container. `/root/.claude` inside the container.
- `state/` — durable notes, the events.sqlite db, and the - `state/` — durable notes, the events.sqlite db, and the

View file

@ -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. 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. 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. Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts.

View file

@ -768,6 +768,8 @@ fn set_nspawn_flags(
claude_dir: &Path, claude_dir: &Path,
notes_dir: &Path, notes_dir: &Path,
) -> Result<()> { ) -> Result<()> {
use std::fmt::Write as _;
// Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist. // Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist.
std::fs::create_dir_all(HOST_SHARED_ROOT) std::fs::create_dir_all(HOST_SHARED_ROOT)
.with_context(|| format!("create {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 path = format!("/etc/nixos-containers/{container}.conf");
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; 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 // Compute the in-container state mount point. Sub-agents get
// /agents/<name>/state; the manager keeps the legacy /state path. // /agents/<name>/state; the manager keeps the legacy /state path.
// Claude credentials always land at /root/.claude for all agents so // 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 { let notes_mount = if container == MANAGER_NAME {
CONTAINER_NOTES_MOUNT.to_owned() CONTAINER_NOTES_MOUNT.to_owned()
} else { } else {
let agent_name = container.strip_prefix(AGENT_PREFIX).unwrap_or(container);
format!("/agents/{agent_name}/state") format!("/agents/{agent_name}/state")
}; };
let claude_mount = CONTAINER_CLAUDE_MOUNT; let claude_mount = CONTAINER_CLAUDE_MOUNT;
@ -796,7 +802,6 @@ fn set_nspawn_flags(
shared = HOST_SHARED_ROOT, shared = HOST_SHARED_ROOT,
); );
if container == MANAGER_NAME { if container == MANAGER_NAME {
use std::fmt::Write as _;
// systemd-nspawn refuses to start a container whose bind // systemd-nspawn refuses to start a container whose bind
// source doesn't exist. The meta repo is created by the // source doesn't exist. The meta repo is created by the
// startup migration, but make sure the directory is there // startup migration, but make sure the directory is there
@ -832,6 +837,24 @@ fn set_nspawn_flags(
" --bind-ro={HOST_META_ROOT}:{mount}", " --bind-ro={HOST_META_ROOT}:{mount}",
mount = crate::meta::CONTAINER_MANAGER_META_MOUNT, mount = crate::meta::CONTAINER_MANAGER_META_MOUNT,
); );
} else {
// Sub-agents get a READ-ONLY view of their own proposed
// config repo at /agents/<name>/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 bind_flag = format!("EXTRA_NSPAWN_FLAGS=\"{binds}\"");
let mut lines: Vec<String> = original let mut lines: Vec<String> = original