Watch
0
0
Fork
You've already forked hyperhive
0

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

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

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

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

Part of #5170.
This commit is contained in:
atlas 2026-10-15 00:24:41 +02:00 • committed by mara
commit f862354e43

View file

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