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

View file

@ -391,10 +391,6 @@ Contents:
(`{ "alice": ["messaging", "inbox", "execution"] }`). Written by (`{ "alice": ["messaging", "inbox", "execution"] }`). Written by
`tool_groups::set_groups`; injected as `HIVE_TOOL_GROUPS` env `tool_groups::set_groups`; injected as `HIVE_TOOL_GROUPS` env
var into each agent's container. 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 - `swarm-resource-limits.json` — the caps the swarm sets for this
hive's agents hive's agents
(`{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }`). (`{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }`).
@ -418,6 +414,13 @@ Contents:
The root agent has the meta dir RO-mounted at `/meta/`. 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. hive-c0re renders containers onto `meta#<n>` at creation.
## Destroy vs purge ## Destroy vs purge

View file

@ -334,35 +334,37 @@ that allows the underlying resource access.
| Capability | Effect | | 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 **Where they live** — in the swarm, not on the hive: the `capabilities` field
`/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`. of the agent's key in the `agent-permissions` KV bucket
Format: `{ "ruth": ["manage_root_agent"] }`. (`swarm-queue-client/src/agent_permissions.rs`), which swarm-controller alone
An absent entry means "no extra capabilities." `render_flake` in `meta.rs` writes. Set them with `swarmctl agent permissions get|set|clear <agent>`
reads this file and injects `HIVE_CAPABILITIES` (comma-separated (`/api/agents/{name}/permissions`). Unset means the hive's default, which is a
`snake_case` names) into each agent's systemd service env; absent entries emit constant in hive-c0re (`capabilities::default_for`): none, except `ruth`, who
no env var so agents without capabilities don't trigger a spurious rebuild. gets `manage_root_agent`. `"capabilities": []` grants none.
**Setting capabilities** — the operator sets capabilities via the **How a change applies** — every 30 seconds hive-c0re reads the key of each
C4P4B1L1T13S section in the dashboard's P3RM1SS10NS tab. agent it runs (`workers/swarm_permissions.rs`) and stores the result in
`hive-c0re::capabilities::set_caps(name, caps)` is the write path. `/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo. nspawn
After a change `meta::sync_agents` commits the updated file; the next agent fixes the bind mounts at container start, so when an agent's capabilities
rebuild picks up the new `HIVE_CAPABILITIES` env var. 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` **Moving off `meta/capabilities.json`** — on its first boot with this code a
and resolves each token to a `Capability` variant, logging and skipping hive copies the file's grants into the stored value, sends them to
unrecognised ones. An absent or empty var means no extra capabilities. 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 **Capability NOT configurable from `agent.nix`** — same reasoning as tool
groups: an agent that could grant its own capabilities via a config commit would groups: an agent that could grant its own capabilities via a config commit would
bypass the operator approval gate. bypass the operator approval gate.
**Adding a new capability** — add a variant to `Capability` in **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` `hive-sh4re/src/permissions.rs` and to `Capability::ALL`. Implement the access
(the source of truth for the permissions UI columns). Implement the access check where hive-c0re enforces it (today: `set_nspawn_flags` in
check in the relevant handler (`hive-c0re/src/socket_server/mod.rs`, `hive-c0re/src/lifecycle/host_config.rs`).
`coordinator.rs`, or a handler under `hive-c0re/src/dashboard/`).
## Async forms ## 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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `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 get`↴](#swarmctl-agent-inference-get)
* [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set) * [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set)
* [`swarmctl agent inference clear`↴](#swarmctl-agent-inference-clear) * [`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`↴](#swarmctl-schedule)
* [`swarmctl schedule add`↴](#swarmctl-schedule-add) * [`swarmctl schedule add`↴](#swarmctl-schedule-add)
* [`swarmctl schedule list`↴](#swarmctl-schedule-list) * [`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 * `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 * `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 * `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` ## `swarmctl schedule`
Manage scheduled prompts. 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, `/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 **K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that
cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`tombstones_changed`, `capabilities_changed`, and `tool_groups_changed` `tombstones_changed` and `tool_groups_changed`
(the latter two re-render the stale-perms sub-section when permission (the latter re-renders the stale-perms sub-section when permission
data changes). (Rebuild queue and meta inputs are dashboard tabs — see data changes). (Rebuild queue and meta inputs are dashboard tabs — see
below.) 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 / needs a recorded destroy — **deferred** to the swarm-controller /
snapshot-storage rework where the problem changes shape. snapshot-storage rework where the problem changes shape.
*Stale permission entries*: agents with explicit capability or *Stale permission entries*: agents with explicit tool-group JSON
tool-group JSON entries but no live container — typically renamed or entries but no live container — typically renamed or deleted agents
deleted agents whose entries persisted in `capabilities.json` / whose entries persisted in `tool-groups.json`. Loaded when the page opens;
`tool-groups.json`. Loaded when the page opens; autorefreshes on `tool_groups_changed` SSE
autorefreshes on `capabilities_changed` + `tool_groups_changed` SSE
events. Each ghost agent gets a `✕ clear perms` button events. Each ghost agent gets a `✕ clear perms` button
(`DELETE /api/permissions/{name}`). (`DELETE /api/permissions/{name}`).
@ -230,11 +229,13 @@ entry with that id pre-expanded.
## P3RM1SS10NS tab ## P3RM1SS10NS tab
Per-agent permission configuration. Two sections, each rendered as a Per-agent tool-group configuration, rendered as a column-driven
column-driven checkbox matrix: rows are agents, columns are the checkbox matrix: rows are agents, columns are the group names fetched
permission names fetched from hive-c0re. The column list is from hive-c0re. The column list is authoritative — adding a new
authoritative — adding a new tool-group or capability to hive-c0re tool-group to hive-c0re requires no UI change; the new column appears
requires no UI change; the new column appears automatically. 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 The snapshot carries `agents` (the full manageable roster — live
containers ∪ agents with an explicit entry) and `effective` (per-agent 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 Fetches fire on tab activation (not page-load) to avoid unnecessary
work when the operator never visits this tab. Live mutations from the work when the operator never visits this tab. Live mutations from the
rebuild-queue worker are also pushed via the `capabilities_changed` / rebuild-queue worker are also pushed via the `tool_groups_changed`
`tool_groups_changed` SSE events (same payload shape as the GET SSE event (same payload shape as the GET endpoint), so an open P3RM1SS10NS tab reflects worker-applied changes
endpoints), so an open P3RM1SS10NS tab reflects worker-applied changes
without requiring navigation. Tab-activation re-fetches remain as a without requiring navigation. Tab-activation re-fetches remain as a
safety net for reconnect windows. 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 --> <!-- vale write-good.Passive = NO -->
**T00L GR0UPS** — per-agent tool-group permissions. Tool groups are **T00L GR0UPS** — per-agent tool-group permissions. Tool groups are
named buckets of MCP tools; each agent starts with a role default 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` / Claude's built-in `WebFetch` and `WebSearch` to `--tools` /
`--allowedTools` for that agent session. `--allowedTools` for that agent session.
Both tables share the same visual shape: `.cap-table-wrap` / The table's shape: a `.tg-table-wrap` outer scroll container, `thead`
`.tg-table-wrap` outer scroll container, `thead` with a label column with a label column (`.tg-agent-col`) + one column per group
(`.cap-agent-col` / `.tg-agent-col`) + one column per permission (`.tg-group-col`). Each tbody row is one agent: a name cell
(`.cap-col` / `.tg-group-col`). Each tbody row is one agent: a name cell
and its checkbox cells, where each checkbox carries `data-baseline` (its and its checkbox cells, where each checkbox carries `data-baseline` (its
render-time state) and the row carries `data-agent` for dirty-tracking. 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 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 agents)** button sits at the bottom of the pane, enabled only when some
checkbox diverges from its baseline. Selecting it diffs every checkbox checkbox diverges from its baseline. Selecting it diffs every checkbox
across *both* matrices and POSTs one batch to `POST /api/permissions` as and POSTs one batch to `POST /api/permissions` as
`{ changes: [ { agent, tool_groups?, capabilities? } ] }` — the batch `{ changes: [ { agent, tool_groups } ] }` — the batch includes only the
includes only the perm-types that actually changed for each agent (an agents whose groups changed (an included array fully replaces the
omitted field leaves that file untouched; an included array fully agent's groups).
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.
The batch is atomic: it validates every change first and on any error The batch is atomic: it validates every change first and on any error
rejects the whole POST (`{error}`, nothing applied); a clean 200 (`ok`) rejects the whole POST (`{error}`, nothing applied); a clean 200 (`ok`)
flips the bar to a queued→rebuilding state and re-fetches both tables. flips the bar to a queued→rebuilding state and re-fetches the table.
Live `capabilities_changed` / `tool_groups_changed` events re-render the Live `tool_groups_changed` events re-render the
matrices unless the section has unsaved edits, so an in-progress edit set matrix unless it has unsaved edits, so an in-progress edit set
isn't clobbered. isn't clobbered.
## ST4TS page (`/stats.html`) ## 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 `/var/lib/hyperhive/meta/tool-groups.json` and queues a rebuild so
`HIVE_TOOL_GROUPS` takes effect. Agent name validated; `HIVE_TOOL_GROUPS` takes effect. Agent name validated;
`guard_agent_name` applied. `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 - `POST /api/permissions` — batch perm apply for the save-all
permissions button. Body permissions button. Body
`{ changes: [{ agent, tool_groups?: ["name", …], capabilities?: ["name", …] }] }`. `{ changes: [{ agent, tool_groups?: ["name", …] }] }`.
Sparse per agent: an omitted field leaves that perm-type untouched, An omitted `tool_groups` leaves the agent untouched, an empty array
an empty array clears it, a populated array fully replaces it (same clears it, a populated array fully replaces it (same replace
replace semantics as the per-agent endpoints above). Each affected semantics as the per-agent endpoint above). Each affected agent gets
agent gets ONE combined `PermChange` queue entry, so changing both one `PermChange` queue entry. **Atomic**: the endpoint validates every
an agent's tool-groups and capabilities triggers a single rebuild, change first (agent names via `guard_agent_name`, group names) and on
not two. **Atomic**: the endpoint validates every change first (agent names via any validation error writes or enqueues nothing (non-2xx `{ error }`);
`guard_agent_name`, group + capability names) and on any validation it skips rows with the field omitted rather than erroring on them.
error writes or enqueues nothing (non-2xx `{ error }`); it skips
rows with both fields omitted rather than erroring on them.
Returns `200 "ok"` on Returns `200 "ok"` on
success. success.
- `POST /api/meta-update` — `nix flake update` the selected - `POST /api/meta-update` — `nix flake update` the selected
@ -841,13 +805,6 @@ payload):
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end` operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
with Reminder kind, and the scheduler tick after each delivery with Reminder kind, and the scheduler tick after each delivery
batch (`reminder_scheduler`). 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, - `tool_groups_changed` (seq, groups: `Vec<str>`, descriptions: map,
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
tool-group assignments. Emitted from the rebuild-queue worker after tool-group assignments. Emitted from the rebuild-queue worker after

View file

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

View file

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

View file

@ -566,57 +566,9 @@ footer .banner-thin {
margin-bottom: 0.8em; 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 ────────────────────────────── /* ─── tool-groups (permissions) table ──────────────────────────────
Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout Agents × tool-groups matrix in the P3RM1SS10NS tab. Horizontally
as the capabilities table above. Horizontally scrollable on narrow scrollable on narrow viewports. */
viewports. */
.tg-table-wrap { .tg-table-wrap {
overflow-x: auto; overflow-x: auto;
margin-top: 0.5em; margin-top: 0.5em;

View file

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

View file

@ -1,11 +1,11 @@
// Dashboard P3RM1SS10NS tab — the per-agent capabilities + tool-groups // Dashboard P3RM1SS10NS tab — the per-agent tool-groups matrix, with a
// matrices, with a single page-level "save all" button. // 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`, // The table is fetched on tab activation (`GET /api/tool-groups`) and
// `GET /api/tool-groups`) and after each save. Columns (caps / groups) // after each save. Columns come from the backend so the UI needs no
// come from the backend so the UI needs no change when a new one is // change when a new group is added. Live updates arrive via the
// added. Live updates arrive via the `capabilities_changed` / // `tool_groups_changed` dashboard event (fired after the rebuild-queue
// `tool_groups_changed` dashboard events (fired after the rebuild-queue
// worker commits the perm JSON file), wired into the entry's mutation // worker commits the perm JSON file), wired into the entry's mutation
// dispatch table. // dispatch table.
// //
@ -15,11 +15,9 @@
// assignments map. // assignments map.
// //
// Editing model: checkboxes are diffed against a `data-baseline` captured at // Editing model: checkboxes are diffed against a `data-baseline` captured at
// render time; only changed perm-types per agent are POSTed to // render time; only changed agents are POSTed to `POST /api/permissions` as
// `POST /api/permissions` as `{ changes: [{agent, tool_groups?, capabilities?}] }`. // `{ changes: [{agent, tool_groups}] }` (a full replace per agent). Batch is
// Omitted field = leave untouched; included array = full replace. The backend // atomic — saved→rebuilding only fires on a clean 200.
// 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.
import { $ } from "./common.js"; import { $ } from "./common.js";
import { el } from "@hive/shared/dom.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 // 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. // 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) { export function applyToolGroupsChanged(ev) {
const root = $("tool-groups-section"); const root = $("tool-groups-section");
if (!root) return; if (!root) return;
@ -56,121 +47,6 @@ function sectionHasDirty(root) {
return false; 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() { export async function fetchAndRenderToolGroups() {
const root = $("tool-groups-section"); const root = $("tool-groups-section");
if (!root) return; if (!root) return;
@ -303,20 +179,11 @@ function onCellToggle() {
updateSaveBar(); updateSaveBar();
} }
// Collect the sparse change-set: one entry per agent that has at least // Collect the sparse change-set: one entry per agent whose groups diverge
// one perm-type diverging from baseline. Only the changed perm-type(s) // from baseline, so untouched defaults stay defaults (an included array is
// are included so untouched defaults stay defaults (an included array is // a full replacement).
// a full replacement → omitting it leaves that file alone).
function collectChanges() { function collectChanges() {
const byAgent = new Map(); // agent -> { capabilities?, tool_groups? } const byAgent = new Map(); // agent -> { tool_groups }
collectSection(
$("capabilities-section"),
".cap-row",
".cap-cb",
"cap",
"capabilities",
byAgent,
);
collectSection( collectSection(
$("tool-groups-section"), $("tool-groups-section"),
".tg-row", ".tg-row",
@ -363,7 +230,7 @@ function updateSaveBar() {
// Remove all explicit permission entries for a stale (non-running) // Remove all explicit permission entries for a stale (non-running)
// agent. Calls DELETE /api/permissions/{agent}, which bypasses the // agent. Calls DELETE /api/permissions/{agent}, which bypasses the
// roster guard so the stale entries can be cleaned up even though 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. // the delete so the row disappears immediately.
async function clearStaleAgent(name, sectionRoot) { async function clearStaleAgent(name, sectionRoot) {
const btn = sectionRoot const btn = sectionRoot
@ -381,11 +248,8 @@ async function clearStaleAgent(name, sectionRoot) {
setSaveNote("failed to remove " + name + ": " + text, true); setSaveNote("failed to remove " + name + ": " + text, true);
return; return;
} }
// Re-fetch both sections so the stale row disappears. // Re-fetch so the stale row disappears.
await Promise.all([ await fetchAndRenderToolGroups();
fetchAndRenderCapabilities(),
fetchAndRenderToolGroups(),
]);
} catch (err) { } catch (err) {
setSaveNote("failed to remove " + name + ": " + err, true); setSaveNote("failed to remove " + name + ": " + err, true);
} }
@ -437,14 +301,13 @@ async function saveAll() {
return; return;
} }
// Atomic 200: the whole batch landed. Show a rebuilding hint and // 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. // resets baselines (dirty clears) and the bar disables itself.
const n = changes.length; const n = changes.length;
btn.textContent = "queued ✓"; btn.textContent = "queued ✓";
setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}…`, false); setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}…`, false);
setTimeout(() => { setTimeout(() => {
btn.dataset.busy = ""; btn.dataset.busy = "";
fetchAndRenderCapabilities();
fetchAndRenderToolGroups(); fetchAndRenderToolGroups();
}, 900); }, 900);
} catch (err) { } catch (err) {

View file

@ -2,7 +2,7 @@
// dashboard SPA: the live agent roster. // dashboard SPA: the live agent roster.
// //
// The roster is read by nearly every tab (the container tree, the // 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 // it lives here as a single source of truth that each domain module
// imports rather than threading through call signatures. Everything // imports rather than threading through call signatures. Everything
// else in the dashboard is domain-local and lives with its own module. // 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). */ sub-step label` — the rule has had no JS referents since then). */
/* ─── K3PT ST4T3 › stale permission entries sub-section ──────────────────── /* ─── K3PT ST4T3 › stale permission entries sub-section ────────────────────
Agents with explicit capability / tool-group entries in the JSON but no Agents with explicit tool-group entries in the JSON but no live
live container (renamed or manually-deleted agents). Lazy-loaded on tab container (renamed or manually-deleted agents). Lazy-loaded on tab
activation, refreshed on capabilities_changed / tool_groups_changed. */ activation, refreshed on tool_groups_changed. */
.tombstones-stale-heading { .tombstones-stale-heading {
margin: 1.2em 0 0.3em; margin: 1.2em 0 0.3em;
color: var(--muted); color: var(--muted);

View file

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

View file

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

View file

@ -1,262 +1,109 @@
//! Per-agent capability configuration. Stored at //! The capabilities the swarm grants this hive's agents, as last read from the
//! `/var/lib/hyperhive/meta/capabilities.json` alongside `topology.json` //! `agent-permissions` bucket by `workers::swarm_permissions`. Stored at
//! and `tool-groups.json`. //! `/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 //! Format: agent name → the stored
//! `hive_sh4re::permissions::Capability` `snake_case` strings: //! [`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 //! Read path: `lifecycle::host_config::set_nspawn_flags`, on every container
//! { //! start, which is why a change takes a restart.
//! "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.
use hive_sh4re::permissions::Capability;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; 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] #[must_use]
pub fn capabilities_path() -> PathBuf { pub fn path() -> PathBuf {
crate::paths::meta_root().join(CAPABILITIES_FILE) crate::paths::state_root().join(FILE)
} }
/// True if `name` matches a recognised [`Capability`], case-insensitive /// Read the stored permissions. An absent file is the empty map: every agent
/// (same comparison [`has_cap`] uses). /// 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] #[must_use]
fn is_known(name: &str) -> bool { pub fn default_for(agent: &str) -> Vec<Capability> {
Capability::ALL if agent == crate::lifecycle::MANAGER_NAME {
.iter() vec![Capability::ManageRootAgent]
.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);
} else { } else {
current.insert(name.to_owned(), known); Vec::new()
} }
} }
/// Set the capabilities for one agent and persist the map. Any name /// The capabilities `agent` holds under `record`.
/// that isn't a recognised [`Capability`] is dropped (with a `warn!`) #[must_use]
/// rather than written — an unknown grant should never look like it pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec<Capability> {
/// took effect. An empty `caps` vec, or one that becomes empty after record
/// dropping unknown names, removes the entry (agent has no .and_then(|r| r.capabilities.clone())
/// capabilities). Fails without writing if the existing file can't be .unwrap_or_else(|| default_for(agent))
/// read or parsed.
pub fn set_caps(name: &str, caps: &[String]) -> std::io::Result<()> {
set_caps_at(&capabilities_path(), name, caps)
} }
fn set_caps_at(path: &Path, name: &str, caps: &[String]) -> std::io::Result<()> { /// Whether `agent` holds `cap` per the file at `path` (normally [`path`]).
let mut current = read_from(path)?; ///
apply_known_caps(&mut current, name, caps); /// # Errors
super::write_map(path, &current) ///
} /// As [`read_at`].
pub fn holds(path: &Path, agent: &str, cap: Capability) -> std::io::Result<bool> {
/// Remove an agent from the capability map entirely. Called by Ok(effective(agent, read_at(path)?.get(agent)).contains(&cap))
/// `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(())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
// `read`/`set_caps`/`remove_agent` call `crate::paths::meta_root`, fn record(json: &str) -> AgentPermissions {
// which is hardcoded to `/var/lib/hyperhive` (no test override) — so serde_json::from_str(json).expect("decodes")
// 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)
} }
#[test] #[test]
fn read_of_a_corrupt_file_is_an_error() { fn unset_is_the_default_and_an_empty_list_is_none() {
let (_dir, path) = corrupt_file(); assert_eq!(effective("ruth", None), vec![Capability::ManageRootAgent]);
read_from(&path).expect_err("truncated JSON must not read as a map"); assert_eq!(
} effective("ruth", Some(&record("{}"))),
vec![Capability::ManageRootAgent]
#[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()],
); );
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, //! Per-agent configuration registries: tool groups, resource limits,
//! resource limits, topology (all JSON files under //! topology (JSON files under `/var/lib/hyperhive/meta/`), the capabilities
//! `/var/lib/hyperhive/meta/`) and the shared wire-protocol size //! the swarm grants (outside the meta repo) and the shared wire-protocol size
//! limits. Each submodule is re-exported at the crate root, so //! limits. Each submodule is re-exported at the crate root, so
//! `crate::topology::…` etc. keep working unchanged. //! `crate::topology::…` etc. keep working unchanged.
//! //!

View file

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

View file

@ -13,7 +13,8 @@
//! bind-mounted ([`all_agents`]). //! bind-mounted ([`all_agents`]).
//! //!
//! Broader-than-your-own bind-mount grants are **not** stored here: they //! 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`. //! `lifecycle::set_nspawn_flags`.
use std::collections::BTreeSet; use std::collections::BTreeSet;

View file

@ -565,41 +565,6 @@ impl Coordinator {
.map(|m| m.keys().cloned().collect()) .map(|m| m.keys().cloned().collect())
} }
/// Emit a `CapabilitiesChanged` snapshot event. Called from the
/// rebuild-queue worker after a `PermChange` / Capabilities entry
/// commits the JSON file, so the P3RM1SS10NS tab updates live.
pub fn emit_capabilities_snapshot(self: &Arc<Self>) {
use hive_sh4re::permissions::Capability;
let caps = Capability::ALL.iter().map(|c| <&str>::from(*c)).collect();
let descriptions = Capability::ALL
.iter()
.map(|c| (<&str>::from(*c), c.description()))
.collect();
// An empty snapshot would show every agent as having lost its
// capabilities; emit nothing and let the HTTP refetch report it.
let assignments = match crate::capabilities::read() {
Ok(map) => map,
Err(e) => {
tracing::warn!(error = ?e, "capabilities snapshot skipped");
return;
}
};
// Best-effort roster (sync path); on a contended cache miss we
// emit explicit keys only — the HTTP refetch fills the rest in.
let roster = self.live_container_names_blocking().unwrap_or_default();
// Capability default is "no extra caps" — empty default slice.
let (agents, effective) =
crate::dashboard::permissions::roster_and_effective(roster, &assignments, &[]);
self.emit_dashboard_event(DashboardEvent::CapabilitiesChanged {
seq: self.next_seq(),
caps,
descriptions,
assignments,
agents,
effective,
});
}
/// Emit a `ToolGroupsChanged` snapshot event. Called from the /// Emit a `ToolGroupsChanged` snapshot event. Called from the
/// rebuild-queue worker after a `PermChange` / `ToolGroups` entry /// rebuild-queue worker after a `PermChange` / `ToolGroups` entry
/// commits the JSON file, so the P3RM1SS10NS tab updates live. /// commits the JSON file, so the P3RM1SS10NS tab updates live.

View file

@ -37,7 +37,7 @@ use crate::lifecycle;
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"), (name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"), (name = "permissions", description = "tool-group assignment for agents"),
(name = "rebuild_queue", description = "rebuild-queue cancel"), (name = "rebuild_queue", description = "rebuild-queue cancel"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"), (name = "state_snapshot", description = "cold-load dashboard snapshot"),
@ -126,8 +126,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(build_logs::get_build_log_raw)) .routes(routes!(build_logs::get_build_log_raw))
.routes(routes!(permissions::get_tool_groups)) .routes(routes!(permissions::get_tool_groups))
.routes(routes!(permissions::post_tool_groups)) .routes(routes!(permissions::post_tool_groups))
.routes(routes!(permissions::get_capabilities))
.routes(routes!(permissions::post_capabilities))
.routes(routes!(permissions::post_permissions)) .routes(routes!(permissions::post_permissions))
.routes(routes!(permissions::get_stale_permissions)) .routes(routes!(permissions::get_stale_permissions))
.routes(routes!(permissions::delete_agent_permissions)) .routes(routes!(permissions::delete_agent_permissions))
@ -380,8 +378,6 @@ mod router_build_probe {
.routes(routes!(build_logs::get_build_log_raw)) .routes(routes!(build_logs::get_build_log_raw))
.routes(routes!(permissions::get_tool_groups)) .routes(routes!(permissions::get_tool_groups))
.routes(routes!(permissions::post_tool_groups)) .routes(routes!(permissions::post_tool_groups))
.routes(routes!(permissions::get_capabilities))
.routes(routes!(permissions::post_capabilities))
.routes(routes!(permissions::post_permissions)) .routes(routes!(permissions::post_permissions))
.routes(routes!(permissions::get_stale_permissions)) .routes(routes!(permissions::get_stale_permissions))
.routes(routes!(permissions::delete_agent_permissions)) .routes(routes!(permissions::delete_agent_permissions))

View file

@ -1,6 +1,6 @@
//! Tool-group + capability permission endpoints for the dashboard. //! Tool-group permission endpoints for the dashboard.
//! //!
//! Read endpoints return the full set of known groups/capabilities plus //! Read endpoints return the full set of known groups plus
//! descriptions and the per-agent assignment map (the UI never hard-codes //! descriptions and the per-agent assignment map (the UI never hard-codes
//! the lists). Write endpoints validate, then enqueue a `PermChange` so the //! the lists). Write endpoints validate, then enqueue a `PermChange` so the
//! JSON file write is serialised through the FIFO rebuild worker. //! JSON file write is serialised through the FIFO rebuild worker.
@ -20,7 +20,7 @@ use super::{AppState, Ident, guard_agent_name, strip_container_prefix};
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub(super) struct ToolGroupsSnapshot { pub(super) struct ToolGroupsSnapshot {
/// Ordered list of all known tool-group names. Drives the column /// Ordered list of all known tool-group names. Drives the column
/// headers in the capabilities table — the UI does not hard-code them. /// headers in the tool-groups table — the UI does not hard-code them.
groups: Vec<&'static str>, groups: Vec<&'static str>,
/// Short description for each group name. Keys match `groups` entries. /// Short description for each group name. Keys match `groups` entries.
descriptions: std::collections::BTreeMap<&'static str, &'static str>, descriptions: std::collections::BTreeMap<&'static str, &'static str>,
@ -100,8 +100,8 @@ pub(crate) fn tool_group_default_names() -> Vec<&'static str> {
/// `agents` is the sorted union of the live roster and any agent that /// `agents` is the sorted union of the live roster and any agent that
/// already has an explicit entry; `effective[agent]` is the explicit /// already has an explicit entry; `effective[agent]` is the explicit
/// assignment when present, else `default` (the role fallback). Shared by /// assignment when present, else `default` (the role fallback). Shared by
/// the capabilities + tool-groups snapshots on both the HTTP and SSE /// the tool-groups snapshots on both the HTTP and SSE paths so the two
/// paths so the two surfaces never drift. /// surfaces never drift.
#[must_use] #[must_use]
pub(crate) fn roster_and_effective( pub(crate) fn roster_and_effective(
roster: impl IntoIterator<Item = String>, roster: impl IntoIterator<Item = String>,
@ -184,135 +184,15 @@ pub(super) async fn post_tool_groups(
Ok((StatusCode::OK, "ok").into_response()) Ok((StatusCode::OK, "ok").into_response())
} }
#[derive(Serialize, ToSchema)] /// One agent's slice of a batch permission change. An omitted
pub(super) struct CapabilitiesSnapshot { /// `tool_groups` leaves the agent untouched, an empty array clears it, a
/// Ordered list of all known capability names. Drives the column
/// headers in the capabilities table — the UI does not hard-code them.
caps: Vec<&'static str>,
/// Short description for each capability name. Keys match `caps` entries.
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent *explicit* capability grant map. Absent agents have no
/// extra caps — the UI uses presence here to badge "(default)".
assignments: std::collections::BTreeMap<String, Vec<String>>,
/// Every agent the operator can manage (live roster ∪ explicit keys),
/// sorted — so default agents always list.
agents: Vec<String>,
/// Per-agent *effective* caps: explicit entry when present, else the
/// default (no caps). Keeps the snapshot shape symmetric with
/// tool-groups; the default here is always empty.
effective: std::collections::BTreeMap<String, Vec<String>>,
}
/// Every known capability name + description,
/// plus the per-agent explicit/effective grant maps.
#[utoipa::path(
get,
path = "/api/capabilities",
responses(
(status = 200, description = "capability catalogue + assignments", body = CapabilitiesSnapshot),
(status = 500, description = "capabilities file unreadable"),
),
tag = "permissions"
)]
pub(super) async fn get_capabilities(
State(state): State<AppState>,
) -> Result<axum::Json<CapabilitiesSnapshot>, ProblemDetails> {
use hive_sh4re::permissions::Capability;
let caps = Capability::ALL.iter().map(|c| <&str>::from(*c)).collect();
let descriptions = Capability::ALL
.iter()
.map(|c| (<&str>::from(*c), c.description()))
.collect();
let assignments = crate::capabilities::read().map_err(|e| unreadable(&e))?;
let roster = state
.coord
.containers_snapshot()
.await
.into_iter()
.map(|c| c.name);
// Capability default is "no extra caps" — empty default slice.
let (agents, effective) = roster_and_effective(roster, &assignments, &[]);
Ok(axum::Json(CapabilitiesSnapshot {
caps,
descriptions,
assignments,
agents,
effective,
}))
}
#[derive(Deserialize, ToSchema)]
pub(super) struct SetCapabilitiesBody {
caps: Vec<String>,
}
/// Replace `agent`'s explicit capability
/// grant set (JSON body `{"caps": [...]}`).
#[utoipa::path(
post,
path = "/api/capabilities/{agent}",
params(("agent" = String, Path, description = "agent name")),
request_body = SetCapabilitiesBody,
responses(
(status = 200, description = "capabilities queued for write", body = String),
(status = 400, description = "unknown capability name"),
(status = 404, description = "no such agent"),
),
tag = "permissions"
)]
pub(super) async fn post_capabilities(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
axum::Json(body): axum::Json<SetCapabilitiesBody>,
) -> Result<Response, ProblemDetails> {
let logical = strip_container_prefix(&name);
if let Some(reject) = guard_agent_name(&state, &logical).await {
return Ok(reject);
}
let known: Vec<&str> = hive_sh4re::permissions::Capability::ALL
.iter()
.map(|c| <&str>::from(*c))
.collect();
for cap in &body.caps {
if !known.contains(&cap.as_str()) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown capability: {cap}")));
}
}
// Submit a PermChange DAG: the JSON file write commits under
// META_LOCK inside the WritePermFile node, so concurrent
// batch-apply actions for different agents never race on the
// shared capabilities.json.
state
.coord
.job_queue
.insert_job(|b| {
crate::job_queue::templates::perm_change(
b,
&logical,
crate::job_queue::PermPayload::Capabilities {
caps: body.caps.clone(),
},
);
Vec::new()
})
.expect("template-declared shapes are acyclic");
state.coord.emit_rebuild_queue_snapshot();
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
Ok((StatusCode::OK, "ok").into_response())
}
/// One agent's slice of a batch permission change. Sparse: an omitted
/// field leaves that perm-type untouched, an empty array clears it, a
/// populated array fully replaces it (same replace semantics as the /// populated array fully replaces it (same replace semantics as the
/// per-agent endpoints). /// per-agent endpoint).
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub(super) struct PermChangeBody { pub(super) struct PermChangeBody {
agent: String, agent: String,
#[serde(default)] #[serde(default)]
tool_groups: Option<Vec<String>>, tool_groups: Option<Vec<String>>,
#[serde(default)]
capabilities: Option<Vec<String>>,
} }
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
@ -320,25 +200,19 @@ pub(super) struct BatchPermsBody {
changes: Vec<PermChangeBody>, changes: Vec<PermChangeBody>,
} }
/// A validated, non-empty change staged for enqueue:
/// `(logical agent, new groups?, new caps?)`.
type StagedPerm = (String, Option<Vec<String>>, Option<Vec<String>>);
/// Batch permission apply — `POST /api/permissions`. /// Batch permission apply — `POST /api/permissions`.
/// ///
/// The save-all permissions UI sends only the perm-types that actually /// The save-all permissions UI sends only the agents whose groups
/// changed per agent; each affected agent gets ONE combined /// changed; each gets one `PermChange`. The whole batch is atomic: every
/// `PermChange`, so the dedup key collapses to `(kind, agent)` and an /// change is validated up front and on any validation error nothing is
/// agent whose caps AND groups both changed rebuilds once, not twice. /// written or enqueued.
/// The whole batch is atomic: every change is validated up front and
/// on any validation error nothing is written or enqueued.
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/permissions", path = "/api/permissions",
request_body = BatchPermsBody, request_body = BatchPermsBody,
responses( responses(
(status = 200, description = "batch permission change queued", body = String), (status = 200, description = "batch permission change queued", body = String),
(status = 400, description = "unknown tool-group/capability name, or no such agent"), (status = 400, description = "unknown tool-group name, or no such agent"),
(status = 404, description = "no such agent"), (status = 404, description = "no such agent"),
), ),
tag = "permissions" tag = "permissions"
@ -347,43 +221,25 @@ pub(super) async fn post_permissions(
State(state): State<AppState>, State(state): State<AppState>,
axum::Json(body): axum::Json<BatchPermsBody>, axum::Json(body): axum::Json<BatchPermsBody>,
) -> Result<Response, ProblemDetails> { ) -> Result<Response, ProblemDetails> {
let known_caps: Vec<&str> = hive_sh4re::permissions::Capability::ALL
.iter()
.map(|c| <&str>::from(*c))
.collect();
// Phase 1 — validate everything before touching any file or the // Phase 1 — validate everything before touching any file or the
// queue, so a bad entry fails the whole POST with zero side effects. // queue, so a bad entry fails the whole POST with zero side effects.
// No-op rows (both fields omitted) are skipped, not errors. // No-op rows (field omitted) are skipped, not errors.
let mut staged: Vec<StagedPerm> = Vec::new(); let mut staged: Vec<(String, Vec<String>)> = Vec::new();
for change in &body.changes { for change in &body.changes {
let logical = strip_container_prefix(&change.agent); let logical = strip_container_prefix(&change.agent);
if let Some(reject) = guard_agent_name(&state, &logical).await { if let Some(reject) = guard_agent_name(&state, &logical).await {
return Ok(reject); return Ok(reject);
} }
if let Some(groups) = &change.tool_groups if let Some(groups) = &change.tool_groups {
&& let Err(e) = crate::tool_groups::validate_groups(groups) if let Err(e) = crate::tool_groups::validate_groups(groups) {
{ return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail(format!("invalid tool-groups for {logical}: {e}")));
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
}
if let Some(caps) = &change.capabilities {
for cap in caps {
if !known_caps.contains(&cap.as_str()) {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown capability for {logical}: {cap}")));
}
} }
} staged.push((logical, groups.clone()));
if change.tool_groups.is_some() || change.capabilities.is_some() {
staged.push((
logical,
change.tool_groups.clone(),
change.capabilities.clone(),
));
} }
} }
// Phase 2 — submit one combined PermChange DAG per affected agent. // Phase 2 — submit one PermChange DAG per affected agent.
for (logical, groups, caps) in staged { for (logical, groups) in staged {
state state
.coord .coord
.job_queue .job_queue
@ -391,7 +247,7 @@ pub(super) async fn post_permissions(
crate::job_queue::templates::perm_change( crate::job_queue::templates::perm_change(
b, b,
&logical, &logical,
crate::job_queue::PermPayload::Combined { groups, caps }, crate::job_queue::PermPayload::ToolGroups { groups },
); );
Vec::new() Vec::new()
}) })
@ -402,7 +258,7 @@ pub(super) async fn post_permissions(
Ok((StatusCode::OK, "ok").into_response()) Ok((StatusCode::OK, "ok").into_response())
} }
/// Agent names that have explicit capability/tool-group entries but are /// Agent names that have explicit tool-group entries but are
/// not in the live container roster AND not in the kept-state directory /// not in the live container roster AND not in the kept-state directory
/// list (i.e. truly gone — renamed or destroyed agents whose JSON entries /// list (i.e. truly gone — renamed or destroyed agents whose JSON entries
/// persisted). The client uses this to drive the "stale permission entries" /// persisted). The client uses this to drive the "stale permission entries"
@ -421,7 +277,7 @@ pub(super) struct StalePermsResponse {
path = "/api/permissions/stale", path = "/api/permissions/stale",
responses( responses(
(status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse), (status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse),
(status = 500, description = "tool-groups/capabilities file unreadable"), (status = 500, description = "tool-groups file unreadable"),
), ),
tag = "permissions" tag = "permissions"
)] )]
@ -447,18 +303,9 @@ pub(super) async fn get_stale_permissions(
.collect(); .collect();
// Known = live roster ∪ kept-state names. // Known = live roster ∪ kept-state names.
let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect(); let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect();
// Explicit entries in either JSON file.
let caps = crate::capabilities::read().map_err(|e| unreadable(&e))?;
let tgs = crate::tool_groups::read().map_err(|e| unreadable(&e))?; let tgs = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
let mut ghost_names: Vec<String> = caps // `tgs` is a `BTreeMap`, so its keys are already sorted.
.keys() let ghost_names: Vec<String> = tgs.keys().filter(|n| !known.contains(n)).cloned().collect();
.chain(tgs.keys())
.filter(|n| !known.contains(n))
.cloned()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
ghost_names.sort();
Ok(axum::Json(StalePermsResponse { stale: ghost_names })) Ok(axum::Json(StalePermsResponse { stale: ghost_names }))
} }
@ -466,15 +313,15 @@ pub(super) async fn get_stale_permissions(
/// requiring it to exist in the live roster. /// requiring it to exist in the live roster.
/// ///
/// Used by the P3RM1SS10NS tab's "remove" button for agents that have /// Used by the P3RM1SS10NS tab's "remove" button for agents that have
/// stale explicit entries in `tool-groups.json` / `capabilities.json` /// stale explicit entries in `tool-groups.json` but are no longer
/// but are no longer running (e.g. an agent that was renamed or /// running (e.g. an agent that was renamed or destroyed while its JSON
/// destroyed while its JSON entries persisted). /// entries persisted).
/// ///
/// Bypasses `guard_agent_name`'s live-roster check intentionally — /// Bypasses `guard_agent_name`'s live-roster check intentionally —
/// the whole point is to remove entries for non-roster agents. Only /// the whole point is to remove entries for non-roster agents. Only
/// the format check ([`Ident::parse`]) is applied. No rebuild is /// the format check ([`Ident::parse`]) is applied. No rebuild is
/// enqueued (the agent doesn't exist to rebuild); the SSE snapshots /// enqueued (the agent doesn't exist to rebuild); the SSE snapshot
/// update the P3RM1SS10NS tab live. /// updates the P3RM1SS10NS tab live.
#[utoipa::path( #[utoipa::path(
delete, delete,
path = "/api/permissions/{agent}", path = "/api/permissions/{agent}",
@ -482,7 +329,7 @@ pub(super) async fn get_stale_permissions(
responses( responses(
(status = 200, description = "stale permission entries cleared", body = String), (status = 200, description = "stale permission entries cleared", body = String),
(status = 400, description = "bad agent name"), (status = 400, description = "bad agent name"),
(status = 500, description = "tool-groups/capabilities file write failed"), (status = 500, description = "tool-groups file write failed"),
), ),
tag = "permissions" tag = "permissions"
)] )]
@ -496,24 +343,13 @@ pub(super) async fn delete_agent_permissions(
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
} }
}; };
// Run both removals regardless so we clean up as much as possible let removed = crate::tool_groups::remove_agent(logical.as_str());
// even on partial I/O errors. Collect errors to surface below.
let tg_err = crate::tool_groups::remove_agent(logical.as_str()).err();
if let Some(ref e) = tg_err {
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
}
let cap_err = crate::capabilities::remove_agent(logical.as_str()).err();
if let Some(ref e) = cap_err {
tracing::warn!(agent = %logical, error = ?e, "failed to remove capabilities entry");
}
// Emit live snapshots even on partial failure so the UI stays as
// accurate as possible — the surviving table gets updated immediately.
state.coord.emit_tool_groups_snapshot(); state.coord.emit_tool_groups_snapshot();
state.coord.emit_capabilities_snapshot(); // Surface an I/O error as 500 so the frontend's `!resp.ok` path
// Surface any I/O error as 500 so the frontend's `!resp.ok` path
// fires and the operator sees a meaningful message rather than a // fires and the operator sees a meaningful message rather than a
// silent "success" followed by the row reappearing unchanged. // silent "success" followed by the row reappearing unchanged.
if let Some(e) = tg_err.or(cap_err) { if let Err(e) = removed {
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to remove permission entries for {logical}: {e}"), format!("failed to remove permission entries for {logical}: {e}"),

View file

@ -141,24 +141,6 @@ pub enum DashboardEvent {
/// (transients, container state), and a poll loop would be /// (transients, container state), and a poll loop would be
/// slower for the same information. /// slower for the same information.
RebuildQueueChanged { seq: u64 }, RebuildQueueChanged { seq: u64 },
/// Full snapshot of capability grants (per-agent `Vec<cap_name>`).
/// Emitted from the rebuild-queue worker after a `PermChange`
/// `Capabilities` entry commits the JSON file. Lets the P3RM1SS10NS
/// tab update live when the worker applies a queued change.
CapabilitiesChanged {
seq: u64,
/// Ordered list of all known capability names (column headers).
caps: Vec<&'static str>,
/// Short description for each capability name (tooltip).
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
/// Per-agent *explicit* capability grant map; absent agents have
/// no extra caps (the UI badges those "(default)").
assignments: std::collections::BTreeMap<String, Vec<String>>,
/// Sorted roster the operator can manage (live ∪ explicit keys).
agents: Vec<String>,
/// Per-agent *effective* caps (explicit-or-default; default empty).
effective: std::collections::BTreeMap<String, Vec<String>>,
},
/// Full snapshot of tool-group assignments (per-agent `Vec<group_name>`). /// Full snapshot of tool-group assignments (per-agent `Vec<group_name>`).
/// Emitted from the rebuild-queue worker after a `PermChange` /// Emitted from the rebuild-queue worker after a `PermChange`
/// `ToolGroups` entry commits the JSON file. Lets the P3RM1SS10NS /// `ToolGroups` entry commits the JSON file. Lets the P3RM1SS10NS
@ -203,7 +185,6 @@ impl DashboardEvent {
DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed", DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed",
DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running",
DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed",
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed", DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
} }
} }
@ -279,14 +260,6 @@ mod tests {
running: false, running: false,
}, },
DashboardEvent::RebuildQueueChanged { seq: 1 }, DashboardEvent::RebuildQueueChanged { seq: 1 },
DashboardEvent::CapabilitiesChanged {
seq: 1,
caps: Vec::new(),
descriptions: std::collections::BTreeMap::new(),
assignments: std::collections::BTreeMap::new(),
agents: Vec::new(),
effective: std::collections::BTreeMap::new(),
},
DashboardEvent::ToolGroupsChanged { DashboardEvent::ToolGroupsChanged {
seq: 1, seq: 1,
groups: Vec::new(), groups: Vec::new(),

View file

@ -810,23 +810,6 @@ async fn run_write_perm_file(
.with_context(|| format!("commit tool-groups for {name}"))?; .with_context(|| format!("commit tool-groups for {name}"))?;
coord.emit_tool_groups_snapshot(); coord.emit_tool_groups_snapshot();
} }
PermPayload::Capabilities { caps } => {
crate::meta::commit_capabilities(name, caps)
.await
.with_context(|| format!("commit capabilities for {name}"))?;
coord.emit_capabilities_snapshot();
}
PermPayload::Combined { groups, caps } => {
crate::meta::commit_perms(name, groups.as_deref(), caps.as_deref())
.await
.with_context(|| format!("commit perms for {name}"))?;
if groups.is_some() {
coord.emit_tool_groups_snapshot();
}
if caps.is_some() {
coord.emit_capabilities_snapshot();
}
}
} }
Ok(()) Ok(())
} }

View file

@ -98,7 +98,7 @@ pub enum NodeKind {
PauseDrain { agent: String }, PauseDrain { agent: String },
/// `set_nspawn_flags` + `set_resource_limits` + daemon-reload. /// `set_nspawn_flags` + `set_resource_limits` + daemon-reload.
WriteDropin { agent: String }, WriteDropin { agent: String },
/// Commit `tool-groups.json` / `capabilities.json` per its `payload`. /// Commit `tool-groups.json` per its `payload`.
WritePermFile { agent: String, payload: PermPayload }, WritePermFile { agent: String, payload: PermPayload },
/// Group root of a rebuild subtree — the **brace**, scoped to one /// Group root of a rebuild subtree — the **brace**, scoped to one
/// agent. Why braces exist and what they cost: _Braces_. /// agent. Why braces exist and what they cost: _Braces_.

View file

@ -403,8 +403,8 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
.after_ok(last); .after_ok(last);
} }
/// Perm change: commit the JSON file(s), then the rebuild subgraph so /// Perm change: commit the JSON file, then the rebuild subgraph so
/// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes /// the updated `HIVE_TOOL_GROUPS` env var takes
/// effect in the container. Group-roots are `WritePermFile` plus the rebuild /// effect in the container. Group-roots are `WritePermFile` plus the rebuild
/// subgraph's `MetaSync` / `Prebuild` / `Reconcile`, so the `EmitRebuilt` tail /// subgraph's `MetaSync` / `Prebuild` / `Reconcile`, so the `EmitRebuilt` tail
/// edges all four. /// edges all four.

View file

@ -1522,10 +1522,7 @@ fn perm_change_shape_prefixes_rebuild_chain() {
templates::perm_change( templates::perm_change(
builder, builder,
"agent-a", "agent-a",
PermPayload::Combined { PermPayload::ToolGroups { groups: vec![] },
groups: Some(vec![]),
caps: None,
},
); );
}); });
assert_eq!( assert_eq!(

View file

@ -381,22 +381,20 @@ fn bind_own_agent_dirs(agent_name: &str, notes_dir: &Path, binds: &mut Vec<BindM
} }
} }
/// Whether `agent_name` holds `ManageRootAgent` per the capabilities file /// Whether `agent_name` holds `ManageRootAgent` per the swarm permissions
/// at `caps_path`. An unreadable file denies: the error is logged and the /// stored at `path`. An unreadable file denies: the error is logged and the
/// agent starts without the cross-agent mounts, so one corrupt file never /// agent starts without the cross-agent mounts, so one corrupt file never
/// blocks any agent's start, spawn or swap. /// blocks any agent's start, spawn or swap.
fn holds_manage_root_agent(agent_name: &str, caps_path: &Path) -> bool { fn holds_manage_root_agent(agent_name: &str, path: &Path) -> bool {
crate::capabilities::has_cap(caps_path, agent_name, Capability::ManageRootAgent).unwrap_or_else( crate::capabilities::holds(path, agent_name, Capability::ManageRootAgent).unwrap_or_else(|e| {
|e| { tracing::error!(
tracing::error!( agent = %agent_name,
agent = %agent_name, path = %path.display(),
path = %caps_path.display(), error = ?e,
error = ?e, "swarm permissions unreadable — no ManageRootAgent mounts"
"capabilities unreadable — no ManageRootAgent mounts" );
); false
false })
},
)
} }
/// Env var naming the host directory `swarm-bao-queue-agent.service` lands /// Env var naming the host directory `swarm-bao-queue-agent.service` lands
@ -587,7 +585,7 @@ async fn set_nspawn_flags(
// parent field took with it the unconditional grant every // parent field took with it the unconditional grant every
// agent used to get over its own direct children — so an agent with // agent used to get over its own direct children — so an agent with
// no capability now sees its own dirs and nothing else. // no capability now sees its own dirs and nothing else.
if holds_manage_root_agent(agent_name, &crate::capabilities::capabilities_path()) { if holds_manage_root_agent(agent_name, &crate::capabilities::path()) {
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is // Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
// `agent_state_dir/state` and `config_bind_source` is shared, so // `agent_state_dir/state` and `config_bind_source` is shared, so
// binding the holder as its own virtual child reproduced the two // binding the holder as its own virtual child reproduced the two
@ -818,22 +816,30 @@ mod tests {
} }
#[test] #[test]
fn a_corrupt_capabilities_file_denies_without_failing() { fn corrupt_swarm_permissions_deny_without_failing() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("capabilities.json"); let path = dir.path().join("swarm-agent-permissions.json");
let truncated = "{\n \"ruth\": [\"manage_root_ag"; let truncated = "{\n \"ruth\": {\"capabilities\": [\"manage_root_ag";
std::fs::write(&path, truncated).expect("seed"); std::fs::write(&path, truncated).expect("seed");
assert!(!holds_manage_root_agent("ruth", &path)); assert!(!holds_manage_root_agent("ruth", &path));
assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes()); assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes());
} }
/// Unset is the default, which gives ruth the recovery mounts and nobody
/// else; a grant or an explicit empty list replaces it.
#[test] #[test]
fn a_readable_grant_is_honoured() { fn the_swarm_grant_or_the_default_decides_the_recovery_mounts() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("capabilities.json"); let path = dir.path().join("swarm-agent-permissions.json");
std::fs::write(&path, r#"{"ruth": ["manage_root_agent"]}"#).expect("seed");
assert!(holds_manage_root_agent("ruth", &path)); assert!(holds_manage_root_agent("ruth", &path));
assert!(!holds_manage_root_agent("alice", &path)); assert!(!holds_manage_root_agent("alice", &path));
std::fs::write(
&path,
r#"{"ruth": {"capabilities": []}, "alice": {"capabilities": ["manage_root_agent"]}}"#,
)
.expect("seed");
assert!(!holds_manage_root_agent("ruth", &path));
assert!(holds_manage_root_agent("alice", &path));
} }
const TRUNCATED_LIMITS: &str = "{\n \"sock\": { \"cpu_quota\": \"50%\", \"mem"; const TRUNCATED_LIMITS: &str = "{\n \"sock\": { \"cpu_quota\": \"50%\", \"mem";

View file

@ -277,6 +277,9 @@ async fn cmd_serve(
if let Err(e) = migrate::run(&coord).await { if let Err(e) = migrate::run(&coord).await {
tracing::warn!(error = ?e, "startup migration failed"); tracing::warn!(error = ?e, "startup migration failed");
} }
// Before any agent starts, so the bind mounts it gets from the stored
// permissions are the meta repo's grants until the swarm answers.
crate::workers::permissions_import::seed_local();
// Auto-create the root agent container if it isn't there yet. Block // Auto-create the root agent container if it isn't there yet. Block
// on this — without root the system has no manager harness. // on this — without root the system has no manager harness.
// Failures are logged but allowed: a broken auto-spawn shouldn't // Failures are logged but allowed: a broken auto-spawn shouldn't

View file

@ -47,8 +47,8 @@ pub struct AgentSpec {
/// Stage every generated meta JSON file that exists: topology.json is /// Stage every generated meta JSON file that exists: topology.json is
/// regenerated by `reconcile` whenever the agent set changed; /// regenerated by `reconcile` whenever the agent set changed;
/// tool-groups/capabilities are created lazily on /// tool-groups is created lazily on
/// first write (`set_groups`/`set_caps`) — absent means /// first write (`set_groups`) — absent means
/// every agent is on defaults, no file needed. Without staging, an /// every agent is on defaults, no file needed. Without staging, an
/// existing-but-untracked file shows up as untracked in the meta repo, /// existing-but-untracked file shows up as untracked in the meta repo,
/// which can confuse nix's dirty-tree fetch. `git add` is a no-op when /// which can confuse nix's dirty-tree fetch. `git add` is a no-op when
@ -57,10 +57,6 @@ async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> {
for (path, name) in [ for (path, name) in [
(crate::topology::topology_path(), "topology.json"), (crate::topology::topology_path(), "topology.json"),
(crate::tool_groups::tool_groups_path(), "tool-groups.json"), (crate::tool_groups::tool_groups_path(), "tool-groups.json"),
(
crate::capabilities::capabilities_path(),
"capabilities.json",
),
] { ] {
if path.exists() { if path.exists() {
git(dir, &["add", name]).await?; git(dir, &["add", name]).await?;
@ -197,7 +193,6 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
// Only ever a REMOVAL now; see `is_embedded_ca_name`. // Only ever a REMOVAL now; see `is_embedded_ca_name`.
f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"), f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"),
"topology.json" => Some("topology"), "topology.json" => Some("topology"),
"capabilities.json" => Some("capabilities"),
"tool-groups.json" => Some("tool-groups"), "tool-groups.json" => Some("tool-groups"),
_ => None, _ => None,
}) })
@ -326,68 +321,28 @@ pub async fn commit_tool_groups(agent: &str, groups: &[String]) -> Result<()> {
Ok(()) Ok(())
} }
/// Write the capabilities file for `agent` and commit it atomically /// Delete `capabilities.json` from the meta repo and commit the removal under
/// under `META_LOCK`. Same rationale as `commit_tool_groups`. /// `META_LOCK`. Its grants live in the swarm's `agent-permissions` bucket
pub async fn commit_capabilities(agent: &str, caps: &[String]) -> Result<()> { /// once `workers::permissions_import` has sent them.
let _guard = META_LOCK.lock().await;
crate::capabilities::set_caps(agent, caps)
.map_err(|e| anyhow::anyhow!("set capabilities for {agent}: {e}"))?;
let dir = crate::paths::meta_root();
if crate::capabilities::capabilities_path().exists() {
git(&dir, &["add", "capabilities.json"]).await?;
}
if paths_dirty(&dir, &["capabilities.json"]).await? {
git_commit_paths(
&dir,
&format!("set capabilities for {agent}"),
&["capabilities.json"],
)
.await?;
}
Ok(())
}
/// Write both perm files for `agent` (whichever are `Some`) and commit
/// them in a SINGLE git commit under `META_LOCK` — the batch
/// `POST /api/permissions` path. A `None` field leaves that file
/// untouched. One commit + (caller does) one rebuild means changing an
/// agent's caps and tool-groups together no longer triggers two
/// rebuilds. Mirrors the staging discipline of `commit_tool_groups` /
/// `commit_capabilities`.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if writing either JSON file fails, a capability name /// Removing the file or a git stage/commit step fails.
/// is invalid (`set_caps`), or a git stage/commit step fails. pub async fn remove_capabilities_file() -> Result<()> {
pub async fn commit_perms( const FILE: &str = crate::workers::permissions_import::META_FILE;
agent: &str,
groups: Option<&[String]>,
caps: Option<&[String]>,
) -> Result<()> {
let _guard = META_LOCK.lock().await; let _guard = META_LOCK.lock().await;
let dir = crate::paths::meta_root(); let dir = crate::paths::meta_root();
let mut parts: Vec<&str> = Vec::new(); match std::fs::remove_file(dir.join(FILE)) {
if let Some(groups) = groups { Ok(()) => {}
crate::tool_groups::set_groups(agent, groups)?; Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
if crate::tool_groups::tool_groups_path().exists() { Err(e) => return Err(e).with_context(|| format!("removing {FILE}")),
git(&dir, &["add", "tool-groups.json"]).await?;
}
parts.push("tool-groups");
} }
if let Some(caps) = caps { git(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", FILE]).await?;
crate::capabilities::set_caps(agent, caps) if paths_dirty(&dir, &[FILE]).await? {
.map_err(|e| anyhow::anyhow!("set capabilities for {agent}: {e}"))?;
if crate::capabilities::capabilities_path().exists() {
git(&dir, &["add", "capabilities.json"]).await?;
}
parts.push("capabilities");
}
let paths = ["tool-groups.json", "capabilities.json"];
if paths_dirty(&dir, &paths).await? {
git_commit_paths( git_commit_paths(
&dir, &dir,
&format!("set {} for {agent}", parts.join(" + ")), "capabilities moved to the swarm's agent-permissions",
&paths, &[FILE],
) )
.await?; .await?;
} }
@ -905,7 +860,7 @@ where
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\""); let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!( let _ = writeln!(
out, out,
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null }}:" " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null }}:"
); );
out.push_str( out.push_str(
r#" let r#" let
@ -915,7 +870,6 @@ where
input = inputs."agent-${name}"; input = inputs."agent-${name}";
service = "hive-agent"; service = "hive-agent";
toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; }; toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; };
capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; };
in in
base.extendModules { base.extendModules {
modules = [ modules = [
@ -1092,7 +1046,7 @@ where
} }
out.push_str( out.push_str(
r" }; r" };
systemd.services.${service}.environment = toolGroupsEnv // capabilitiesEnv // { systemd.services.${service}.environment = toolGroupsEnv // {
HIVE_PORT = toString port; HIVE_PORT = toString port;
HIVE_LABEL = name; HIVE_LABEL = name;
HIVE_DASHBOARD_PORT = toString dashboardPort; HIVE_DASHBOARD_PORT = toString dashboardPort;
@ -1140,7 +1094,6 @@ where
// renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails // renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails
// open for any agent whose explicit entry is narrower. // open for any agent whose explicit entry is narrower.
let tool_groups_map = crate::tool_groups::read()?; let tool_groups_map = crate::tool_groups::read()?;
let capabilities_map = crate::capabilities::read()?;
for spec in agents { for spec in agents {
// Emit `toolGroups = "group1,group2"` when the operator has // Emit `toolGroups = "group1,group2"` when the operator has
// explicitly configured groups for this agent. Absent entry = null // explicitly configured groups for this agent. Absent entry = null
@ -1153,28 +1106,14 @@ where
let joined = groups.join(","); let joined = groups.join(",");
format!("\"{joined}\"") format!("\"{joined}\"")
}; };
// Emit `capabilities = "cap1,cap2"` when the operator has
// granted capabilities to this agent. Absent entry = null = no
// capability env var injected, capability-gated tools hidden.
let caps = capabilities_map
.get(&spec.name)
.cloned()
.unwrap_or_default();
let capabilities_attr = if caps.is_empty() {
"null".to_owned()
} else {
let joined = caps.join(",");
format!("\"{joined}\"")
};
let _ = writeln!( let _ = writeln!(
out, out,
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; }};", " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; }};",
spec.name, spec.name,
spec.name, spec.name,
if spec.is_manager { "true" } else { "false" }, if spec.is_manager { "true" } else { "false" },
spec.port, spec.port,
tool_groups_attr, tool_groups_attr,
capabilities_attr,
); );
} }
out.push_str(" };\n };\n}\n"); out.push_str(" };\n };\n}\n");

View file

@ -98,7 +98,7 @@ pub fn spawn(
// reason, and is a third consumer rather than a second connection. // reason, and is a third consumer rather than a second connection.
tokio::spawn(crate::workers::wanted::watch_declarations( tokio::spawn(crate::workers::wanted::watch_declarations(
client.clone(), client.clone(),
coord, std::sync::Arc::clone(&coord),
hive.clone(), hive.clone(),
shutdown.clone(), shutdown.clone(),
)); ));
@ -109,6 +109,14 @@ pub fn spawn(
shutdown.clone(), shutdown.clone(),
)); ));
// The capabilities the swarm grants, and the import of the meta repo's.
tokio::spawn(crate::workers::swarm_permissions::follow(
client.clone(),
std::sync::Arc::clone(&coord),
hive.clone(),
shutdown.clone(),
));
let mut health = SweepHealth::new("swarm_status_publish", "warn", FAILURES_BEFORE_BANNER); let mut health = SweepHealth::new("swarm_status_publish", "warn", FAILURES_BEFORE_BANNER);
loop { loop {
match publish(&client, &hive).await { match publish(&client, &hive).await {

View file

@ -124,10 +124,6 @@ pub async fn ensure_root_agent(coord: &Arc<Coordinator>) -> Result<()> {
); );
return Ok(()); return Ok(());
} }
// Before the create/start branch, not inside it: on a hive whose root
// container already exists this is the only run that can still seed the
// grant, and it has to land before her next rebuild bakes the binds.
seed_manager_capabilities();
let list_result = lifecycle::list().await; let list_result = lifecycle::list().await;
if let Err(e) = &list_result { if let Err(e) = &list_result {
tracing::warn!( tracing::warn!(
@ -257,68 +253,6 @@ fn seed_manager_tool_groups() {
} }
} }
/// The capability the root agent is seeded with, as the `snake_case` string
/// `capabilities.json` stores and `capabilities::has_cap` compares against.
/// Taken from the enum rather than written out, so the seed cannot drift into
/// a name `capabilities::prune_unknown` would silently drop.
fn manager_seed_caps() -> Vec<String> {
vec![<&str>::from(hive_sh4re::permissions::Capability::ManageRootAgent).to_owned()]
}
/// Whether the root agent's default capability grant should be written.
///
/// `store_written` is "`capabilities.json` exists" — see
/// [`seed_manager_capabilities`] for why that, and not the absence of an
/// entry for the manager, is the condition.
fn should_seed_manager_caps(store_written: bool) -> bool {
!store_written
}
/// Give ruth the `ManageRootAgent` capability on the path that deploys her.
///
/// This is the capability-store half of the grant `roles.json` used to make:
/// `topology::reconcile_roles` seeded `can_manage_top_level_agents` onto
/// `MANAGER_NAME` on every meta sync, and that role is what put the other
/// agents' state/config dirs, `/applied` and `/meta` into her nspawn binds.
/// Collapsing the role into the capability means that without a seed here she
/// loses those recovery mounts at her next container rebuild — silently, and
/// only then, because nspawn bakes bind flags at container start.
///
/// **Seeded once, not re-ensured on every boot.** The role kept an empty-list
/// tombstone so an explicit revoke stuck; the capability store *deletes* an
/// entry that has been emptied rather than tombstoning it, so "the manager
/// has no entry" cannot tell a fresh hive apart from a deliberate revoke.
/// File existence can: every grant and revoke goes through
/// `meta::commit_capabilities` → `capabilities::set_caps`, which
/// writes the file even when the result is an empty `{}`. So while the file
/// is absent nobody has ever had a say, and once it exists this is inert
/// forever — including on the destroy+recreate path, matching
/// [`seed_manager_tool_groups`]'s refusal to reset an operator's choice.
///
/// Written with plain `set_caps` rather than `meta::commit_capabilities`: the
/// role's own seed wrote the file and left it for `stage_generated_meta_files`
/// to `git add` into the next deploy commit, and taking `META_LOCK` on the
/// boot path would be a new ordering constraint for no gain.
fn seed_manager_capabilities() {
if !should_seed_manager_caps(crate::capabilities::capabilities_path().exists()) {
tracing::debug!(
"capabilities.json already written — leaving the manager's capabilities as-is"
);
return;
}
let caps = manager_seed_caps();
match crate::capabilities::set_caps(MANAGER_NAME, &caps) {
Ok(()) => tracing::info!(
caps = ?caps,
"seeded ruth's default capabilities (recovery bind mounts for every agent)"
),
Err(e) => tracing::warn!(
error = ?e,
"failed to seed ruth's capabilities — she will come up without the recovery mounts"
),
}
}
/// Boot reconcile (see the module doc): classify every agent by rev /// Boot reconcile (see the module doc): classify every agent by rev
/// freshness + persisted `wanted` intent, submit one `Boot` DAG /// freshness + persisted `wanted` intent, submit one `Boot` DAG
/// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale /// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale
@ -572,10 +506,7 @@ fn submit_boot_tree(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{BootAction, MANAGER_NAME, RootAgentPlan, boot_action, plan_root_agent};
BootAction, MANAGER_NAME, RootAgentPlan, boot_action, manager_seed_caps, plan_root_agent,
should_seed_manager_caps,
};
use crate::lifecycle::AGENT_PREFIX; use crate::lifecycle::AGENT_PREFIX;
use crate::power::Wanted; use crate::power::Wanted;
@ -604,45 +535,6 @@ mod tests {
assert_eq!(plan_root_agent(result), RootAgentPlan::Present); assert_eq!(plan_root_agent(result), RootAgentPlan::Present);
} }
// -----------------------------------------------------------------------
// Root-agent capability seed. `capabilities_path()` resolves under
// `paths::meta_root()`, which is hardcoded to `/var/lib/hyperhive` with no
// test override, so — same as `capabilities.rs`'s own tests — these pin
// the pure decision (`should_seed_manager_caps`, `manager_seed_caps`)
// rather than round-tripping the real file. Together they are the
// replacement for the deleted `reconcile_roles_in_seeds_root_when_absent`
// and `reconcile_roles_in_does_not_reseed_after_explicit_revoke`.
#[test]
fn the_manager_is_seeded_while_the_capability_store_has_never_been_written() {
assert!(should_seed_manager_caps(false));
assert_eq!(manager_seed_caps(), vec!["manage_root_agent".to_owned()]);
}
/// The revoke-sticks half, and the reason the condition is the file rather
/// than the manager's entry: the store deletes an emptied entry instead of
/// keeping a tombstone, but a revoke still writes the file — so once it
/// exists the seed must never fire again.
#[test]
fn a_written_capability_store_is_never_reseeded() {
assert!(!should_seed_manager_caps(true));
}
/// A seed the store would drop as unrecognised grants nothing while
/// logging success, so pin that what we write is a name `prune_unknown`
/// keeps — i.e. one of `Capability::ALL`'s `snake_case` spellings.
#[test]
fn the_seeded_name_is_a_capability_the_store_recognises() {
for cap in manager_seed_caps() {
assert!(
hive_sh4re::permissions::Capability::ALL
.iter()
.any(|known| <&str>::from(*known) == cap),
"{cap} is not a known capability"
);
}
}
// The regression the `Unreadable` variant exists for: the arm used to // The regression the `Unreadable` variant exists for: the arm used to
// substitute `from_running(running)`, which is `Noop` against BOTH // substitute `from_running(running)`, which is `Noop` against BOTH
// observations — so no combination of inputs could reach a reconcile. // observations — so no combination of inputs could reach a reconcile.

View file

@ -1,7 +1,7 @@
//! Background tasks and periodic sweeps: crash/login watcher, boot-time //! Background tasks and periodic sweeps: crash/login watcher, boot-time
//! auto-update reconcile, the agent-sockets.json writer loop, the MCP socket listener //! auto-update reconcile, the agent-sockets.json writer loop, the MCP socket listener
//! reconcile loop, knowledge-repo sync, the swarm wanted-state pull, and the //! reconcile loop, knowledge-repo sync, the swarm wanted-state pull, and the
//! swarm resource-cap read. //! swarm resource-cap and permissions reads.
//! Each submodule is re-exported //! Each submodule is re-exported
//! at the crate root, so `crate::crash_watch::…` etc. keep working unchanged. //! at the crate root, so `crate::crash_watch::…` etc. keep working unchanged.
@ -10,5 +10,7 @@ pub mod auto_update;
pub mod crash_watch; pub mod crash_watch;
pub mod knowledge; pub mod knowledge;
pub mod mcp_sockets; pub mod mcp_sockets;
pub mod permissions_import;
pub mod swarm_limits; pub mod swarm_limits;
pub mod swarm_permissions;
pub mod wanted; pub mod wanted;

View file

@ -0,0 +1,197 @@
//! The one-shot move of the capabilities this hive's meta repo held
//! (`meta/capabilities.json`) into the swarm's `agent-permissions` bucket.
//!
//! At boot, before any agent starts, [`seed_local`] copies the file's grants
//! into [`crate::capabilities`] when nothing is stored there yet, so agents
//! keep their mounts until the swarm answers. [`send_pending`] then sends the
//! grants to swarm-controller, which writes each agent's key only where there
//! is none, and on its answer deletes the file from the meta repo. Nothing
//! reads the file after that.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_sh4re::permissions::Capability;
use swarm_queue_client::agent_permissions::{
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
};
/// The meta repo file the grants are moved out of.
pub const META_FILE: &str = "capabilities.json";
fn meta_path() -> PathBuf {
crate::paths::meta_root().join(META_FILE)
}
/// The grants the meta file holds, by agent, as the swarm stores them. A name
/// that is not a [`Capability`] is dropped, with a warning.
///
/// `ruth` with no entry in a file that exists had her grant revoked: the file
/// was created holding it, and a revoke removed the entry. She is sent an
/// empty list, since unset would give her the default back.
fn grants(file: BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<Capability>> {
let mut out: BTreeMap<String, Vec<Capability>> = file
.into_iter()
.map(|(agent, names)| {
let caps = names
.iter()
.filter_map(|name| {
let cap = Capability::ALL
.iter()
.copied()
.find(|c| name.eq_ignore_ascii_case(<&str>::from(*c)));
if cap.is_none() {
tracing::warn!(%agent, capability = %name, "import: dropping unrecognised capability");
}
cap
})
.collect();
(agent, caps)
})
.collect();
out.entry(crate::lifecycle::MANAGER_NAME.to_owned())
.or_default();
out
}
fn read_grants(path: &Path) -> Result<Option<BTreeMap<String, Vec<Capability>>>> {
if !path.exists() {
return Ok(None);
}
let file: BTreeMap<String, Vec<String>> = serde_json::from_slice(
&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?,
)
.with_context(|| format!("parsing {}", path.display()))?;
Ok(Some(grants(file)))
}
/// Store the meta file's grants as the last-read swarm permissions, when the
/// file is still there and nothing has been stored yet.
pub fn seed_local() {
if let Err(e) = seed_local_at(&meta_path(), &crate::capabilities::path()) {
tracing::warn!(error = ?e, "import: seeding the stored permissions from the meta repo failed");
}
}
fn seed_local_at(meta: &Path, stored: &Path) -> Result<()> {
if stored.exists() {
return Ok(());
}
let Some(grants) = read_grants(meta)? else {
return Ok(());
};
let records = grants
.into_iter()
.map(|(agent, caps)| {
(
agent,
AgentPermissions {
capabilities: Some(caps),
},
)
})
.collect();
crate::capabilities::write_at(stored, &records).context("storing the seeded permissions")?;
tracing::info!("import: seeded the stored permissions from the meta repo's capabilities");
Ok(())
}
/// Send the meta file's grants to swarm-controller while the file is there,
/// and delete it once the controller has answered that it handled them.
///
/// # Errors
///
/// The file is still there: the import failed, or deleting the file did. The
/// next pass sends again; the controller keeps a key it already has.
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> Result<()> {
send_at(client, hive, &meta_path())
.await
.context("importing the meta repo's capabilities")
}
async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result<()> {
let Some(capabilities) = read_grants(path)? else {
return Ok(());
};
swarm_queue_client::ensure_connected(client)?;
let payload = serde_json::to_vec(&Import { capabilities })?;
let reply = tokio::time::timeout(
IMPORT_REPLY_DEADLINE,
client.request(agent_permissions::import_subject(hive), payload.into()),
)
.await
.context("the controller did not answer the import")?
.context("sending the import")?;
match serde_json::from_slice(&reply.payload).context("decoding the controller's answer")? {
ImportReply::Imported {
written,
kept,
skipped,
} => {
tracing::info!(
?written,
?kept,
?skipped,
"import: the controller took the meta repo's capabilities"
);
crate::meta::remove_capabilities_file()
.await
.context("deleting the imported capabilities file")
}
ImportReply::Failed { error } => {
anyhow::bail!("the controller refused the import: {error}")
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{grants, seed_local_at};
use hive_sh4re::permissions::Capability;
fn file(json: &str) -> BTreeMap<String, Vec<String>> {
serde_json::from_str(json).expect("decodes")
}
#[test]
fn grants_keep_known_names_and_a_revoked_ruth_stays_revoked() {
let out = grants(file(
r#"{"atlas": ["manage_root_agent", "fly_to_the_moon"], "iris": []}"#,
));
assert_eq!(out["atlas"], vec![Capability::ManageRootAgent]);
assert_eq!(out["iris"], Vec::<Capability>::new());
assert_eq!(out["ruth"], Vec::<Capability>::new());
let out = grants(file(r#"{"ruth": ["manage_root_agent"]}"#));
assert_eq!(out["ruth"], vec![Capability::ManageRootAgent]);
}
/// The seed writes once, from the file, and never over a stored read.
#[test]
fn the_seed_copies_the_file_only_when_nothing_is_stored() {
let dir = tempfile::tempdir().expect("tempdir");
let meta = dir.path().join("capabilities.json");
let stored = dir.path().join("stored.json");
seed_local_at(&meta, &stored).expect("no file is no seed");
assert!(!stored.exists());
std::fs::write(&meta, r#"{"atlas": ["manage_root_agent"]}"#).expect("seed");
seed_local_at(&meta, &stored).expect("seeded");
assert!(
crate::capabilities::holds(&stored, "atlas", Capability::ManageRootAgent)
.expect("read")
);
assert!(
!crate::capabilities::holds(&stored, "ruth", Capability::ManageRootAgent)
.expect("read")
);
std::fs::write(&stored, "{}").expect("a later read");
seed_local_at(&meta, &stored).expect("left alone");
assert_eq!(std::fs::read_to_string(&stored).expect("read"), "{}");
}
}

View file

@ -0,0 +1,295 @@
//! Reading the capabilities the swarm grants this hive's agents.
//!
//! swarm-controller keeps one key per agent in the `agent-permissions` bucket
//! ([`swarm_queue_client::agent_permissions`]). Every [`READ_INTERVAL`] this
//! reads the key of each agent this hive has and stores what it finds with
//! [`crate::capabilities`], where the container's bind mounts are worked out.
//! Those are fixed when the container starts, so an agent whose capabilities
//! changed is restarted, after its current turn.
//!
//! A failed read, or a bucket that does not answer, leaves the stored
//! permissions as they are and restarts nothing: dropping them would put an
//! agent back on its default, which can be wider than what the swarm set.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Read};
use crate::capabilities::{self, Stored};
use crate::coordinator::Coordinator;
use crate::stats::sweep_health::{self, SweepHealth};
/// How often the permissions are read.
pub const READ_INTERVAL: Duration = Duration::from_secs(30);
/// At [`READ_INTERVAL`] this is ~90 seconds of failing reads.
const FAILURES_BEFORE_BANNER: u32 = 3;
/// Send the meta repo's grants once, then read the permissions now and every
/// [`READ_INTERVAL`] until shutdown.
pub async fn follow(
client: async_nats::Client,
coord: Arc<Coordinator>,
hive: String,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let mut health = SweepHealth::new("swarm_permissions_read", "warn", FAILURES_BEFORE_BANNER);
loop {
// While the meta repo's grants are not in the swarm yet, an unset key
// is not the swarm's answer, so nothing is read until they are.
let pass = match super::permissions_import::send_pending(&client, &hive).await {
Ok(()) => sync(&client).await,
Err(e) => Err(e),
};
match pass {
Ok(changed) => {
health.record_ok();
restart(&coord, changed).await;
}
Err(e) => {
tracing::warn!(error = ?e, "swarm permissions: the pass failed");
let err = format!("{e:#}");
health.record_err(|ctx| {
let age = ctx.since_last_ok.map_or_else(
|| "no success this session".to_owned(),
|d| format!("last ok {} ago", sweep_health::fmt_age(d)),
);
format!(
"syncing the swarm's agent permissions is failing ({} consecutive, \
{age}) — agents keep the permissions last read: {err}",
ctx.consecutive
)
});
}
}
tokio::select! {
() = tokio::time::sleep(READ_INTERVAL) => {}
_ = shutdown.changed() => {
tracing::info!("swarm permissions: shutdown signal received");
return;
}
}
}
}
/// Read every agent's key and store the result. Returns the agents whose
/// capabilities changed.
async fn sync(client: &async_nats::Client) -> Result<Vec<String>> {
// An unconnected client does not fail a JetStream request, it hangs on it.
swarm_queue_client::ensure_connected(client)?;
let agents: Vec<String> = crate::lifecycle::agents_for_meta_listing()
.await
.context("enumerating this hive's agents")?
.into_iter()
.map(|spec| spec.name)
.collect();
sync_with(&agents, &capabilities::path(), |agent| async move {
agent_permissions::read(client, &agent)
.await
.map_err(|e| anyhow::anyhow!(e))
.with_context(|| format!("reading the swarm's permissions for {agent}"))
})
.await
}
/// [`sync`] with the read and the file given. Nothing is written unless every
/// read succeeds.
async fn sync_with<F, Fut>(agents: &[String], path: &Path, read: F) -> Result<Vec<String>>
where
F: Fn(String) -> Fut,
Fut: std::future::Future<Output = Result<Read>>,
{
let mut read_now = Stored::new();
for agent in agents {
match read(agent.clone()).await? {
Read::Current(record) => {
if record != AgentPermissions::default() {
read_now.insert(agent.clone(), record);
}
}
Read::NoBucket => {
tracing::debug!("swarm permissions: the agent-permissions bucket does not answer");
return Ok(Vec::new());
}
}
}
let stored = capabilities::read_at(path).context("reading the stored swarm permissions")?;
if read_now == stored {
return Ok(Vec::new());
}
capabilities::write_at(path, &read_now).context("storing the swarm permissions")?;
tracing::info!(permissions = ?read_now, "swarm permissions: stored the swarm's permissions");
Ok(agents
.iter()
.filter(|a| {
capabilities::effective(a, stored.get(*a))
!= capabilities::effective(a, read_now.get(*a))
})
.cloned()
.collect())
}
/// Restart each running agent in `changed` once its current turn is done, so
/// its container starts with the bind mounts its new capabilities give. A
/// stopped agent gets them at its next start.
async fn restart(coord: &Arc<Coordinator>, changed: Vec<String>) {
let mut running = Vec::new();
for agent in changed {
if crate::lifecycle::is_running(&agent).await {
running.push(agent);
}
}
if running.is_empty() {
return;
}
tracing::info!(agents = ?running, "swarm permissions: capabilities changed, restarting");
if let Err(e) = crate::job_queue::power::restart_many(coord, &running, true).await {
tracing::error!(agents = ?running, error = ?e, "swarm permissions: restart insert failed");
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{Read, sync_with};
use swarm_queue_client::agent_permissions::AgentPermissions;
fn record(json: &str) -> AgentPermissions {
serde_json::from_str(json).expect("decodes")
}
fn agents() -> Vec<String> {
vec!["atlas".to_owned(), "ruth".to_owned()]
}
/// Sync `agents()` against `path` with each agent read as `answer` gives.
async fn sync(
path: &Path,
answer: impl Fn(&str) -> anyhow::Result<Read>,
) -> anyhow::Result<Vec<String>> {
sync_with(&agents(), path, |agent| {
let out = answer(&agent);
async move { out }
})
.await
}
/// No key anywhere is everyone on the default: nothing stored, and no
/// restart, since that is what the agents already run with.
#[tokio::test]
async fn missing_keys_are_the_default_and_restart_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |_| Ok(Read::Current(AgentPermissions::default())))
.await
.expect("read");
assert!(changed.is_empty());
assert!(
crate::capabilities::read_at(&path)
.expect("read")
.is_empty()
);
assert!(
crate::capabilities::holds(
&path,
"ruth",
hive_sh4re::permissions::Capability::ManageRootAgent
)
.expect("read")
);
}
/// Revoking ruth's default, and granting atlas, restarts both; clearing
/// them again restarts both back.
#[tokio::test]
async fn a_changed_capability_asks_for_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"capabilities":[]}"#
} else {
r#"{"capabilities":["manage_root_agent"]}"#
})))
})
.await
.expect("read");
assert_eq!(changed, agents());
// The same answer again changes nothing.
let unchanged = sync(&path, |agent| {
Ok(Read::Current(record(if agent == "ruth" {
r#"{"capabilities":[]}"#
} else {
r#"{"capabilities":["manage_root_agent"]}"#
})))
})
.await
.expect("read");
assert!(unchanged.is_empty());
let cleared = sync(&path, |_| Ok(Read::Current(AgentPermissions::default())))
.await
.expect("read");
assert_eq!(cleared, agents());
}
/// Setting ruth's default explicitly is a different record but the same
/// mounts, so it is stored and restarts nothing.
#[tokio::test]
async fn an_explicit_default_is_stored_without_a_restart() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let changed = sync(&path, |agent| {
Ok(Read::Current(if agent == "ruth" {
record(r#"{"capabilities":["manage_root_agent"]}"#)
} else {
AgentPermissions::default()
}))
})
.await
.expect("read");
assert!(changed.is_empty());
assert!(
crate::capabilities::read_at(&path)
.expect("read")
.contains_key("ruth")
);
}
/// One failed read, or a bucket that does not answer, keeps what was
/// stored and restarts nothing: never a fall back to the default.
#[tokio::test]
async fn a_failed_read_keeps_the_last_value() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("p.json");
let revoked = || Ok(Read::Current(record(r#"{"capabilities":[]}"#)));
// An empty list is atlas's default already, so only ruth changes.
assert_eq!(
sync(&path, |_| revoked()).await.expect("read"),
vec!["ruth".to_owned()]
);
let before = std::fs::read(&path).expect("stored");
sync(&path, |agent| {
if agent == "ruth" {
Err(anyhow::anyhow!("timed out"))
} else {
Ok(Read::Current(AgentPermissions::default()))
}
})
.await
.expect_err("a failed read fails the pass");
assert_eq!(std::fs::read(&path).expect("stored"), before);
let changed = sync(&path, |_| Ok(Read::NoBucket))
.await
.expect("no bucket");
assert!(changed.is_empty());
assert_eq!(std::fs::read(&path).expect("stored"), before);
}
}

View file

@ -25,15 +25,6 @@ pub use hive_jobq::State;
pub enum PermPayload { pub enum PermPayload {
/// Set the tool groups for one agent (`tool-groups.json`). /// Set the tool groups for one agent (`tool-groups.json`).
ToolGroups { groups: Vec<String> }, ToolGroups { groups: Vec<String> },
/// Set the capabilities for one agent (`capabilities.json`).
Capabilities { caps: Vec<String> },
/// Set both perm-types in one entry — the batch
/// `POST /api/permissions` path. `None` leaves that file untouched;
/// present fields commit together and rebuild once.
Combined {
groups: Option<Vec<String>>,
caps: Option<Vec<String>>,
},
} }
/// Node id. Carries the scheduler crate's globally-monotonic node id /// Node id. Carries the scheduler crate's globally-monotonic node id

View file

@ -1,8 +1,9 @@
//! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and //! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and
//! system-level capability grants (`Capability`). Both are declared in //! system-level capability grants (`Capability`). Tool groups are declared
//! per-agent config (`tool-groups.json` / `capabilities.json`), injected //! in the hive's `tool-groups.json`, injected into the container as an env
//! into the container as env vars, and read by the harness to decide //! var, and read by the harness to decide which MCP tools claude actually
//! which MCP tools claude actually sees. //! sees. Capabilities are granted in the swarm's `agent-permissions` bucket
//! and enforced by hive-c0re.
//! //!
//! It also resolves the *built-in* claude tool list ([`builtin_tools_arg`]): //! It also resolves the *built-in* claude tool list ([`builtin_tools_arg`]):
//! that set is group-gated too (`web_tools`), so it belongs next to the //! that set is group-gated too (`web_tools`), so it belongs next to the
@ -154,11 +155,11 @@ pub enum ToolGroup {
Inbox, Inbox,
/// Gates no tool today — `list_containers`, its only member, was /// Gates no tool today — `list_containers`, its only member, was
/// removed with no replacement. Kept so existing /// removed with no replacement. Kept so existing
/// `meta/capabilities.json` grants still parse; `tools()` returns /// `meta/tool-groups.json` grants still parse; `tools()` returns
/// `&[]`. /// `&[]`.
Lifecycle, Lifecycle,
/// Gates no tool today — `create_repo`, its only member, was removed /// Gates no tool today — `create_repo`, its only member, was removed
/// with no replacement. Kept so existing `meta/capabilities.json` /// with no replacement. Kept so existing `meta/tool-groups.json`
/// grants still parse; `tools()` returns `&[]`. /// grants still parse; `tools()` returns `&[]`.
Forge, Forge,
/// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/ /// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/
@ -323,16 +324,12 @@ impl ToolGroup {
} }
} }
/// Per-agent capability grants. Stored in `meta/capabilities.json` /// Per-agent capability grants: the `capabilities` field of the swarm's
/// (same shape as `tool-groups.json`: `{ "alice": ["manage_root_agent"] }`). /// `agent-permissions` record (`swarm_queue_client::agent_permissions`), set
/// Capabilities control system-level access hive-c0re enforces at /// with `swarmctl agent permissions set`. Capabilities control system-level
/// dispatch time; they are orthogonal to tool groups (which control /// access hive-c0re enforces, through the container's bind mounts; they are
/// which MCP tools the harness exposes to claude). /// orthogonal to tool groups (which control which MCP tools the harness
/// /// exposes to claude). See `docs/process/conventions.md::Capabilities`.
/// Injected into containers as `HIVE_CAPABILITIES` (comma-separated
/// `snake_case`) via `meta::render_flake`. The harness reads this to
/// conditionally register capability-gated MCP tools so claude only
/// sees tools it can actually invoke. See `docs/process/conventions.md::Capabilities`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")] #[strum(serialize_all = "snake_case")]
@ -348,26 +345,14 @@ pub enum Capability {
/// there was. With the hierarchy removed there is no privileged root /// there was. With the hierarchy removed there is no privileged root
/// to single out, so the same grant now means "manage any agent". /// to single out, so the same grant now means "manage any agent".
/// Kept under the old spelling deliberately — renaming it would turn /// Kept under the old spelling deliberately — renaming it would turn
/// every `manage_root_agent` already stored in `capabilities.json` /// every `manage_root_agent` already stored in a swarm record into a
/// into an unrecognised name that `capabilities::prune_unknown` /// name the hive can no longer decode.
/// silently drops.
ManageRootAgent, ManageRootAgent,
} }
impl Capability { impl Capability {
/// Every known capability in a stable order. Use this to enumerate /// Every known capability in a stable order.
/// columns in the permissions UI or validate incoming capability strings.
pub const ALL: &'static [Self] = &[Self::ManageRootAgent]; pub const ALL: &'static [Self] = &[Self::ManageRootAgent];
/// Short human-readable description suitable for a tooltip or help text.
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::ManageRootAgent => {
"manage any agent: every agent's state (rw) and config (ro) mounted for recovery"
}
}
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -66,6 +66,9 @@ sha2.workspace = true
# `forge/pull_diff.rs` diffs a PR's changed files itself, at a context width # `forge/pull_diff.rs` diffs a PR's changed files itself, at a context width
# the forge's own `.diff` does not offer. # the forge's own `.diff` does not offer.
similar.workspace = true similar.workspace = true
# The `Capability` names `/api/agents/{name}/permissions` accepts, the same
# enum the hive enforces.
hive-sh4re.workspace = true
# Validates `POST /api/agents`' `name` before it becomes `agent`/`repo` # Validates `POST /api/agents`' `name` before it becomes `agent`/`repo`
# everywhere downstream — see `create_agent`'s doc comment for why this is # everywhere downstream — see `create_agent`'s doc comment for why this is
# defense-in-depth, not the only gate (per an argus review finding). # defense-in-depth, not the only gate (per an argus review finding).
@ -102,6 +105,7 @@ swarm-authelia-bridge-sock.workspace = true
# carries. # carries.
swarm-queue-client = { workspace = true, features = [ swarm-queue-client = { workspace = true, features = [
"kv", "kv",
"agent-permissions",
"subagent-term", "subagent-term",
"agent-term", "agent-term",
"schema", "schema",

View file

@ -0,0 +1,503 @@
//! `/api/agents/{name}/permissions` — the capabilities the swarm grants one
//! agent — and the one-shot import of the grants a hive's meta repo held.
//!
//! Each agent's permissions are one value under its own key of
//! `agent-permissions` (`swarm_queue_client::agent_permissions`), which this
//! daemon alone writes. The hive running the agent reads the key and restarts
//! the agent when its capabilities change. A field left unset leaves the agent
//! on the hive's compiled-in default; an empty list grants nothing.
use std::collections::BTreeSet;
use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use futures_util::StreamExt as _;
use hive_sh4re::permissions::Capability;
use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply};
use utoipa::ToSchema;
use crate::schedules::{live, queue, read_or_default, refused, write};
use crate::wanted::WantedWriter;
use crate::{AppState, error_problem, wanted_error_status};
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
/// One agent's permissions. A field left out is not set by the swarm, and the
/// hive's default applies; an empty list grants nothing.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct Permissions {
/// Capability names, `snake_case`: `manage_root_agent` mounts every agent's
/// state and config into this one for recovery.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Vec<String>>, example = json!(["manage_root_agent"]))]
capabilities: Option<Vec<Capability>>,
}
impl Permissions {
/// The value to store, or why it is refused.
fn into_stored(self) -> Result<AgentPermissions, String> {
if let Some(caps) = &self.capabilities {
let mut seen = std::collections::HashSet::new();
for cap in caps {
if !seen.insert(*cap) {
let name: &str = (*cap).into();
return Err(format!("capability {name:?} is listed twice"));
}
}
}
Ok(AgentPermissions {
capabilities: self.capabilities,
})
}
}
impl From<AgentPermissions> for Permissions {
fn from(v: AgentPermissions) -> Self {
Self {
capabilities: v.capabilities,
}
}
}
fn agent_name(name: &str) -> Result<String, (StatusCode, String)> {
hive_types::Ident::parse(name)
.map(hive_types::Ident::into_string)
.map_err(|reason| (StatusCode::BAD_REQUEST, reason.to_owned()))
}
fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
tracing::warn!(error = %format!("{e:#}"), "agent permissions: queue failure");
error_problem(wanted_error_status(e), &format!("{e:#}"))
}
async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, ProblemDetails> {
agent_permissions::open_or_create(client)
.await
.map_err(|e| queue_problem(&anyhow::Error::new(e)))
}
#[utoipa::path(
get,
path = "/api/agents/{name}/permissions",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 200, description = "the permissions the swarm sets for the agent; `{}` \
when it sets none", body = Permissions),
(status = 400, description = "not an identifier (problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the permissions could not be read (problem+json)",
body = String),
),
tag = "agents"
)]
pub(crate) async fn get_agent_permissions(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<Permissions>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?;
let client = queue(&state).map_err(refused)?;
let store = open_or_create(&client).await?;
let (current, _, _) = read_or_default::<AgentPermissions>(&store, &agent)
.await
.map_err(|e| queue_problem(&e))?;
Ok(Json(current.into()))
}
/// Set the permissions the swarm grants one agent.
///
/// The value replaces the stored one whole. The hive running the agent reads
/// it within 30 seconds and, when the agent's capabilities changed, restarts
/// it after its current turn.
#[utoipa::path(
put,
path = "/api/agents/{name}/permissions",
params(("name" = String, Path, description = "agent name")),
request_body = Permissions,
responses(
(status = 200, description = "the permissions as stored", body = Permissions),
(status = 400, description = "not an identifier, not declared on any hive, or a \
capability listed twice (problem+json)", body = String),
(status = 422, description = "an unknown field or capability name"),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the permissions could not be written (problem+json)",
body = String),
),
tag = "agents"
)]
pub(crate) async fn put_agent_permissions(
State(state): State<AppState>,
Path(name): Path<String>,
Json(req): Json<Permissions>,
) -> Result<Json<Permissions>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?;
let value = req
.into_stored()
.map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
let client = queue(&state).map_err(refused)?;
if !live(&state).await?.contains(&agent) {
return Err(error_problem(
StatusCode::BAD_REQUEST,
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
));
}
let store = open_or_create(&client).await?;
for _ in 0..MAX_ATTEMPTS {
let (current, revision, undecodable) = read_or_default::<AgentPermissions>(&store, &agent)
.await
.map_err(|e| queue_problem(&e))?;
if (current == value && !undecodable)
|| write(&store, &agent, &value, revision)
.await
.map_err(|e| queue_problem(&e))?
{
tracing::info!(%agent, ?value, "set agent permissions");
return Ok(Json(value.into()));
}
}
Err(queue_problem(&anyhow::anyhow!(
"gave up writing the permissions of {agent} after {MAX_ATTEMPTS} conflicting writes"
)))
}
#[utoipa::path(
delete,
path = "/api/agents/{name}/permissions",
params(("name" = String, Path, description = "agent name")),
responses(
(status = 204, description = "cleared; the hive's default applies again"),
(status = 400, description = "not an identifier (problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the permissions could not be cleared (problem+json)",
body = String),
),
tag = "agents"
)]
pub(crate) async fn delete_agent_permissions(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<StatusCode, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?;
let client = queue(&state).map_err(refused)?;
let store = open_or_create(&client).await?;
store
.delete(&agent)
.await
.map_err(|e| queue_problem(&anyhow::Error::new(e)))?;
tracing::info!(%agent, "cleared agent permissions");
Ok(StatusCode::NO_CONTENT)
}
/// Create the bucket as soon as the queue is connected, then answer every
/// hive's import. Creating it first means a hive that finds no bucket knows
/// the queue is not serving it, rather than that nothing is set.
pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives: Vec<String>) {
tokio::spawn(async move {
const RETRY: std::time::Duration = std::time::Duration::from_secs(5);
let store = loop {
if swarm_queue_client::ensure_connected(&client).is_ok() {
match agent_permissions::open_or_create(&client).await {
Ok(store) => break store,
Err(e) => tracing::warn!(
error = swarm_queue_client::chain(&e),
"creating the agent-permissions bucket failed; retrying"
),
}
}
tokio::time::sleep(RETRY).await;
};
let subject = agent_permissions::import_subject_wildcard();
loop {
match client.subscribe(subject.clone()).await {
Ok(mut requests) => {
tracing::info!(%subject, "agent permissions: answering imports");
while let Some(msg) = requests.next().await {
let Some(reply) = msg.reply.clone() else {
continue;
};
let answer = answer_import(&store, &wanted, &hives, &msg).await;
let encoded = serde_json::to_vec(&answer).unwrap_or_default();
if let Err(e) = client.publish(reply, encoded.into()).await {
tracing::warn!(error = %e, "agent permissions: answering an import failed");
}
}
tracing::warn!(%subject, "agent permissions: import subscription ended");
}
Err(e) => {
tracing::warn!(%subject, error = %e, "agent permissions: subscribe failed");
}
}
tokio::time::sleep(RETRY).await;
}
});
}
async fn answer_import(
store: &async_nats::jetstream::kv::Store,
wanted: &WantedWriter,
hives: &[String],
msg: &async_nats::Message,
) -> ImportReply {
let failed = |error: String| {
tracing::warn!(subject = %msg.subject, %error, "agent permissions: import refused");
ImportReply::Failed { error }
};
let Some(hive) = agent_permissions::hive_from_import_subject(&msg.subject) else {
return failed(format!("{} is not an import subject", msg.subject));
};
if !hives.iter().any(|h| h == hive) {
return failed(format!("{hive} is not a hive this controller serves"));
}
let import: Import = match serde_json::from_slice(&msg.payload) {
Ok(import) => import,
Err(e) => return failed(format!("the import does not decode: {e}")),
};
let declared = match wanted.view(hive).await {
Ok(d) => crate::agent_renewal::live_agents(d.as_slice()),
Err(e) => return failed(format!("reading {hive}'s wanted state: {e:#}")),
};
match import_into(store, &declared, import).await {
Ok(reply) => {
tracing::info!(%hive, ?reply, "agent permissions: imported a hive's grants");
reply
}
Err(e) => failed(format!("{e:#}")),
}
}
/// Write each agent's imported capabilities under its key when it has none,
/// for the agents in `declared` only.
async fn import_into(
store: &async_nats::jetstream::kv::Store,
declared: &BTreeSet<String>,
import: Import,
) -> anyhow::Result<ImportReply> {
let (mut written, mut kept, mut skipped) = (Vec::new(), Vec::new(), Vec::new());
for (agent, caps) in import.capabilities {
if !declared.contains(&agent) {
skipped.push(agent);
continue;
}
let value = AgentPermissions {
capabilities: Some(caps),
};
if write(store, &agent, &value, None).await? {
written.push(agent);
} else {
kept.push(agent);
}
}
Ok(ImportReply::Imported {
written,
kept,
skipped,
})
}
#[cfg(test)]
mod tests {
use super::{
Permissions, delete_agent_permissions, get_agent_permissions, import_into,
put_agent_permissions,
};
use crate::test_nats::TestNats;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use hive_sh4re::permissions::Capability;
use swarm_queue_client::agent_permissions::{Import, ImportReply};
async fn published(client: &async_nats::Client, agent: &str) -> Option<String> {
let store = swarm_queue_client::agent_permissions::open_or_create(client)
.await
.expect("bucket");
store
.get(agent)
.await
.expect("read")
.map(|v| String::from_utf8(v.to_vec()).expect("utf-8"))
}
fn granted() -> Permissions {
Permissions {
capabilities: Some(vec![Capability::ManageRootAgent]),
}
}
async fn put(
state: &crate::AppState,
agent: &str,
value: Permissions,
) -> Result<axum::Json<Permissions>, problem_details::ProblemDetails> {
put_agent_permissions(
State(state.clone()),
Path(agent.to_owned()),
axum::Json(value),
)
.await
}
async fn get(state: &crate::AppState, agent: &str) -> Permissions {
get_agent_permissions(State(state.clone()), Path(agent.to_owned()))
.await
.expect("read")
.0
}
/// Through the real handlers: a grant lands as the hive reads it, an empty
/// list is stored as an empty list, and `DELETE` takes the key out.
#[tokio::test]
async fn the_route_writes_what_a_hive_reads_and_delete_clears_it() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
assert_eq!(
put(&state, "atlas", granted()).await.expect("set").0,
granted()
);
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"capabilities":["manage_root_agent"]}"#)
);
assert_eq!(get(&state, "atlas").await, granted());
let none = Permissions {
capabilities: Some(Vec::new()),
};
let _ = put(&state, "atlas", none.clone()).await.expect("set");
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"capabilities":[]}"#)
);
assert_eq!(
delete_agent_permissions(State(state.clone()), Path("atlas".to_owned())).await,
Ok(StatusCode::NO_CONTENT)
);
assert_eq!(published(&client, "atlas").await, None);
assert_eq!(get(&state, "atlas").await, Permissions::default());
}
/// A name no hive declares, or a name that is not an identifier, is a 400
/// and writes nothing.
#[tokio::test]
async fn an_undeclared_or_malformed_agent_is_a_400() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
for agent in ["iris", "Not_An_Ident"] {
let problem = put(&state, agent, granted()).await.expect_err("refused");
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{agent}");
}
assert_eq!(published(&client, "iris").await, None);
}
async fn put_raw(app: &axum::Router, body: &'static str) -> StatusCode {
use tower::ServiceExt as _;
app.clone()
.oneshot(
axum::http::Request::put("/api/agents/atlas/permissions")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(body))
.expect("request"),
)
.await
.expect("router answers")
.status()
}
/// Behind the real router: an unknown field or capability name is a 422,
/// a duplicate is a 400, and none of them changes what is stored.
#[tokio::test]
async fn a_bad_body_is_refused_and_the_grant_stays() {
const GRANTED: &str = r#"{"capabilities":["manage_root_agent"]}"#;
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::declared(&client).await;
let _ = put(&state, "atlas", granted()).await.expect("set");
let app = crate::build_app(state);
for (body, status) in [
(
r#"{"capabilities":["manage_root_agent"],"tool_groups":["meta"]}"#,
StatusCode::UNPROCESSABLE_ENTITY,
),
(r#"{"capabilites":[]}"#, StatusCode::UNPROCESSABLE_ENTITY),
(
r#"{"capabilities":["fly_to_the_moon"]}"#,
StatusCode::UNPROCESSABLE_ENTITY,
),
(
r#"{"capabilities":["manage_root_agent","manage_root_agent"]}"#,
StatusCode::BAD_REQUEST,
),
] {
assert_eq!(put_raw(&app, body).await, status, "{body}");
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(GRANTED),
"{body}"
);
}
// Control: the same router and request do reach the bucket.
assert_eq!(put_raw(&app, "{}").await, StatusCode::OK);
assert_eq!(published(&client, "atlas").await.as_deref(), Some("{}"));
}
/// The import writes a key only where there is none, and only for the
/// agents the sending hive declares.
#[tokio::test]
async fn an_import_writes_only_missing_keys_of_the_hives_own_agents() {
let nats = TestNats::start();
let client = nats.client().await;
let store = swarm_queue_client::agent_permissions::open_or_create(&client)
.await
.expect("bucket");
store
.put("atlas", br#"{"capabilities":[]}"#.to_vec().into())
.await
.expect("seed");
let declared = ["atlas".to_owned(), "ruth".to_owned()].into();
let import = Import {
capabilities: [
("atlas".to_owned(), vec![Capability::ManageRootAgent]),
("ruth".to_owned(), vec![Capability::ManageRootAgent]),
("iris".to_owned(), vec![Capability::ManageRootAgent]),
]
.into(),
};
let reply = import_into(&store, &declared, import)
.await
.expect("imported");
assert_eq!(
reply,
ImportReply::Imported {
written: vec!["ruth".to_owned()],
kept: vec!["atlas".to_owned()],
skipped: vec!["iris".to_owned()],
}
);
assert_eq!(
published(&client, "atlas").await.as_deref(),
Some(r#"{"capabilities":[]}"#)
);
assert_eq!(
published(&client, "ruth").await.as_deref(),
Some(r#"{"capabilities":["manage_root_agent"]}"#)
);
assert_eq!(published(&client, "iris").await, None);
}
}

View file

@ -47,6 +47,7 @@ mod agent_identity;
mod agent_input; mod agent_input;
mod agent_limits; mod agent_limits;
mod agent_login; mod agent_login;
mod agent_permissions;
mod agent_renewal; mod agent_renewal;
mod agent_state_stream; mod agent_state_stream;
mod agent_status; mod agent_status;
@ -2877,11 +2878,22 @@ fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[H
} }
/// The per-agent background passes: subagent and terminal streams, and /// The per-agent background passes: subagent and terminal streams, and
/// schedule keys. /// schedule keys, and the permissions bucket and its import.
/// Lifted out of `main` for `clippy::too_many_lines`. /// Lifted out of `main` for `clippy::too_many_lines`.
fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) { fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
spawn_subagent_streams(status, hives); spawn_subagent_streams(status, hives);
spawn_schedule_reconcile(status, hives); spawn_schedule_reconcile(status, hives);
spawn_agent_permissions(status, hives);
}
/// Create the agent-permissions bucket and answer hives' imports when a swarm
/// queue is wired up. Same shape as [`spawn_subagent_streams`].
fn spawn_agent_permissions(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
return;
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
agent_permissions::spawn(status.queue_client(), wanted, hives);
} }
/// Start rewriting every agent's schedule key from the definitions when a /// Start rewriting every agent's schedule key from the definitions when a
@ -3493,6 +3505,11 @@ fn build_app(state: AppState) -> axum::Router {
agent_limits::put_agent_limits, agent_limits::put_agent_limits,
agent_limits::delete_agent_limits agent_limits::delete_agent_limits
)) ))
.routes(routes!(
agent_permissions::get_agent_permissions,
agent_permissions::put_agent_permissions,
agent_permissions::delete_agent_permissions
))
.routes(routes!(get_config_prs)) .routes(routes!(get_config_prs))
.routes(routes!(create_agent)) .routes(routes!(create_agent))
.routes(routes!(mint_agent_forge_token)) .routes(routes!(mint_agent_forge_token))

View file

@ -229,7 +229,9 @@ impl Policy {
return Some(Permissions { return Some(Permissions {
publish: self.reader_subjects(), publish: self.reader_subjects(),
subscribe: Some(Self::reader_subscribe(client_id)), subscribe: Some(Self::reader_subscribe(client_id)),
responses: None, // The answer to a hive's permissions import. NATS lets it
// answer only a message it received, once.
responses: Some(swarm_queue_client::agent_permissions::IMPORT_REPLY_DEADLINE),
}); });
} }
if self.review_classifiers.iter().any(|c| c == client_id) { if self.review_classifiers.iter().any(|c| c == client_id) {
@ -307,7 +309,7 @@ impl Policy {
/// What a reader may subscribe to: every agent's terminal, its /// What a reader may subscribe to: every agent's terminal, its
/// subagents' terminals and its turn state, which the controller relays /// subagents' terminals and its turn state, which the controller relays
/// to swarm-ui, and its own reply inbox. /// to swarm-ui, every hive's permissions import, and its own reply inbox.
/// ///
/// The three families are the publishers' (`hive-agent`'s `swarm_term` /// The three families are the publishers' (`hive-agent`'s `swarm_term`
/// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), spelled /// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), spelled
@ -318,6 +320,7 @@ impl Policy {
swarm_queue_client::term_subject_wildcard(), swarm_queue_client::term_subject_wildcard(),
swarm_queue_client::subagent_term::subject_wildcard(), swarm_queue_client::subagent_term::subject_wildcard(),
swarm_queue_client::agent_state_subject_wildcard(), swarm_queue_client::agent_state_subject_wildcard(),
swarm_queue_client::agent_permissions::import_subject_wildcard(),
Self::inbox(client_id), Self::inbox(client_id),
] ]
} }
@ -561,6 +564,17 @@ impl Policy {
"$JS.API.DIRECT.GET.{}.>", "$JS.API.DIRECT.GET.{}.>",
swarm_queue_client::agent_limits::STREAM swarm_queue_client::agent_limits::STREAM
)); ));
// The capabilities the swarm grants those agents
// (`swarm_queue_client::agent_permissions`), read the same way and
// every key for the same reason.
subjects.push(format!(
"$JS.API.DIRECT.GET.{}.>",
swarm_queue_client::agent_permissions::STREAM
));
// The one-shot import of the grants this hive's meta repo held, sent
// as a request on this hive's own subject: the controller takes the
// hive from the subject, so one hive cannot import for another.
subjects.push(swarm_queue_client::agent_permissions::import_subject(hive));
subjects.extend( subjects.extend(
self.extra_hive_subjects self.extra_hive_subjects
.iter() .iter()
@ -837,6 +851,78 @@ mod tests {
} }
} }
/// A hive reads any agent's permissions and can change none of them; an
/// agent, even one granted direct gets on its own keys elsewhere, reads
/// none of them.
#[test]
fn a_hive_may_read_every_agents_permissions_and_an_agent_none() {
use swarm_queue_client::agent_permissions::{STREAM, direct_get_subject, key_subject};
let p = policy()
.with_agent_token_subjects(vec![
"$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.{agent}".to_owned(),
])
.expect("a per-agent template is valid");
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
for agent in ["atlas", "ruth"] {
assert!(hive_may(&direct_get_subject(agent)), "cannot read {agent}");
}
for forbidden in [
key_subject("atlas"),
format!("$JS.API.STREAM.CREATE.{STREAM}"),
format!("$JS.API.STREAM.DELETE.{STREAM}"),
format!("$JS.API.STREAM.PURGE.{STREAM}"),
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
] {
assert!(!hive_may(&forbidden), "hive alpha may publish {forbidden}");
}
let agent = p.agent_token_permissions("atlas").expect("configured");
let agent_may = |subject: &str| agent.publish.iter().any(|g| covers(g, subject));
assert!(
!agent_may(&direct_get_subject("atlas")),
"an agent may read its own permissions: {:?}",
agent.publish
);
// Control: the agent's direct-get grant is real, just not this bucket.
assert!(agent_may(
&swarm_queue_client::agent_inference::direct_get_subject("atlas")
));
}
/// A hive sends its import on its own subject only; the reader hears every
/// hive's and may answer, and no other role may send one.
#[test]
fn a_hive_imports_only_under_its_own_name_and_the_reader_answers() {
use swarm_queue_client::agent_permissions::{
IMPORT_REPLY_DEADLINE, import_subject, import_subject_wildcard,
};
let p = policy();
let hive = p.permissions("hive-alpha").expect("a hive is admitted");
let hive_may = |subject: &str| hive.publish.iter().any(|g| covers(g, subject));
assert!(hive_may(&import_subject("alpha")));
assert!(!hive_may(&import_subject("beta")));
let reader = p.permissions("swarm-controller").expect("the reader");
assert!(
reader
.subscribe
.as_ref()
.expect("scoped")
.contains(&import_subject_wildcard())
);
assert_eq!(reader.responses, Some(IMPORT_REPLY_DEADLINE));
assert!(
!reader
.publish
.iter()
.any(|g| covers(g, &import_subject("alpha"))),
"the reader may send an import as a hive"
);
}
#[test] #[test]
fn an_extra_subject_without_the_placeholder_is_refused() { fn an_extra_subject_without_the_placeholder_is_refused() {
// 🩸 The option exists to put a second stream inside ONE hive's // 🩸 The option exists to put a second stream inside ONE hive's
@ -1356,7 +1442,6 @@ mod tests {
.expect("a per-agent template is valid"); .expect("a per-agent template is valid");
let reader = p.permissions("swarm-controller").expect("the reader"); let reader = p.permissions("swarm-controller").expect("the reader");
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT))); assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
assert_eq!(reader.responses, None);
for (who, grant) in [ for (who, grant) in [
("a hive", p.permissions("hive-alpha").expect("a hive")), ("a hive", p.permissions("hive-alpha").expect("a hive")),
( (

View file

@ -50,6 +50,10 @@ schema = ["dep:utoipa"]
# `agent_limits::read`, for hive-c0re, which reads the key of each agent it # `agent_limits::read`, for hive-c0re, which reads the key of each agent it
# runs the same way. Explicit for the same reason as `notices`. # runs the same way. Explicit for the same reason as `notices`.
agent-limits = ["async-nats/jetstream"] agent-limits = ["async-nats/jetstream"]
# `agent_permissions::AgentPermissions` and `read`, for hive-c0re and
# swarm-controller. Pulls `hive-sh4re` for the `Capability` names, so the
# crates that only need the bucket name do not link it.
agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"]
[dependencies] [dependencies]
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature # Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature
@ -58,6 +62,7 @@ async-nats.workspace = true
# `Shared`, so every caller refused while a secret re-read runs waits on that # `Shared`, so every caller refused while a secret re-read runs waits on that
# one read (`stored_secret`). # one read (`stored_secret`).
futures-util.workspace = true futures-util.workspace = true
hive-sh4re = { workspace = true, optional = true }
# `blocking` on top of the workspace default (`form`/`json`/`rustls`) — # `blocking` on top of the workspace default (`form`/`json`/`rustls`) —
# `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller # `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller
# with no tokio reactor to `.await` an async request on (an OTLP exporter's # with no tokio reactor to `.await` an async request on (an OTLP exporter's

View file

@ -0,0 +1,255 @@
//! The per-agent permissions KV bucket: one key per agent, holding the
//! capabilities the swarm grants it.
//!
//! **swarm-controller is the only writer**, and creates the bucket when it
//! starts, so a bucket that does not answer means the queue is not serving it,
//! never "nothing set". A field left unset leaves the agent on its hive's
//! compiled-in default; an empty list grants nothing.
//!
//! **The hive running the agent reads the key**, not the agent: capabilities
//! are bind mounts the host sets up at container start. A hive reads with one
//! `DIRECT.GET` per agent
//! ([`crate::agent_permissions::direct_get_subject`]), and its grant covers
//! every key of this bucket, read-only, for the reason [`crate::agent_limits`]
//! gives.
//!
//! Keyed by the agent alone, like [`crate::agent_limits`]: an agent can move
//! between hives, and its permissions go with it.
//!
//! Also carries the one-shot import a hive sends of the grants its meta repo
//! held ([`crate::agent_permissions::import_subject`]), answered by the
//! controller.
/// The KV bucket permissions are published into, keyed by agent name.
pub const BUCKET: &str = "agent-permissions";
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
pub const STREAM: &str = "KV_agent-permissions";
/// The subject `agent`'s entry is stored under.
#[must_use]
pub fn key_subject(agent: &str) -> String {
format!("$KV.{BUCKET}.{agent}")
}
/// The request subject that reads `agent`'s current entry.
#[must_use]
pub fn direct_get_subject(agent: &str) -> String {
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent))
}
const IMPORT_SUBJECT_PREFIX: &str = "$SWARM.permissions-import";
/// The subject `hive` sends its import on. One per hive, so the hive's grant
/// names only its own, and the controller takes the hive from the subject
/// rather than from the payload.
#[must_use]
pub fn import_subject(hive: &str) -> String {
format!("{IMPORT_SUBJECT_PREFIX}.{hive}")
}
/// Every [`import_subject`] at once, for the controller's subscription.
#[must_use]
pub fn import_subject_wildcard() -> String {
import_subject("*")
}
/// The hive an [`import_subject`] names, or `None` for any other subject.
#[must_use]
pub fn hive_from_import_subject(subject: &str) -> Option<&str> {
let hive = subject
.strip_prefix(IMPORT_SUBJECT_PREFIX)?
.strip_prefix('.')?;
(!hive.is_empty() && !hive.contains('.')).then_some(hive)
}
/// How long a hive waits for the controller's answer to an import, and the
/// queue lets the controller answer.
pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
/// One agent's permissions: the value under `BUCKET`/`<agent>`. `None` is
/// "not set by the swarm", which a hive reads as its default.
///
/// No `deny_unknown_fields`: a field a newer controller adds must not cost an
/// older hive the fields it knows.
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentPermissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<Vec<hive_sh4re::permissions::Capability>>,
}
/// What a hive sends once: the capabilities its meta repo held, by agent.
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Import {
pub capabilities: std::collections::BTreeMap<String, Vec<hive_sh4re::permissions::Capability>>,
}
/// The controller's answer to an [`Import`].
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum ImportReply {
/// Every agent was handled. `written` had no key and got the hive's value;
/// `kept` already had a key, which was left alone; `skipped` is not an
/// agent the sending hive's wanted state declares.
Imported {
written: Vec<String>,
kept: Vec<String>,
skipped: Vec<String>,
},
/// Nothing is known to be written; the hive sends again later.
Failed { error: String },
}
/// Open the permissions bucket, creating it if nothing has yet.
///
/// **Controller-side only.** `history: 1`: a hive wants the current value.
#[cfg(feature = "kv")]
pub async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, crate::Error> {
let js = async_nats::jetstream::new(client.clone());
crate::kv::open_or_create(
&js,
BUCKET,
"agent-permissions",
"Permissions the swarm controller sets for each agent".to_owned(),
)
.await
}
/// What reading one agent's key found.
#[cfg(feature = "agent-permissions")]
#[derive(Debug)]
pub enum Read {
/// The bucket answered; an absent or deleted key has every field unset.
Current(AgentPermissions),
/// Nothing answers for the bucket's stream. Not an unset value: it says
/// nothing about the agent's permissions.
NoBucket,
}
/// Read `agent`'s current permissions with one direct get.
///
/// # Errors
///
/// A failed request or an undecodable value. A request the queue refused for
/// permissions reaches the client as a timeout, not as a refusal.
#[cfg(feature = "agent-permissions")]
pub async fn read(
client: &async_nats::Client,
agent: &str,
) -> Result<Read, Box<dyn std::error::Error + Send + Sync>> {
use async_nats::jetstream::stream::DirectGetErrorKind;
let js = async_nats::jetstream::new(client.clone());
let stream = js.get_stream_no_info(STREAM).await?;
let message = match stream.direct_get_last_for_subject(key_subject(agent)).await {
Ok(message) => message,
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
return Ok(Read::Current(AgentPermissions::default()));
}
Err(e)
if matches!(
e.kind(),
DirectGetErrorKind::ErrorResponse(status, _)
if status == async_nats::StatusCode::NO_RESPONDERS
) =>
{
return Ok(Read::NoBucket);
}
Err(e) => return Err(e.into()),
};
Ok(Read::Current(decode(
message
.headers
.get("KV-Operation")
.map(async_nats::HeaderValue::as_str),
&message.payload,
)?))
}
/// One stored message as a value: a KV delete or purge is stored as an empty
/// marker carrying the `KV-Operation` header, and reads as every field unset.
#[cfg(feature = "agent-permissions")]
fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result<AgentPermissions> {
if matches!(operation, Some("DEL" | "PURGE")) {
return Ok(AgentPermissions::default());
}
serde_json::from_slice(payload)
}
#[cfg(test)]
mod tests {
use super::*;
/// The names the grants in `swarm-nats-auth` are built from.
#[test]
fn names_match_the_queue_grant() {
assert_eq!(STREAM, format!("KV_{BUCKET}"));
assert_eq!(key_subject("iris"), "$KV.agent-permissions.iris");
assert_eq!(
direct_get_subject("iris"),
"$JS.API.DIRECT.GET.KV_agent-permissions.$KV.agent-permissions.iris"
);
assert_eq!(import_subject("pr1ma"), "$SWARM.permissions-import.pr1ma");
assert_eq!(import_subject_wildcard(), "$SWARM.permissions-import.*");
}
#[test]
fn the_hive_is_read_back_from_its_import_subject_only() {
assert_eq!(
hive_from_import_subject(&import_subject("pr1ma")),
Some("pr1ma")
);
for other in [
"$SWARM.permissions-import",
"$SWARM.permissions-import.",
"$SWARM.permissions-import.a.b",
"$SWARM.permissions-importx.pr1ma",
"$SWARM.deploy.pr1ma",
] {
assert_eq!(hive_from_import_subject(other), None, "{other}");
}
}
/// Unset is left out on the wire, `{}` is unset, and an empty list stays
/// an empty list: "grants nothing" is not "not set".
#[cfg(feature = "agent-permissions")]
#[test]
fn unset_and_empty_are_different_values() {
use hive_sh4re::permissions::Capability;
assert_eq!(
serde_json::to_string(&AgentPermissions::default()).unwrap(),
"{}"
);
let none: AgentPermissions = serde_json::from_str(r#"{"capabilities":[]}"#).unwrap();
assert_eq!(none.capabilities, Some(Vec::new()));
let ruth: AgentPermissions =
serde_json::from_str(r#"{"capabilities":["manage_root_agent"]}"#).unwrap();
assert_eq!(ruth.capabilities, Some(vec![Capability::ManageRootAgent]));
}
/// Fields a newer controller adds must not cost an older hive the ones it
/// knows; a deleted key is unset.
#[cfg(feature = "agent-permissions")]
#[test]
fn decoding_tolerates_unknown_fields_and_reads_a_delete_as_unset() {
let decoded = decode(
None,
br#"{"capabilities":["manage_root_agent"],"tool_groups":["meta"]}"#,
)
.unwrap();
assert_eq!(decoded.capabilities.map(|c| c.len()), Some(1));
assert_eq!(
decode(Some("DEL"), b"").unwrap(),
AgentPermissions::default()
);
assert_eq!(
decode(Some("PURGE"), b"").unwrap(),
AgentPermissions::default()
);
}
}

View file

@ -221,6 +221,12 @@ pub mod agent_inference;
/// writing needs `kv` and reading needs `agent-limits`. /// writing needs `kv` and reading needs `agent-limits`.
pub mod agent_limits; pub mod agent_limits;
/// The bucket swarm-controller sets each agent's permissions in — one key
/// per agent, read by the hive that runs it, plus the one-shot import a hive
/// sends. The names are unconditional; writing needs `kv` and the stored type
/// and reading need `agent-permissions`.
pub mod agent_permissions;
/// The login flag in an agent's turn-state header. No bucket: it travels /// The login flag in an agent's turn-state header. No bucket: it travels
/// on [`agent_state_subject`] only. /// on [`agent_state_subject`] only.
pub mod agent_login; pub mod agent_login;

View file

@ -29,6 +29,7 @@ mod agent;
mod forge; mod forge;
mod inference; mod inference;
mod login; mod login;
mod permissions;
mod remote; mod remote;
mod schedule; mod schedule;
mod users; mod users;
@ -290,6 +291,43 @@ enum AgentVerb {
#[command(subcommand)] #[command(subcommand)]
command: InferenceVerb, command: InferenceVerb,
}, },
/// 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`.
Permissions {
#[command(subcommand)]
command: PermissionsVerb,
},
}
#[derive(Subcommand)]
enum PermissionsVerb {
/// Show the capabilities the swarm grants an agent, or that it sets none.
Get(InferenceAgentArgs),
/// Replace the capabilities the swarm grants an agent.
Set(PermissionsSetArgs),
/// Clear the agent's permissions, so its hive's default applies again.
Clear(InferenceAgentArgs),
}
#[derive(Args)]
struct PermissionsSetArgs {
/// Agent name. A hive must declare it.
agent: String,
/// The capabilities to grant, comma-separated (`manage_root_agent`).
/// `--capabilities ''` grants none.
#[arg(
long,
value_name = "CAPABILITIES",
value_delimiter = ',',
required = true
)]
capabilities: Vec<String>,
#[command(flatten)]
socket: ControllerSocketArg,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@ -602,6 +640,9 @@ fn main() -> Result<()> {
Verb::Agent { Verb::Agent {
command: AgentVerb::Inference { command }, command: AgentVerb::Inference { command },
} => inference(&remote, command), } => inference(&remote, command),
Verb::Agent {
command: AgentVerb::Permissions { command },
} => permissions(&remote, command),
// Same socket-resolution reasoning as `Create` above. // Same socket-resolution reasoning as `Create` above.
Verb::Forge { Verb::Forge {
command: ForgeVerb::MakeAdmin(args), command: ForgeVerb::MakeAdmin(args),
@ -671,6 +712,16 @@ fn inference(remote: &RemoteArgs, command: InferenceVerb) -> Result<()> {
} }
} }
fn permissions(remote: &RemoteArgs, command: PermissionsVerb) -> Result<()> {
match command {
PermissionsVerb::Get(a) => permissions::get(&a.socket.resolve(remote)?, &a.agent),
PermissionsVerb::Set(a) => {
permissions::set(&a.socket.resolve(remote)?, &a.agent, a.capabilities)
}
PermissionsVerb::Clear(a) => permissions::clear(&a.socket.resolve(remote)?, &a.agent),
}
}
fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> { fn schedule(remote: &RemoteArgs, command: ScheduleVerb) -> Result<()> {
match command { match command {
ScheduleVerb::Add(a) => schedule::add( ScheduleVerb::Add(a) => schedule::add(

126
swarmctl/src/permissions.rs Normal file
View file

@ -0,0 +1,126 @@
//! `swarmctl agent permissions` — the capabilities the swarm grants one
//! agent, through swarm-controller's `/api/agents/{name}/permissions` routes.
//!
//! The hive running the agent reads its key every 30 seconds and restarts the
//! agent, after its current turn, when its capabilities change. Unset leaves
//! the agent on its hive's default; an empty list grants nothing.
//!
//! The shape below mirrors the controller's `Permissions`, for the reason
//! [`crate::agent`]'s module doc gives. Capability names are checked by the
//! controller, which answers 422 for one it does not know.
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use crate::agent::{parse_ident, send};
use crate::remote::Target;
/// One agent's permissions; an absent field is not set at swarm level.
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Permissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
capabilities: Option<Vec<String>>,
}
fn runtime() -> Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("starting a tokio runtime for the controller request")
}
/// The agent's permissions path. The name is checked first, so it cannot
/// reshape the URI.
fn member(agent: &str) -> Result<String> {
let agent = parse_ident(agent, "agent name")?;
Ok(format!("/api/agents/{agent}/permissions"))
}
/// Run `swarmctl agent permissions get`.
pub(crate) fn get(target: &Target, agent: &str) -> Result<()> {
let uri = member(agent)?;
let stored: Permissions = runtime()?.block_on(send(
target,
hyper::Method::GET,
&uri,
None::<&()>,
"permissions-get",
))?;
print!("{}", render(&stored));
Ok(())
}
/// Run `swarmctl agent permissions set`: store the given capabilities, which
/// replace the agent's record whole.
pub(crate) fn set(target: &Target, agent: &str, capabilities: Vec<String>) -> Result<()> {
let uri = member(agent)?;
let stored: Permissions = runtime()?.block_on(send(
target,
hyper::Method::PUT,
&uri,
Some(&with_capabilities(capabilities)),
"permissions-set",
))?;
print!("{}", render(&stored));
Ok(())
}
/// The record granting `capabilities`. An empty name, as `--capabilities ''`
/// gives, is dropped, so that spelling grants nothing.
fn with_capabilities(capabilities: Vec<String>) -> Permissions {
Permissions {
capabilities: Some(capabilities.into_iter().filter(|c| !c.is_empty()).collect()),
}
}
/// Run `swarmctl agent permissions clear`.
pub(crate) fn clear(target: &Target, agent: &str) -> Result<()> {
let uri = member(agent)?;
runtime()?.block_on(send::<(), ()>(
target,
hyper::Method::DELETE,
&uri,
None,
"permissions-clear",
))?;
println!("cleared; {agent} goes back to its hive's default");
Ok(())
}
fn render(p: &Permissions) -> String {
let caps = match p.capabilities.as_deref() {
None => "(not set; the hive's default)".to_owned(),
Some([]) => "(none)".to_owned(),
Some(caps) => caps.join(", "),
};
format!("capabilities: {caps}\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_tells_unset_from_none() {
let unset: Permissions = serde_json::from_str("{}").unwrap();
assert_eq!(
render(&unset),
"capabilities: (not set; the hive's default)\n"
);
let none: Permissions = serde_json::from_str(r#"{"capabilities":[]}"#).unwrap();
assert_eq!(render(&none), "capabilities: (none)\n");
let ruth: Permissions =
serde_json::from_str(r#"{"capabilities":["manage_root_agent"]}"#).unwrap();
assert_eq!(render(&ruth), "capabilities: manage_root_agent\n");
}
/// `--capabilities ''` stores an empty list, not one empty name.
#[test]
fn an_empty_list_flag_grants_nothing() {
let set = with_capabilities(vec![String::new()]);
assert_eq!(
serde_json::to_string(&set).unwrap(),
r#"{"capabilities":[]}"#
);
}
}