12 KiB
Agent roster & privileges
Agents are a flat set, with no parent/child tree: topology.json
carries no parent field, and the capability store — not tree
position — scopes which agents can manage which others.
This doc covers what the roster file holds today, the map-shaped
alternative topology::all_agents still accepts, and where the manager
still gets special-cased, as a tracked cleanup.
Where the roster lives
"The roster" means two different, non-overlapping things depending on
scope. The README's control-plane row ("swarm-controller
holds the hive directory, the agent roster and the job graph") is the
swarm-wide one: GET /api/agents on swarm-controller reads
straight from the swarm's authelia identity store over
swarm-authelia-bridge (swarm-controller/src/auth.rs::list_agent_identities)
— "every agent the swarm holds an identity for" (swarm-controller/src/main.rs's
get_agents doc comment). It's authoritative: an agent exists in the
swarm if and only if it has an identity there, and swarmctl agent create (or the swarm UI, via POST /api/agents) writes that identity
once, by calling ensure_agent_identity.
This page is about a different, hive-local file: the hive-c0re-owned
meta repo, alongside flake.nix, at
/var/lib/hyperhive/meta/topology.json:
["alice", "bob", "ruth"]
One entry per agent this hive currently has state/config for, in name
order. Unlike the swarm roster, nothing writes this file directly —
it's a derived cache, rebuilt by the reconcile pass below from what the
hive observes locally (config repos cloned, containers spawned), and it
answers a narrower question than "does this agent exist": which of
this hive's local agents the ManageRootAgent capability's bind-mounts
should cover. 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 map-shaped format
topology.json may also be a map of name → parent | null; the reader
accepts that shape too and keeps its keys, so a hive with a file in that
shape still 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 replaced the parent field
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: reaching a child's state dir
requires holding ManageRootAgent; parentage grants nothing. 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. A hive can opt out of having one at all
(services.hyperhive.ruthless = true skips hive-c0re's root-agent
create/start sweep entirely, hive-c0re/src/workers/auto_update.rs);
everything below applies only when it doesn'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 is created at swarm level (swarmctl agent create). Roster-wise,ruthis just another entry. - Wire-protocol — the only
*(privileged)*Requestvariants left inhive-core-agent-sock's unified enum are the scheduling ops (RequestSchedulePrompt,CancelSchedule,FireScheduleNow,EditSchedule), reachable only from the manager's socket flavour today, matching theschedulingtool group (docs/turn-loop/mcp.md). Container lifecycle ops (kill/start/restart/rebuild) never lived on this socket — they go through the separate host-admin sockethivectlspeaks.Wake(inject afrom: <X>message straight into the caller's own inbox) is on this socket too but isn't privileged to either flavour; no built-in in-container producer calls it today — matrix, bash and forge notifications push a todo on the harness's in-agent socket instead (seedocs/turn-loop/mcp.md). - 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, which ruth holds; nothing checks the agent's name. hive-c0re will gate RO/metaaccess on a "meta read" capability; no agent-facing path writesflake.lock— the operator dashboard'sPOST /api/meta-updateis the only entry point. - Prompt — treated identically:
prompt::renderalways filters foragent.prompts/system.mdstill carries<!-- role:agent -->/<!-- role:manager -->marker blocks, butprompt::renderfilters for"agent"unconditionally for every container, manager included ("alwaysagentrole — there is only one role",hive-agent/src/prompt.rs's own module doc). Therole:managerblocks are dead in production, exercised only by a unit test (filter_role_blocks(SAMPLE, "manager")). - Tool allow-list — also not a flavour switch: the MCP tools claude
sees come from
HIVE_TOOL_GROUPSalone, the same mechanism for every agent (docs/turn-loop/mcp.md). Ruth's wider default surface is just a wider default grant (ToolGroup::MANAGER_DEFAULT, seeded byauto_update.rswhenever its groups aren't already set), not anything keyed off its name or container. - 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.
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