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:
parent
a781addcfe
commit
3c09e14a76
8 changed files with 103 additions and 288 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 [
|
let _ = std::fs::create_dir_all(&host);
|
||||||
("state", child_root.join("state"), false),
|
binds.push(BindMount {
|
||||||
("config", config_bind_source(child.as_str()), true),
|
host_path: host.to_string_lossy().into_owned(),
|
||||||
] {
|
container_path: format!("/agents/{child}/state"),
|
||||||
let _ = std::fs::create_dir_all(&host);
|
read_only: false,
|
||||||
binds.push(BindMount {
|
});
|
||||||
host_path: host.to_string_lossy().into_owned(),
|
|
||||||
container_path: format!("/agents/{child}/{sub}"),
|
|
||||||
read_only,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue