Watch
0
0
Fork
You've already forked hyperhive
0

drop the config and /applied mounts: an agent reads its config by cloning agent-configs, and the prompt says so

This commit is contained in:
damocles 2026-11-01 15:38:50 +01:00 • committed by mara
commit 3c09e14a76
8 changed files with 103 additions and 288 deletions

View file

@ -100,10 +100,9 @@ state dirs work as for every other agent.
- **Storage/mounts** — the `manage_root_agent` permission, which the swarm - **Storage/mounts** — the `manage_root_agent` permission, which the swarm
UI's setup assistant creates ruth with, gets a container UI's setup assistant creates ruth with, gets a container
`/var/lib/hyperhive/agents` bind-mounted RW at `/agents` (so it can `/var/lib/hyperhive/agents` bind-mounted RW at `/agents` (so it can
manage any agent's state dir — config isn't authored there, since a manage any agent's state dir; config isn't mounted, since a real
real config change is a PR from a clone), plus RO mounts for config change is a PR from a clone), plus an RO mount for
`/applied` (diff against what's deployed) and `/meta` (the hive's `/meta` (the hive's topology and limits files). Any agent holding it gets the same mounts.
topology and limits files). Any agent holding it gets the same mounts.
- **Prompt** — identical for every container but one block (below): - **Prompt** — identical for every container but one block (below):
`prompts/system.md` `prompts/system.md`
carries `<!-- role:agent -->` / `<!-- role:manager -->` marker carries `<!-- role:agent -->` / `<!-- role:manager -->` marker

View file

@ -24,10 +24,8 @@ No MCP tool exists for config changes: opening the PR IS the request.
tracked path, but `agent.nix` is the contract entry point), commits tracked path, but `agent.nix` is the contract entry point), commits
with its own git identity, and pushes a branch + opens a PR with with its own git identity, and pushes a branch + opens a PR with
`hive-forge` — the same way it would change any other repo. `hive-forge` — the same way it would change any other repo.
The bind-mounted `/agents/<name>/config/` is a **copy for reading** a The container has no config mount: the clone is how an agent reads its
config, not the tree to edit: authoring in place there produces no PR. own config as well as how it changes it.
(it's currently mounted read-write, which is a defect tracked
separately, not an authoring path.)
Branch protection (the agent isn't on the `main` push allowlist; Branch protection (the agent isn't on the `main` push allowlist;
merge and approvals allowlist = `operators` team; see "Forge config repos" merge and approvals allowlist = `operators` team; see "Forge config repos"
below) makes the agent a write collaborator that **can't merge its own below) makes the agent a write collaborator that **can't merge its own
@ -205,8 +203,8 @@ the swarm builder built the agent from the updated repo (the
hive-c0re seeds the applied repo at first spawn from the forge's hive-c0re seeds the applied repo at first spawn from the forge's
`agent-configs/<name>` `main`, or from a template when the forge has none yet, `agent-configs/<name>` `main`, or from a template when the forge has none yet,
and fast-forwards it to each merged config commit that deploys. An agent never and fast-forwards it to each merged config commit that deploys. An agent never
edits it: the agent sees it read-only at `/agents/<name>/config`, so it can edits it: it isn't mounted into any container, and a config change is a PR
read what governs it, while a config change is a PR against its forge repo. against the agent's forge repo.
Older hives also kept a *proposed* clone at `agents/<name>/config`. Nothing Older hives also kept a *proposed* clone at `agents/<name>/config`. Nothing
reads it any more, and you can delete it. reads it any more, and you can delete it.
@ -247,22 +245,17 @@ the forge. Branch protection keeps the agent off the `main` push
allowlist and allowlists merging to the `operators` team only, so an allowlist and allowlists merging to the `operators` team only, so an
agent can't fast-forward its own config or self-merge its PR (see agent can't fast-forward its own config or self-merge its PR (see
[Config changes](#config-changes) above). hive-c0re passes the credential to [Config changes](#config-changes) above). hive-c0re passes the credential to
each `git` call inline, never writing it into each `git` call inline, never writing it into `applied/<n>/.git/config`, so
`applied/<n>/.git/config`; that repo is RO-bind-mounted into the root no stored token sits in a repo an agent might one day read.
agent, and a stored token would leak the hive's forge credential to an
agent.
The dashboard deep-links into this org — a `config repo` link The dashboard deep-links into this org — a `config repo` link
per container row. See `docs/web-ui/dashboard.md`. per container row. See `docs/web-ui/dashboard.md`.
### Submitting agent's view of config repos ### Submitting agent's view of config repos
An agent holding `ManageRootAgent` has every other agent's config repo No agent has a config repo mounted, not even one holding
bind-mounted **read-only** (`hive-c0re/src/lifecycle/host_config.rs` `ManageRootAgent`: reading another agent's config means cloning its repo
calls `bind_child_agent_dirs` for each entry in from the forge, subject to the forge's own access rules.
`topology::all_agents()`). it's a copy to *read* another agent's
current config — not an editing surface. An agent without the
permission sees no other agent's config at all.
An agent submits a change to another agent's config the same way it An agent submits a change to another agent's config the same way it
makes any other change: **clone the target agent's config repo from the makes any other change: **clone the target agent's config repo from the
@ -276,10 +269,8 @@ the swarm's `agent-permissions` record; see
mounts via `set_nspawn_flags`: mounts via `set_nspawn_flags`:
- `/var/lib/hyperhive/agents/<n>/state` → `/agents/<n>/state` (RW) for - `/var/lib/hyperhive/agents/<n>/state` → `/agents/<n>/state` (RW) for
**every** agent, and `/agents/<n>/config` (RO, the applied repo). The **every** agent. The permission means "may manage any agent," so the mounted
permission means "may manage any agent," so the mounted set is every agent. set is every agent.
- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's
authoritative applied repo, including `.git`.
- `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state - `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state
(topology, swarm limits). (topology, swarm limits).
@ -297,18 +288,6 @@ permissions were unset, swarm-controller writes the default plus
`manage_root_agent` into her key once at start, so she keeps her mounts; `manage_root_agent` into her key once at start, so she keeps her mounts;
it never touches a key that's already set. it never touches a key that's already set.
With the full `/applied` mount, an agent can read what hive-c0re deployed,
failed or denied for any agent:
```sh
git -C /applied/<n> log main --oneline
git -C /applied/<n> show refs/tags/deployed/0 # the seed commit
git -C /applied/<n> show refs/tags/denied/<id> # body = operator note
```
The RO binds block push at the kernel level — git plumbing inside the
container can't corrupt either authoritative repo.
## Startup migrations (older hosts) ## Startup migrations (older hosts)
hive-c0re runs a couple of idempotent migrations on every startup so a hive-c0re runs a couple of idempotent migrations on every startup so a
@ -338,10 +317,9 @@ The root agent container runs through the **same lifecycle as
sub-agents**. Nothing creates it at hive boot: you create ruth from the sub-agents**. Nothing creates it at hive boot: you create ruth from the
swarm dashboard like any agent, and an existing ruth container starts with swarm dashboard like any agent, and an existing ruth container starts with
the hive like every other. The root agent's flake lives at the hive like every other. The root agent's flake lives at
`/var/lib/hyperhive/applied/ruth/`. The root agent can read its own `/var/lib/hyperhive/applied/ruth/`. The root agent can clone
`agent.nix` (visible inside the container at `/agents/ruth/config/`) `agent-configs/ruth` to read its own `agent.nix` and open a config PR there
and open a config PR on `agent-configs/ruth` for operator review, for operator review, same as any other agent.
same as any other agent.
Differences from sub-agents: Differences from sub-agents:
@ -352,11 +330,9 @@ Differences from sub-agents:
- Web UI port via `lifecycle::agent_web_port("ruth")` — same - Web UI port via `lifecycle::agent_web_port("ruth")` — same
FNV-1a hash as every other agent (8100..8999 range). FNV-1a hash as every other agent (8100..8999 range).
- The root agent holds `manage_root_agent` (the swarm UI's setup - The root agent holds `manage_root_agent` (the swarm UI's setup
assistant creates her with it), for which `set_nspawn_flags` adds two extra binds: assistant creates her with it), for which `set_nspawn_flags` adds extra
`/var/lib/hyperhive/agents` → `/agents` (RW) for every agent's state, binds: every agent's `state` dir (RW) under `/agents/<n>/state`, and the
and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent hive's plain state under `/meta` (RO).
can `git fetch` deployed/failed/denied tags from any agent's
authoritative applied repo (see "Root-agent view of applied" below).
- Its socket comes from the generic per-agent registration, in the same - Its socket comes from the generic per-agent registration, in the same
per-agent runtime dir as any other agent (`/run/hyperhive/agents/ruth/`), per-agent runtime dir as any other agent (`/run/hyperhive/agents/ruth/`),
not a separate manager-only path. not a separate manager-only path.

View file

@ -255,8 +255,8 @@ than an accident of storage.
Under `/var/lib/hyperhive/agents/<name>/`: Under `/var/lib/hyperhive/agents/<name>/`:
- `config/` — unused. Older hives kept a proposed nix repo here; nothing - `config/` — unused. Older hives kept a proposed nix repo here; nothing
reads it any more, and you can delete it. The agent's config mount at reads it any more, and you can delete it. An agent reads its config by
`/agents/<name>/config` is the applied repo, read-only. cloning `agent-configs/<name>` from the forge.
- `claude/` — claude OAuth credentials, bind-mounted RW to - `claude/` — claude OAuth credentials, bind-mounted RW to
`/home/<name>/.claude` inside the container. `/home/<name>/.claude` inside the container.
- `state/` — durable notes and `hyperhive-harness.json`. Bind-mounted - `state/` — durable notes and `hyperhive-harness.json`. Bind-mounted
@ -459,38 +459,25 @@ the lines written after the last push.
### Cross-agent access to state ### Cross-agent access to state
An agent holding the `manage_root_agent` permission gets every other An agent holding the `manage_root_agent` permission gets every other
agent's `state` dir bind-mounted **read-write** and its `config` dir agent's `state` dir bind-mounted **read-write** (`bind_child_agent_dirs` in
**read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`). `lifecycle/host_config.rs`). The RW is deliberate, not an oversight: the
The RW on `state` is deliberate, not an oversight: the holder recovers holder recovers other agents, which includes writing into their state (for
other agents, which includes writing into their state (for example example seeding notes, clearing a stuck sentinel) as well as reading it.
seeding notes, clearing a stuck sentinel) as well as reading it.
This is the **only** cross-agent mount. An agent without that permission This is the **only** cross-agent mount. An agent without that permission
sees its own dirs and nothing else. sees its own dirs and nothing else.
**`harness` isn't mounted at all.** It holds that agent's own runtime **`harness` isn't mounted at all.** It holds that agent's own runtime
material — `bash-tasks/`, the turn-stats and event sqlite dbs — and material - `bash-tasks/`, the turn-stats and event sqlite dbs - and
nothing argues for anyone else reading it, let alone writing it. nothing argues for anyone else reading it, let alone writing it.
hive-c0re reads a harness dir **directly on the host** when it wants hive-c0re reads a harness dir **directly on the host** when it wants
those stats, which needs no mount into another container. those stats, which needs no mount into another container.
<!-- vale write-good.Passive = NO --> **`config` isn't mounted either.** A config change is a PR on that agent's
**`config` is read-only, including for the holder.** A config change is config repo, made from a clone and merged after review. Anyone who wants to
a PR on that agent's config repo, made from a clone and merged after read the config clones `agent-configs/<name>` from the forge. A mount, even a
review — so the bind-mounted `config` dir is a *copy to read*, never a read-only one, would show an agent a tree that's not the one that governs it
tree anyone edits in place. Mounting it writable would leave a second whenever the forge has moved on.
path to the same file that skips the review entirely, which makes the
boundary a convention rather than a permission.
<!-- vale write-good.Passive = YES -->
<!-- vale write-good.Passive = NO -->
⚠️ Don't confuse it with the config-repo seeding hive-c0re does at
spawn (`lifecycle::setup_applied`): that writes the agent's initial
config repo as **hive-c0re, against the host path**, and `read_only` on a bind
constrains writers *inside* a container only. The two are unrelated —
conflating them can lead you to reason your way into thinking this
mount should be writable when it shouldn't.
<!-- vale write-good.Passive = YES -->
Isolation still holds by default: a container has its *own* dirs and, Isolation still holds by default: a container has its *own* dirs and,
unless it holds the permission, nothing else. unless it holds the permission, nothing else.

View file

@ -288,7 +288,7 @@ host access it has.
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` | | `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` |
| `execution` | the `bash` MCP server (`mcp__bash__run`/`status`/`kill`), rendered into the agent's MCP config only with it; a subagent gets Claude's own `Bash` instead. See `docs/tools/bash.md`. | | `execution` | the `bash` MCP server (`mcp__bash__run`/`status`/`kill`), rendered into the agent's MCP config only with it; a subagent gets Claude's own `Bash` instead. See `docs/tools/bash.md`. |
| `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` | | `web_tools` | the Claude built-ins `WebFetch`/`WebSearch` |
| `manage_root_agent` | every agent's state (rw) + config (ro) bind-mounted into the holder's container, plus `/applied` and `/meta` (ro) | | `manage_root_agent` | every agent's state (rw) bind-mounted into the holder's container, plus `/meta` (ro) |
**Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`, **Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`,
`compact`, and `mark_todos_done` to every agent regardless of its `compact`, and `mark_todos_done` to every agent regardless of its

View file

@ -7,16 +7,16 @@ Tools (hyperhive surface). Full signature + behavior for each comes from the too
<!-- config:repo --> <!-- config:repo -->
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits. Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. All changes flow through operator-approved commits.
Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line. Your config repo is `agent-configs/{label}` on the forge: `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Clone it with `hive-forge -r agent-configs/{label} clone` and read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
_Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it.
<!-- /config:repo --> <!-- /config:repo -->
<!-- config:archetype --> <!-- config:archetype -->
You are built from the `{archetype}` archetype and have no config repo of your own: there is no `agent-configs/<name>` repo for you, and `/agents/{label}/config/` is empty. Need new packages, env vars, or other NixOS config for yourself? Ask the operator: what defines you is the archetype, shared with every agent built from it. You are built from the `{archetype}` archetype and have no config repo of your own: there is no `agent-configs/<name>` repo for you. Need new packages, env vars, or other NixOS config for yourself? Ask the operator: what defines you is the archetype, shared with every agent built from it.
<!-- /config:archetype --> <!-- /config:archetype -->

View file

@ -288,75 +288,34 @@ async fn systemd_daemon_reload() -> Result<()> {
crate::priv_client::daemon_reload().await crate::priv_client::daemon_reload().await
} }
/// Where the manager sees the applied trees of every agent, read-only. /// Append the bind flag for `child`'s `state` dir into `binds`. See
/// Manager runs `git fetch /applied/<n>/.git refs/tags/*:refs/tags/applied/*` /// docs/agent-lifecycle/persistence.md ("Cross-agent access to state") for what
/// to learn what hive-c0re deployed (or rejected, or failed to /// the holder may touch and why. Creates the missing host-side directory so
/// build); the RO bind makes accidental writes impossible from /// nspawn doesn't refuse to start; a missing dir is non-fatal.
/// inside the container.
pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
/// Host path behind every `/agents/<name>/config` mount: the **applied**
/// (deployed) repo, not the working clone at `agents/<name>/config`. That
/// clone is where a config change is staged, so it can hold a proposal
/// that is still under review or was rejected outright — mounting it shows
/// an agent a config which does not govern it. Both mounts (an agent's own
/// and a `ManageRootAgent` holder's view of another agent's) go through
/// here so they cannot drift.
/// ///
/// For a custom agent it is never empty under a live container: /// **One dir, one answer.** `state` is **read-write**: the holder reads and
/// `provision_container` runs `setup_applied` before `create_only` makes the /// writes another agent's notes to recover it, which is the one case that
/// container at all. An archetype agent (`crate::archetypes`) has no applied /// needs to work while that agent is down. The agent's `config` is not mounted
/// repo, so its mount is the empty directory the caller creates. /// at all: a config change is a PR against its repo on the forge, reviewed and
fn config_bind_source(name: &str) -> PathBuf { /// merged, and the holder reads the config by cloning it from there. Its
crate::paths::applied_dir(name) /// `harness` and `work` are absent too: they hold that agent's own runtime
} /// material (`bash-tasks/`, turn-stats and event sqlite dbs) and its, often
/// very large, worktrees and build trees, none of which the holder has a stated
/// Append bind flags for `child`'s state and config dirs into `binds`. /// reason to read, let alone write. hive-c0re still reads `harness` directly on
/// See docs/agent-lifecycle/persistence.md ("Cross-agent access to state") /// the host (`stats::hive_stats`), which needs no bind mount into anyone else's
/// for what the holder may touch and why. Creates missing host-side /// container.
/// directories so nspawn doesn't refuse to start; missing dirs are
/// non-fatal.
///
/// **Four dirs, three different answers** — the uniformity of the
/// original loop is what hid that:
///
/// - `state` is **read-write**: the holder reads and writes another
/// agent's notes to recover it, which is the one case that needs to
/// work while that agent is down.
/// - `config` is **read-only**, and read-only for the *holder* is the
/// point: a config change is a PR against the other agent's repo on the
/// forge, reviewed and merged, never an edit in place. A writable mount
/// here is a second path to the same file that skips the review — the
/// boundary would then be a convention rather than a permission.
/// - `harness` is **absent entirely**. It holds that agent's own runtime
/// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of
/// which the holder has a stated reason to read, let alone write.
/// hive-c0re still reads it directly on the host (`stats::hive_stats`),
/// which needs no bind mount into anyone else's container.
/// - `work` is **absent entirely**, like `harness`: the agent's own, often
/// very large, worktrees and build trees.
///
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
/// the `config` flag and must not be read as a reason to widen it: that runs
/// as hive-c0re against the host path (see `lifecycle::setup_applied`), and
/// `read_only` on a bind constrains writers *inside* the container only.
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) { fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let Ok(child) = hive_types::Ident::parse(child) else { let Ok(child) = hive_types::Ident::parse(child) else {
tracing::warn!(%child, "skipping child bind: invalid agent name"); tracing::warn!(%child, "skipping child bind: invalid agent name");
return; return;
}; };
let child_root = crate::paths::agent_state_dir(&child); let host = crate::paths::agent_state_dir(&child).join("state");
for (sub, host, read_only) in [
("state", child_root.join("state"), false),
("config", config_bind_source(child.as_str()), true),
] {
let _ = std::fs::create_dir_all(&host); let _ = std::fs::create_dir_all(&host);
binds.push(BindMount { binds.push(BindMount {
host_path: host.to_string_lossy().into_owned(), host_path: host.to_string_lossy().into_owned(),
container_path: format!("/agents/{child}/{sub}"), container_path: format!("/agents/{child}/state"),
read_only, read_only: false,
}); });
}
} }
/// Append RW bind flags for `agent_name`'s own `state`, `harness` and `work` /// Append RW bind flags for `agent_name`'s own `state`, `harness` and `work`
@ -442,25 +401,18 @@ async fn set_nspawn_flags(
let mut binds = base_binds(runtime_dir, claude_dir, container_claude_mount(agent_name)); let mut binds = base_binds(runtime_dir, claude_dir, container_claude_mount(agent_name));
// Own state, harness, work, and config dirs — same for every agent // Own state, harness and work dirs - same for every agent including the
// including the manager. Config is RO: an agent must not edit its own // manager. There is no config mount: an agent reads its config by cloning
// config; changes only ever flow through a config PR on the forge. // `agent-configs/<name>` from the forge, and changes only ever flow
// through a config PR there.
bind_own_agent_dirs(agent_name, notes_dir, &mut binds); bind_own_agent_dirs(agent_name, notes_dir, &mut binds);
hive_types::Ident::parse(agent_name) hive_types::Ident::parse(agent_name)
.map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?; .map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?;
let own_config = config_bind_source(agent_name);
std::fs::create_dir_all(&own_config)
.with_context(|| format!("create {}", own_config.display()))?;
binds.push(BindMount {
host_path: own_config.to_string_lossy().into_owned(),
container_path: format!("/agents/{agent_name}/config"),
read_only: true,
});
// `ManageRootAgent` permission: mount *every* agent in the hive as a // `ManageRootAgent` permission: mount *every* agent in the hive as a
// virtual child. Enables recovery — the holder can update another // virtual child. Enables recovery: the holder can read and write another
// agent's config even when that agent is down. Also grants RO access // agent's notes even when that agent is down. Also grants RO access
// to /applied and /meta. // to /meta.
// //
// ⚠️ "every agent" is the definition of this permission, not an // ⚠️ "every agent" is the definition of this permission, not an
// accident of how the set is computed. The grant used to hang off a // accident of how the set is computed. The grant used to hang off a
@ -477,9 +429,8 @@ async fn set_nspawn_flags(
// no permission now sees its own dirs and nothing else. // no permission now sees its own dirs and nothing else.
if holds_manage_root_agent(agent_name, &crate::permissions::path()) { if holds_manage_root_agent(agent_name, &crate::permissions::path()) {
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is // Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
// `agent_state_dir/state` and `config_bind_source` is shared, so // `agent_state_dir/state`, so binding the holder as its own virtual
// binding the holder as its own virtual child reproduced the two // child reproduced the own-dir mount pushed above, byte for byte.
// own-dir mounts pushed above, byte for byte.
for other in crate::topology::all_agents() { for other in crate::topology::all_agents() {
if other != agent_name { if other != agent_name {
bind_child_agent_dirs(&other, &mut binds); bind_child_agent_dirs(&other, &mut binds);
@ -493,11 +444,6 @@ async fn set_nspawn_flags(
let meta_root = crate::paths::meta_root(); let meta_root = crate::paths::meta_root();
std::fs::create_dir_all(&meta_root) std::fs::create_dir_all(&meta_root)
.with_context(|| format!("create {}", meta_root.display()))?; .with_context(|| format!("create {}", meta_root.display()))?;
binds.push(BindMount {
host_path: crate::paths::applied_root().to_string_lossy().into_owned(),
container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(),
read_only: true,
});
binds.push(BindMount { binds.push(BindMount {
host_path: meta_root.to_string_lossy().into_owned(), host_path: meta_root.to_string_lossy().into_owned(),
container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(), container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(),
@ -600,36 +546,15 @@ mod tests {
} }
/// The boundary, asserted as a whole rather than per-dir: a /// The boundary, asserted as a whole rather than per-dir: a
/// `ManageRootAgent` holder sees another agent's `state` and /// `ManageRootAgent` holder sees another agent's `state`, and nothing
/// `config`, and nothing else. /// else. In particular no `config`: it is read by cloning the repo.
#[test] #[test]
fn holder_sees_only_other_agents_state_and_config() { fn holder_sees_only_other_agents_state() {
let paths: Vec<String> = child_binds() let paths: Vec<String> = child_binds()
.into_iter() .into_iter()
.map(|b| b.container_path) .map(|b| b.container_path)
.collect(); .collect();
assert_eq!(paths, ["/agents/kiddo/state", "/agents/kiddo/config"]); assert_eq!(paths, ["/agents/kiddo/state"]);
}
/// The `config` mount names the **deployed** tree, not the working
/// clone the proposal is staged in. Asserted as "outside the child's
/// own dir" rather than by equality: the point is that the two are
/// different objects, which is what makes the mount unable to show a
/// config that was never approved. Equality with `applied_dir` would
/// restate the implementation and pass under any future relocation.
#[test]
fn child_config_mount_is_the_deployed_tree_not_the_working_clone() {
let working_clone =
crate::paths::agent_state_dir(&hive_types::Ident::parse("kiddo").expect("valid ident"));
let config = child_binds()
.into_iter()
.find(|b| b.container_path.ends_with("/config"))
.expect("a config bind");
assert!(
!std::path::Path::new(&config.host_path).starts_with(&working_clone),
"config mount must not come from the child's working clone: {}",
config.host_path
);
} }
/// The regression this exists for. `harness` holds the other agent's /// The regression this exists for. `harness` holds the other agent's

View file

@ -325,46 +325,28 @@ fn validate_bind_path(path: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// `--tmpfs=<mount>/.git` for every bound git repo, hiding its metadata /// `--tmpfs=/knowledge/.git`, hiding the shared docs repo's metadata from
/// from inside the container. /// inside the container.
/// ///
/// Two kinds of mount qualify, for one reason: **the agent is given a /// **The agent is given a working tree, never a repository.** `/knowledge` is
/// working tree, never a repository.** `/knowledge` is the hive's shared /// the hive's shared docs, whose `.git/config` has held a credential the
/// docs — whose `.git/config` has held a credential the host-side worker /// host-side worker embedded, and whose `.git` carries every branch and the
/// embedded — and `/agents/<name>/config` is a config repo, an agent's own /// full history of a document whose *currently deployed* value is the only
/// or a parent's read-only view of a child's. In both cases `.git` carries /// thing a reader may act on: an abandoned branch is indistinguishable from a
/// every branch and the full history of a document whose *currently /// live one.
/// deployed* value is the only thing a reader may act on, and an abandoned
/// branch is indistinguishable from a live one.
/// ///
/// An overlay rather than an exported copy: there is no second tree to /// An overlay rather than an exported copy: there is no second tree to keep in
/// keep in sync, so nothing can go stale, and no code path has to remember /// sync, so nothing can go stale, and no code path has to remember to refresh
/// to refresh it. /// it. It is applied unconditionally: the directory is created empty and cloned
/// into later, so a container started before the clone must already hide the
/// `.git` that arrives.
/// ///
/// ⚠️ Ordering matters — these must be appended **after** the `--bind` /// ⚠️ Ordering matters: these must be appended **after** the `--bind` flags so
/// flags so nspawn mounts them on top of the already-mounted trees. /// nspawn mounts them on top of the already-mounted trees.
/// Config mounts are matched by shape, not by a name list: the set is
/// dynamic, growing with each child bound into a parent.
///
/// `/knowledge` is overlaid unconditionally: its directory is created empty
/// and cloned into later, so a container started before the clone must
/// already hide the `.git` that arrives. A config mount gets the overlay only
/// when its host source has a `.git` entry: nspawn must create the mount
/// point inside the read-only bind, which fails (`EROFS`) where there is
/// nothing to mount over, as in an archetype agent's empty config directory.
/// The entry is looked up without following a symlink, and a symlink named
/// `.git` counts as present, so a path that leads somewhere else is still
/// hidden.
fn git_overlay_flags(binds: &[BindMount]) -> Vec<String> { fn git_overlay_flags(binds: &[BindMount]) -> Vec<String> {
binds binds
.iter() .iter()
.filter(|b| { .filter(|b| b.container_path == "/knowledge")
let p = b.container_path.as_str();
p == "/knowledge"
|| (p.starts_with("/agents/")
&& p.ends_with("/config")
&& std::fs::symlink_metadata(Path::new(&b.host_path).join(".git")).is_ok())
})
.map(|b| format!("--tmpfs={}/.git", b.container_path)) .map(|b| format!("--tmpfs={}/.git", b.container_path))
.collect() .collect()
} }
@ -670,76 +652,33 @@ mod tests {
} }
} }
/// Every bound git repo gets its `.git` overlaid — the knowledge tree /// The knowledge tree gets its `.git` overlaid, whatever its source holds:
/// and *each* config mount, an agent's own plus every child's. /// it is cloned into after the container may have started, so the overlay
/// /// does not wait for a `.git` to exist.
/// The child case is the one worth pinning: that set grows at runtime
/// as agents gain children, so a rule written as a list of names would
/// silently stop covering new ones.
#[test] #[test]
fn every_bound_git_repo_gets_its_dot_git_hidden() { fn the_knowledge_tree_gets_its_dot_git_hidden() {
let src = repo_source(); let flags = git_overlay_flags(&[bind_from(&repo_source(), "/knowledge")]);
let flags = git_overlay_flags(&[ assert_eq!(flags, ["--tmpfs=/knowledge/.git"]);
bind_from(&src, "/knowledge"), let empty = scratch().to_string_lossy().into_owned();
bind_from(&src, "/agents/atlas/config"), let flags = git_overlay_flags(&[bind_from(&empty, "/knowledge")]);
bind_from(&src, "/agents/kiddo/config"), assert_eq!(flags, ["--tmpfs=/knowledge/.git"]);
]);
assert_eq!(
flags,
[
"--tmpfs=/knowledge/.git",
"--tmpfs=/agents/atlas/config/.git",
"--tmpfs=/agents/kiddo/config/.git",
]
);
} }
/// ...and nothing else does. A blanket "overlay .git on every bind" /// ...and nothing else does. A blanket "overlay .git on every bind"
/// would mask a real `.git` under `state/`, where an agent legitimately /// would mask a real `.git` under `state/`, where an agent legitimately
/// keeps working clones of its own. /// keeps working clones of its own.
#[test] #[test]
fn non_repo_mounts_are_left_alone() { fn other_mounts_are_left_alone() {
let src = repo_source(); let src = repo_source();
let flags = git_overlay_flags(&[ let flags = git_overlay_flags(&[
bind_from(&src, "/agents/atlas/state"), bind_from(&src, "/agents/atlas/state"),
bind_from(&src, "/work"), bind_from(&src, "/work"),
bind_from(&src, "/applied"), bind_from(&src, "/agents/atlas/config"),
bind_from(&src, "/agents/atlas/config-notes"),
]); ]);
assert!(flags.is_empty(), "overlaid a non-repo mount: {flags:?}"); assert!(
} flags.is_empty(),
"overlaid a non-knowledge mount: {flags:?}"
/// A config mount whose source has no `.git` (an archetype agent's empty );
/// directory) gets no overlay: nspawn cannot create the mount point in a
/// read-only bind. The sibling with a `.git` is the presence control.
#[test]
fn a_source_without_dot_git_gets_no_overlay() {
let empty = scratch().to_string_lossy().into_owned();
let flags = git_overlay_flags(&[
bind_from(&empty, "/agents/updo/config"),
bind_from(&repo_source(), "/agents/atlas/config"),
]);
assert_eq!(flags, ["--tmpfs=/agents/atlas/config/.git"]);
}
/// `/knowledge` is cloned into after the container may have started, so
/// its overlay does not wait for a `.git` to exist.
#[test]
fn an_empty_knowledge_source_still_gets_the_overlay() {
let empty = scratch().to_string_lossy().into_owned();
let flags = git_overlay_flags(&[bind_from(&empty, "/knowledge")]);
assert_eq!(flags, ["--tmpfs=/knowledge/.git"]);
}
/// A symlink named `.git` is not followed: it counts as present, even
/// dangling, so the path it leads to is hidden rather than skipped.
#[test]
fn a_symlinked_dot_git_is_not_followed() {
let dir = scratch();
std::os::unix::fs::symlink("/nonexistent-target", dir.join(".git")).unwrap();
let src = dir.to_string_lossy().into_owned();
let flags = git_overlay_flags(&[bind_from(&src, "/agents/atlas/config")]);
assert_eq!(flags, ["--tmpfs=/agents/atlas/config/.git"]);
} }
/// The whole rendered body, pinned literally — including the values, /// The whole rendered body, pinned literally — including the values,

View file

@ -227,9 +227,7 @@ in
# bind-mounted into a `ManageRootAgent` holder's container, which # bind-mounted into a `ManageRootAgent` holder's container, which
# would clobber the ownership those dirs' own activation scripts set # would clobber the ownership those dirs' own activation scripts set
# — producing intermittent EACCES for that agent's harness between # — producing intermittent EACCES for that agent's harness between
# the holder's rebuild and its own next activation. Config dirs are # the holder's rebuild and its own next activation.
# kept broad because the holder legitimately owns the proposed-config
# repos it edits.
if [ -d "/agents/$userName/state" ]; then if [ -d "/agents/$userName/state" ]; then
heal_own_dir "/agents/$userName/state" heal_own_dir "/agents/$userName/state"
fi fi
@ -259,15 +257,6 @@ in
&& chmod 0751 "$socketDir" \ && chmod 0751 "$socketDir" \
|| echo "hive-agent-user-migrate: could not hand $socketDir to $userName; the harness cannot bind its sockets" >&2 || echo "hive-agent-user-migrate: could not hand $socketDir to $userName; the harness cannot bind its sockets" >&2
fi fi
# The proposed-config repo is RW-mounted into the editing agent and
# owned by it; hive-c0re only pulls from it. Heal
# it to this user too — same as state/harness. In an agent's own
# container its config is RO-mounted, so the chown there fails with
# EROFS on every run: deliberately silent, unlike the dirs above.
for configDir in /agents/*/config; do
[ -d "$configDir" ] || continue
chown -hR "$userName:$userName" "$configDir" 2>/dev/null || true
done
if [ -d "$homeDir/.claude" ]; then if [ -d "$homeDir/.claude" ]; then
heal_own_dir "$homeDir/.claude" heal_own_dir "$homeDir/.claude"
# 0755 so hive-core (a different unix user) can list the dir and # 0755 so hive-core (a different unix user) can list the dir and