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
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 `<!-- 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
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/<name>/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/<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
edits it: the agent sees it read-only at `/agents/<name>/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/<name>/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/<n>/.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/<n>/.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/<n>/state` → `/agents/<n>/state` (RW) for
**every** agent, and `/agents/<n>/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/<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)
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/<n>/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.

View file

@ -255,8 +255,8 @@ than an accident of storage.
Under `/var/lib/hyperhive/agents/<name>/`:
- `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/<name>/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/<name>` from the forge.
- `claude/` — claude OAuth credentials, bind-mounted RW to
`/home/<name>/.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.
<!-- vale write-good.Passive = NO -->
**`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.
<!-- 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 -->
**`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/<name>` 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.

View file

@ -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

View file

@ -7,16 +7,16 @@ Tools (hyperhive surface). Full signature + behavior for each comes from the too
<!-- 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.
<!-- /config:repo -->
<!-- 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 -->

View file

@ -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/<n>/.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/<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.
/// 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<BindMount>) {
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 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}/{sub}"),
read_only,
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/<name>` 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<String> = 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

View file

@ -325,46 +325,28 @@ fn validate_bind_path(path: &str) -> Result<()> {
Ok(())
}
/// `--tmpfs=<mount>/.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/<name>/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<String> {
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,

View file

@ -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