diff --git a/docs/agent-lifecycle/agent-roster.md b/docs/agent-lifecycle/agent-roster.md index da65c1da..26746ae3 100644 --- a/docs/agent-lifecycle/agent-roster.md +++ b/docs/agent-lifecycle/agent-roster.md @@ -100,10 +100,9 @@ state dirs work as for every other agent. - **Storage/mounts** — the `manage_root_agent` permission, which the swarm UI's setup assistant creates ruth with, gets a container `/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 - real config change is a PR from a clone), plus RO mounts for - `/applied` (diff against what's deployed) and `/meta` (the hive's - topology and limits files). Any agent holding it gets the same mounts. + manage any agent's state dir; config isn't mounted, since a real + config change is a PR from a clone), plus an RO mount for + `/meta` (the hive's topology and limits files). Any agent holding it gets the same mounts. - **Prompt** — identical for every container but one block (below): `prompts/system.md` carries `` / `` marker diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index 7a5acf1d..ae64cda0 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -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 with its own git identity, and pushes a branch + opens a PR with `hive-forge` — the same way it would change any other repo. - The bind-mounted `/agents//config/` is a **copy for reading** a - config, not the tree to edit: authoring in place there produces no PR. - (it's currently mounted read-write, which is a defect tracked - separately, not an authoring path.) + The container has no config mount: the clone is how an agent reads its + own config as well as how it changes it. Branch protection (the agent isn't on the `main` push allowlist; merge and approvals allowlist = `operators` team; see "Forge config repos" 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 `agent-configs/` `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 -edits it: the agent sees it read-only at `/agents//config`, so it can -read what governs it, while a config change is a PR against its forge repo. +edits it: it isn't mounted into any container, and a config change is a PR +against the agent's forge repo. Older hives also kept a *proposed* clone at `agents//config`. Nothing 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 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 -each `git` call inline, never writing it into -`applied//.git/config`; that repo is RO-bind-mounted into the root -agent, and a stored token would leak the hive's forge credential to an -agent. +each `git` call inline, never writing it into `applied//.git/config`, so +no stored token sits in a repo an agent might one day read. The dashboard deep-links into this org — a `config repo` link per container row. See `docs/web-ui/dashboard.md`. ### Submitting agent's view of config repos -An agent holding `ManageRootAgent` has every other agent's config repo -bind-mounted **read-only** (`hive-c0re/src/lifecycle/host_config.rs` -calls `bind_child_agent_dirs` for each entry in -`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. +No agent has a config repo mounted, not even one holding +`ManageRootAgent`: reading another agent's config means cloning its repo +from the forge, subject to the forge's own access rules. 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 @@ -276,10 +269,8 @@ the swarm's `agent-permissions` record; see mounts via `set_nspawn_flags`: - `/var/lib/hyperhive/agents//state` → `/agents//state` (RW) for - **every** agent, and `/agents//config` (RO, the applied repo). The - permission means "may manage any agent," so the mounted set is every agent. -- `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's - authoritative applied repo, including `.git`. + **every** agent. The permission means "may manage any agent," so the mounted + set is every agent. - `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the hive's plain state (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; 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/ log main --oneline -git -C /applied/ show refs/tags/deployed/0 # the seed commit -git -C /applied/ show refs/tags/denied/ # 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) 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 swarm dashboard like any agent, and an existing ruth container starts with the hive like every other. The root agent's flake lives at -`/var/lib/hyperhive/applied/ruth/`. The root agent can read its own -`agent.nix` (visible inside the container at `/agents/ruth/config/`) -and open a config PR on `agent-configs/ruth` for operator review, -same as any other agent. +`/var/lib/hyperhive/applied/ruth/`. The root agent can clone +`agent-configs/ruth` to read its own `agent.nix` and open a config PR there +for operator review, same as any other agent. Differences from sub-agents: @@ -352,11 +330,9 @@ Differences from sub-agents: - Web UI port via `lifecycle::agent_web_port("ruth")` — same FNV-1a hash as every other agent (8100..8999 range). - 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: - `/var/lib/hyperhive/agents` → `/agents` (RW) for every agent's state, - and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent - can `git fetch` deployed/failed/denied tags from any agent's - authoritative applied repo (see "Root-agent view of applied" below). + assistant creates her with it), for which `set_nspawn_flags` adds extra + binds: every agent's `state` dir (RW) under `/agents//state`, and the + hive's plain state under `/meta` (RO). - Its socket comes from the generic per-agent registration, in the same per-agent runtime dir as any other agent (`/run/hyperhive/agents/ruth/`), not a separate manager-only path. diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index b4083375..a0d17fc9 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -255,8 +255,8 @@ than an accident of storage. Under `/var/lib/hyperhive/agents//`: - `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 - `/agents//config` is the applied repo, read-only. + reads it any more, and you can delete it. An agent reads its config by + cloning `agent-configs/` from the forge. - `claude/` — claude OAuth credentials, bind-mounted RW to `/home//.claude` inside the container. - `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 An agent holding the `manage_root_agent` permission gets every other -agent's `state` dir bind-mounted **read-write** and its `config` dir -**read-only** (`bind_child_agent_dirs` in `lifecycle/host_config.rs`). -The RW on `state` is deliberate, not an oversight: the holder recovers -other agents, which includes writing into their state (for example -seeding notes, clearing a stuck sentinel) as well as reading it. +agent's `state` dir bind-mounted **read-write** (`bind_child_agent_dirs` in +`lifecycle/host_config.rs`). The RW is deliberate, not an oversight: the +holder recovers other agents, which includes writing into their state (for +example seeding notes, clearing a stuck sentinel) as well as reading it. This is the **only** cross-agent mount. An agent without that permission sees its own dirs and nothing else. **`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. hive-c0re reads a harness dir **directly on the host** when it wants those stats, which needs no mount into another container. - -**`config` is read-only, including for the holder.** A config change is -a PR on that agent's config repo, made from a clone and merged after -review — so the bind-mounted `config` dir is a *copy to read*, never a -tree anyone edits in place. Mounting it writable would leave a second -path to the same file that skips the review entirely, which makes the -boundary a convention rather than a permission. - - - -⚠️ 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. - +**`config` isn't mounted either.** A config change is a PR on that agent's +config repo, made from a clone and merged after review. Anyone who wants to +read the config clones `agent-configs/` from the forge. A mount, even a +read-only one, would show an agent a tree that's not the one that governs it +whenever the forge has moved on. Isolation still holds by default: a container has its *own* dirs and, unless it holds the permission, nothing else. diff --git a/docs/process/conventions.md b/docs/process/conventions.md index eda3f669..d1081233 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -288,7 +288,7 @@ host access it has. | `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`. | | `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`, `compact`, and `mark_todos_done` to every agent regardless of its diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 2d4bdccc..e5e2b6e8 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -7,16 +7,16 @@ Tools (hyperhive surface). Full signature + behavior for each comes from the too -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/` 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/` 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/` repo, which an operator reviews and merges on the forge; the merge deploys it. -You are built from the `{archetype}` archetype and have no config repo of your own: there is no `agent-configs/` 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/` 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. diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 15215ece..c936a3dc 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -288,75 +288,34 @@ async fn systemd_daemon_reload() -> Result<()> { crate::priv_client::daemon_reload().await } -/// Where the manager sees the applied trees of every agent, read-only. -/// Manager runs `git fetch /applied//.git refs/tags/*:refs/tags/applied/*` -/// to learn what hive-c0re deployed (or rejected, or failed to -/// build); the RO bind makes accidental writes impossible from -/// inside the container. -pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; - -/// Host path behind every `/agents//config` mount: the **applied** -/// (deployed) repo, not the working clone at `agents//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. +/// Append the bind flag for `child`'s `state` dir into `binds`. See +/// docs/agent-lifecycle/persistence.md ("Cross-agent access to state") for what +/// the holder may touch and why. Creates the missing host-side directory so +/// nspawn doesn't refuse to start; a missing dir is non-fatal. /// -/// For a custom agent it is never empty under a live container: -/// `provision_container` runs `setup_applied` before `create_only` makes the -/// container at all. An archetype agent (`crate::archetypes`) has no applied -/// repo, so its mount is the empty directory the caller creates. -fn config_bind_source(name: &str) -> PathBuf { - crate::paths::applied_dir(name) -} - -/// Append bind flags for `child`'s state and config dirs into `binds`. -/// See docs/agent-lifecycle/persistence.md ("Cross-agent access to state") -/// for what the holder may touch and why. Creates missing host-side -/// 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. +/// **One dir, one answer.** `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. The agent's `config` is not mounted +/// at all: a config change is a PR against its repo on the forge, reviewed and +/// merged, and the holder reads the config by cloning it from there. Its +/// `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 +/// reason to read, let alone write. hive-c0re still reads `harness` directly on +/// the host (`stats::hive_stats`), which needs no bind mount into anyone else's +/// container. fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { let Ok(child) = hive_types::Ident::parse(child) else { tracing::warn!(%child, "skipping child bind: invalid agent name"); return; }; - let child_root = crate::paths::agent_state_dir(&child); - 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); - binds.push(BindMount { - host_path: host.to_string_lossy().into_owned(), - container_path: format!("/agents/{child}/{sub}"), - read_only, - }); - } + let host = crate::paths::agent_state_dir(&child).join("state"); + let _ = std::fs::create_dir_all(&host); + binds.push(BindMount { + host_path: host.to_string_lossy().into_owned(), + container_path: format!("/agents/{child}/state"), + read_only: false, + }); } /// 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)); - // Own state, harness, work, and config dirs — same for every agent - // including the manager. Config is RO: an agent must not edit its own - // config; changes only ever flow through a config PR on the forge. + // Own state, harness and work dirs - same for every agent including the + // manager. There is no config mount: an agent reads its config by cloning + // `agent-configs/` from the forge, and changes only ever flow + // through a config PR there. bind_own_agent_dirs(agent_name, notes_dir, &mut binds); hive_types::Ident::parse(agent_name) .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 - // virtual child. Enables recovery — the holder can update another - // agent's config even when that agent is down. Also grants RO access - // to /applied and /meta. + // virtual child. Enables recovery: the holder can read and write another + // agent's notes even when that agent is down. Also grants RO access + // to /meta. // // ⚠️ "every agent" is the definition of this permission, not an // 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. if holds_manage_root_agent(agent_name, &crate::permissions::path()) { // Skipping self is a no-op, not a narrowing: `agent_notes_dir` is - // `agent_state_dir/state` and `config_bind_source` is shared, so - // binding the holder as its own virtual child reproduced the two - // own-dir mounts pushed above, byte for byte. + // `agent_state_dir/state`, so binding the holder as its own virtual + // child reproduced the own-dir mount pushed above, byte for byte. for other in crate::topology::all_agents() { if other != agent_name { bind_child_agent_dirs(&other, &mut binds); @@ -493,11 +444,6 @@ async fn set_nspawn_flags( let meta_root = crate::paths::meta_root(); std::fs::create_dir_all(&meta_root) .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 { host_path: meta_root.to_string_lossy().into_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 - /// `ManageRootAgent` holder sees another agent's `state` and - /// `config`, and nothing else. + /// `ManageRootAgent` holder sees another agent's `state`, and nothing + /// else. In particular no `config`: it is read by cloning the repo. #[test] - fn holder_sees_only_other_agents_state_and_config() { + fn holder_sees_only_other_agents_state() { let paths: Vec = child_binds() .into_iter() .map(|b| b.container_path) .collect(); - assert_eq!(paths, ["/agents/kiddo/state", "/agents/kiddo/config"]); - } - - /// 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 - ); + assert_eq!(paths, ["/agents/kiddo/state"]); } /// The regression this exists for. `harness` holds the other agent's diff --git a/hive-priv/src/container_config.rs b/hive-priv/src/container_config.rs index a012c85a..fb685a3b 100644 --- a/hive-priv/src/container_config.rs +++ b/hive-priv/src/container_config.rs @@ -325,46 +325,28 @@ fn validate_bind_path(path: &str) -> Result<()> { Ok(()) } -/// `--tmpfs=/.git` for every bound git repo, hiding its metadata -/// from inside the container. +/// `--tmpfs=/knowledge/.git`, hiding the shared docs repo's metadata from +/// inside the container. /// -/// Two kinds of mount qualify, for one reason: **the agent is given a -/// working tree, never a repository.** `/knowledge` is the hive's shared -/// docs — whose `.git/config` has held a credential the host-side worker -/// embedded — and `/agents//config` is a config repo, an agent's own -/// or a parent's read-only view of a child's. In both cases `.git` carries -/// every branch and the full history of a document whose *currently -/// deployed* value is the only thing a reader may act on, and an abandoned -/// branch is indistinguishable from a live one. +/// **The agent is given a working tree, never a repository.** `/knowledge` is +/// the hive's shared docs, whose `.git/config` has held a credential the +/// host-side worker embedded, and whose `.git` carries every branch and the +/// full history of a document whose *currently deployed* value is the only +/// thing a reader may act on: an abandoned branch is indistinguishable from a +/// live one. /// -/// An overlay rather than an exported copy: there is no second tree to -/// keep in sync, so nothing can go stale, and no code path has to remember -/// to refresh it. +/// An overlay rather than an exported copy: there is no second tree to keep in +/// sync, so nothing can go stale, and no code path has to remember to refresh +/// 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` -/// flags so 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. +/// ⚠️ Ordering matters: these must be appended **after** the `--bind` flags so +/// nspawn mounts them on top of the already-mounted trees. fn git_overlay_flags(binds: &[BindMount]) -> Vec { binds .iter() - .filter(|b| { - 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()) - }) + .filter(|b| b.container_path == "/knowledge") .map(|b| format!("--tmpfs={}/.git", b.container_path)) .collect() } @@ -670,76 +652,33 @@ mod tests { } } - /// Every bound git repo gets its `.git` overlaid — the knowledge tree - /// and *each* config mount, an agent's own plus every child's. - /// - /// 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. + /// The knowledge tree gets its `.git` overlaid, whatever its source holds: + /// it is cloned into after the container may have started, so the overlay + /// does not wait for a `.git` to exist. #[test] - fn every_bound_git_repo_gets_its_dot_git_hidden() { - let src = repo_source(); - let flags = git_overlay_flags(&[ - bind_from(&src, "/knowledge"), - bind_from(&src, "/agents/atlas/config"), - bind_from(&src, "/agents/kiddo/config"), - ]); - assert_eq!( - flags, - [ - "--tmpfs=/knowledge/.git", - "--tmpfs=/agents/atlas/config/.git", - "--tmpfs=/agents/kiddo/config/.git", - ] - ); + fn the_knowledge_tree_gets_its_dot_git_hidden() { + let flags = git_overlay_flags(&[bind_from(&repo_source(), "/knowledge")]); + assert_eq!(flags, ["--tmpfs=/knowledge/.git"]); + let empty = scratch().to_string_lossy().into_owned(); + let flags = git_overlay_flags(&[bind_from(&empty, "/knowledge")]); + assert_eq!(flags, ["--tmpfs=/knowledge/.git"]); } /// ...and nothing else does. A blanket "overlay .git on every bind" /// would mask a real `.git` under `state/`, where an agent legitimately /// keeps working clones of its own. #[test] - fn non_repo_mounts_are_left_alone() { + fn other_mounts_are_left_alone() { let src = repo_source(); let flags = git_overlay_flags(&[ bind_from(&src, "/agents/atlas/state"), bind_from(&src, "/work"), - bind_from(&src, "/applied"), - bind_from(&src, "/agents/atlas/config-notes"), + bind_from(&src, "/agents/atlas/config"), ]); - assert!(flags.is_empty(), "overlaid a non-repo 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"]); + assert!( + flags.is_empty(), + "overlaid a non-knowledge mount: {flags:?}" + ); } /// The whole rendered body, pinned literally — including the values, diff --git a/nix/agent-modules/user.nix b/nix/agent-modules/user.nix index afd9f6dd..6574b163 100644 --- a/nix/agent-modules/user.nix +++ b/nix/agent-modules/user.nix @@ -227,9 +227,7 @@ in # bind-mounted into a `ManageRootAgent` holder's container, which # would clobber the ownership those dirs' own activation scripts set # — producing intermittent EACCES for that agent's harness between - # the holder's rebuild and its own next activation. Config dirs are - # kept broad because the holder legitimately owns the proposed-config - # repos it edits. + # the holder's rebuild and its own next activation. if [ -d "/agents/$userName/state" ]; then heal_own_dir "/agents/$userName/state" fi @@ -259,15 +257,6 @@ in && chmod 0751 "$socketDir" \ || echo "hive-agent-user-migrate: could not hand $socketDir to $userName; the harness cannot bind its sockets" >&2 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 heal_own_dir "$homeDir/.claude" # 0755 so hive-core (a different unix user) can list the dir and