The prose added by this branch named the tracker item in seventeen places, which check-issue-refs.sh rejects: a `#N` tag is dead weight for anyone reading the public mirror, where no issue data exists. Each one now states the fact it was pointing at — the parent field is gone — so the sentence stands on its own. Two of those lines also carried a rustdoc break: `[`write`]` in topology.rs is ambiguous between the module's own `write` fn and the `write!` macro, which `-D rustdoc::broken-intra-doc-links` fails. Spelled `[`write()`]`, per rustdoc's own suggestion. The host_config.rs rewrite is two lines rather than three so the doc block stays under check-comment-blocks.sh's 30-line ceiling.
10 KiB
Agent roster & privileges
Agents are a flat set, with no parent/child tree: the parent field
topology.json used to carry is gone, along with every mechanism that
read it. The capability store scopes which agents can manage which
others; a tree position no longer scopes anything.
This doc covers what the roster file is now, what the removal took with it, and where the manager still gets special-cased, as a tracked cleanup.
Where the roster lives
The roster lives in the hive-c0re-owned meta repo, alongside
flake.nix, at /var/lib/hyperhive/meta/topology.json:
["alice", "bob", "ruth"]
One entry per agent the hive knows about, in name order. The file
carries no per-agent value any more, and encodes no ordering or
grouping — it answers exactly one question, which agents exist, and
topology::all_agents is the only reader that matters.
That reader is a permission boundary: the set it returns is what an agent
holding the ManageRootAgent capability gets bind-mounted (each other
agent's state read-write and config read-only; never harness). An
agent holding no capability sees its own dirs and nothing else. See
persistence.md's Cross-agent access to state.
Reading the legacy format
topology.json used to be a map of name → parent | null. The reader
still accepts that shape and keeps its keys, so a hive upgrading across
the change reads the same roster rather than an empty one. An empty
roster costs more than a cosmetic gap: every capability holder loses its
mounts until the next reconcile pass writes the array form.
Why meta, not per-agent agent.nix
An agent shouldn't be able to add itself to a set that governs who may reach its state dir. The roster IS a system-level fact; meta is where system-level facts live.
How topology.json gets updated
- Read — parsed into a set of names; a missing or unparsable file degrades safely to "no agents" (covers a fresh install that hasn't synced yet).
- Reconcile — runs alongside the periodic meta/flake regeneration. Adds newly-spawned agents, drops removed ones. Reconcile keeps agents whose config repo exists but that haven't spawned yet, so the gap until the container appears doesn't churn the file.
No write API and no operator verb reach this file. Reconcile derives it from which agents exist, so the next pass overwrites a hand edit.
See hive-c0re/src/agent_config/topology.rs and hive-c0re/src/meta.rs's
module docs for the exact call chain.
What the parent field used to do
Recorded so a reader who finds one of these in an old branch, an issue thread or a stale comment knows each one went away rather than moved:
| gone | what replaced it |
|---|---|
<parent> recipient sentinel |
address operator directly |
<children> fan-out recipient |
nothing — name the recipients, or broadcast to * |
hivectl agent <name> set-parent |
nothing |
POST /api/topology/set-parent{,-bulk} |
nothing |
HostRequest::SetParent |
nothing |
NodeKind::Reparent and its DAG template |
nothing |
HIVE_PARENT on the container |
nothing — no consumer ever read it |
| every agent's grant over its direct children | the ManageRootAgent capability, for every agent |
| rebuild ordering by topology depth | alphabetical, which the depth sort already produced |
The last row is the one with teeth: an agent that used to reach a child's
state dir by virtue of being its parent no longer reaches it at all
unless it holds ManageRootAgent. That narrowing is the intended
consequence of removing the field, not a side effect of it.
Manager special-casing today
Capability enforcement isn't fully wired yet, so the
manager (ruth) still gets some hard-coded special treatment
other agents don't:
- Naming/bootstrap — the manager's broker recipient name, state-dir
key, and nixos-container name are all
ruth(containerh-ruth).hive-c0respawns it directly at boot if missing, with no operator approval step — every other agent goes through aSpawnapproval. Roster-wise,ruthis just another entry. - Wire-protocol — the privileged
Requestvariants (Kill/Start/Restart/Update;GetLogs) — marked*(privileged)*inhive-core-agent-sock's unifiedRequestenum — are reachable only from the manager's socket flavour today; each is planned to become a capability check. One exception:Wake(inject afrom: <X>message into the caller's own inbox) isn't really privileged — every per-agent daemon (for examplehive-forge-notify) needs it, and sub-agents already have the equivalent on their own socket. - Storage/mounts — only the manager container gets
/var/lib/hyperhive/agentsbind-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(system-wide deploy log). That grant is theManageRootAgentcapability now, and ruth holds it; no name check remains. hive-c0re will gate RO/metaaccess on a "meta read" capability; no agent-facing path writesflake.lockany more —request_update_meta_inputswas removed, leaving the operator dashboard'sPOST /api/meta-updateas the only entry point. - Prompt/tools — the system prompt uses
<!-- role:agent -->/<!-- role:manager -->marker blocks, and aFlavor::{Agent, Manager}switch picks the MCP tool allow-list claude sees. Both are already parametrised on a single flavour value, so the planned per-capability-group version (cap:<group>prompt blocks + a matching tool allow-list) is additive rather than a rewrite. - State dirs — not special-cased:
HYPERHIVE_STATE_DIRis injected uniformly viasystemd.globalEnvironmentfor every container including the manager, so all token/state paths resolve through it the same way everywhere. - Scattered ownership checks — a handful of independent
manager-only overrides exist across
hive-c0retoday: loose-ends visibility (manager sees hive-wide, sub-agents only their own),destroyrefusing to act on the manager, and crash-watch skipping the manager (it autorestarts via systemd instead of going through the crash-watch loop). Each is planned to become a capability check instead of a manager-name check — see the module docs forloose_ends.rs,stores/broker.rs,actions.rs, andworkers/crash_watch.rsfor the current owner-check logic in each. (The harness handles reminder cancellation fully in-agent — see the note onCancelLooseEndKind::Reminderinhive-c0re/src/socket_server/mod.rs.)
None of the above is a stable interface — treat the module doc comments as the source of truth for exactly which checks exist today.
Harness systemd unit shape
One harness serve binary (hive-agent, with its hive-agent-mcp
sibling), one shared nix/agent-modules/ tree, one service unit
(systemd.services.hive-agent) for all agents. No separate manager
service name or role distinction exists in the harness — privilege
differences live server-side in the broker socket (which tool groups
and manager-surface calls each agent receives).
agent.nix and ruth.nix both import the shared nix/agent-modules/.
ruth.nix additionally sets forge defaults to suppress the
subscription/participation firehose so ruth's inbox stays focused on
direct mentions, reviews, and assignments.
Environment variables set on the unit
HOME = /home/<userName>— systemd defaultsHOMEto/for services withoutUser=set; with the per-agent user the harness needs the right home so claude finds its bind-mounted~/.claude/session dir.HIVE_STATIC_DIR = <mergedDist>—tower_http::ServeDirroot for the per-agent web UI; merged dist = agent default + everyservices.hyperhive.agent.frontend.extraFilesoverlay.HIVE_ASSETS_DIR = pkgs.hyperhive-assets/share/hyperhive— set directly on the unit, not viaenvironment.variables, because the latter only populates/etc/profilewhich systemd services don't inherit.
PATH setup (the wrapper-dir trick)
path = [ "/run/wrappers" "/run/current-system/sw" ];
/run/wrappers (not /run/wrappers/bin) comes first so setuid
wrappers — notably sudo — resolve before bare nix-store binaries; see
docs/process/gotchas.md ("systemd.services.*.path appends
/bin to every entry") for why the trailing /bin matters in
general. It's load-bearing here because the harness runs as the
per-agent user: without the wrapper dir on PATH, sudo resolves to
the non-setuid nix-store binary and every
services.hyperhive.agent.user.passwordlessSudo grant fails with "must be owned by
uid 0 and have the setuid bit set."
serviceConfig highlights
ExecStart = pkgs.hyperhive/bin/hive-agent— same binary for every agent.Restart = on-failure,RestartSec = 2— keeps the harness resilient across transient crashes without thundering retries.RuntimeDirectory = "hive-config"→/run/hive-config/owned byUser=, autocleared on stop. The harness writes regeneratedclaude-{mcp-config,settings,system-prompt}files there (paths::config_dir). Deliberately separate from/run/hive, which the host bind-mounts in root-owned and which holds hive-c0re'smcp.sock.User = Group = userName— drops root inside the container; sudo is the explicit escalation surface (services.hyperhive.agent.user.passwordlessSudo).
Cross-references
- Milestone: "Agent privileges and sub-agents" (tracked internally)
- Audit table source: milestone comment (tracked internally)
- Operator/agent trust boundary (orthogonal axis):
boundary.md