Watch
0
0
Fork
You've already forked hyperhive
0

agent capabilities live in a swarm KV record, set from swarmctl

An agent's capabilities move from the hive's meta repo
(`meta/capabilities.json`, injected as `HIVE_CAPABILITIES`) into the
`capabilities` field of its key in a new `agent-permissions` KV bucket,
which swarm-controller alone writes.

- swarm-queue-client: `agent_permissions` (bucket, key, `AgentPermissions`,
  direct-get read, the import subject and its wire types).
- swarm-controller: creates the bucket at start; `GET|PUT|DELETE
  /api/agents/{name}/permissions` (deny_unknown_fields and unknown
  capability ⇒ 422, duplicate ⇒ 400, agent must be declared by a hive);
  answers each hive's one-shot import, writing a key only where there is
  none and only for agents that hive's wanted state declares.
- swarmctl: `agent permissions get|set|clear`.
- swarm-nats-auth: a hive reads every key of the bucket and sends its
  import on its own subject; the controller subscribes to the imports and
  may answer them (`responses`).
- hive-c0re: reads each of its agents' keys every 30s into
  /var/lib/hyperhive/swarm-agent-permissions.json and restarts an agent,
  gracefully, when its capabilities change. Unset is the compiled-in
  default (ruth: manage_root_agent; everyone else: none). A failed read
  keeps the stored value. On first boot it seeds that file from
  meta/capabilities.json, sends the grants to the controller, and deletes
  the file once the controller answers.

Deleted: the capabilities.json reader/writer, `HIVE_CAPABILITIES` in the
rendered meta flake, ruth's capability seed, the capabilities half of the
permissions rebuild job (`PermPayload::Capabilities`/`Combined`), the
dashboard's capabilities table and `capabilities_changed` event, and the
now-unused `Capability::description`.

Part of #5170.
This commit is contained in:
atlas 2026-10-15 00:24:41 +02:00 • committed by mara
commit f862354e43
47 changed files with 1943 additions and 1146 deletions

2
Cargo.lock generated
View file

@ -4884,6 +4884,7 @@ dependencies = [
"hive-jobq-metrics",
"hive-jobq-wire",
"hive-log",
"hive-sh4re",
"hive-types",
"hmac 0.13.0",
"http",
@ -5014,6 +5015,7 @@ dependencies = [
"axum",
"data-encoding",
"futures-util",
"hive-sh4re",
"reqwest",
"rustls",
"serde",

View file

@ -253,8 +253,9 @@ the operator review and merge it. By design, no second, mount-shaped
path reaches the same file without the review.
Agents holding the `manage_root_agent` capability (granted per agent in
`capabilities.json`; see `hive-sh4re/src/permissions.rs`) get additional
host-side bind mounts via `set_nspawn_flags`:
the swarm's `agent-permissions` record; see
`docs/process/conventions.md#capabilities`) get additional host-side bind
mounts via `set_nspawn_flags`:
- `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's
proposed repo, not just direct children. The capability means "may
@ -267,16 +268,13 @@ host-side bind mounts via `set_nspawn_flags`:
An agent without the capability only has its direct children's config
dirs.
⚠️ nspawn bakes bind flags at container start, so granting or
revoking this capability doesn't change any mount until that agent's
container is rebuilt/restarted.
⚠️ nspawn bakes bind flags at container start, so hive-c0re restarts
an agent, after its current turn, when its capabilities change.
The root agent gets the capability by default, seeded on its autodeploy
path (`workers::auto_update::ensure_root_agent`) so the recovery mounts
are there from its first container. That seed only fires while
`capabilities.json` doesn't exist yet: any grant or revoke through
the dashboard creates the file, so a revoked root-agent grant stays
revoked and isn't re-applied on the next hive-c0re restart.
The root agent holds the capability by default: an agent whose record
leaves `capabilities` unset gets the hive's default, which is
`manage_root_agent` for `ruth` and nothing for every other agent.
Setting `"capabilities": []` for her revokes it.
Each proposed repo (`/agents/<n>/config/`) is pre-configured
with `applied` as a git remote pointing at

View file

@ -391,10 +391,6 @@ Contents:
(`{ "alice": ["messaging", "inbox", "execution"] }`). Written by
`tool_groups::set_groups`; injected as `HIVE_TOOL_GROUPS` env
var into each agent's container.
- `capabilities.json` — per-agent capability grants
(`{ "ruth": ["manage_root_agent"] }`). Written by
`capabilities::set_caps`; injected as `HIVE_CAPABILITIES` env
var. Absent agents have no extra capabilities.
- `swarm-resource-limits.json` — the caps the swarm sets for this
hive's agents
(`{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }`).
@ -418,6 +414,13 @@ Contents:
The root agent has the meta dir RO-mounted at `/meta/`.
Outside the meta repo, `/var/lib/hyperhive/swarm-agent-permissions.json`
holds the capabilities the swarm grants this hive's agents, as
`workers::swarm_permissions` last read them from the swarm's
`agent-permissions` bucket (every 30 seconds): a copy, not the
record, which `swarmctl agent permissions` sets. `set_nspawn_flags`
reads it at every container start.
hive-c0re renders containers onto `meta#<n>` at creation.
## Destroy vs purge

View file

@ -334,35 +334,37 @@ that allows the underlying resource access.
| Capability | Effect |
|---|---|
| `manage_root_agent` | may manage *any* agent: bind-mounts every agent's state (rw) + config (ro) into the holder's container, plus `/applied` and `/meta` (ro). Takes effect on the holder's next container rebuild/restart |
| `manage_root_agent` | may manage *any* agent: bind-mounts every agent's state (rw) + config (ro) into the holder's container, plus `/applied` and `/meta` (ro). Takes effect when the holder's container restarts, which hive-c0re does itself on a change |
**Config storage** — per-agent capabilities live in
`/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`.
Format: `{ "ruth": ["manage_root_agent"] }`.
An absent entry means "no extra capabilities." `render_flake` in `meta.rs`
reads this file and injects `HIVE_CAPABILITIES` (comma-separated
`snake_case` names) into each agent's systemd service env; absent entries emit
no env var so agents without capabilities don't trigger a spurious rebuild.
**Where they live** — in the swarm, not on the hive: the `capabilities` 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`). Unset means the hive's default, which is a
constant in hive-c0re (`capabilities::default_for`): none, except `ruth`, who
gets `manage_root_agent`. `"capabilities": []` grants none.
**Setting capabilities** — the operator sets capabilities via the
C4P4B1L1T13S section in the dashboard's P3RM1SS10NS tab.
`hive-c0re::capabilities::set_caps(name, caps)` is the write path.
After a change `meta::sync_agents` commits the updated file; the next agent
rebuild picks up the new `HIVE_CAPABILITIES` env var.
**How a change applies** — every 30 seconds hive-c0re reads the key of each
agent it runs (`workers/swarm_permissions.rs`) and stores the result in
`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo. nspawn
fixes the bind mounts at container start, so when an agent's capabilities
change hive-c0re restarts it after its current turn. A failed read keeps the
last stored value; it never falls back to the default.
**Runtime resolution** — at session start the harness reads `HIVE_CAPABILITIES`
and resolves each token to a `Capability` variant, logging and skipping
unrecognised ones. An absent or empty var means no extra capabilities.
**Moving off `meta/capabilities.json`** — on its first boot with this code a
hive copies the file's grants into the stored value, sends them to
swarm-controller once (written only for agents with no key, and only for
agents that hive's wanted state declares), and deletes the file from the meta
repo when the controller answers (`workers/permissions_import.rs`).
**Capability NOT configurable from `agent.nix`** — same reasoning as tool
groups: an agent that could grant its own capabilities via a config commit would
bypass the operator approval gate.
**Adding a new capability** — add a variant to `Capability` in
`hive-sh4re/src/permissions.rs` + an arm to `as_str`. Add it to `Capability::ALL`
(the source of truth for the permissions UI columns). Implement the access
check in the relevant handler (`hive-c0re/src/socket_server/mod.rs`,
`coordinator.rs`, or a handler under `hive-c0re/src/dashboard/`).
`hive-sh4re/src/permissions.rs` and to `Capability::ALL`. Implement the access
check where hive-c0re enforces it (today: `set_nspawn_flags` in
`hive-c0re/src/lifecycle/host_config.rs`).
## Async forms

View file

@ -73,7 +73,7 @@ Cheap — no build slot:
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots |
| `WritePermFile` | commit `tool-groups.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshot |
| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless |
| `MatrixSweep` | matrix user/space sweep (`matrix::ensure_all`): the boot-time instance, plus one every 30 min from a loop in `main.rs`. Holds `Resource::MatrixSweep` (capacity 1), so two passes never overlap; each tick queues its own pass, which waits for the resource if one is already live. Agentless |
| `KnowledgePull` | `/knowledge` pull (`knowledge::pull`): at boot (commits that landed while `hive-c0re` was down), on the swarm knowledge-changed event, and hourly as a fallback. Holds `Resource::KnowledgeTree` (capacity 1), so two pulls never overlap on the working tree; each trigger queues its own pass, which waits for the resource if one is already live. Agentless |

View file

@ -12,6 +12,10 @@ This document contains the help content for the `swarmctl` command-line program.
* [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get)
* [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set)
* [`swarmctl agent inference clear`↴](#swarmctl-agent-inference-clear)
* [`swarmctl agent permissions`↴](#swarmctl-agent-permissions)
* [`swarmctl agent permissions get`↴](#swarmctl-agent-permissions-get)
* [`swarmctl agent permissions set`↴](#swarmctl-agent-permissions-set)
* [`swarmctl agent permissions clear`↴](#swarmctl-agent-permissions-clear)
* [`swarmctl schedule`↴](#swarmctl-schedule)
* [`swarmctl schedule add`↴](#swarmctl-schedule-add)
* [`swarmctl schedule list`↴](#swarmctl-schedule-list)
@ -66,6 +70,7 @@ Manage agents across the swarm
* `create` — Queue creation of a new agent on a hive in this swarm
* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale
* `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models
* `permissions` — Show or change the capabilities the swarm grants an agent
@ -198,6 +203,77 @@ Clear every field for an agent, so it goes back to its own values
## `swarmctl agent permissions`
Show or change the capabilities the swarm grants an agent.
The hive running the agent reads them every 30 seconds and, when they change, restarts the agent after its current turn. Unset leaves the agent on its hive's default: none, except `ruth`, who gets `manage_root_agent`.
**Usage:** `swarmctl agent permissions <COMMAND>`
###### **Subcommands:**
* `get` — Show the capabilities the swarm grants an agent, or that it sets none
* `set` — Replace the capabilities the swarm grants an agent
* `clear` — Clear the agent's permissions, so its hive's default applies again
## `swarmctl agent permissions get`
Show the capabilities the swarm grants an agent, or that it sets none
**Usage:** `swarmctl agent permissions get [OPTIONS] <AGENT>`
###### **Arguments:**
* `<AGENT>` — Agent name
###### **Options:**
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl agent permissions set`
Replace the capabilities the swarm grants an agent
**Usage:** `swarmctl agent permissions set [OPTIONS] --capabilities <CAPABILITIES> <AGENT>`
###### **Arguments:**
* `<AGENT>` — Agent name. A hive must declare it
###### **Options:**
* `--capabilities <CAPABILITIES>` — The capabilities to grant, comma-separated (`manage_root_agent`). `--capabilities ''` grants none
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl agent permissions clear`
Clear the agent's permissions, so its hive's default applies again
**Usage:** `swarmctl agent permissions clear [OPTIONS] <AGENT>`
###### **Arguments:**
* `<AGENT>` — Agent name
###### **Options:**
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl schedule`
Manage scheduled prompts.

View file

@ -77,8 +77,8 @@ H0M3 hub (served at
`/core.html`), with minimal chrome: a `← home` back-link and a single pane,
**K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that
cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`tombstones_changed`, `capabilities_changed`, and `tool_groups_changed`
(the latter two re-render the stale-perms sub-section when permission
`tombstones_changed` and `tool_groups_changed`
(the latter re-renders the stale-perms sub-section when permission
data changes). (Rebuild queue and meta inputs are dashboard tabs — see
below.)
@ -104,11 +104,10 @@ container is all hive-c0re can actually prove. Fixing it properly
needs a recorded destroy — **deferred** to the swarm-controller /
snapshot-storage rework where the problem changes shape.
*Stale permission entries*: agents with explicit capability or
tool-group JSON entries but no live container — typically renamed or
deleted agents whose entries persisted in `capabilities.json` /
`tool-groups.json`. Loaded when the page opens;
autorefreshes on `capabilities_changed` + `tool_groups_changed` SSE
*Stale permission entries*: agents with explicit tool-group JSON
entries but no live container — typically renamed or deleted agents
whose entries persisted in `tool-groups.json`. Loaded when the page opens;
autorefreshes on `tool_groups_changed` SSE
events. Each ghost agent gets a `✕ clear perms` button
(`DELETE /api/permissions/{name}`).
@ -230,11 +229,13 @@ entry with that id pre-expanded.
## P3RM1SS10NS tab
Per-agent permission configuration. Two sections, each rendered as a
column-driven checkbox matrix: rows are agents, columns are the
permission names fetched from hive-c0re. The column list is
authoritative — adding a new tool-group or capability to hive-c0re
requires no UI change; the new column appears automatically.
Per-agent tool-group configuration, rendered as a column-driven
checkbox matrix: rows are agents, columns are the group names fetched
from hive-c0re. The column list is authoritative — adding a new
tool-group to hive-c0re requires no UI change; the new column appears
automatically. Capabilities aren't set here: they're the swarm's,
set with `swarmctl agent permissions` (see
`docs/process/conventions.md#capabilities`).
The snapshot carries `agents` (the full manageable roster — live
containers ∪ agents with an explicit entry) and `effective` (per-agent
@ -248,29 +249,11 @@ strip those defaults. The `(default)` badge keys off absence from
Fetches fire on tab activation (not page-load) to avoid unnecessary
work when the operator never visits this tab. Live mutations from the
rebuild-queue worker are also pushed via the `capabilities_changed` /
`tool_groups_changed` SSE events (same payload shape as the GET
endpoints), so an open P3RM1SS10NS tab reflects worker-applied changes
rebuild-queue worker are also pushed via the `tool_groups_changed`
SSE event (same payload shape as the GET endpoint), so an open P3RM1SS10NS tab reflects worker-applied changes
without requiring navigation. Tab-activation re-fetches remain as a
safety net for reconnect windows.
**C4P4B1L1T13S** — per-agent capability grants. Capabilities unlock
gated MCP tools and system-level access beyond the default agent
surface. A saving POST queues a rebuild for the affected agent so the
new `HIVE_CAPABILITIES` env var takes effect in the next session.
The current capabilities are:
| Name | Effect |
|------|--------|
| `manage_root_agent` | mounts every agent's state (rw) + config (ro) into this agent's container so it can manage/recover any agent |
Each row is one agent. Columns are the capability names returned by
`GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking
boxes only stages the change in-browser; the browser writes nothing until
the operator selects the page-level **save all** button (described below). Row
values follow the `effective`/`assignments` rule described above.
<!-- vale write-good.Passive = NO -->
**T00L GR0UPS** — per-agent tool-group permissions. Tool groups are
named buckets of MCP tools; each agent starts with a role default
@ -291,10 +274,9 @@ The current tool groups are: `messaging`, `meta`, `inbox`, `lifecycle`,
Claude's built-in `WebFetch` and `WebSearch` to `--tools` /
`--allowedTools` for that agent session.
Both tables share the same visual shape: `.cap-table-wrap` /
`.tg-table-wrap` outer scroll container, `thead` with a label column
(`.cap-agent-col` / `.tg-agent-col`) + one column per permission
(`.cap-col` / `.tg-group-col`). Each tbody row is one agent: a name cell
The table's shape: a `.tg-table-wrap` outer scroll container, `thead`
with a label column (`.tg-agent-col`) + one column per group
(`.tg-group-col`). Each tbody row is one agent: a name cell
and its checkbox cells, where each checkbox carries `data-baseline` (its
render-time state) and the row carries `data-agent` for dirty-tracking.
@ -302,18 +284,15 @@ render-time state) and the row carries `data-agent` for dirty-tracking.
buttons exist. A single page-level `.perm-save-bar` with a **save all (N
agents)** button sits at the bottom of the pane, enabled only when some
checkbox diverges from its baseline. Selecting it diffs every checkbox
across *both* matrices and POSTs one batch to `POST /api/permissions` as
`{ changes: [ { agent, tool_groups?, capabilities? } ] }` — the batch
includes only the perm-types that actually changed for each agent (an
omitted field leaves that file untouched; an included array fully
replaces it).
hive-c0re coalesces an agent's capabilities + tool-groups into a
single rebuild, so changing both for one agent is one rebuild, not two.
and POSTs one batch to `POST /api/permissions` as
`{ changes: [ { agent, tool_groups } ] }` — the batch includes only the
agents whose groups changed (an included array fully replaces the
agent's groups).
The batch is atomic: it validates every change first and on any error
rejects the whole POST (`{error}`, nothing applied); a clean 200 (`ok`)
flips the bar to a queued→rebuilding state and re-fetches both tables.
Live `capabilities_changed` / `tool_groups_changed` events re-render the
matrices unless the section has unsaved edits, so an in-progress edit set
flips the bar to a queued→rebuilding state and re-fetches the table.
Live `tool_groups_changed` events re-render the
matrix unless it has unsaved edits, so an in-progress edit set
isn't clobbered.
## ST4TS page (`/stats.html`)
@ -767,31 +746,16 @@ below — some endpoints aren't in it yet.
`/var/lib/hyperhive/meta/tool-groups.json` and queues a rebuild so
`HIVE_TOOL_GROUPS` takes effect. Agent name validated;
`guard_agent_name` applied.
- `GET /api/capabilities` — returns `{ caps: Vec<String>,
assignments: BTreeMap<String, Vec<String>>,
descriptions: BTreeMap<String, String> }`. `caps` is the
ordered list of all known capability names; `assignments` is the
per-agent grant map (absent agents have no extra capabilities);
`descriptions` maps each capability name to a short human-readable
string surfaced as a column-header tooltip on hover.
- `POST /api/capabilities/{agent}` — body `{ caps: ["name", …] }`.
Writes the capability set for `{agent}` to
`/var/lib/hyperhive/meta/capabilities.json` and queues a rebuild so
`HIVE_CAPABILITIES` takes effect. Agent name validated;
unknown capability strings rejected (400). `guard_agent_name`
applied.
- `POST /api/permissions` — batch perm apply for the save-all
permissions button. Body
`{ changes: [{ agent, tool_groups?: ["name", …], capabilities?: ["name", …] }] }`.
Sparse per agent: an omitted field leaves that perm-type untouched,
an empty array clears it, a populated array fully replaces it (same
replace semantics as the per-agent endpoints above). Each affected
agent gets ONE combined `PermChange` queue entry, so changing both
an agent's tool-groups and capabilities triggers a single rebuild,
not two. **Atomic**: the endpoint validates every change first (agent names via
`guard_agent_name`, group + capability names) and on any validation
error writes or enqueues nothing (non-2xx `{ error }`); it skips
rows with both fields omitted rather than erroring on them.
`{ changes: [{ agent, tool_groups?: ["name", …] }] }`.
An omitted `tool_groups` leaves the agent untouched, an empty array
clears it, a populated array fully replaces it (same replace
semantics as the per-agent endpoint above). Each affected agent gets
one `PermChange` queue entry. **Atomic**: the endpoint validates every
change first (agent names via `guard_agent_name`, group names) and on
any validation error writes or enqueues nothing (non-2xx `{ error }`);
it skips rows with the field omitted rather than erroring on them.
Returns `200 "ok"` on
success.
- `POST /api/meta-update` — `nix flake update` the selected
@ -841,13 +805,6 @@ payload):
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
with Reminder kind, and the scheduler tick after each delivery
batch (`reminder_scheduler`).
- `capabilities_changed` (seq, caps: `Vec<str>`, descriptions: map,
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
capability grants. Emitted from the rebuild-queue worker after a
`PermChange` / Capabilities entry commits the JSON file. Payload
matches `GET /api/capabilities` shape so the client can call
`renderCapabilities` directly. P3RM1SS10NS tab subscribes; activation
re-fetch still runs as a safety net.
- `tool_groups_changed` (seq, groups: `Vec<str>`, descriptions: map,
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
tool-group assignments. Emitted from the rebuild-queue worker after

View file

@ -27,8 +27,8 @@
<main class="core-main">
<!-- tombstones-section shows destroyed agents (purge button).
tombstones-stale-perms shows agents with explicit capability/
tool-group entries but no live container (typically renamed/deleted
tombstones-stale-perms shows agents with explicit tool-group
entries but no live container (typically renamed/deleted
agents whose JSON entries persisted). Each stale entry gets a
"✕ clear perms" button. -->
<p class="meta">

View file

@ -96,7 +96,7 @@ function renderTombstones(s) {
}
// ─── stale permission entries (K3PT ST4T3 pane sub-section) ──────────────
// Agents with explicit capability / tool-group JSON entries but no live
// Agents with explicit tool-group JSON entries but no live
// container AND no kept-state tombstone (e.g. renamed agents like the old
// "root" manager name). Ghost detection is server-side via
// GET /api/permissions/stale so the client doesn't need to maintain a
@ -114,7 +114,7 @@ function renderStalePerms(root, ghosts) {
el(
"p",
{ class: "meta" },
"agents with explicit capability or tool-group entries but no live container " +
"agents with explicit tool-group entries but no live container " +
"or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).",
),
);
@ -129,7 +129,7 @@ function renderStalePerms(root, ghosts) {
{
type: "button",
class: "btn btn-destroy",
title: "remove explicit capability and tool-group entries for " + name,
title: "remove explicit tool-group entries for " + name,
},
"✕ clear perms",
);
@ -194,9 +194,6 @@ const SSE_HANDLERS = {
},
// Refresh the stale-perms sub-section when perm data changes (a ghost
// was cleared, or perms were saved for an agent whose name collides).
capabilities_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
tool_groups_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
@ -234,8 +231,7 @@ async function init() {
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream(
"/api/dashboard/stream?kinds=tombstones_changed," +
"capabilities_changed,tool_groups_changed",
"/api/dashboard/stream?kinds=tombstones_changed,tool_groups_changed",
);
if (es) {
es.onmessage = (e) => {

View file

@ -566,57 +566,9 @@ footer .banner-thin {
margin-bottom: 0.8em;
}
/* ─── capabilities (permissions) table ─────────────────────────────
Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout
as the tool-groups table below. Horizontally scrollable on narrow
viewports. */
.cap-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.cap-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.cap-table th,
.cap-table td {
padding: 0.35em 0.6em;
border: 1px solid var(--border);
text-align: center;
vertical-align: middle;
}
.cap-table thead th {
background: var(--bg-elev);
color: var(--muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.cap-agent-col {
text-align: left !important;
min-width: 8em;
}
.cap-col {
min-width: 5em;
}
.cap-agent-name {
color: var(--fg);
font-weight: 600;
}
.cap-cb {
cursor: pointer;
width: 1em;
height: 1em;
accent-color: var(--purple);
}
.cap-row:hover td {
background: var(--bg-elev);
}
/* ─── tool-groups (permissions) table ──────────────────────────────
Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout
as the capabilities table above. Horizontally scrollable on narrow
viewports. */
Agents × tool-groups matrix in the P3RM1SS10NS tab. Horizontally
scrollable on narrow viewports. */
.tg-table-wrap {
overflow-x: auto;
margin-top: 0.5em;

View file

@ -71,8 +71,8 @@
>
<span class="tab-label">◆ BUILD L0GS ◆</span>
</a>
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
assignments. Both tables are fetched on tab activation. -->
<!-- P3RM1SS10NS: per-agent tool-group
assignments, fetched on tab activation. -->
<a
class="tab"
id="tab-permissions"
@ -205,13 +205,12 @@
<div id="build-list"><p class="meta">loading…</p></div>
</section>
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
assignments. Both tables are column-driven from the backend
(GET /api/capabilities, GET /api/tool-groups) so new entries
require no UI change. Edits stage in-browser across both
matrices; the single page-level "save all" button POSTs every
changed agent to /api/permissions in one atomic batch (caps +
groups coalesce into one rebuild per agent). -->
<!-- P3RM1SS10NS: per-agent tool-group assignments, column-driven
from the backend (GET /api/tool-groups) so new groups require no
UI change. Edits stage in-browser; the page-level "save all"
button POSTs every changed agent to /api/permissions in one
atomic batch (one rebuild per agent). Capabilities are the
swarm's (`swarmctl agent permissions`), not set here. -->
<section
class="tab-pane"
id="tab-pane-permissions"
@ -220,25 +219,11 @@
role="tabpanel"
aria-labelledby="tab-permissions"
>
<h2>◆ C4P4B1L1T13S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent capability grants. capabilities unlock gated MCP tools and
system access. toggle any cells across both tables, then hit "save
all" at the bottom — each changed agent rebuilds exactly once.
</p>
<div id="capabilities-section">
<p class="meta">loading…</p>
</div>
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
cols = tool groups fetched from GET /api/tool-groups.
Checking / unchecking is staged in the UI; the single page-level
"save all" button POSTs every changed agent to
/api/permissions in one atomic batch, coalescing caps + groups
per agent into one rebuild each.
/api/permissions in one atomic batch, one rebuild each.
Absent agents default to the role default (shown in parens). -->
<h2>◆ T00L GR0UPS ◆</h2>
<div class="divider">
@ -255,10 +240,9 @@
</div>
<!-- Page-level save bar (save-all issue): one button for the whole
permissions page. Staged checkbox edits across BOTH matrices are
diffed against their render-time baselines and POSTed together to
/api/permissions; caps + groups for one agent coalesce into a
single rebuild. Disabled until something is dirty. The atomic
permissions page. Staged checkbox edits are diffed against their
render-time baselines and POSTed together to /api/permissions, one
rebuild per agent. Disabled until something is dirty. The atomic
batch either fully lands (queued ✓ → rebuilding) or fully fails
(error note, nothing applied). -->
<div class="perm-save-bar">

View file

@ -1,11 +1,11 @@
// Dashboard P3RM1SS10NS tab — the per-agent capabilities + tool-groups
// matrices, with a single page-level "save all" button.
// Dashboard P3RM1SS10NS tab — the per-agent tool-groups matrix, with a
// page-level "save all" button. Capabilities are not set here: they are
// the swarm's (`swarmctl agent permissions`).
//
// Both tables are fetched on tab activation (`GET /api/capabilities`,
// `GET /api/tool-groups`) and after each save. Columns (caps / groups)
// come from the backend so the UI needs no change when a new one is
// added. Live updates arrive via the `capabilities_changed` /
// `tool_groups_changed` dashboard events (fired after the rebuild-queue
// The table is fetched on tab activation (`GET /api/tool-groups`) and
// after each save. Columns come from the backend so the UI needs no
// change when a new group is added. Live updates arrive via the
// `tool_groups_changed` dashboard event (fired after the rebuild-queue
// worker commits the perm JSON file), wired into the entry's mutation
// dispatch table.
//
@ -15,11 +15,9 @@
// assignments map.
//
// Editing model: checkboxes are diffed against a `data-baseline` captured at
// render time; only changed perm-types per agent are POSTed to
// `POST /api/permissions` as `{ changes: [{agent, tool_groups?, capabilities?}] }`.
// Omitted field = leave untouched; included array = full replace. The backend
// coalesces caps+groups per agent into ONE queue entry (one rebuild, no
// double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200.
// render time; only changed agents are POSTed to `POST /api/permissions` as
// `{ changes: [{agent, tool_groups}] }` (a full replace per agent). Batch is
// atomic — saved→rebuilding only fires on a clean 200.
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
@ -32,13 +30,6 @@ import { asyncBtn } from "@hive/shared/forms.js";
// is worse than a brief staleness window. The tab-activation re-fetch
// and the post-save re-fetch are the recovery paths; both clear dirty.
export function applyCapabilitiesChanged(ev) {
const root = $("capabilities-section");
if (!root) return;
if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return;
renderCapabilities(root, ev);
}
export function applyToolGroupsChanged(ev) {
const root = $("tool-groups-section");
if (!root) return;
@ -56,121 +47,6 @@ function sectionHasDirty(root) {
return false;
}
export async function fetchAndRenderCapabilities() {
const root = $("capabilities-section");
if (!root) return;
root.replaceChildren();
root.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch("/api/capabilities");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderCapabilities(root, data);
} catch (err) {
root.replaceChildren();
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
function renderCapabilities(root, data) {
root.replaceChildren();
const { caps, descriptions = {}, assignments, effective = {} } = data;
if (!caps || !caps.length) {
root.append(el("p", { class: "meta" }, "(no capabilities defined)"));
updateSaveBar();
return;
}
// Agent rows: prefer the backend roster (every manageable agent,
// default-perms included). Fall back to the live-container ∪ explicit
// union for older payloads that don't carry `agents`.
const agentNames =
data.agents && data.agents.length
? [...data.agents]
: [
...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
]),
].sort();
if (!agentNames.length) {
root.append(el("p", { class: "meta" }, "(no agents)"));
updateSaveBar();
return;
}
const wrap = el("div", { class: "cap-table-wrap" });
const table = el("table", { class: "cap-table" });
// Header row.
const thead = el("thead");
const hrow = el("tr");
hrow.append(el("th", { class: "cap-agent-col" }, "agent"));
for (const c of caps) {
hrow.append(el("th", { class: "cap-col", title: descriptions[c] || c }, c));
}
thead.append(hrow);
table.append(thead);
const tbody = el("tbody");
for (const name of agentNames) {
// Effective caps (explicit-or-default) drive the checkboxes so
// default-perms agents show their real grants, not blank.
const assigned = effective[name] || assignments[name] || [];
// `containersState` is keyed from `nixos-container list`, which
// includes stopped-but-configured containers — so a temporarily-stopped
// agent is NOT stale. Only destroyed/renamed agents are absent here.
const isStale = !containersState.has(name);
const tr = el("tr", {
class: "cap-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell.
const nameTd = el("td", { class: "cap-agent-col" });
nameTd.append(el("span", { class: "cap-agent-name" }, name));
if (isStale) {
nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
const removeBtn = el(
"button",
{
type: "button",
class: "perm-remove-btn",
title: "remove stale permission entries for " + name,
},
"✕ remove",
);
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
nameTd.append(removeBtn);
}
tr.append(nameTd);
// One checkbox per capability.
for (const c of caps) {
const checked = assigned.includes(c);
const td = el("td", { class: "cap-col" });
const cb = el("input", {
type: "checkbox",
class: "cap-cb",
"data-cap": c,
"data-baseline": checked ? "1" : "0",
"aria-label": c,
});
cb.checked = checked;
cb.addEventListener("change", onCellToggle);
td.append(cb);
tr.append(td);
}
tbody.append(tr);
}
table.append(tbody);
wrap.append(table);
root.append(wrap);
updateSaveBar();
}
export async function fetchAndRenderToolGroups() {
const root = $("tool-groups-section");
if (!root) return;
@ -303,20 +179,11 @@ function onCellToggle() {
updateSaveBar();
}
// Collect the sparse change-set: one entry per agent that has at least
// one perm-type diverging from baseline. Only the changed perm-type(s)
// are included so untouched defaults stay defaults (an included array is
// a full replacement → omitting it leaves that file alone).
// Collect the sparse change-set: one entry per agent whose groups diverge
// from baseline, so untouched defaults stay defaults (an included array is
// a full replacement).
function collectChanges() {
const byAgent = new Map(); // agent -> { capabilities?, tool_groups? }
collectSection(
$("capabilities-section"),
".cap-row",
".cap-cb",
"cap",
"capabilities",
byAgent,
);
const byAgent = new Map(); // agent -> { tool_groups }
collectSection(
$("tool-groups-section"),
".tg-row",
@ -363,7 +230,7 @@ function updateSaveBar() {
// Remove all explicit permission entries for a stale (non-running)
// agent. Calls DELETE /api/permissions/{agent}, which bypasses the
// roster guard so the stale entries can be cleaned up even though the
// agent isn't in the live container list. Re-fetches both tables after
// agent isn't in the live container list. Re-fetches the table after
// the delete so the row disappears immediately.
async function clearStaleAgent(name, sectionRoot) {
const btn = sectionRoot
@ -381,11 +248,8 @@ async function clearStaleAgent(name, sectionRoot) {
setSaveNote("failed to remove " + name + ": " + text, true);
return;
}
// Re-fetch both sections so the stale row disappears.
await Promise.all([
fetchAndRenderCapabilities(),
fetchAndRenderToolGroups(),
]);
// Re-fetch so the stale row disappears.
await fetchAndRenderToolGroups();
} catch (err) {
setSaveNote("failed to remove " + name + ": " + err, true);
}
@ -437,14 +301,13 @@ async function saveAll() {
return;
}
// Atomic 200: the whole batch landed. Show a rebuilding hint and
// re-fetch both tables once the queue worker has committed — that
// re-fetch the table once the queue worker has committed — that
// resets baselines (dirty clears) and the bar disables itself.
const n = changes.length;
btn.textContent = "queued ✓";
setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}…`, false);
setTimeout(() => {
btn.dataset.busy = "";
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}, 900);
} catch (err) {

View file

@ -2,7 +2,7 @@
// dashboard SPA: the live agent roster.
//
// The roster is read by nearly every tab (the container tree, the
// capabilities + tool-group matrices, and the selection bar), so
// tool-group matrix, and the selection bar), so
// it lives here as a single source of truth that each domain module
// imports rather than threading through call signatures. Everything
// else in the dashboard is domain-local and lives with its own module.

View file

@ -201,9 +201,9 @@
sub-step label` — the rule has had no JS referents since then). */
/* ─── K3PT ST4T3 › stale permission entries sub-section ────────────────────
Agents with explicit capability / tool-group entries in the JSON but no
live container (renamed or manually-deleted agents). Lazy-loaded on tab
activation, refreshed on capabilities_changed / tool_groups_changed. */
Agents with explicit tool-group entries in the JSON but no live
container (renamed or manually-deleted agents). Lazy-loaded on tab
activation, refreshed on tool_groups_changed. */
.tombstones-stale-heading {
margin: 1.2em 0 0.3em;
color: var(--muted);

View file

@ -17,9 +17,7 @@ import { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js";
import { containersState, syncContainersFromSnapshot } from "./state.js";
import {
applyCapabilitiesChanged,
applyToolGroupsChanged,
fetchAndRenderCapabilities,
fetchAndRenderToolGroups,
initPermissions,
} from "./permissions.js";
@ -64,7 +62,6 @@ window.marked = marked;
const MANAGED_SECTION_IDS = [
"containers-section",
"inbox-section",
"capabilities-section",
"tool-groups-section",
];
// <details> sections that should survive a refresh need a stable
@ -210,7 +207,6 @@ window.marked = marked;
},
meta_inputs_changed: onMetaInputsChanged,
meta_update_running: onMetaUpdateRunning,
capabilities_changed: applyCapabilitiesChanged,
tool_groups_changed: applyToolGroupsChanged,
};
(function bindDashboardStream() {
@ -228,7 +224,7 @@ window.marked = marked;
"transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," +
"meta_inputs_changed,meta_update_running," +
"capabilities_changed,tool_groups_changed",
"tool_groups_changed",
);
es.onmessage = (e) => {
let ev;
@ -278,13 +274,12 @@ window.marked = marked;
// visibility (bar only belongs on 4G3NTS where agent cards live).
document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values()));
// Permissions tables: SSE covers worker-applied changes
// (capabilities_changed / tool_groups_changed); re-fetch on
// Permissions table: SSE covers worker-applied changes
// (tool_groups_changed); re-fetch on
// activation as a safety net for any gap between SSE events and
// the cold-load snapshot.
if (target === "permissions") {
initPermissions();
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}
onBuildsTabShown(target);

View file

@ -57,6 +57,7 @@ swarm-queue-client = { workspace = true, features = [
"kv",
"notices",
"agent-limits",
"agent-permissions",
] }
swarm-secret-client.workspace = true
tokio.workspace = true

View file

@ -1,262 +1,109 @@
//! Per-agent capability configuration. Stored at
//! `/var/lib/hyperhive/meta/capabilities.json` alongside `topology.json`
//! and `tool-groups.json`.
//! The capabilities the swarm grants this hive's agents, as last read from the
//! `agent-permissions` bucket by `workers::swarm_permissions`. Stored at
//! `/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo:
//! the swarm is the record, this file is the hive's last read of it.
//!
//! Format: a JSON object mapping agent name to an array of
//! `hive_sh4re::permissions::Capability` `snake_case` strings:
//! Format: agent name → the stored
//! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with
//! no entry, or an entry with `capabilities` unset, holds its default
//! ([`default_for`]); `"capabilities": []` holds none.
//!
//! ```json
//! {
//! "atlas": ["manage_root_agent"]
//! }
//! ```
//!
//! An absent entry (or an absent file) means "no extra capabilities".
//! `render_flake` in `meta.rs` reads this file and injects
//! `HIVE_CAPABILITIES` into each agent's systemd service env; absent
//! entries emit no env var so agents without capabilities don't trigger
//! a spurious rebuild.
//!
//! Write path: `set_caps` is called from the dashboard action handler
//! that the operator uses to grant/revoke capabilities per agent.
//! Read path: `lifecycle::host_config::set_nspawn_flags`, on every container
//! start, which is why a change takes a restart.
use hive_sh4re::permissions::Capability;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const CAPABILITIES_FILE: &str = "capabilities.json";
use hive_sh4re::permissions::Capability;
use swarm_queue_client::agent_permissions::AgentPermissions;
const FILE: &str = "swarm-agent-permissions.json";
pub type Stored = BTreeMap<String, AgentPermissions>;
#[must_use]
pub fn capabilities_path() -> PathBuf {
crate::paths::meta_root().join(CAPABILITIES_FILE)
pub fn path() -> PathBuf {
crate::paths::state_root().join(FILE)
}
/// True if `name` matches a recognised [`Capability`], case-insensitive
/// (same comparison [`has_cap`] uses).
/// Read the stored permissions. An absent file is the empty map: every agent
/// on its default. A file that exists but can't be read or parsed is an error.
pub fn read_at(path: &Path) -> std::io::Result<Stored> {
super::read_map(path)
}
/// Persist the permissions atomically.
pub fn write_at(path: &Path, stored: &Stored) -> std::io::Result<()> {
super::write_map(path, stored)
}
/// The capabilities an agent holds when the swarm sets none: `ruth` recovers
/// the other agents, every other agent holds nothing.
#[must_use]
fn is_known(name: &str) -> bool {
Capability::ALL
.iter()
.any(|cap| name.eq_ignore_ascii_case(<&str>::from(*cap)))
}
/// Drop any capability string in `map` that isn't a recognised
/// [`Capability`], warning per dropped string so a typo'd or stale
/// grant doesn't look like it worked. An agent left with no
/// capabilities is removed entirely, matching [`set_caps`]'s
/// empty-vec behaviour. Returns whether anything was dropped.
fn prune_unknown(map: &mut BTreeMap<String, Vec<String>>) -> bool {
let mut dropped_any = false;
map.retain(|agent, caps| {
caps.retain(|cap| {
let known = is_known(cap);
if !known {
dropped_any = true;
tracing::warn!(agent, capability = %cap, "dropping unrecognised capability");
}
known
});
!caps.is_empty()
});
dropped_any
}
/// Read the per-agent capability map. An absent file is the empty map —
/// callers treat a missing entry as "no extra capabilities". A file
/// that exists but can't be read or parsed is an error. Any entry that
/// isn't a recognised [`Capability`] is dropped (with a `warn!`) from
/// what's returned — a stale or typo'd name is never honoured — but
/// `read` itself never writes. Unknown names stay on disk until the next
/// write: `set_caps` always rewrites the file, `remove_agent` only when
/// the agent had an entry.
pub fn read() -> std::io::Result<BTreeMap<String, Vec<String>>> {
read_from(&capabilities_path())
}
fn read_from(path: &Path) -> std::io::Result<BTreeMap<String, Vec<String>>> {
let mut map = super::read_map(path)?;
prune_unknown(&mut map);
Ok(map)
}
/// Look up the configured capabilities for one agent in the file at
/// `path` (normally [`capabilities_path`]). Returns an empty vec when
/// the agent has no entry. Errors as [`read`] does.
pub fn caps_for(path: &Path, name: &str) -> std::io::Result<Vec<String>> {
Ok(read_from(path)?.get(name).cloned().unwrap_or_default())
}
/// Check whether an agent holds a specific capability, per the file at
/// `path` (normally [`capabilities_path`]). Errors as [`read`] does.
pub fn has_cap(
path: &Path,
name: &str,
cap: hive_sh4re::permissions::Capability,
) -> std::io::Result<bool> {
Ok(caps_for(path, name)?
.iter()
.any(|s| s.eq_ignore_ascii_case(<&str>::from(cap))))
}
/// Filter `caps` down to recognised [`Capability`] names (warning per
/// drop) and apply the result to `current` for `name`: an empty
/// result — whether `caps` started empty or every name in it was
/// unknown — removes the entry, matching `read`'s empty-entry
/// convention. Split out from [`set_caps`] so the decision logic is
/// testable without touching the real capabilities file.
fn apply_known_caps(current: &mut BTreeMap<String, Vec<String>>, name: &str, caps: &[String]) {
let known: Vec<String> = caps
.iter()
.filter(|cap| {
let ok = is_known(cap);
if !ok {
tracing::warn!(agent = name, capability = %cap, "dropping unrecognised capability");
}
ok
})
.cloned()
.collect();
if known.is_empty() {
current.remove(name);
pub fn default_for(agent: &str) -> Vec<Capability> {
if agent == crate::lifecycle::MANAGER_NAME {
vec![Capability::ManageRootAgent]
} else {
current.insert(name.to_owned(), known);
Vec::new()
}
}
/// Set the capabilities for one agent and persist the map. Any name
/// that isn't a recognised [`Capability`] is dropped (with a `warn!`)
/// rather than written — an unknown grant should never look like it
/// took effect. An empty `caps` vec, or one that becomes empty after
/// dropping unknown names, removes the entry (agent has no
/// capabilities). Fails without writing if the existing file can't be
/// read or parsed.
pub fn set_caps(name: &str, caps: &[String]) -> std::io::Result<()> {
set_caps_at(&capabilities_path(), name, caps)
/// The capabilities `agent` holds under `record`.
#[must_use]
pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec<Capability> {
record
.and_then(|r| r.capabilities.clone())
.unwrap_or_else(|| default_for(agent))
}
fn set_caps_at(path: &Path, name: &str, caps: &[String]) -> std::io::Result<()> {
let mut current = read_from(path)?;
apply_known_caps(&mut current, name, caps);
super::write_map(path, &current)
}
/// Remove an agent from the capability map entirely. Called by
/// `meta::sync_agents` when an agent is deprovisioned so stale entries
/// don't accumulate. No-op if the agent has no entry. Fails without
/// writing if the existing file can't be read or parsed.
pub fn remove_agent(name: &str) -> std::io::Result<()> {
remove_agent_at(&capabilities_path(), name)
}
fn remove_agent_at(path: &Path, name: &str) -> std::io::Result<()> {
let mut current = read_from(path)?;
if current.remove(name).is_some() {
super::write_map(path, &current)?;
}
Ok(())
/// Whether `agent` holds `cap` per the file at `path` (normally [`path`]).
///
/// # Errors
///
/// As [`read_at`].
pub fn holds(path: &Path, agent: &str, cap: Capability) -> std::io::Result<bool> {
Ok(effective(agent, read_at(path)?.get(agent)).contains(&cap))
}
#[cfg(test)]
mod tests {
use super::*;
// `read`/`set_caps`/`remove_agent` call `crate::paths::meta_root`,
// which is hardcoded to `/var/lib/hyperhive` (no test override) — so
// these tests pin the pure decision logic (`prune_unknown`,
// `apply_known_caps`) against in-memory maps, and the file handling
// through the path-taking `*_at` / `read_from` variants.
const TRUNCATED: &str = "{\n \"atlas\": [\"manage_root_ag";
fn corrupt_file() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(CAPABILITIES_FILE);
std::fs::write(&path, TRUNCATED).expect("seed");
(dir, path)
fn record(json: &str) -> AgentPermissions {
serde_json::from_str(json).expect("decodes")
}
#[test]
fn read_of_a_corrupt_file_is_an_error() {
let (_dir, path) = corrupt_file();
read_from(&path).expect_err("truncated JSON must not read as a map");
}
#[test]
fn set_caps_leaves_a_corrupt_file_untouched() {
let (_dir, path) = corrupt_file();
let err = set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()])
.expect_err("a corrupt file must not be overwritten");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes());
}
#[test]
fn remove_agent_leaves_a_corrupt_file_untouched() {
let (_dir, path) = corrupt_file();
let err =
remove_agent_at(&path, "atlas").expect_err("a corrupt file must not be overwritten");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes());
}
#[test]
fn set_caps_round_trips_through_a_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(CAPABILITIES_FILE);
assert!(read_from(&path).expect("missing file is empty").is_empty());
set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()]).expect("set");
let map = read_from(&path).expect("read");
assert_eq!(map["ruth"], vec!["manage_root_agent".to_owned()]);
}
#[test]
fn prune_unknown_keeps_known_name() {
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
assert!(!prune_unknown(&mut map));
assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]);
}
#[test]
fn prune_unknown_drops_unrecognised_name() {
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["fly_to_the_moon".to_owned()])]);
assert!(prune_unknown(&mut map));
assert!(!map.contains_key("atlas"));
}
#[test]
fn prune_unknown_keeps_known_and_drops_unknown_in_same_entry() {
let mut map = BTreeMap::from([(
"atlas".to_owned(),
vec!["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()],
)]);
assert!(prune_unknown(&mut map));
assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]);
}
#[test]
fn apply_known_caps_removes_entry_on_empty_input() {
let mut current =
BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
apply_known_caps(&mut current, "atlas", &[]);
assert!(!current.contains_key("atlas"));
}
#[test]
fn apply_known_caps_removes_entry_when_only_unknown_names_given() {
let mut current =
BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
apply_known_caps(&mut current, "atlas", &["fly_to_the_moon".to_owned()]);
assert!(!current.contains_key("atlas"));
}
#[test]
fn apply_known_caps_keeps_only_the_known_name() {
let mut current = BTreeMap::new();
apply_known_caps(
&mut current,
"atlas",
&["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()],
fn unset_is_the_default_and_an_empty_list_is_none() {
assert_eq!(effective("ruth", None), vec![Capability::ManageRootAgent]);
assert_eq!(
effective("ruth", Some(&record("{}"))),
vec![Capability::ManageRootAgent]
);
assert_eq!(current["atlas"], vec!["manage_root_agent".to_owned()]);
assert!(effective("ruth", Some(&record(r#"{"capabilities":[]}"#))).is_empty());
assert!(effective("atlas", None).is_empty());
assert_eq!(
effective(
"atlas",
Some(&record(r#"{"capabilities":["manage_root_agent"]}"#))
),
vec![Capability::ManageRootAgent]
);
}
#[test]
fn a_missing_file_holds_the_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE);
assert!(holds(&path, "ruth", Capability::ManageRootAgent).expect("missing is empty"));
assert!(!holds(&path, "atlas", Capability::ManageRootAgent).expect("missing is empty"));
}
#[test]
fn a_corrupt_file_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE);
std::fs::write(&path, "{\n \"ruth\": {\"capabilities\": [\"manage_root_ag").expect("seed");
holds(&path, "ruth", Capability::ManageRootAgent).expect_err("truncated JSON");
}
}

View file

@ -1,6 +1,6 @@
//! Per-agent configuration registries: tool groups, capabilities,
//! resource limits, topology (all JSON files under
//! `/var/lib/hyperhive/meta/`) and the shared wire-protocol size
//! Per-agent configuration registries: tool groups, resource limits,
//! topology (JSON files under `/var/lib/hyperhive/meta/`), the capabilities
//! the swarm grants (outside the meta repo) and the shared wire-protocol size
//! limits. Each submodule is re-exported at the crate root, so
//! `crate::topology::…` etc. keep working unchanged.
//!

View file

@ -3,7 +3,7 @@
//!
//! The swarm caps are stored at
//! `/var/lib/hyperhive/meta/swarm-resource-limits.json` alongside
//! `topology.json`, `tool-groups.json` and `capabilities.json`.
//! `topology.json` and `tool-groups.json`.
//!
//! Format: a JSON object mapping agent name to an object with optional
//! `cpu_quota` / `memory_max` strings, passed verbatim to systemd's

View file

@ -13,7 +13,8 @@
//! bind-mounted ([`all_agents`]).
//!
//! Broader-than-your-own bind-mount grants are **not** stored here: they
//! hang off the `ManageRootAgent` capability in `capabilities.json`. See
//! hang off the `ManageRootAgent` capability the swarm grants
//! (`swarm_permissions`). See
//! `lifecycle::set_nspawn_flags`.
use std::collections::BTreeSet;

Some files were not shown because too many files have changed in this diff Show more