lifecycle: bind each sub-agent's config repo read-only at /agents/<name>/config
This commit is contained in:
parent
56e7eb6e73
commit
1529c2d777
4 changed files with 46 additions and 3 deletions
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue