diff --git a/Cargo.lock b/Cargo.lock index d4df3417..3cb4580d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4884,6 +4884,7 @@ dependencies = [ "hive-jobq-metrics", "hive-jobq-wire", "hive-log", + "hive-sh4re", "hive-types", "hmac 0.13.0", "http", @@ -5014,6 +5015,7 @@ dependencies = [ "axum", "data-encoding", "futures-util", + "hive-sh4re", "reqwest", "rustls", "serde", diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index 0cf83813..59b2a9c2 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -253,8 +253,9 @@ the operator review and merge it. By design, no second, mount-shaped path reaches the same file without the review. Agents holding the `manage_root_agent` capability (granted per agent in -`capabilities.json`; see `hive-sh4re/src/permissions.rs`) get additional -host-side bind mounts via `set_nspawn_flags`: +the swarm's `agent-permissions` record; see +`docs/process/conventions.md#capabilities`) get additional host-side bind +mounts via `set_nspawn_flags`: - `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's proposed repo, not just direct children. The capability means "may @@ -267,16 +268,13 @@ host-side bind mounts via `set_nspawn_flags`: An agent without the capability only has its direct children's config dirs. -⚠️ nspawn bakes bind flags at container start, so granting or -revoking this capability doesn't change any mount until that agent's -container is rebuilt/restarted. +⚠️ nspawn bakes bind flags at container start, so hive-c0re restarts +an agent, after its current turn, when its capabilities change. -The root agent gets the capability by default, seeded on its autodeploy -path (`workers::auto_update::ensure_root_agent`) so the recovery mounts -are there from its first container. That seed only fires while -`capabilities.json` doesn't exist yet: any grant or revoke through -the dashboard creates the file, so a revoked root-agent grant stays -revoked and isn't re-applied on the next hive-c0re restart. +The root agent holds the capability by default: an agent whose record +leaves `capabilities` unset gets the hive's default, which is +`manage_root_agent` for `ruth` and nothing for every other agent. +Setting `"capabilities": []` for her revokes it. Each proposed repo (`/agents//config/`) is pre-configured with `applied` as a git remote pointing at diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 1dbc2cb8..ce507539 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -391,10 +391,6 @@ Contents: (`{ "alice": ["messaging", "inbox", "execution"] }`). Written by `tool_groups::set_groups`; injected as `HIVE_TOOL_GROUPS` env var into each agent's container. -- `capabilities.json` — per-agent capability grants - (`{ "ruth": ["manage_root_agent"] }`). Written by - `capabilities::set_caps`; injected as `HIVE_CAPABILITIES` env - var. Absent agents have no extra capabilities. - `swarm-resource-limits.json` — the caps the swarm sets for this hive's agents (`{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }`). @@ -418,6 +414,13 @@ Contents: The root agent has the meta dir RO-mounted at `/meta/`. +Outside the meta repo, `/var/lib/hyperhive/swarm-agent-permissions.json` +holds the capabilities the swarm grants this hive's agents, as +`workers::swarm_permissions` last read them from the swarm's +`agent-permissions` bucket (every 30 seconds): a copy, not the +record, which `swarmctl agent permissions` sets. `set_nspawn_flags` +reads it at every container start. + hive-c0re renders containers onto `meta#` at creation. ## Destroy vs purge diff --git a/docs/process/conventions.md b/docs/process/conventions.md index de91633f..31683462 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -334,35 +334,37 @@ that allows the underlying resource access. | Capability | Effect | |---|---| -| `manage_root_agent` | may manage *any* agent: bind-mounts every agent's state (rw) + config (ro) into the holder's container, plus `/applied` and `/meta` (ro). Takes effect on the holder's next container rebuild/restart | +| `manage_root_agent` | may manage *any* agent: bind-mounts every agent's state (rw) + config (ro) into the holder's container, plus `/applied` and `/meta` (ro). Takes effect when the holder's container restarts, which hive-c0re does itself on a change | -**Config storage** — per-agent capabilities live in -`/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`. -Format: `{ "ruth": ["manage_root_agent"] }`. -An absent entry means "no extra capabilities." `render_flake` in `meta.rs` -reads this file and injects `HIVE_CAPABILITIES` (comma-separated -`snake_case` names) into each agent's systemd service env; absent entries emit -no env var so agents without capabilities don't trigger a spurious rebuild. +**Where they live** — in the swarm, not on the hive: the `capabilities` field +of the agent's key in the `agent-permissions` KV bucket +(`swarm-queue-client/src/agent_permissions.rs`), which swarm-controller alone +writes. Set them with `swarmctl agent permissions get|set|clear ` +(`/api/agents/{name}/permissions`). Unset means the hive's default, which is a +constant in hive-c0re (`capabilities::default_for`): none, except `ruth`, who +gets `manage_root_agent`. `"capabilities": []` grants none. -**Setting capabilities** — the operator sets capabilities via the -C4P4B1L1T13S section in the dashboard's P3RM1SS10NS tab. -`hive-c0re::capabilities::set_caps(name, caps)` is the write path. -After a change `meta::sync_agents` commits the updated file; the next agent -rebuild picks up the new `HIVE_CAPABILITIES` env var. +**How a change applies** — every 30 seconds hive-c0re reads the key of each +agent it runs (`workers/swarm_permissions.rs`) and stores the result in +`/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo. nspawn +fixes the bind mounts at container start, so when an agent's capabilities +change hive-c0re restarts it after its current turn. A failed read keeps the +last stored value; it never falls back to the default. -**Runtime resolution** — at session start the harness reads `HIVE_CAPABILITIES` -and resolves each token to a `Capability` variant, logging and skipping -unrecognised ones. An absent or empty var means no extra capabilities. +**Moving off `meta/capabilities.json`** — on its first boot with this code a +hive copies the file's grants into the stored value, sends them to +swarm-controller once (written only for agents with no key, and only for +agents that hive's wanted state declares), and deletes the file from the meta +repo when the controller answers (`workers/permissions_import.rs`). **Capability NOT configurable from `agent.nix`** — same reasoning as tool groups: an agent that could grant its own capabilities via a config commit would bypass the operator approval gate. **Adding a new capability** — add a variant to `Capability` in -`hive-sh4re/src/permissions.rs` + an arm to `as_str`. Add it to `Capability::ALL` -(the source of truth for the permissions UI columns). Implement the access -check in the relevant handler (`hive-c0re/src/socket_server/mod.rs`, -`coordinator.rs`, or a handler under `hive-c0re/src/dashboard/`). +`hive-sh4re/src/permissions.rs` and to `Capability::ALL`. Implement the access +check where hive-c0re enforces it (today: `set_nspawn_flags` in +`hive-c0re/src/lifecycle/host_config.rs`). ## Async forms diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 4634269f..09611c83 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -73,7 +73,7 @@ Cheap — no build slot: | `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles | | `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome | | `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload | -| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots | +| `WritePermFile` | commit `tool-groups.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshot | | `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless | | `MatrixSweep` | matrix user/space sweep (`matrix::ensure_all`): the boot-time instance, plus one every 30 min from a loop in `main.rs`. Holds `Resource::MatrixSweep` (capacity 1), so two passes never overlap; each tick queues its own pass, which waits for the resource if one is already live. Agentless | | `KnowledgePull` | `/knowledge` pull (`knowledge::pull`): at boot (commits that landed while `hive-c0re` was down), on the swarm knowledge-changed event, and hourly as a fallback. Holds `Resource::KnowledgeTree` (capacity 1), so two pulls never overlap on the working tree; each trigger queues its own pass, which waits for the resource if one is already live. Agentless | diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 3a54968c..8d3f6bb3 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -12,6 +12,10 @@ This document contains the help content for the `swarmctl` command-line program. * [`swarmctl agent inference get`↴](#swarmctl-agent-inference-get) * [`swarmctl agent inference set`↴](#swarmctl-agent-inference-set) * [`swarmctl agent inference clear`↴](#swarmctl-agent-inference-clear) +* [`swarmctl agent permissions`↴](#swarmctl-agent-permissions) +* [`swarmctl agent permissions get`↴](#swarmctl-agent-permissions-get) +* [`swarmctl agent permissions set`↴](#swarmctl-agent-permissions-set) +* [`swarmctl agent permissions clear`↴](#swarmctl-agent-permissions-clear) * [`swarmctl schedule`↴](#swarmctl-schedule) * [`swarmctl schedule add`↴](#swarmctl-schedule-add) * [`swarmctl schedule list`↴](#swarmctl-schedule-list) @@ -66,6 +70,7 @@ Manage agents across the swarm * `create` — Queue creation of a new agent on a hive in this swarm * `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale * `inference` — Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models +* `permissions` — Show or change the capabilities the swarm grants an agent @@ -198,6 +203,77 @@ Clear every field for an agent, so it goes back to its own values +## `swarmctl agent permissions` + +Show or change the capabilities the swarm grants an agent. + +The hive running the agent reads them every 30 seconds and, when they change, restarts the agent after its current turn. Unset leaves the agent on its hive's default: none, except `ruth`, who gets `manage_root_agent`. + +**Usage:** `swarmctl agent permissions ` + +###### **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] ` + +###### **Arguments:** + +* `` — Agent name + +###### **Options:** + +* `--controller-socket ` — 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 ` + +###### **Arguments:** + +* `` — Agent name. A hive must declare it + +###### **Options:** + +* `--capabilities ` — The capabilities to grant, comma-separated (`manage_root_agent`). `--capabilities ''` grants none +* `--controller-socket ` — 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] ` + +###### **Arguments:** + +* `` — Agent name + +###### **Options:** + +* `--controller-socket ` — swarm-controller's unix socket. + + Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. + + + ## `swarmctl schedule` Manage scheduled prompts. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 6cfd43f1..38a3feac 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -77,8 +77,8 @@ H0M3 hub (served at `/core.html`), with minimal chrome: a `← home` back-link and a single pane, **K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for -`tombstones_changed`, `capabilities_changed`, and `tool_groups_changed` -(the latter two re-render the stale-perms sub-section when permission +`tombstones_changed` and `tool_groups_changed` +(the latter re-renders the stale-perms sub-section when permission data changes). (Rebuild queue and meta inputs are dashboard tabs — see below.) @@ -104,11 +104,10 @@ container is all hive-c0re can actually prove. Fixing it properly needs a recorded destroy — **deferred** to the swarm-controller / snapshot-storage rework where the problem changes shape. -*Stale permission entries*: agents with explicit capability or -tool-group JSON entries but no live container — typically renamed or -deleted agents whose entries persisted in `capabilities.json` / -`tool-groups.json`. Loaded when the page opens; -autorefreshes on `capabilities_changed` + `tool_groups_changed` SSE +*Stale permission entries*: agents with explicit tool-group JSON +entries but no live container — typically renamed or deleted agents +whose entries persisted in `tool-groups.json`. Loaded when the page opens; +autorefreshes on `tool_groups_changed` SSE events. Each ghost agent gets a `✕ clear perms` button (`DELETE /api/permissions/{name}`). @@ -230,11 +229,13 @@ entry with that id pre-expanded. ## P3RM1SS10NS tab -Per-agent permission configuration. Two sections, each rendered as a -column-driven checkbox matrix: rows are agents, columns are the -permission names fetched from hive-c0re. The column list is -authoritative — adding a new tool-group or capability to hive-c0re -requires no UI change; the new column appears automatically. +Per-agent tool-group configuration, rendered as a column-driven +checkbox matrix: rows are agents, columns are the group names fetched +from hive-c0re. The column list is authoritative — adding a new +tool-group to hive-c0re requires no UI change; the new column appears +automatically. Capabilities aren't set here: they're the swarm's, +set with `swarmctl agent permissions` (see +`docs/process/conventions.md#capabilities`). The snapshot carries `agents` (the full manageable roster — live containers ∪ agents with an explicit entry) and `effective` (per-agent @@ -248,29 +249,11 @@ strip those defaults. The `(default)` badge keys off absence from Fetches fire on tab activation (not page-load) to avoid unnecessary work when the operator never visits this tab. Live mutations from the -rebuild-queue worker are also pushed via the `capabilities_changed` / -`tool_groups_changed` SSE events (same payload shape as the GET -endpoints), so an open P3RM1SS10NS tab reflects worker-applied changes +rebuild-queue worker are also pushed via the `tool_groups_changed` +SSE event (same payload shape as the GET endpoint), so an open P3RM1SS10NS tab reflects worker-applied changes without requiring navigation. Tab-activation re-fetches remain as a safety net for reconnect windows. -**C4P4B1L1T13S** — per-agent capability grants. Capabilities unlock -gated MCP tools and system-level access beyond the default agent -surface. A saving POST queues a rebuild for the affected agent so the -new `HIVE_CAPABILITIES` env var takes effect in the next session. - -The current capabilities are: - -| Name | Effect | -|------|--------| -| `manage_root_agent` | mounts every agent's state (rw) + config (ro) into this agent's container so it can manage/recover any agent | - -Each row is one agent. Columns are the capability names returned by -`GET /api/capabilities` as `caps: Vec`. 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. - **T00L GR0UPS** — per-agent tool-group permissions. Tool groups are named buckets of MCP tools; each agent starts with a role default @@ -291,10 +274,9 @@ The current tool groups are: `messaging`, `meta`, `inbox`, `lifecycle`, Claude's built-in `WebFetch` and `WebSearch` to `--tools` / `--allowedTools` for that agent session. -Both tables share the same visual shape: `.cap-table-wrap` / -`.tg-table-wrap` outer scroll container, `thead` with a label column -(`.cap-agent-col` / `.tg-agent-col`) + one column per permission -(`.cap-col` / `.tg-group-col`). Each tbody row is one agent: a name cell +The table's shape: a `.tg-table-wrap` outer scroll container, `thead` +with a label column (`.tg-agent-col`) + one column per group +(`.tg-group-col`). Each tbody row is one agent: a name cell and its checkbox cells, where each checkbox carries `data-baseline` (its render-time state) and the row carries `data-agent` for dirty-tracking. @@ -302,18 +284,15 @@ render-time state) and the row carries `data-agent` for dirty-tracking. buttons exist. A single page-level `.perm-save-bar` with a **save all (N agents)** button sits at the bottom of the pane, enabled only when some checkbox diverges from its baseline. Selecting it diffs every checkbox -across *both* matrices and POSTs one batch to `POST /api/permissions` as -`{ changes: [ { agent, tool_groups?, capabilities? } ] }` — the batch -includes only the perm-types that actually changed for each agent (an -omitted field leaves that file untouched; an included array fully -replaces it). -hive-c0re coalesces an agent's capabilities + tool-groups into a -single rebuild, so changing both for one agent is one rebuild, not two. +and POSTs one batch to `POST /api/permissions` as +`{ changes: [ { agent, tool_groups } ] }` — the batch includes only the +agents whose groups changed (an included array fully replaces the +agent's groups). The batch is atomic: it validates every change first and on any error rejects the whole POST (`{error}`, nothing applied); a clean 200 (`ok`) -flips the bar to a queued→rebuilding state and re-fetches both tables. -Live `capabilities_changed` / `tool_groups_changed` events re-render the -matrices unless the section has unsaved edits, so an in-progress edit set +flips the bar to a queued→rebuilding state and re-fetches the table. +Live `tool_groups_changed` events re-render the +matrix unless it has unsaved edits, so an in-progress edit set isn't clobbered. ## ST4TS page (`/stats.html`) @@ -767,31 +746,16 @@ below — some endpoints aren't in it yet. `/var/lib/hyperhive/meta/tool-groups.json` and queues a rebuild so `HIVE_TOOL_GROUPS` takes effect. Agent name validated; `guard_agent_name` applied. -- `GET /api/capabilities` — returns `{ caps: Vec, - assignments: BTreeMap>, - descriptions: BTreeMap }`. `caps` is the - ordered list of all known capability names; `assignments` is the - per-agent grant map (absent agents have no extra capabilities); - `descriptions` maps each capability name to a short human-readable - string surfaced as a column-header tooltip on hover. -- `POST /api/capabilities/{agent}` — body `{ caps: ["name", …] }`. - Writes the capability set for `{agent}` to - `/var/lib/hyperhive/meta/capabilities.json` and queues a rebuild so - `HIVE_CAPABILITIES` takes effect. Agent name validated; - unknown capability strings rejected (400). `guard_agent_name` - applied. - `POST /api/permissions` — batch perm apply for the save-all permissions button. Body - `{ changes: [{ agent, tool_groups?: ["name", …], capabilities?: ["name", …] }] }`. - Sparse per agent: an omitted field leaves that perm-type untouched, - an empty array clears it, a populated array fully replaces it (same - replace semantics as the per-agent endpoints above). Each affected - agent gets ONE combined `PermChange` queue entry, so changing both - an agent's tool-groups and capabilities triggers a single rebuild, - not two. **Atomic**: the endpoint validates every change first (agent names via - `guard_agent_name`, group + capability names) and on any validation - error writes or enqueues nothing (non-2xx `{ error }`); it skips - rows with both fields omitted rather than erroring on them. + `{ changes: [{ agent, tool_groups?: ["name", …] }] }`. + An omitted `tool_groups` leaves the agent untouched, an empty array + clears it, a populated array fully replaces it (same replace + semantics as the per-agent endpoint above). Each affected agent gets + one `PermChange` queue entry. **Atomic**: the endpoint validates every + change first (agent names via `guard_agent_name`, group names) and on + any validation error writes or enqueues nothing (non-2xx `{ error }`); + it skips rows with the field omitted rather than erroring on them. Returns `200 "ok"` on success. - `POST /api/meta-update` — `nix flake update` the selected @@ -841,13 +805,6 @@ payload): operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end` with Reminder kind, and the scheduler tick after each delivery batch (`reminder_scheduler`). -- `capabilities_changed` (seq, caps: `Vec`, descriptions: map, - assignments: `BTreeMap>`) — 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`, descriptions: map, assignments: `BTreeMap>`) — full snapshot of tool-group assignments. Emitted from the rebuild-queue worker after diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index 95ac4964..678def0e 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -27,8 +27,8 @@

diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index 1e8919c1..459f2ff6 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -96,7 +96,7 @@ function renderTombstones(s) { } // ─── stale permission entries (K3PT ST4T3 pane sub-section) ────────────── -// Agents with explicit capability / tool-group JSON entries but no live +// Agents with explicit tool-group JSON entries but no live // container AND no kept-state tombstone (e.g. renamed agents like the old // "root" manager name). Ghost detection is server-side via // GET /api/permissions/stale so the client doesn't need to maintain a @@ -114,7 +114,7 @@ function renderStalePerms(root, ghosts) { el( "p", { class: "meta" }, - "agents with explicit capability or tool-group entries but no live container " + + "agents with explicit tool-group entries but no live container " + "or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).", ), ); @@ -129,7 +129,7 @@ function renderStalePerms(root, ghosts) { { type: "button", class: "btn btn-destroy", - title: "remove explicit capability and tool-group entries for " + name, + title: "remove explicit tool-group entries for " + name, }, "✕ clear perms", ); @@ -194,9 +194,6 @@ const SSE_HANDLERS = { }, // Refresh the stale-perms sub-section when perm data changes (a ghost // was cleared, or perms were saved for an agent whose name collides). - capabilities_changed(_ev) { - if (stalePermsLoaded) fetchAndRenderStalePerms(); - }, tool_groups_changed(_ev) { if (stalePermsLoaded) fetchAndRenderStalePerms(); }, @@ -234,8 +231,7 @@ async function init() { // (subscription discipline, part 1 of the dashboard-event-stream- // split issue). const es = openStream( - "/api/dashboard/stream?kinds=tombstones_changed," + - "capabilities_changed,tool_groups_changed", + "/api/dashboard/stream?kinds=tombstones_changed,tool_groups_changed", ); if (es) { es.onmessage = (e) => { diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index c1ecf3c6..94713cbe 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -566,57 +566,9 @@ footer .banner-thin { margin-bottom: 0.8em; } -/* ─── capabilities (permissions) table ───────────────────────────── - Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout - as the tool-groups table below. Horizontally scrollable on narrow - viewports. */ -.cap-table-wrap { - overflow-x: auto; - margin-top: 0.5em; -} -.cap-table { - border-collapse: collapse; - font-size: 0.82em; - min-width: 100%; -} -.cap-table th, -.cap-table td { - padding: 0.35em 0.6em; - border: 1px solid var(--border); - text-align: center; - vertical-align: middle; -} -.cap-table thead th { - background: var(--bg-elev); - color: var(--muted); - letter-spacing: 0.05em; - white-space: nowrap; -} -.cap-agent-col { - text-align: left !important; - min-width: 8em; -} -.cap-col { - min-width: 5em; -} -.cap-agent-name { - color: var(--fg); - font-weight: 600; -} -.cap-cb { - cursor: pointer; - width: 1em; - height: 1em; - accent-color: var(--purple); -} -.cap-row:hover td { - background: var(--bg-elev); -} - /* ─── tool-groups (permissions) table ────────────────────────────── - Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout - as the capabilities table above. Horizontally scrollable on narrow - viewports. */ + Agents × tool-groups matrix in the P3RM1SS10NS tab. Horizontally + scrollable on narrow viewports. */ .tg-table-wrap { overflow-x: auto; margin-top: 0.5em; diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index fb889a80..2fbd405f 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -71,8 +71,8 @@ > ◆ BUILD L0GS ◆ - +

loading…

- +
-

◆ C4P4B1L1T13S ◆

-
- ══════════════════════════════════════════════════════════════ -
-

- 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. -

-
-

loading…

-
-

◆ T00L GR0UPS ◆

@@ -255,10 +240,9 @@
diff --git a/frontend/packages/dashboard/src/permissions.js b/frontend/packages/dashboard/src/permissions.js index e4aa0794..367079a3 100644 --- a/frontend/packages/dashboard/src/permissions.js +++ b/frontend/packages/dashboard/src/permissions.js @@ -1,11 +1,11 @@ -// Dashboard P3RM1SS10NS tab — the per-agent capabilities + tool-groups -// matrices, with a single page-level "save all" button. +// Dashboard P3RM1SS10NS tab — the per-agent tool-groups matrix, with a +// page-level "save all" button. Capabilities are not set here: they are +// the swarm's (`swarmctl agent permissions`). // -// Both tables are fetched on tab activation (`GET /api/capabilities`, -// `GET /api/tool-groups`) and after each save. Columns (caps / groups) -// come from the backend so the UI needs no change when a new one is -// added. Live updates arrive via the `capabilities_changed` / -// `tool_groups_changed` dashboard events (fired after the rebuild-queue +// The table is fetched on tab activation (`GET /api/tool-groups`) and +// after each save. Columns come from the backend so the UI needs no +// change when a new group is added. Live updates arrive via the +// `tool_groups_changed` dashboard event (fired after the rebuild-queue // worker commits the perm JSON file), wired into the entry's mutation // dispatch table. // @@ -15,11 +15,9 @@ // assignments map. // // Editing model: checkboxes are diffed against a `data-baseline` captured at -// render time; only changed perm-types per agent are POSTed to -// `POST /api/permissions` as `{ changes: [{agent, tool_groups?, capabilities?}] }`. -// Omitted field = leave untouched; included array = full replace. The backend -// coalesces caps+groups per agent into ONE queue entry (one rebuild, no -// double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200. +// render time; only changed agents are POSTed to `POST /api/permissions` as +// `{ changes: [{agent, tool_groups}] }` (a full replace per agent). Batch is +// atomic — saved→rebuilding only fires on a clean 200. import { $ } from "./common.js"; import { el } from "@hive/shared/dom.js"; @@ -32,13 +30,6 @@ import { asyncBtn } from "@hive/shared/forms.js"; // is worse than a brief staleness window. The tab-activation re-fetch // and the post-save re-fetch are the recovery paths; both clear dirty. -export function applyCapabilitiesChanged(ev) { - const root = $("capabilities-section"); - if (!root) return; - if (root.contains(document.activeElement)) return; - if (sectionHasDirty(root)) return; - renderCapabilities(root, ev); -} export function applyToolGroupsChanged(ev) { const root = $("tool-groups-section"); if (!root) return; @@ -56,121 +47,6 @@ function sectionHasDirty(root) { return false; } -export async function fetchAndRenderCapabilities() { - const root = $("capabilities-section"); - if (!root) return; - root.replaceChildren(); - root.append(el("p", { class: "meta" }, "loading…")); - try { - const resp = await fetch("/api/capabilities"); - if (!resp.ok) throw new Error("http " + resp.status); - const data = await resp.json(); - renderCapabilities(root, data); - } catch (err) { - root.replaceChildren(); - root.append(el("p", { class: "meta" }, "fetch failed: " + err)); - } -} - -function renderCapabilities(root, data) { - root.replaceChildren(); - const { caps, descriptions = {}, assignments, effective = {} } = data; - if (!caps || !caps.length) { - root.append(el("p", { class: "meta" }, "(no capabilities defined)")); - updateSaveBar(); - return; - } - - // Agent rows: prefer the backend roster (every manageable agent, - // default-perms included). Fall back to the live-container ∪ explicit - // union for older payloads that don't carry `agents`. - const agentNames = - data.agents && data.agents.length - ? [...data.agents] - : [ - ...new Set([ - ...Array.from(containersState.keys()), - ...Object.keys(assignments), - ]), - ].sort(); - - if (!agentNames.length) { - root.append(el("p", { class: "meta" }, "(no agents)")); - updateSaveBar(); - return; - } - - const wrap = el("div", { class: "cap-table-wrap" }); - const table = el("table", { class: "cap-table" }); - - // Header row. - const thead = el("thead"); - const hrow = el("tr"); - hrow.append(el("th", { class: "cap-agent-col" }, "agent")); - for (const c of caps) { - hrow.append(el("th", { class: "cap-col", title: descriptions[c] || c }, c)); - } - thead.append(hrow); - table.append(thead); - - const tbody = el("tbody"); - for (const name of agentNames) { - // Effective caps (explicit-or-default) drive the checkboxes so - // default-perms agents show their real grants, not blank. - const assigned = effective[name] || assignments[name] || []; - // `containersState` is keyed from `nixos-container list`, which - // includes stopped-but-configured containers — so a temporarily-stopped - // agent is NOT stale. Only destroyed/renamed agents are absent here. - const isStale = !containersState.has(name); - const tr = el("tr", { - class: "cap-row" + (isStale ? " perm-row-stale" : ""), - "data-agent": name, - }); - - // Agent name cell. - const nameTd = el("td", { class: "cap-agent-col" }); - nameTd.append(el("span", { class: "cap-agent-name" }, name)); - if (isStale) { - nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)")); - const removeBtn = el( - "button", - { - type: "button", - class: "perm-remove-btn", - title: "remove stale permission entries for " + name, - }, - "✕ remove", - ); - removeBtn.addEventListener("click", () => clearStaleAgent(name, root)); - nameTd.append(removeBtn); - } - tr.append(nameTd); - - // One checkbox per capability. - for (const c of caps) { - const checked = assigned.includes(c); - const td = el("td", { class: "cap-col" }); - const cb = el("input", { - type: "checkbox", - class: "cap-cb", - "data-cap": c, - "data-baseline": checked ? "1" : "0", - "aria-label": c, - }); - cb.checked = checked; - cb.addEventListener("change", onCellToggle); - td.append(cb); - tr.append(td); - } - - tbody.append(tr); - } - table.append(tbody); - wrap.append(table); - root.append(wrap); - updateSaveBar(); -} - export async function fetchAndRenderToolGroups() { const root = $("tool-groups-section"); if (!root) return; @@ -303,20 +179,11 @@ function onCellToggle() { updateSaveBar(); } -// Collect the sparse change-set: one entry per agent that has at least -// one perm-type diverging from baseline. Only the changed perm-type(s) -// are included so untouched defaults stay defaults (an included array is -// a full replacement → omitting it leaves that file alone). +// Collect the sparse change-set: one entry per agent whose groups diverge +// from baseline, so untouched defaults stay defaults (an included array is +// a full replacement). function collectChanges() { - const byAgent = new Map(); // agent -> { capabilities?, tool_groups? } - collectSection( - $("capabilities-section"), - ".cap-row", - ".cap-cb", - "cap", - "capabilities", - byAgent, - ); + const byAgent = new Map(); // agent -> { tool_groups } collectSection( $("tool-groups-section"), ".tg-row", @@ -363,7 +230,7 @@ function updateSaveBar() { // Remove all explicit permission entries for a stale (non-running) // agent. Calls DELETE /api/permissions/{agent}, which bypasses the // roster guard so the stale entries can be cleaned up even though the -// agent isn't in the live container list. Re-fetches both tables after +// agent isn't in the live container list. Re-fetches the table after // the delete so the row disappears immediately. async function clearStaleAgent(name, sectionRoot) { const btn = sectionRoot @@ -381,11 +248,8 @@ async function clearStaleAgent(name, sectionRoot) { setSaveNote("failed to remove " + name + ": " + text, true); return; } - // Re-fetch both sections so the stale row disappears. - await Promise.all([ - fetchAndRenderCapabilities(), - fetchAndRenderToolGroups(), - ]); + // Re-fetch so the stale row disappears. + await fetchAndRenderToolGroups(); } catch (err) { setSaveNote("failed to remove " + name + ": " + err, true); } @@ -437,14 +301,13 @@ async function saveAll() { return; } // Atomic 200: the whole batch landed. Show a rebuilding hint and - // re-fetch both tables once the queue worker has committed — that + // re-fetch the table once the queue worker has committed — that // resets baselines (dirty clears) and the bar disables itself. const n = changes.length; btn.textContent = "queued ✓"; setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}…`, false); setTimeout(() => { btn.dataset.busy = ""; - fetchAndRenderCapabilities(); fetchAndRenderToolGroups(); }, 900); } catch (err) { diff --git a/frontend/packages/dashboard/src/state.js b/frontend/packages/dashboard/src/state.js index 0b3b7a9e..09df4747 100644 --- a/frontend/packages/dashboard/src/state.js +++ b/frontend/packages/dashboard/src/state.js @@ -2,7 +2,7 @@ // dashboard SPA: the live agent roster. // // The roster is read by nearly every tab (the container tree, the -// capabilities + tool-group matrices, and the selection bar), so +// tool-group matrix, and the selection bar), so // it lives here as a single source of truth that each domain module // imports rather than threading through call signatures. Everything // else in the dashboard is domain-local and lives with its own module. diff --git a/frontend/packages/dashboard/src/system-sections.css b/frontend/packages/dashboard/src/system-sections.css index 1f862cab..c8da583a 100644 --- a/frontend/packages/dashboard/src/system-sections.css +++ b/frontend/packages/dashboard/src/system-sections.css @@ -201,9 +201,9 @@ sub-step label` — the rule has had no JS referents since then). */ /* ─── K3PT ST4T3 › stale permission entries sub-section ──────────────────── - Agents with explicit capability / tool-group entries in the JSON but no - live container (renamed or manually-deleted agents). Lazy-loaded on tab - activation, refreshed on capabilities_changed / tool_groups_changed. */ + Agents with explicit tool-group entries in the JSON but no live + container (renamed or manually-deleted agents). Lazy-loaded on tab + activation, refreshed on tool_groups_changed. */ .tombstones-stale-heading { margin: 1.2em 0 0.3em; color: var(--muted); diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index adef8516..df62a678 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -17,9 +17,7 @@ import { bindAsyncForms } from "@hive/shared/forms.js"; import { createTabStrip } from "@hive/shared/tabs.js"; import { containersState, syncContainersFromSnapshot } from "./state.js"; import { - applyCapabilitiesChanged, applyToolGroupsChanged, - fetchAndRenderCapabilities, fetchAndRenderToolGroups, initPermissions, } from "./permissions.js"; @@ -64,7 +62,6 @@ window.marked = marked; const MANAGED_SECTION_IDS = [ "containers-section", "inbox-section", - "capabilities-section", "tool-groups-section", ]; //
sections that should survive a refresh need a stable @@ -210,7 +207,6 @@ window.marked = marked; }, meta_inputs_changed: onMetaInputsChanged, meta_update_running: onMetaUpdateRunning, - capabilities_changed: applyCapabilitiesChanged, tool_groups_changed: applyToolGroupsChanged, }; (function bindDashboardStream() { @@ -228,7 +224,7 @@ window.marked = marked; "transient_set,transient_cleared," + "container_state_changed,container_removed,rebuild_queue_changed," + "meta_inputs_changed,meta_update_running," + - "capabilities_changed,tool_groups_changed", + "tool_groups_changed", ); es.onmessage = (e) => { let ev; @@ -278,13 +274,12 @@ window.marked = marked; // visibility (bar only belongs on 4G3NTS where agent cards live). document.body.dataset.activeTab = target; renderSelectionBar(Array.from(containersState.values())); - // Permissions tables: SSE covers worker-applied changes - // (capabilities_changed / tool_groups_changed); re-fetch on + // Permissions table: SSE covers worker-applied changes + // (tool_groups_changed); re-fetch on // activation as a safety net for any gap between SSE events and // the cold-load snapshot. if (target === "permissions") { initPermissions(); - fetchAndRenderCapabilities(); fetchAndRenderToolGroups(); } onBuildsTabShown(target); diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 9e8d0f0c..59ee8731 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -57,6 +57,7 @@ swarm-queue-client = { workspace = true, features = [ "kv", "notices", "agent-limits", + "agent-permissions", ] } swarm-secret-client.workspace = true tokio.workspace = true diff --git a/hive-c0re/src/agent_config/capabilities.rs b/hive-c0re/src/agent_config/capabilities.rs index c520b0e6..41b99fb2 100644 --- a/hive-c0re/src/agent_config/capabilities.rs +++ b/hive-c0re/src/agent_config/capabilities.rs @@ -1,262 +1,109 @@ -//! Per-agent capability configuration. Stored at -//! `/var/lib/hyperhive/meta/capabilities.json` alongside `topology.json` -//! and `tool-groups.json`. +//! The capabilities the swarm grants this hive's agents, as last read from the +//! `agent-permissions` bucket by `workers::swarm_permissions`. Stored at +//! `/var/lib/hyperhive/swarm-agent-permissions.json`, outside the meta repo: +//! the swarm is the record, this file is the hive's last read of it. //! -//! Format: a JSON object mapping agent name to an array of -//! `hive_sh4re::permissions::Capability` `snake_case` strings: +//! Format: agent name → the stored +//! [`swarm_queue_client::agent_permissions::AgentPermissions`]. An agent with +//! no entry, or an entry with `capabilities` unset, holds its default +//! ([`default_for`]); `"capabilities": []` holds none. //! -//! ```json -//! { -//! "atlas": ["manage_root_agent"] -//! } -//! ``` -//! -//! An absent entry (or an absent file) means "no extra capabilities". -//! `render_flake` in `meta.rs` reads this file and injects -//! `HIVE_CAPABILITIES` into each agent's systemd service env; absent -//! entries emit no env var so agents without capabilities don't trigger -//! a spurious rebuild. -//! -//! Write path: `set_caps` is called from the dashboard action handler -//! that the operator uses to grant/revoke capabilities per agent. +//! Read path: `lifecycle::host_config::set_nspawn_flags`, on every container +//! start, which is why a change takes a restart. -use hive_sh4re::permissions::Capability; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -const CAPABILITIES_FILE: &str = "capabilities.json"; +use hive_sh4re::permissions::Capability; +use swarm_queue_client::agent_permissions::AgentPermissions; + +const FILE: &str = "swarm-agent-permissions.json"; + +pub type Stored = BTreeMap; #[must_use] -pub fn capabilities_path() -> PathBuf { - crate::paths::meta_root().join(CAPABILITIES_FILE) +pub fn path() -> PathBuf { + crate::paths::state_root().join(FILE) } -/// True if `name` matches a recognised [`Capability`], case-insensitive -/// (same comparison [`has_cap`] uses). +/// Read the stored permissions. An absent file is the empty map: every agent +/// on its default. A file that exists but can't be read or parsed is an error. +pub fn read_at(path: &Path) -> std::io::Result { + super::read_map(path) +} + +/// Persist the permissions atomically. +pub fn write_at(path: &Path, stored: &Stored) -> std::io::Result<()> { + super::write_map(path, stored) +} + +/// The capabilities an agent holds when the swarm sets none: `ruth` recovers +/// the other agents, every other agent holds nothing. #[must_use] -fn is_known(name: &str) -> bool { - Capability::ALL - .iter() - .any(|cap| name.eq_ignore_ascii_case(<&str>::from(*cap))) -} - -/// Drop any capability string in `map` that isn't a recognised -/// [`Capability`], warning per dropped string so a typo'd or stale -/// grant doesn't look like it worked. An agent left with no -/// capabilities is removed entirely, matching [`set_caps`]'s -/// empty-vec behaviour. Returns whether anything was dropped. -fn prune_unknown(map: &mut BTreeMap>) -> 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>> { - read_from(&capabilities_path()) -} - -fn read_from(path: &Path) -> std::io::Result>> { - 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> { - 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 { - 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>, name: &str, caps: &[String]) { - let known: Vec = caps - .iter() - .filter(|cap| { - let ok = is_known(cap); - if !ok { - tracing::warn!(agent = name, capability = %cap, "dropping unrecognised capability"); - } - ok - }) - .cloned() - .collect(); - if known.is_empty() { - current.remove(name); +pub fn default_for(agent: &str) -> Vec { + if agent == crate::lifecycle::MANAGER_NAME { + vec![Capability::ManageRootAgent] } else { - current.insert(name.to_owned(), known); + Vec::new() } } -/// Set the capabilities for one agent and persist the map. Any name -/// that isn't a recognised [`Capability`] is dropped (with a `warn!`) -/// rather than written — an unknown grant should never look like it -/// took effect. An empty `caps` vec, or one that becomes empty after -/// dropping unknown names, removes the entry (agent has no -/// capabilities). Fails without writing if the existing file can't be -/// read or parsed. -pub fn set_caps(name: &str, caps: &[String]) -> std::io::Result<()> { - set_caps_at(&capabilities_path(), name, caps) +/// The capabilities `agent` holds under `record`. +#[must_use] +pub fn effective(agent: &str, record: Option<&AgentPermissions>) -> Vec { + record + .and_then(|r| r.capabilities.clone()) + .unwrap_or_else(|| default_for(agent)) } -fn set_caps_at(path: &Path, name: &str, caps: &[String]) -> std::io::Result<()> { - let mut current = read_from(path)?; - apply_known_caps(&mut current, name, caps); - super::write_map(path, ¤t) -} - -/// Remove an agent from the capability map entirely. Called by -/// `meta::sync_agents` when an agent is deprovisioned so stale entries -/// don't accumulate. No-op if the agent has no entry. Fails without -/// writing if the existing file can't be read or parsed. -pub fn remove_agent(name: &str) -> std::io::Result<()> { - remove_agent_at(&capabilities_path(), name) -} - -fn remove_agent_at(path: &Path, name: &str) -> std::io::Result<()> { - let mut current = read_from(path)?; - if current.remove(name).is_some() { - super::write_map(path, ¤t)?; - } - Ok(()) +/// Whether `agent` holds `cap` per the file at `path` (normally [`path`]). +/// +/// # Errors +/// +/// As [`read_at`]. +pub fn holds(path: &Path, agent: &str, cap: Capability) -> std::io::Result { + Ok(effective(agent, read_at(path)?.get(agent)).contains(&cap)) } #[cfg(test)] mod tests { use super::*; - // `read`/`set_caps`/`remove_agent` call `crate::paths::meta_root`, - // which is hardcoded to `/var/lib/hyperhive` (no test override) — so - // these tests pin the pure decision logic (`prune_unknown`, - // `apply_known_caps`) against in-memory maps, and the file handling - // through the path-taking `*_at` / `read_from` variants. - - const TRUNCATED: &str = "{\n \"atlas\": [\"manage_root_ag"; - - fn corrupt_file() -> (tempfile::TempDir, std::path::PathBuf) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join(CAPABILITIES_FILE); - std::fs::write(&path, TRUNCATED).expect("seed"); - (dir, path) + fn record(json: &str) -> AgentPermissions { + serde_json::from_str(json).expect("decodes") } #[test] - fn read_of_a_corrupt_file_is_an_error() { - let (_dir, path) = corrupt_file(); - read_from(&path).expect_err("truncated JSON must not read as a map"); - } - - #[test] - fn set_caps_leaves_a_corrupt_file_untouched() { - let (_dir, path) = corrupt_file(); - let err = set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()]) - .expect_err("a corrupt file must not be overwritten"); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); - } - - #[test] - fn remove_agent_leaves_a_corrupt_file_untouched() { - let (_dir, path) = corrupt_file(); - let err = - remove_agent_at(&path, "atlas").expect_err("a corrupt file must not be overwritten"); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); - } - - #[test] - fn set_caps_round_trips_through_a_missing_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join(CAPABILITIES_FILE); - assert!(read_from(&path).expect("missing file is empty").is_empty()); - set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()]).expect("set"); - let map = read_from(&path).expect("read"); - assert_eq!(map["ruth"], vec!["manage_root_agent".to_owned()]); - } - - #[test] - fn prune_unknown_keeps_known_name() { - let mut map = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]); - assert!(!prune_unknown(&mut map)); - assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]); - } - - #[test] - fn prune_unknown_drops_unrecognised_name() { - let mut map = BTreeMap::from([("atlas".to_owned(), vec!["fly_to_the_moon".to_owned()])]); - assert!(prune_unknown(&mut map)); - assert!(!map.contains_key("atlas")); - } - - #[test] - fn prune_unknown_keeps_known_and_drops_unknown_in_same_entry() { - let mut map = BTreeMap::from([( - "atlas".to_owned(), - vec!["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()], - )]); - assert!(prune_unknown(&mut map)); - assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]); - } - - #[test] - fn apply_known_caps_removes_entry_on_empty_input() { - let mut current = - BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]); - apply_known_caps(&mut current, "atlas", &[]); - assert!(!current.contains_key("atlas")); - } - - #[test] - fn apply_known_caps_removes_entry_when_only_unknown_names_given() { - let mut current = - BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]); - apply_known_caps(&mut current, "atlas", &["fly_to_the_moon".to_owned()]); - assert!(!current.contains_key("atlas")); - } - - #[test] - fn apply_known_caps_keeps_only_the_known_name() { - let mut current = BTreeMap::new(); - apply_known_caps( - &mut current, - "atlas", - &["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()], + fn unset_is_the_default_and_an_empty_list_is_none() { + assert_eq!(effective("ruth", None), vec![Capability::ManageRootAgent]); + assert_eq!( + effective("ruth", Some(&record("{}"))), + vec![Capability::ManageRootAgent] ); - assert_eq!(current["atlas"], vec!["manage_root_agent".to_owned()]); + assert!(effective("ruth", Some(&record(r#"{"capabilities":[]}"#))).is_empty()); + assert!(effective("atlas", None).is_empty()); + assert_eq!( + effective( + "atlas", + Some(&record(r#"{"capabilities":["manage_root_agent"]}"#)) + ), + vec![Capability::ManageRootAgent] + ); + } + + #[test] + fn a_missing_file_holds_the_defaults() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(FILE); + assert!(holds(&path, "ruth", Capability::ManageRootAgent).expect("missing is empty")); + assert!(!holds(&path, "atlas", Capability::ManageRootAgent).expect("missing is empty")); + } + + #[test] + fn a_corrupt_file_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(FILE); + std::fs::write(&path, "{\n \"ruth\": {\"capabilities\": [\"manage_root_ag").expect("seed"); + holds(&path, "ruth", Capability::ManageRootAgent).expect_err("truncated JSON"); } } diff --git a/hive-c0re/src/agent_config/mod.rs b/hive-c0re/src/agent_config/mod.rs index 04dee8f5..af5a36eb 100644 --- a/hive-c0re/src/agent_config/mod.rs +++ b/hive-c0re/src/agent_config/mod.rs @@ -1,6 +1,6 @@ -//! Per-agent configuration registries: tool groups, capabilities, -//! resource limits, topology (all JSON files under -//! `/var/lib/hyperhive/meta/`) and the shared wire-protocol size +//! Per-agent configuration registries: tool groups, resource limits, +//! topology (JSON files under `/var/lib/hyperhive/meta/`), the capabilities +//! the swarm grants (outside the meta repo) and the shared wire-protocol size //! limits. Each submodule is re-exported at the crate root, so //! `crate::topology::…` etc. keep working unchanged. //! diff --git a/hive-c0re/src/agent_config/resource_limits.rs b/hive-c0re/src/agent_config/resource_limits.rs index d9e058c9..0eafbfe0 100644 --- a/hive-c0re/src/agent_config/resource_limits.rs +++ b/hive-c0re/src/agent_config/resource_limits.rs @@ -3,7 +3,7 @@ //! //! The swarm caps are stored at //! `/var/lib/hyperhive/meta/swarm-resource-limits.json` alongside -//! `topology.json`, `tool-groups.json` and `capabilities.json`. +//! `topology.json` and `tool-groups.json`. //! //! Format: a JSON object mapping agent name to an object with optional //! `cpu_quota` / `memory_max` strings, passed verbatim to systemd's diff --git a/hive-c0re/src/agent_config/topology.rs b/hive-c0re/src/agent_config/topology.rs index d12c297e..cccfe3e5 100644 --- a/hive-c0re/src/agent_config/topology.rs +++ b/hive-c0re/src/agent_config/topology.rs @@ -13,7 +13,8 @@ //! bind-mounted ([`all_agents`]). //! //! Broader-than-your-own bind-mount grants are **not** stored here: they -//! hang off the `ManageRootAgent` capability in `capabilities.json`. See +//! hang off the `ManageRootAgent` capability the swarm grants +//! (`swarm_permissions`). See //! `lifecycle::set_nspawn_flags`. use std::collections::BTreeSet; diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 26815947..50277b1d 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -565,41 +565,6 @@ impl Coordinator { .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) { - 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 /// rebuild-queue worker after a `PermChange` / `ToolGroups` entry /// commits the JSON file, so the P3RM1SS10NS tab updates live. diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 6f1fec8d..7c9fffd2 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -37,7 +37,7 @@ use crate::lifecycle; (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 = "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 = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_snapshot", description = "cold-load dashboard snapshot"), @@ -126,8 +126,6 @@ fn app(coord: Arc) -> axum::Router { .routes(routes!(build_logs::get_build_log_raw)) .routes(routes!(permissions::get_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::get_stale_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!(permissions::get_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::get_stale_permissions)) .routes(routes!(permissions::delete_agent_permissions)) diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index 0ec25b05..7be82410 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -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 //! the lists). Write endpoints validate, then enqueue a `PermChange` so the //! 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)] pub(super) struct ToolGroupsSnapshot { /// 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>, /// Short description for each group name. Keys match `groups` entries. 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 /// already has an explicit entry; `effective[agent]` is the explicit /// assignment when present, else `default` (the role fallback). Shared by -/// the capabilities + tool-groups snapshots on both the HTTP and SSE -/// paths so the two surfaces never drift. +/// the tool-groups snapshots on both the HTTP and SSE paths so the two +/// surfaces never drift. #[must_use] pub(crate) fn roster_and_effective( roster: impl IntoIterator, @@ -184,135 +184,15 @@ pub(super) async fn post_tool_groups( Ok((StatusCode::OK, "ok").into_response()) } -#[derive(Serialize, ToSchema)] -pub(super) struct CapabilitiesSnapshot { - /// 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>, - /// Every agent the operator can manage (live roster ∪ explicit keys), - /// sorted — so default agents always list. - agents: Vec, - /// 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>, -} - -/// 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, -) -> Result, 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, -} - -/// 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, - AxumPath(name): AxumPath, - axum::Json(body): axum::Json, -) -> Result { - 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 +/// One agent's slice of a batch permission change. An omitted +/// `tool_groups` leaves the agent untouched, an empty array clears it, a /// populated array fully replaces it (same replace semantics as the -/// per-agent endpoints). +/// per-agent endpoint). #[derive(Deserialize, ToSchema)] pub(super) struct PermChangeBody { agent: String, #[serde(default)] tool_groups: Option>, - #[serde(default)] - capabilities: Option>, } #[derive(Deserialize, ToSchema)] @@ -320,25 +200,19 @@ pub(super) struct BatchPermsBody { changes: Vec, } -/// A validated, non-empty change staged for enqueue: -/// `(logical agent, new groups?, new caps?)`. -type StagedPerm = (String, Option>, Option>); - /// Batch permission apply — `POST /api/permissions`. /// -/// The save-all permissions UI sends only the perm-types that actually -/// changed per agent; each affected agent gets ONE combined -/// `PermChange`, so the dedup key collapses to `(kind, agent)` and an -/// agent whose caps AND groups both changed rebuilds once, not twice. -/// The whole batch is atomic: every change is validated up front and -/// on any validation error nothing is written or enqueued. +/// The save-all permissions UI sends only the agents whose groups +/// changed; each gets one `PermChange`. The whole batch is atomic: every +/// change is validated up front and on any validation error nothing is +/// written or enqueued. #[utoipa::path( post, path = "/api/permissions", request_body = BatchPermsBody, responses( (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"), ), tag = "permissions" @@ -347,43 +221,25 @@ pub(super) async fn post_permissions( State(state): State, axum::Json(body): axum::Json, ) -> Result { - 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 // queue, so a bad entry fails the whole POST with zero side effects. - // No-op rows (both fields omitted) are skipped, not errors. - let mut staged: Vec = Vec::new(); + // No-op rows (field omitted) are skipped, not errors. + let mut staged: Vec<(String, Vec)> = Vec::new(); for change in &body.changes { let logical = strip_container_prefix(&change.agent); if let Some(reject) = guard_agent_name(&state, &logical).await { return Ok(reject); } - if let Some(groups) = &change.tool_groups - && let Err(e) = crate::tool_groups::validate_groups(groups) - { - return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) - .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}"))); - } + if let Some(groups) = &change.tool_groups { + if let Err(e) = crate::tool_groups::validate_groups(groups) { + return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("invalid tool-groups for {logical}: {e}"))); } - } - if change.tool_groups.is_some() || change.capabilities.is_some() { - staged.push(( - logical, - change.tool_groups.clone(), - change.capabilities.clone(), - )); + staged.push((logical, groups.clone())); } } - // Phase 2 — submit one combined PermChange DAG per affected agent. - for (logical, groups, caps) in staged { + // Phase 2 — submit one PermChange DAG per affected agent. + for (logical, groups) in staged { state .coord .job_queue @@ -391,7 +247,7 @@ pub(super) async fn post_permissions( crate::job_queue::templates::perm_change( b, &logical, - crate::job_queue::PermPayload::Combined { groups, caps }, + crate::job_queue::PermPayload::ToolGroups { groups }, ); Vec::new() }) @@ -402,7 +258,7 @@ pub(super) async fn post_permissions( 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 /// list (i.e. truly gone — renamed or destroyed agents whose JSON entries /// persisted). The client uses this to drive the "stale permission entries" @@ -421,7 +277,7 @@ pub(super) struct StalePermsResponse { path = "/api/permissions/stale", responses( (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" )] @@ -447,18 +303,9 @@ pub(super) async fn get_stale_permissions( .collect(); // Known = live roster ∪ kept-state names. 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 mut ghost_names: Vec = caps - .keys() - .chain(tgs.keys()) - .filter(|n| !known.contains(n)) - .cloned() - .collect::>() - .into_iter() - .collect(); - ghost_names.sort(); + // `tgs` is a `BTreeMap`, so its keys are already sorted. + let ghost_names: Vec = tgs.keys().filter(|n| !known.contains(n)).cloned().collect(); 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. /// /// Used by the P3RM1SS10NS tab's "remove" button for agents that have -/// stale explicit entries in `tool-groups.json` / `capabilities.json` -/// but are no longer running (e.g. an agent that was renamed or -/// destroyed while its JSON entries persisted). +/// stale explicit entries in `tool-groups.json` but are no longer +/// running (e.g. an agent that was renamed or destroyed while its JSON +/// entries persisted). /// /// Bypasses `guard_agent_name`'s live-roster check intentionally — /// the whole point is to remove entries for non-roster agents. Only /// the format check ([`Ident::parse`]) is applied. No rebuild is -/// enqueued (the agent doesn't exist to rebuild); the SSE snapshots -/// update the P3RM1SS10NS tab live. +/// enqueued (the agent doesn't exist to rebuild); the SSE snapshot +/// updates the P3RM1SS10NS tab live. #[utoipa::path( delete, path = "/api/permissions/{agent}", @@ -482,7 +329,7 @@ pub(super) async fn get_stale_permissions( responses( (status = 200, description = "stale permission entries cleared", body = String), (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" )] @@ -496,24 +343,13 @@ pub(super) async fn delete_agent_permissions( return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); } }; - // Run both removals regardless so we clean up as much as possible - // 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. + let removed = crate::tool_groups::remove_agent(logical.as_str()); state.coord.emit_tool_groups_snapshot(); - state.coord.emit_capabilities_snapshot(); - // Surface any I/O error as 500 so the frontend's `!resp.ok` path + // Surface an I/O error as 500 so the frontend's `!resp.ok` path // fires and the operator sees a meaningful message rather than a // 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 ( StatusCode::INTERNAL_SERVER_ERROR, format!("failed to remove permission entries for {logical}: {e}"), diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index 7cfd5804..0f852c3c 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -141,24 +141,6 @@ pub enum DashboardEvent { /// (transients, container state), and a poll loop would be /// slower for the same information. RebuildQueueChanged { seq: u64 }, - /// Full snapshot of capability grants (per-agent `Vec`). - /// 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>, - /// Sorted roster the operator can manage (live ∪ explicit keys). - agents: Vec, - /// Per-agent *effective* caps (explicit-or-default; default empty). - effective: std::collections::BTreeMap>, - }, /// Full snapshot of tool-group assignments (per-agent `Vec`). /// Emitted from the rebuild-queue worker after a `PermChange` /// `ToolGroups` entry commits the JSON file. Lets the P3RM1SS10NS @@ -203,7 +185,6 @@ impl DashboardEvent { DashboardEvent::MetaInputsChanged { .. } => "meta_inputs_changed", DashboardEvent::MetaUpdateRunning { .. } => "meta_update_running", DashboardEvent::RebuildQueueChanged { .. } => "rebuild_queue_changed", - DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed", DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed", } } @@ -279,14 +260,6 @@ mod tests { running: false, }, 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 { seq: 1, groups: Vec::new(), diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index f924aa28..cb08c497 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -810,23 +810,6 @@ async fn run_write_perm_file( .with_context(|| format!("commit tool-groups for {name}"))?; 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(()) } diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index 33e0ce87..97979df5 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -98,7 +98,7 @@ pub enum NodeKind { PauseDrain { agent: String }, /// `set_nspawn_flags` + `set_resource_limits` + daemon-reload. WriteDropin { agent: String }, - /// Commit `tool-groups.json` / `capabilities.json` per its `payload`. + /// Commit `tool-groups.json` per its `payload`. WritePermFile { agent: String, payload: PermPayload }, /// Group root of a rebuild subtree — the **brace**, scoped to one /// agent. Why braces exist and what they cost: _Braces_. diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index b7d4e7e4..3f6ff76d 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -403,8 +403,8 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) { .after_ok(last); } -/// Perm change: commit the JSON file(s), then the rebuild subgraph so -/// the updated `HIVE_TOOL_GROUPS` / `HIVE_CAPABILITIES` env var takes +/// Perm change: commit the JSON file, then the rebuild subgraph so +/// the updated `HIVE_TOOL_GROUPS` env var takes /// effect in the container. Group-roots are `WritePermFile` plus the rebuild /// subgraph's `MetaSync` / `Prebuild` / `Reconcile`, so the `EmitRebuilt` tail /// edges all four. diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index 8ad97b8d..24165391 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -1522,10 +1522,7 @@ fn perm_change_shape_prefixes_rebuild_chain() { templates::perm_change( builder, "agent-a", - PermPayload::Combined { - groups: Some(vec![]), - caps: None, - }, + PermPayload::ToolGroups { groups: vec![] }, ); }); assert_eq!( diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index a0fdd10f..c92564d7 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -381,22 +381,20 @@ fn bind_own_agent_dirs(agent_name: &str, notes_dir: &Path, binds: &mut Vec bool { - crate::capabilities::has_cap(caps_path, agent_name, Capability::ManageRootAgent).unwrap_or_else( - |e| { - tracing::error!( - agent = %agent_name, - path = %caps_path.display(), - error = ?e, - "capabilities unreadable — no ManageRootAgent mounts" - ); - false - }, - ) +fn holds_manage_root_agent(agent_name: &str, path: &Path) -> bool { + crate::capabilities::holds(path, agent_name, Capability::ManageRootAgent).unwrap_or_else(|e| { + tracing::error!( + agent = %agent_name, + path = %path.display(), + error = ?e, + "swarm permissions unreadable — no ManageRootAgent mounts" + ); + false + }) } /// 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 // agent used to get over its own direct children — so an agent with // 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 // `agent_state_dir/state` and `config_bind_source` is shared, so // binding the holder as its own virtual child reproduced the two @@ -818,22 +816,30 @@ mod tests { } #[test] - fn a_corrupt_capabilities_file_denies_without_failing() { + fn corrupt_swarm_permissions_deny_without_failing() { let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("capabilities.json"); - let truncated = "{\n \"ruth\": [\"manage_root_ag"; + let path = dir.path().join("swarm-agent-permissions.json"); + let truncated = "{\n \"ruth\": {\"capabilities\": [\"manage_root_ag"; std::fs::write(&path, truncated).expect("seed"); assert!(!holds_manage_root_agent("ruth", &path)); 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] - fn a_readable_grant_is_honoured() { + fn the_swarm_grant_or_the_default_decides_the_recovery_mounts() { let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("capabilities.json"); - std::fs::write(&path, r#"{"ruth": ["manage_root_agent"]}"#).expect("seed"); + let path = dir.path().join("swarm-agent-permissions.json"); assert!(holds_manage_root_agent("ruth", &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"; diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index c0ff3bed..bc022efc 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -277,6 +277,9 @@ async fn cmd_serve( if let Err(e) = migrate::run(&coord).await { 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 // on this — without root the system has no manager harness. // Failures are logged but allowed: a broken auto-spawn shouldn't diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index ce916fa8..c57559a6 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -47,8 +47,8 @@ pub struct AgentSpec { /// Stage every generated meta JSON file that exists: topology.json is /// regenerated by `reconcile` whenever the agent set changed; -/// tool-groups/capabilities are created lazily on -/// first write (`set_groups`/`set_caps`) — absent means +/// tool-groups is created lazily on +/// first write (`set_groups`) — absent means /// every agent is on defaults, no file needed. Without staging, an /// 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 @@ -57,10 +57,6 @@ async fn stage_generated_meta_files(dir: &std::path::Path) -> Result<()> { for (path, name) in [ (crate::topology::topology_path(), "topology.json"), (crate::tool_groups::tool_groups_path(), "tool-groups.json"), - ( - crate::capabilities::capabilities_path(), - "capabilities.json", - ), ] { if path.exists() { 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`. f if f.starts_with("peer-ca-") && has_pem_ext(f) => Some("peer-ca"), "topology.json" => Some("topology"), - "capabilities.json" => Some("capabilities"), "tool-groups.json" => Some("tool-groups"), _ => None, }) @@ -326,68 +321,28 @@ pub async fn commit_tool_groups(agent: &str, groups: &[String]) -> Result<()> { Ok(()) } -/// Write the capabilities file for `agent` and commit it atomically -/// under `META_LOCK`. Same rationale as `commit_tool_groups`. -pub async fn commit_capabilities(agent: &str, caps: &[String]) -> Result<()> { - 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`. +/// Delete `capabilities.json` from the meta repo and commit the removal under +/// `META_LOCK`. Its grants live in the swarm's `agent-permissions` bucket +/// once `workers::permissions_import` has sent them. /// /// # Errors /// -/// Returns an error if writing either JSON file fails, a capability name -/// is invalid (`set_caps`), or a git stage/commit step fails. -pub async fn commit_perms( - agent: &str, - groups: Option<&[String]>, - caps: Option<&[String]>, -) -> Result<()> { +/// Removing the file or a git stage/commit step fails. +pub async fn remove_capabilities_file() -> Result<()> { + const FILE: &str = crate::workers::permissions_import::META_FILE; let _guard = META_LOCK.lock().await; let dir = crate::paths::meta_root(); - let mut parts: Vec<&str> = Vec::new(); - if let Some(groups) = groups { - crate::tool_groups::set_groups(agent, groups)?; - if crate::tool_groups::tool_groups_path().exists() { - git(&dir, &["add", "tool-groups.json"]).await?; - } - parts.push("tool-groups"); + match std::fs::remove_file(dir.join(FILE)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("removing {FILE}")), } - if let Some(caps) = caps { - crate::capabilities::set_caps(agent, caps) - .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(&dir, &["rm", "--cached", "--ignore-unmatch", "-q", FILE]).await?; + if paths_dirty(&dir, &[FILE]).await? { git_commit_paths( &dir, - &format!("set {} for {agent}", parts.join(" + ")), - &paths, + "capabilities moved to the swarm's agent-permissions", + &[FILE], ) .await?; } @@ -905,7 +860,7 @@ where let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!( 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( r#" let @@ -915,7 +870,6 @@ where input = inputs."agent-${name}"; service = "hive-agent"; toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; }; - capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; }; in base.extendModules { modules = [ @@ -1092,7 +1046,7 @@ where } out.push_str( r" }; - systemd.services.${service}.environment = toolGroupsEnv // capabilitiesEnv // { + systemd.services.${service}.environment = toolGroupsEnv // { HIVE_PORT = toString port; HIVE_LABEL = name; HIVE_DASHBOARD_PORT = toString dashboardPort; @@ -1140,7 +1094,6 @@ where // renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails // open for any agent whose explicit entry is narrower. let tool_groups_map = crate::tool_groups::read()?; - let capabilities_map = crate::capabilities::read()?; for spec in agents { // Emit `toolGroups = "group1,group2"` when the operator has // explicitly configured groups for this agent. Absent entry = null @@ -1153,28 +1106,14 @@ where let joined = groups.join(","); 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!( out, - " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; }};", + " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; }};", spec.name, spec.name, if spec.is_manager { "true" } else { "false" }, spec.port, tool_groups_attr, - capabilities_attr, ); } out.push_str(" };\n };\n}\n"); diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index c4cfc54b..c24a5ba5 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -98,7 +98,7 @@ pub fn spawn( // reason, and is a third consumer rather than a second connection. tokio::spawn(crate::workers::wanted::watch_declarations( client.clone(), - coord, + std::sync::Arc::clone(&coord), hive.clone(), shutdown.clone(), )); @@ -109,6 +109,14 @@ pub fn spawn( 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); loop { match publish(&client, &hive).await { diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index ac31b48f..029032f7 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -124,10 +124,6 @@ pub async fn ensure_root_agent(coord: &Arc) -> Result<()> { ); 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; if let Err(e) = &list_result { 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 { - 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 /// freshness + persisted `wanted` intent, submit one `Boot` DAG /// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale @@ -572,10 +506,7 @@ fn submit_boot_tree( #[cfg(test)] mod tests { - use super::{ - BootAction, MANAGER_NAME, RootAgentPlan, boot_action, manager_seed_caps, plan_root_agent, - should_seed_manager_caps, - }; + use super::{BootAction, MANAGER_NAME, RootAgentPlan, boot_action, plan_root_agent}; use crate::lifecycle::AGENT_PREFIX; use crate::power::Wanted; @@ -604,45 +535,6 @@ mod tests { 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 // substitute `from_running(running)`, which is `Noop` against BOTH // observations — so no combination of inputs could reach a reconcile. diff --git a/hive-c0re/src/workers/mod.rs b/hive-c0re/src/workers/mod.rs index 6f2ffa4c..118f923b 100644 --- a/hive-c0re/src/workers/mod.rs +++ b/hive-c0re/src/workers/mod.rs @@ -1,7 +1,7 @@ //! Background tasks and periodic sweeps: crash/login watcher, boot-time //! 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 -//! swarm resource-cap read. +//! swarm resource-cap and permissions reads. //! Each submodule is re-exported //! 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 knowledge; pub mod mcp_sockets; +pub mod permissions_import; pub mod swarm_limits; +pub mod swarm_permissions; pub mod wanted; diff --git a/hive-c0re/src/workers/permissions_import.rs b/hive-c0re/src/workers/permissions_import.rs new file mode 100644 index 00000000..a0463e3b --- /dev/null +++ b/hive-c0re/src/workers/permissions_import.rs @@ -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>) -> BTreeMap> { + let mut out: BTreeMap> = 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>>> { + if !path.exists() { + return Ok(None); + } + let file: BTreeMap> = 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> { + 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::::new()); + assert_eq!(out["ruth"], Vec::::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"), "{}"); + } +} diff --git a/hive-c0re/src/workers/swarm_permissions.rs b/hive-c0re/src/workers/swarm_permissions.rs new file mode 100644 index 00000000..7467d61e --- /dev/null +++ b/hive-c0re/src/workers/swarm_permissions.rs @@ -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, + hive: String, + mut shutdown: tokio::sync::watch::Receiver, +) { + 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> { + // An unconnected client does not fail a JetStream request, it hangs on it. + swarm_queue_client::ensure_connected(client)?; + let agents: Vec = 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(agents: &[String], path: &Path, read: F) -> Result> +where + F: Fn(String) -> Fut, + Fut: std::future::Future>, +{ + 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, changed: Vec) { + 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 { + 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, + ) -> anyhow::Result> { + 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); + } +} diff --git a/hive-host-sock/src/jobs.rs b/hive-host-sock/src/jobs.rs index 36f7048a..189b4c69 100644 --- a/hive-host-sock/src/jobs.rs +++ b/hive-host-sock/src/jobs.rs @@ -25,15 +25,6 @@ pub use hive_jobq::State; pub enum PermPayload { /// Set the tool groups for one agent (`tool-groups.json`). ToolGroups { groups: Vec }, - /// Set the capabilities for one agent (`capabilities.json`). - Capabilities { caps: Vec }, - /// 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>, - caps: Option>, - }, } /// Node id. Carries the scheduler crate's globally-monotonic node id diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index 88247574..8713576e 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -1,8 +1,9 @@ //! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and -//! system-level capability grants (`Capability`). Both are declared in -//! per-agent config (`tool-groups.json` / `capabilities.json`), injected -//! into the container as env vars, and read by the harness to decide -//! which MCP tools claude actually sees. +//! system-level capability grants (`Capability`). Tool groups are declared +//! in the hive's `tool-groups.json`, injected into the container as an env +//! var, and read by the harness to decide which MCP tools claude actually +//! 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`]): //! that set is group-gated too (`web_tools`), so it belongs next to the @@ -154,11 +155,11 @@ pub enum ToolGroup { Inbox, /// Gates no tool today — `list_containers`, its only member, was /// 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, /// 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 `&[]`. Forge, /// 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` -/// (same shape as `tool-groups.json`: `{ "alice": ["manage_root_agent"] }`). -/// Capabilities control system-level access hive-c0re enforces at -/// dispatch time; they are orthogonal to tool groups (which control -/// which MCP tools the harness exposes to claude). -/// -/// 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`. +/// Per-agent capability grants: the `capabilities` field of the swarm's +/// `agent-permissions` record (`swarm_queue_client::agent_permissions`), set +/// with `swarmctl agent permissions set`. Capabilities control system-level +/// access hive-c0re enforces, through the container's bind mounts; they are +/// orthogonal to tool groups (which control which MCP tools the harness +/// exposes to claude). See `docs/process/conventions.md::Capabilities`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] #[serde(rename_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 /// to single out, so the same grant now means "manage any agent". /// Kept under the old spelling deliberately — renaming it would turn - /// every `manage_root_agent` already stored in `capabilities.json` - /// into an unrecognised name that `capabilities::prune_unknown` - /// silently drops. + /// every `manage_root_agent` already stored in a swarm record into a + /// name the hive can no longer decode. ManageRootAgent, } impl Capability { - /// Every known capability in a stable order. Use this to enumerate - /// columns in the permissions UI or validate incoming capability strings. + /// Every known capability in a stable order. 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)] diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index 56e7eefa..9300318e 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -66,6 +66,9 @@ sha2.workspace = true # `forge/pull_diff.rs` diffs a PR's changed files itself, at a context width # the forge's own `.diff` does not offer. 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` # everywhere downstream — see `create_agent`'s doc comment for why this is # defense-in-depth, not the only gate (per an argus review finding). @@ -102,6 +105,7 @@ swarm-authelia-bridge-sock.workspace = true # carries. swarm-queue-client = { workspace = true, features = [ "kv", + "agent-permissions", "subagent-term", "agent-term", "schema", diff --git a/swarm-controller/src/agent_permissions.rs b/swarm-controller/src/agent_permissions.rs new file mode 100644 index 00000000..9d8d8df1 --- /dev/null +++ b/swarm-controller/src/agent_permissions.rs @@ -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>, example = json!(["manage_root_agent"]))] + capabilities: Option>, +} + +impl Permissions { + /// The value to store, or why it is refused. + fn into_stored(self) -> Result { + 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 for Permissions { + fn from(v: AgentPermissions) -> Self { + Self { + capabilities: v.capabilities, + } + } +} + +fn agent_name(name: &str) -> Result { + 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 { + 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, + Path(name): Path, +) -> Result, 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::(&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, + Path(name): Path, + Json(req): Json, +) -> Result, 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::(&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, + Path(name): Path, +) -> Result { + 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, hives: Vec) { + 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, + import: Import, +) -> anyhow::Result { + 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 { + 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, 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); + } +} diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index dd30e4db..7d77cf00 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -47,6 +47,7 @@ mod agent_identity; mod agent_input; mod agent_limits; mod agent_login; +mod agent_permissions; mod agent_renewal; mod agent_state_stream; mod agent_status; @@ -2877,11 +2878,22 @@ fn spawn_subagent_streams(status: Option<&Arc>, hives: &[H } /// 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`. fn spawn_per_agent_upkeep(status: Option<&Arc>, hives: &[HiveEntry]) { spawn_subagent_streams(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>, 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 @@ -3493,6 +3505,11 @@ fn build_app(state: AppState) -> axum::Router { agent_limits::put_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!(create_agent)) .routes(routes!(mint_agent_forge_token)) diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 94463a40..cf3ab2d7 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -229,7 +229,9 @@ impl Policy { return Some(Permissions { publish: self.reader_subjects(), 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) { @@ -307,7 +309,7 @@ impl Policy { /// What a reader may subscribe to: every agent's terminal, its /// 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` /// 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::subagent_term::subject_wildcard(), swarm_queue_client::agent_state_subject_wildcard(), + swarm_queue_client::agent_permissions::import_subject_wildcard(), Self::inbox(client_id), ] } @@ -561,6 +564,17 @@ impl Policy { "$JS.API.DIRECT.GET.{}.>", 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( self.extra_hive_subjects .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] fn an_extra_subject_without_the_placeholder_is_refused() { // 🩸 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"); let reader = p.permissions("swarm-controller").expect("the reader"); assert!(reader.publish.iter().any(|g| covers(g, SUBJECT))); - assert_eq!(reader.responses, None); for (who, grant) in [ ("a hive", p.permissions("hive-alpha").expect("a hive")), ( diff --git a/swarm-queue-client/Cargo.toml b/swarm-queue-client/Cargo.toml index 0fb0389f..4ce7b27f 100644 --- a/swarm-queue-client/Cargo.toml +++ b/swarm-queue-client/Cargo.toml @@ -50,6 +50,10 @@ schema = ["dep:utoipa"] # `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`. 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] # 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 # one read (`stored_secret`). futures-util.workspace = true +hive-sh4re = { workspace = true, optional = true } # `blocking` on top of the workspace default (`form`/`json`/`rustls`) — # `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 diff --git a/swarm-queue-client/src/agent_permissions.rs b/swarm-queue-client/src/agent_permissions.rs new file mode 100644 index 00000000..e712eaac --- /dev/null +++ b/swarm-queue-client/src/agent_permissions.rs @@ -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`/``. `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>, +} + +/// 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>, +} + +/// 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, + kept: Vec, + skipped: Vec, + }, + /// 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 { + 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> { + 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 { + 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() + ); + } +} diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index b2b31dc2..a064af7e 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -221,6 +221,12 @@ pub mod agent_inference; /// writing needs `kv` and reading needs `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 /// on [`agent_state_subject`] only. pub mod agent_login; diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 92f802fe..10346ceb 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -29,6 +29,7 @@ mod agent; mod forge; mod inference; mod login; +mod permissions; mod remote; mod schedule; mod users; @@ -290,6 +291,43 @@ enum AgentVerb { #[command(subcommand)] 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, + #[command(flatten)] + socket: ControllerSocketArg, } #[derive(Subcommand)] @@ -602,6 +640,9 @@ fn main() -> Result<()> { Verb::Agent { command: AgentVerb::Inference { command }, } => inference(&remote, command), + Verb::Agent { + command: AgentVerb::Permissions { command }, + } => permissions(&remote, command), // Same socket-resolution reasoning as `Create` above. Verb::Forge { 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<()> { match command { ScheduleVerb::Add(a) => schedule::add( diff --git a/swarmctl/src/permissions.rs b/swarmctl/src/permissions.rs new file mode 100644 index 00000000..67e2ef2b --- /dev/null +++ b/swarmctl/src/permissions.rs @@ -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>, +} + +fn runtime() -> Result { + 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 { + 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) -> 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) -> 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":[]}"# + ); + } +}