Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/docs/process/conventions.md
atlas f0844f6da3 agent permissions: one set, tool groups included, set at agent creation
mara on #5170: "dont split caps and perms. its all perms. / dont include
the now empty tool groups / nothing set -> default / also add perms to
agent creation screen".

- hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution,
  web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`;
  `lifecycle`/`forge` are gone. `Permission::default_for` is the default
  for an agent the swarm sets nothing for.
- The record is `{ "permissions": [...] }`. Missing key, unset field or
  no successful read yet = default; `[]` = none; a failed read after a
  good one keeps the last value.
- hive-agent reads its own key every 10s and builds each turn's --tools,
  --allowedTools and MCP config (rewritten before every turn) from it;
  the subagent daemon reads it on each start/continue. Agents get a
  read-only grant on their own key.
- hive-c0re restarts an agent only when its manage_root_agent changes.
  The import now merges meta/capabilities.json and meta/tool-groups.json
  into one list per agent and deletes both; the "import pending, don't
  read" decision is `ImportOutcome::read_allowed`, table-tested.
- swarm-controller: `GET /api/permissions` (names + default) and an
  optional `permissions` on `POST /api/agents`, stored before the job is
  queued. swarm-ui's create-agent form gets a permissions field.
- Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon,
  tool_groups.rs, ruth's tool-group seed, the permissions rebuild job
  (PermPayload/WritePermFile/perm_change), the dashboard permissions tab
  and its stale-entries panel.

Part of #5170.
2026-10-15 18:01:40 +02:00

24 KiB

Conventions

Code-style and process expectations across the workspace. Most of these exist because something already went wrong without them.

Naming

  • Containers are length-bounded by nixos-container (≤ 11 chars).
  • Sub-agents are h-<name> with <name> ≤ 9 chars.
  • One agent is the bootstrap/root container, with a fixed name (ruth today).
  • MAX_AGENT_NAME in hive-c0re/src/lifecycle/mod.rs enforces the cap.
  • Per-agent web UI port = WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE (8100..8999) for every agent; dashboard services.hyperhive.c0re.dashboardPort (default 7000).

Hive identity (label + domain + display names)

Four env vars cover the identity surface, read by hive-agent/src/identity.rs:

  • HIVE_LABEL — short, hive-local agent label (iris, damocles). label() returns it; falls back to empty string if the env var is missing so downstream callers can decide how to surface "unknown agent" rather than getting a panic from this module.
  • HYPERHIVE_HIVE_DOMAIN — the hive's canonical DNS domain (e.g. darkest.space), set by nix/host-modules/hive-c0re/environment.nix from services.hyperhive.domain. When configured, qualify(label) returns ${label}@${domain} (for example iris@darkest.space); when unset (single-hive deployments, dev/test) it degrades to just the short label so existing callers see no change. The qualified form surfaces in the per-agent web UI title, the system-prompt template, and /api/state.qualified_label.
  • HYPERHIVE_HIVE_NAME — human display name of this hive (pr1ma). Read by hive_name(); None when unset.
  • HYPERHIVE_SWARM_NAME — human display name of the wider swarm this hive belongs to (constellat1on). Read by swarm_name(); federated hives at different DNS domains can share a swarm name.

Inside an agent container, the hive's name and domain don't come from the agent's build. hive-priv writes them to /etc/hyperhive-hive-facts before every container start. The agent's environment generator (nix/agent-modules/hive-facts.nix) passes them to every unit, including hivectl choom sessions, and /etc/set-environment exports them in login shells that no unit starts (nixos-container root-login). Neither is part of an agent's closure, and an agent started on another hive reports that hive.

hive_name + swarm_name are distinct from HYPERHIVE_HIVE_DOMAIN: the domain may carry the hive name as its leftmost label by convention, but the convention isn't machine-readable, and federated hives at different DNS domains can share a swarm name. Humans want both: the address (@darkest.space) AND the prose name (pr1ma). Matrix MXIDs use the domain-based convention.

Identity = socket

No auth tokens exist on the per-agent unix sockets. The socket path identifies the principal; perms come from "who has the bind-mount." A sub-agent only sees its own /run/hive/mcp.sock; hive-c0re owns the host admin socket.

Wake injection

hive_core_agent_sock::Request::Wake { from, body } is the wake-event-injection surface on the host-served per-agent socket. Recipient is implicit — the agent the socket belongs to — and from is caller-chosen so the wake prompt can label the source verbatim ("matrix: new message in #general", "forge: PR #42 opened", etc.). hive-c0re handles it; matrix, bash and forge don't call it — their notifications upsert a todo on the harness's in-agent socket (HIVE_AGENT_SOCKET), which signals the same turn loop without a hive-c0re round-trip. See docs/turn-loop/mcp.md § Waking the agent from inside the container.

Identity = socket means anything that can connect to /run/hive/mcp.sock is implicitly trusted to inject wakes. That's fine: the bind-mount only exposes the socket inside the agent's own container, so the trust boundary is the container's process namespace, not the wire surface.

Recipient sentinels

The broker reserves a few recipient names, which have special meaning that ordinary agent labels can never collide with — agent name validation rejects any character outside [a-z0-9_-], so the angle-bracket and asterisk shapes below are structurally safe.

  • * — broadcast: deliver to every running agent except the sender (socket_server::handle_send fans out via Coordinator::broadcast_send).
  • operator — the human at the dashboard. Reserved so no agent can take the name, and refused as a recipient: the broker answers a send to it with OPERATOR_SEND_REFUSAL (hive-sh4re/src/manager.rs). It appears only as the sender of operator-authored messages.

The broker stores the recipient exactly as the agent passed it.

Wire protocol

JSON line-delimited over unix sockets in both directions (host admin / manager / agent). SSE streams (/dashboard/stream on hive-c0re, /events/stream on the per-agent web UIs) are text/event-stream; each frame carries a seq field for the snapshot-dedupe dance (see docs/web-ui/shape.md). Request/response types live in hive-sh4re — change them in one place. The dashboard event vocabulary lives in hive-c0re::dashboard_events::DashboardEvent.

Broker delivery + ack cycle

AgentRequest::Recv is the only path that delivers messages to an agent. Always returns a list (Messages { messages }) — empty when nothing's pending, single-pop when max = None (default 1, the single-message behaviour), batched up to max when caller asks for more (server-side cap is 5; values above clamp silently). The wire request still carries an optional wait_seconds (long-poll the first message, once one arrives — or one is already pending — the call drains up to max in total): the harness's own turn-driving loop uses it internally (hive-agent's recv_next, 180s). The agent-facing MCP recv tool doesn't expose this parameter — it always passes wait_seconds: None, an immediate peek.

Per-row bookkeeping inside the broker:

  • delivered_at = NOW set on every popped row.
  • Each recipient has an in-memory unacked_ids list of every row delivered since the last AckTurn.
  • redelivered = true on a row if RequeueInflight resurfaced it (the harness prepends a "may already be handled" hint when this flag is set so the per-message warning is visible).

AgentRequest::AckTurn closes out the in-memory list — the harness fires it after TurnOutcome::Ok, marking every message popped since the last ack as fully handled. Claude doesn't see this surface; it's strictly a harness↔broker pairing. On TurnOutcome::Failed the harness intentionally skips the ack so the unacked rows stay in-flight in the DB and get picked up by the next requeue sweep.

AgentRequest::RequeueInflight is the recovery pair: fired by the harness exactly once at boot, before the serve loop starts. Catches the crashed-mid-turn / OOM-killed / container-restarted cases where a previous harness session popped messages but never drove them to a clean turn-end. Resets delivered_at back to NULL on every unacked row (so the next Recv pops them again), and remembers each id in a per-recipient in-memory set so the next Recv can tag the row with redelivered: true. Idempotent + cheap when there's nothing in flight, so the at-boot fire is unconditional.

AgentRequest::AckUntil { up_to } is the agent-facing bulk-triage escape hatch (mcp__hyperhive__ack_until). Unlike AckTurn it's visible to claude: each recv row and wake prompt carries a [msg #<id>] marker (the broker row id; transient pings show no marker — their sentinel id 0 has nothing to ack), and ack_until(up_to: n) marks every one of the agent's rows with id <= n handled in a single UPDATE — pending and delivered alike. This bounds the redelivered-flood cost after a restart: instead of popping dozens of already-handled messages one turn at a time, the agent notes the highest id it has seen and acks up to it. Recipient-scoped (an agent can only ack its own rows); also drains the in-memory unacked_ids / requeued_ids bookkeeping below the cutoff so a later AckTurn doesn't double-update and a stale redelivery tag can't outlive its row. The operator-side sibling is the dashboard's "mark all read" (unbounded, per-agent).

Loose-ends wire shape

LooseEnd is the per-row response shape for GetLooseEnds — one request shape, no per-socket flavours. Tagged enum so new thread kinds (forge PRs, etc.) can land later without breaking existing handlers. Each row carries enough context that the caller renders it directly as a bulleted list, no follow-up fetch needed.

GetLooseEnds takes no target — it always returns the caller's own rows, on both the agent and manager sockets alike (the manager, ruth, is a normal agent here, just deployed automatically with different default permissions). Reminder rows always carry owner == self.

Per-variant fields:

  • Reminder { id, owner, message, due_at, age_seconds } — due_at is the absolute time the scheduler is targeting (RFC 3339 on the wire, see Timestamps on the wire below); clients compute time-until-fire against it.
  • PendingMessages { count } — undelivered inbox messages the agent still owes itself a recv for. Informational + not cancellable (drain with recv); only emitted when count > 0, and surfaced first in the list as the most actionable signal. Counted host-side from the broker (count_pending), so it reflects what's genuinely still queued — the wake-message that drove the current turn is already delivered and not counted.
  • UnreadMatrix { rooms, summary } — unread matrix notifications. Informational + not cancellable (clear with mark_read). Unlike the others, the in-container harness injects this, not hive-c0re, because the matrix daemon lives inside the agent.

age_seconds saturates at zero on any clock anomaly (back-step, unsynchronised wall clock, etc.) so the bulleted list never shows nonsense ages.

cancel_loose_end is the matching write surface, handled in the harness without a hive-c0re round-trip: reminder deletes one of the caller's own pending reminders, todo clears one of its todos.

Agent metadata

AgentRequest::GetAgentMeta { name } returns identity + status for an agent. Self-introspection when name = None; target query when name = Some.

Response is AgentMeta { name, running, hyperhive_rev, status_text, status_set_at, hive_name, swarm_name, matrix_accounts }:

  • hyperhive_rev: None only when the configured flake URL has no canonical path. Otherwise carries the rev the target is currently pinned at.
  • running: whether the target's container is currently up. When false, the host clears status_text / status_set_at — on-disk values from before the stop are stale snapshots, not live status. Defaults to true on the wire: the deserializer treats a payload without the field as running.
  • status_text / status_set_at: last value written via SetStatus, plus its unix timestamp. Both None when the target has never set a status, when the agent name is unknown, or when running = false (see above).
  • hive_name / swarm_name: display names read from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env (sourced from services.hyperhive.hiveName / services.hyperhive.swarm.name). Both None when the options aren't configured.
  • matrix_accounts: one MatrixIdentity per configured + live matrix account the agent can act as. Empty for agents with no matrix provisioning.

Timestamps on the wire

Timestamp fields that cross a JSON boundary (dashboard API + SSE, the wire structs in hive-sh4re) serialize as RFC 3339 UTC strings (2026-07-02T18:30:00Z) via hive_sh4re::wire_time — Rust keeps the fields as i64 unix seconds internally, only the JSON representation changes, and deserialization accepts both the string form and a bare integer (rolling-deploy skew, persisted blobs). Input-direction fields agents compute as epoch (remind's at_unix_timestamp) stay integers. The *_unix field names stay for now — renaming is the wire-types refactor's concern. The dashboard frontend parses via util.js::epochSec wherever it needs arithmetic and feeds the string straight to new Date(s) for display.

HTTP error bodies

Every HTTP API in this repo answers failures with RFC 9457 application/problem+json ({ type, title, status, detail }), with the human-readable cause in detail. An endpoint returning a bare string or a bespoke error shape is a bug to file against the daemon that returned it, not something for the caller to work around.

Use the problem_details crate (features = ["axum"]), which the daemons already depend on: type a handler Result<_, ProblemDetails> and hand ProblemDetails::from_status_code(...).with_detail(...) to Err.

The reason is the consumer, not tidiness. The UIs show errors through one shared component with a copy button, so a caller has to know which part of the body is the message. A bare string forces it to treat the whole payload as prose, which is the difference between offering "copy the cause" and dumping a response — and the cause is frequently the entire diagnosis (a JetStream permission refusal, a TLS chain failure) rather than a summary.

Not in scope: the hivectl host-admin and in-agent unix sockets. Those are a JSON-line protocol with their own result types; RFC 9457 is an HTTP format.

Permissions

One set of named Permission values (hive_sh4re::permissions::Permission) decides what an agent may do: which MCP and built-in tools it gets, and which host access it has.

Permission Grants
messaging send, recv, ack_until
meta get_agent_meta (set_status is always-on, see below)
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)

Always-on tools — Permission::ALWAYS_ON_TOOLS exposes set_status, compact, and mark_todos_done to every agent regardless of its permissions. The operator dashboard depends on every agent being able to report its status chip, and the server-side SetStatus handler has no permission check (only length validation), so gating it would only desync the --allowedTools list from what the host actually accepts. Revoking meta therefore drops get_agent_meta but never set_status. mark_todos_done is here because producers push todos to an agent whether or not it holds inbox, and an agent without it still needs to clear them.

Where they live — in the swarm, not on the hive: the permissions field of the agent's key in the agent-permissions KV bucket (swarm-queue-client/src/agent_permissions.rs), which swarm-controller alone writes. Set them with swarmctl agent permissions get|set|clear <agent> (/api/agents/{name}/permissions), or on the swarm UI's create-agent screen (permissions in POST /api/agents). A missing key, an unset field, or no successful read yet means the default (Permission::default_for): messaging, meta, inbox, execution, plus manage_root_agent for ruth. "permissions": [] grants none.

How a change applies —

  • The agent's harness reads its own key every 10 seconds and builds every turn's --tools, --allowedTools and MCP config from the last value, so a change reaches the next turn. An ACP agent's permission check reads it on each request.
  • The subagent daemon reads the key on each start/continue; a running subagent keeps its tools.
  • hive-c0re reads each of its agents' keys every 30 seconds (workers/swarm_permissions.rs) into /var/lib/hyperhive/swarm-agent-permissions.json, outside the meta repo. nspawn fixes the bind mounts at container start, so when an agent's manage_root_agent changes hive-c0re restarts it after its current turn.

Every reader keeps its last good value on a failed read.

Moving off the meta repo — on its first boot with this code a hive copies meta/capabilities.json and meta/tool-groups.json into the stored value, sends them to swarm-controller once as one list per agent (without lifecycle and forge, which gave no tools; the controller writes a key only where there is none, and only for agents that hive's wanted state declares), and deletes both files from the meta repo when the controller answers (workers/permissions_import.rs). Until then it reads no keys.

Not configurable from agent.nix — that file changes through config PRs an agent can open, so letting it declare its own permissions would let an agent grant itself any tool by submitting a config commit, bypassing the operator gate.

Adding to the set — add a variant to Permission in hive-sh4re/src/permissions.rs and to Permission::ALL, and enforce it where it applies. A new #[tool] fn on AgentServer (hive-agent-mcp/src/mcp/mod.rs) goes into the matching Permission::tools() slice: that's the single source of truth mcp_config::allowed_mcp_tools (hive-agent/src/mcp_config.rs) reads each turn.

Async forms

Dashboard + per-agent mutating forms carry data-async; the shared bindAsyncForms submit listener (frontend/packages/shared/src/forms.js, imported as @hive/shared/forms.js and wired up from tabs.js on the dashboard and app.js on the per-agent UI) intercepts, shows a spinner, POSTs application/x-www-form-urlencoded (axum's Form extractor rejects multipart), calls refreshState() on success. New mutating forms should add data-async and optionally data-confirm (for a JS-side confirm() prompt).

refreshState defers automatically when document.activeElement sits inside a managed section so the operator's typing isn't lost; collapsible <details data-restore-key=…> survive the re-render via snapshotOpenDetails / restoreOpenDetails.

rebuild is the reconcile verb

job_queue::templates::rebuild builds the DAG that reconciles a container to its wanted state: it folds write_dropins (the nspawn-conf rewrite — PRIVATE_NETWORK=1, HOST_ADDRESS = the bridge gateway IP, sets EXTRA_NSPAWN_FLAGS — plus the systemd resource-limits drop-in) into the Swap node, then runs nixos-container update + stop + start across the StopForUpdate → Swap → RebuildBookkeeping brace and the tail Reconcile node. flake.nix itself lives in the agent's proposed/applied repos and rides along on every fetch (see docs/agent-lifecycle/config-changes.md::Two repos per agent).

Anything that changes per-container state on the host should be re-applied here so a manual ↻ R3BU1LD from the dashboard is sufficient to recover.

Actions live in one place

destroy (and the lifecycle helper) live in actions.rs / hive-c0re/src/dashboard/. The admin socket and the dashboard POST handlers both call into them so the two surfaces never drift.

Commit messages

Short, lowercase, no Co-Authored-By trailer. Imperative mood, no period. Body explains why when the reasoning isn't self-evident; otherwise the subject alone is fine. Wrap at ~72 cols.

Commit before test

Stage and commit when work looks ready, then run validation (cargo check, nix flake check, real deploy). Failures get a follow-up commit rather than an amend. The commit history is the work log; rewriting it loses signal.

Building & local checks

Build through the flake devshell, not a bare toolchain — agent containers ship no global rust. nix develop -c <cmd> runs one command inside the project-pinned env (cargo/clippy/rustfmt plus the C compiler + libsqlite3/ring link deps); outside it a bare cargo build fails with failed to find tool "cc" / cannot find -lsqlite3. One command per invocation — agents run each task as a fresh non-interactive process, so there's no persistent shell to reuse.

nix develop -c cargo clippy --all-targets -- -D warnings
nix develop -c cargo test
nix fmt                       # treefmt — authoritative, NOT bare cargo fmt

nix fmt (treefmt) is the formatter CI gates on; bare cargo fmt misses the non-rust files treefmt also covers, so always run nix fmt before pushing.

Clippy discipline — never add #[allow(clippy::…)]. All lints are CI-fatal at -D warnings (pedantic included); every warning that fires must be fixed, not silenced. Common patterns:

  • too_many_lines — extract a helper function or a sub-struct (the TurnAccum extraction in stats.rs is a worked example).
  • doc_markdown (brand name without backticks in a doc comment) — add backticks: `DOMPurify` instead of DOMPurify.
  • must_use / unused_results — actually handle or explicitly discard the return value (let _ = … is fine when intentional).

If a lint seems wrong for a specific call site, file an issue and ask mara — don't add #[allow] speculatively. The gate is intentional.

The devshell checks aren't the full nix flake check. Clippy / fmt / cargo test cover most gates, but nix flake check runs extra check derivations they don't:

  • hivectl-docs regenerates docs/tools/hivectl-cli.md from hivectl's clap tree and fails if the committed copy is stale. After any change to a hivectl verb or flag, regenerate it:
    nix develop -c cargo run --bin hivectl -- markdown-docs > docs/tools/hivectl-cli.md
    
    clippy / fmt / cargo test all pass without this — only the flake check catches the drift, and ci-log often can't show you why (it 500s on a fast failure), so you're left guessing "builder flake" when it's a stale doc.
  • swarmctl-docs is the same check for swarmctl / docs/tools/swarmctl-cli.md:
    nix develop -c cargo run --bin swarmctl -- markdown-docs > docs/tools/swarmctl-cli.md
    
  • there's also a flake cargo-test check and NixOS module evaluation in the set.

When local clippy/fmt/test pass but CI's nix flake check fails, don't assume a transient builder problem — reproduce the real gate locally: nix flake check (shares the build farm, use sparingly) or build just the suspect check, for example nix build .#checks.x86_64-linux.hivectl-docs.

Best-effort oneshot services

The harness ships a family of one-shot systemd services that configure agent-side surfaces from values delivered to the agent at provisioning time:

  • forge-avatar-sync — uploads services.hyperhive.agent.icon SVG to the agent's Forgejo profile, so the icon shows up on commits / PRs / issue comments.

(The matrix profile avatar is not a oneshot — hive-matrix-daemon sets it over its live authenticated Client; see docs/agent-lifecycle/persistence.md::matrix avatar.)

Shape contract — every one of these:

  1. Always exit 0, even on internal failure. A non-zero exit would mark the unit failed, which in turn aborts nixos-container update and blocks rebuilds. The agent's capability surface isn't allowed to gate the container build.
  2. No set -e in the script body. Subshell failures must not propagate. Use ... || true on every external call that can fail (forge unreachable, missing icon, parse error, etc.)
  3. Skip silently when prerequisites are missing: no token file, no icon, no reachable upstream → echo a short skip line + exit 0. The next boot tries again.
  4. Wired to multi-user.target so they run on every boot (lets a rotated token / new icon take effect without systemctl restart gymnastics).
  5. Re-runnable: a second invocation produces the same final state (idempotent uploads, idempotent config rewrites). Used by the .path watchers that re-fire on token appearance (see docs/agent-lifecycle/persistence.md::Matrix per-agent daemon).

The service stays root-owned so the bootstrap ordering doesn't need a user-existence check before each fire.

This pattern keeps the rebuild path resilient: any failure inside these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The operator notices through journalctl -u <unit> rather than a broken switch-to-configuration.