Watch
0
0
Fork
You've already forked hyperhive
0

agent permissions: one set, tool groups included, set at agent creation

mara on #5170: "dont split caps and perms. its all perms. / dont include
the now empty tool groups / nothing set -> default / also add perms to
agent creation screen".

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

Part of #5170.
This commit is contained in:
atlas 2026-10-15 04:09:53 +02:00 • committed by mara
commit f0844f6da3
73 changed files with 1578 additions and 2742 deletions

View file

@ -13,10 +13,9 @@
<body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean: kept state (tombstones) and the
stale permission entries. Rebuild queue + meta inputs are tabs of
/dashboard.html. The section <div> ids (tombstones-section,
tombstones-stale-perms) match what core.js's renderers target. Hive
dashboard tab strip stays lean: kept state (tombstones). Rebuild
queue + meta inputs are tabs of /dashboard.html. The section <div>
id (tombstones-section) matches what core.js's renderers target. Hive
infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have
no dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. -->
@ -26,11 +25,7 @@
</header>
<main class="core-main">
<!-- tombstones-section shows destroyed agents (purge button).
tombstones-stale-perms shows agents with explicit tool-group
entries but no live container (typically renamed/deleted
agents whose JSON entries persisted). Each stale entry gets a
"✕ clear perms" button. -->
<!-- tombstones-section shows destroyed agents (purge button). -->
<p class="meta">
kept state from previously tombstoned agents — recreating an agent with
the same name reuses it.
@ -38,7 +33,6 @@
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
<div id="tombstones-stale-perms"></div>
</main>
<script type="module" src="/static/core.js" defer></script>

View file

@ -1,6 +1,6 @@
// C0R3 page entry (/core.html). The host/coordinator surface, its own
// standalone page so the dashboard tab strip stays lean: kept state
// (tombstones) and the stale permission entries.
// (tombstones).
//
// Rebuild queue + meta inputs are tabs of /dashboard.html, not here. This
// page is its own esbuild bundle with its own runtime: it cold-loads
@ -10,7 +10,7 @@
import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
// ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = [];
@ -95,92 +95,6 @@ function renderTombstones(s) {
root.append(ul);
}
// ─── stale permission entries (K3PT ST4T3 pane sub-section) ──────────────
// 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
// container-roster cache or perform set arithmetic. Lazy-loaded on first
// "kept" tab activation; refreshed when perm data changes via SSE.
let stalePermsLoaded = false;
function renderStalePerms(root, ghosts) {
root.replaceChildren();
if (!ghosts.length) return;
root.append(
el("p", { class: "tombstones-stale-heading" }, "stale permission entries"),
);
root.append(
el(
"p",
{ class: "meta" },
"agents with explicit tool-group entries but no live container " +
"or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).",
),
);
const errP = el("p", { class: "tombstones-stale-err", hidden: true });
const ul = el("ul", { class: "tombstones-stale-list" });
for (const name of ghosts) {
const li = el("li", { class: "tombstones-stale-row" });
li.append(el("span", { class: "tombstones-stale-name" }, name));
li.append(el("span", { class: "hive-pill-sm badge-muted" }, "stale perms"));
const btn = el(
"button",
{
type: "button",
class: "btn btn-destroy",
title: "remove explicit tool-group entries for " + name,
},
"✕ clear perms",
);
btn.addEventListener("click", () =>
asyncBtn(btn, async () => {
errP.hidden = true;
try {
const resp = await fetch(
"/api/permissions/" + encodeURIComponent(name),
{ method: "DELETE" },
);
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = "failed to clear perms for " + name + ": " + msg;
errP.hidden = false;
return;
}
await fetchAndRenderStalePerms();
} catch (err) {
errP.textContent = "failed to clear perms for " + name + ": " + err;
errP.hidden = false;
}
}),
);
li.append(btn);
ul.append(li);
}
root.append(ul, errP);
}
// Ghost detection is entirely server-side: GET /api/permissions/stale
// returns the computed list of agent names that have explicit JSON entries
// but are absent from both the live roster and the kept-state tombstones.
// One call, no client-side roster cache, always authoritative.
async function fetchAndRenderStalePerms() {
const root = $("tombstones-stale-perms");
if (!root) return;
try {
const resp = await fetch("/api/permissions/stale");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderStalePerms(root, data.stale || []);
} catch (err) {
root.replaceChildren();
root.append(
el("p", { class: "meta" }, "failed to load stale perm data: " + err),
);
}
stalePermsLoaded = true;
}
// ─── render-all (cold load + any full re-render) ──────────────────────────
function renderAll() {
renderTombstones({ tombstones: tombstonesState });
@ -192,11 +106,6 @@ const SSE_HANDLERS = {
tombstonesState = (ev.tombstones || []).slice();
renderTombstones({ tombstones: tombstonesState });
},
// Refresh the stale-perms sub-section when perm data changes (a ghost
// was cleared, or perms were saved for an agent whose name collides).
tool_groups_changed(_ev) {
if (stalePermsLoaded) fetchAndRenderStalePerms();
},
};
// ─── boot ─────────────────────────────────────────────────────────────────
@ -223,16 +132,13 @@ async function init() {
bindAsyncForms(() => refreshState());
await refreshState();
fetchAndRenderStalePerms();
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all
// 13 wire kinds down to the 3 this page acts on. This page was one
// kinds= matches SSE_HANDLERS below verbatim — narrows the stream down
// to the kinds this page acts on. This page was one
// of 4 unfiltered `/api/dashboard/stream` subscribers before this
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream(
"/api/dashboard/stream?kinds=tombstones_changed,tool_groups_changed",
);
const es = openStream("/api/dashboard/stream?kinds=tombstones_changed");
if (es) {
es.onmessage = (e) => {
let ev;

View file

@ -566,107 +566,6 @@ footer .banner-thin {
margin-bottom: 0.8em;
}
/* ─── tool-groups (permissions) table ──────────────────────────────
Agents × tool-groups matrix in the P3RM1SS10NS tab. Horizontally
scrollable on narrow viewports. */
.tg-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.tg-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.tg-table th,
.tg-table td {
padding: 0.35em 0.6em;
border: 1px solid var(--border);
text-align: center;
vertical-align: middle;
}
.tg-table thead th {
background: var(--bg-elev);
color: var(--muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.tg-agent-col {
text-align: left !important;
min-width: 8em;
}
.tg-group-col {
min-width: 5em;
}
.tg-agent-name {
color: var(--fg);
font-weight: 600;
}
.tg-cb {
cursor: pointer;
width: 1em;
height: 1em;
accent-color: var(--purple);
}
.tg-row:hover td {
background: var(--bg-elev);
}
/* ─── permissions save bar (save-all) ──────────────────────────────
One page-level save button for the whole P3RM1SS10NS tab. Sticks to
the bottom of the pane so it stays reachable while scrolling a long
agent×perm matrix. Disabled (dim) until an edit makes the page dirty;
the (N agents) count in the label reflects how many agents will
rebuild. `#perm-save-note` carries the transient rebuilding/error
line beside it. */
.perm-save-bar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: 0.8em;
margin-top: 1em;
padding: 0.7em 0;
background: var(--bg);
border-top: 1px solid var(--border);
}
.perm-save-bar .btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.perm-save-err {
color: var(--red);
}
/* Stale permission entry (agent not in the live roster) */
.perm-row-stale td {
opacity: 0.7;
}
.perm-stale-label {
font-size: 0.75em;
color: var(--muted);
margin-left: 0.4em;
}
.perm-remove-btn {
margin-left: 0.5em;
padding: 0.05em 0.5em;
font-size: 0.75em;
color: var(--red);
background: transparent;
border: 1px solid currentColor;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
opacity: 0.8;
}
.perm-remove-btn:hover {
opacity: 1;
}
.perm-remove-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Selection bar — sticky-bottom strip for bulk actions. */
.selection-bar {
position: fixed;

View file

@ -71,18 +71,6 @@
>
<span class="tab-label">◆ BUILD L0GS ◆</span>
</a>
<!-- P3RM1SS10NS: per-agent tool-group
assignments, fetched on tab activation. -->
<a
class="tab"
id="tab-permissions"
href="#permissions"
role="tab"
aria-controls="tab-pane-permissions"
data-tab="permissions"
>
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only
@ -205,59 +193,6 @@
<div id="build-list"><p class="meta">loading…</p></div>
</section>
<!-- P3RM1SS10NS: per-agent tool-group assignments, column-driven
from the backend (GET /api/tool-groups) so new groups require no
UI change. Edits stage in-browser; the page-level "save all"
button POSTs every changed agent to /api/permissions in one
atomic batch (one rebuild per agent). Capabilities are the
swarm's (`swarmctl agent permissions`), not set here. -->
<section
class="tab-pane"
id="tab-pane-permissions"
data-tab-pane="permissions"
hidden
role="tabpanel"
aria-labelledby="tab-permissions"
>
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
cols = tool groups fetched from GET /api/tool-groups.
Checking / unchecking is staged in the UI; the single page-level
"save all" button POSTs every changed agent to
/api/permissions in one atomic batch, one rebuild each.
Absent agents default to the role default (shown in parens). -->
<h2>◆ T00L GR0UPS ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent tool-group permissions. columns are filled from the
backend — adding a new group requires no UI change. agents without
an explicit entry use the role default (agents: messaging, meta,
inbox, execution).
</p>
<div id="tool-groups-section">
<p class="meta">loading…</p>
</div>
<!-- Page-level save bar (save-all issue): one button for the whole
permissions page. Staged checkbox edits are diffed against their
render-time baselines and POSTed together to /api/permissions, one
rebuild per agent. Disabled until something is dirty. The atomic
batch either fully lands (queued ✓ → rebuilding) or fully fails
(error note, nothing applied). -->
<div class="perm-save-bar">
<button
type="button"
id="perm-save-all"
class="btn btn-approve"
disabled
>
save all
</button>
<span id="perm-save-note" class="meta"></span>
</div>
</section>
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on the

View file

@ -41,9 +41,7 @@
<span class="home-tile-icon" aria-hidden="true">🖥</span>
<span class="home-tile-label">Dashboard</span>
</span>
<span class="home-tile-desc"
>containers · builds · permissions</span
>
<span class="home-tile-desc">containers · builds</span>
</a>
<a class="home-tile" href="/flow.html">
@ -69,7 +67,7 @@
<span class="home-tile-icon" aria-hidden="true">⬡</span>
<span class="home-tile-label">Core</span>
</span>
<span class="home-tile-desc">kept state · stale permissions</span>
<span class="home-tile-desc">kept state</span>
</a>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by

View file

@ -1,331 +0,0 @@
// 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`).
//
// 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.
//
// Stateless at module scope: each render builds fresh from the fetched
// payload. The agent roster (`containersState`) is the only shared state
// it reads — to union live containers with agents already named in the
// assignments map.
//
// Editing model: checkboxes are diffed against a `data-baseline` captured at
// 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";
import { containersState } from "./state.js";
import { asyncBtn } from "@hive/shared/forms.js";
// ── SSE re-render guards ────────────────────────────────────────────
// Skip the live re-render when the operator has unsaved edits in that
// section (or a checkbox focused) — clobbering a half-finished edit set
// 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 applyToolGroupsChanged(ev) {
const root = $("tool-groups-section");
if (!root) return;
if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return;
renderToolGroups(root, ev);
}
// A section is dirty if any checkbox diverges from its render-time
// baseline. Cheap DOM scan; no module-level mirror to drift.
function sectionHasDirty(root) {
for (const cb of root.querySelectorAll("input[type=checkbox]")) {
if (cb.checked !== (cb.dataset.baseline === "1")) return true;
}
return false;
}
export async function fetchAndRenderToolGroups() {
const root = $("tool-groups-section");
if (!root) return;
root.replaceChildren();
root.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch("/api/tool-groups");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderToolGroups(root, data);
} catch (err) {
root.replaceChildren();
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
function renderToolGroups(root, data) {
root.replaceChildren();
const { groups, descriptions = {}, assignments, effective = {} } = data;
if (!groups || !groups.length) {
root.append(el("p", { class: "meta" }, "(no tool groups defined)"));
updateSaveBar();
return;
}
// Agent rows: prefer the backend roster (default-perms agents included);
// fall back to the live-container ∪ explicit union for older payloads.
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: "tg-table-wrap" });
const table = el("table", { class: "tg-table" });
// Header row.
const thead = el("thead");
const hrow = el("tr");
hrow.append(el("th", { class: "tg-agent-col" }, "agent"));
for (const g of groups) {
hrow.append(
el("th", { class: "tg-group-col", title: descriptions[g] || g }, g),
);
}
thead.append(hrow);
table.append(thead);
const tbody = el("tbody");
for (const name of agentNames) {
// Effective groups (explicit-or-role-default) drive the checkboxes so
// a default agent shows its real groups, not blank — and saving keeps
// them instead of silently stripping the defaults. The "(default)"
// badge still keys off explicit-assignment presence.
const assigned = effective[name] || assignments[name] || [];
const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name);
// `containersState` is keyed from `nixos-container list`, which
// includes stopped-but-configured containers — only destroyed/renamed
// agents are absent.
const isStale = !containersState.has(name);
const tr = el("tr", {
class: "tg-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell.
const nameTd = el("td", { class: "tg-agent-col" });
nameTd.append(el("span", { class: "tg-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);
} else if (!hasExplicit) {
nameTd.append(
el("span", { class: "meta tg-default-label" }, "(default)"),
);
}
tr.append(nameTd);
// One checkbox per group.
for (const g of groups) {
const checked = assigned.includes(g);
const td = el("td", { class: "tg-group-col" });
const cb = el("input", {
type: "checkbox",
class: "tg-cb",
"data-group": g,
"data-baseline": checked ? "1" : "0",
"aria-label": g,
});
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();
}
// ── Save-all wiring ─────────────────────────────────────────────────
// Recompute the save bar on every cell toggle. Also clears any stale
// error/✓ status the bar may be showing from a prior save.
function onCellToggle() {
clearSaveStatus();
updateSaveBar();
}
// 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 -> { tool_groups }
collectSection(
$("tool-groups-section"),
".tg-row",
".tg-cb",
"group",
"tool_groups",
byAgent,
);
const changes = [];
for (const [agent, obj] of byAgent) changes.push({ agent, ...obj });
return changes;
}
function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
if (!root) return;
for (const tr of root.querySelectorAll(rowSel)) {
const agent = tr.dataset.agent;
if (!agent) continue;
let dirty = false;
const selected = [];
for (const cb of tr.querySelectorAll(cbSel)) {
if (cb.checked !== (cb.dataset.baseline === "1")) dirty = true;
if (cb.checked) selected.push(cb.dataset[dataKey]);
}
if (dirty) {
const obj = byAgent.get(agent) || {};
obj[field] = selected;
byAgent.set(agent, obj);
}
}
}
function updateSaveBar() {
const btn = $("perm-save-all");
if (!btn) return;
// Don't stomp a transient saving/rebuilding label.
if (btn.dataset.busy === "1") return;
const n = collectChanges().length;
btn.disabled = n === 0;
btn.textContent =
n === 0 ? "save all" : `save all (${n} agent${n === 1 ? "" : "s"})`;
}
// 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 the table after
// the delete so the row disappears immediately.
async function clearStaleAgent(name, sectionRoot) {
const btn = sectionRoot
? sectionRoot.querySelector(
`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`,
)
: null;
const doDelete = async () => {
try {
const resp = await fetch("/api/permissions/" + encodeURIComponent(name), {
method: "DELETE",
});
if (!resp.ok) {
const text = await resp.text().catch(() => resp.status);
setSaveNote("failed to remove " + name + ": " + text, true);
return;
}
// Re-fetch so the stale row disappears.
await fetchAndRenderToolGroups();
} catch (err) {
setSaveNote("failed to remove " + name + ": " + err, true);
}
};
// asyncBtn guards double-submit; fall through without guard when there
// is no button (e.g. called programmatically without a DOM context).
// Return the promise so callers can await clearStaleAgent() if needed.
if (btn) return asyncBtn(btn, doDelete);
await doDelete();
}
function clearSaveStatus() {
const note = $("perm-save-note");
if (note) {
note.textContent = "";
note.classList.remove("perm-save-err");
}
}
function setSaveNote(text, isErr) {
const note = $("perm-save-note");
if (!note) return;
note.textContent = text;
note.classList.toggle("perm-save-err", !!isErr);
}
async function saveAll() {
const btn = $("perm-save-all");
if (!btn) return;
const changes = collectChanges();
if (!changes.length) return;
btn.dataset.busy = "1";
btn.disabled = true;
btn.textContent = "saving…";
clearSaveStatus();
try {
const r = await fetch("/api/permissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
if (!r.ok) {
const txt = await r.text();
btn.dataset.busy = "";
btn.disabled = false;
btn.textContent = "save all";
setSaveNote("save failed: " + (txt || "http " + r.status), true);
updateSaveBar();
return;
}
// Atomic 200: the whole batch landed. Show a rebuilding hint and
// 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 = "";
fetchAndRenderToolGroups();
}, 900);
} catch (err) {
btn.dataset.busy = "";
btn.disabled = false;
btn.textContent = "save all";
setSaveNote("save failed: " + String(err), true);
updateSaveBar();
}
}
// Bind the page-level save button once. Called from the dashboard entry
// after the DOM is ready (the button lives in the static permissions
// pane markup, so it exists before any fetch).
export function initPermissions() {
const btn = $("perm-save-all");
if (btn && !btn.dataset.bound) {
btn.dataset.bound = "1";
btn.addEventListener("click", saveAll);
}
}

View file

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

View file

@ -199,42 +199,3 @@
/* Remove the now-dead `.rqe-step` rule (the `step` sub-step label was
retired from the wire in `feat(job_queue): retire the now-off-wire step
sub-step label` — the rule has had no JS referents since then). */
/* ─── K3PT ST4T3 › stale permission entries sub-section ────────────────────
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);
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.08em;
border-top: 1px solid var(--border);
padding-top: 0.8em;
}
.tombstones-stale-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.3em;
}
.tombstones-stale-row {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.35em 0.6em;
border: 1px dashed var(--border);
background: color-mix(in srgb, var(--bg-elev) 35%, transparent);
}
.tombstones-stale-name {
color: var(--muted);
font-weight: bold;
font-size: 0.9em;
}
.tombstones-stale-err {
margin-top: 0.4em;
color: var(--red);
font-size: 0.85em;
}

View file

@ -16,11 +16,6 @@ import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js";
import { containersState, syncContainersFromSnapshot } from "./state.js";
import {
applyToolGroupsChanged,
fetchAndRenderToolGroups,
initPermissions,
} from "./permissions.js";
import {
initJobqRollup,
syncTransientsFromSnapshot,
@ -59,11 +54,7 @@ window.marked = marked;
// Sections whose innerHTML gets blown away on each refresh. If the
// operator is typing in one of them, skip the refresh — the next
// tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [
"containers-section",
"inbox-section",
"tool-groups-section",
];
const MANAGED_SECTION_IDS = ["containers-section", "inbox-section"];
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
// sections and records which keys are currently open; restoreOpenDetails
@ -207,7 +198,6 @@ window.marked = marked;
},
meta_inputs_changed: onMetaInputsChanged,
meta_update_running: onMetaUpdateRunning,
tool_groups_changed: applyToolGroupsChanged,
};
(function bindDashboardStream() {
// Route through the SharedWorker so all open hyperhive tabs on the
@ -218,13 +208,12 @@ window.marked = marked;
// fallback when SharedWorker isn't supported.
//
// kinds= matches MUTATION_HANDLERS verbatim, so the stream carries
// only the 9 wire kinds this page acts on.
// only the 7 wire kinds this page acts on.
const es = openStream(
"/api/dashboard/stream?kinds=" +
"transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," +
"meta_inputs_changed,meta_update_running," +
"tool_groups_changed",
"meta_inputs_changed,meta_update_running",
);
es.onmessage = (e) => {
let ev;
@ -256,8 +245,8 @@ window.marked = marked;
})();
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` /
// `#permissions` activate the matching pane on the dashboard. Empty
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs`
// activate the matching pane on the dashboard. Empty
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from
// the H0M3 hub. Tab routing only
@ -274,14 +263,6 @@ window.marked = marked;
// visibility (bar only belongs on 4G3NTS where agent cards live).
document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values()));
// 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();
fetchAndRenderToolGroups();
}
onBuildsTabShown(target);
}

View file

@ -25,6 +25,9 @@
// Its name field, hive select, and submit button all come from the
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why.
//
// Permissions: checkboxes from `GET /api/permissions`, on the default set;
// sent only when changed, so an untouched agent follows the default.
import { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
@ -44,6 +47,12 @@ import {
InferenceFields,
type InferenceDraft,
} from "../agent/detail/AgentInference.js";
import { FormField } from "../ui/form-field/FormField.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import {
requestPermissions,
togglePermission,
} from "./CreateAgentPermissions.js";
import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
@ -59,6 +68,12 @@ interface CreateAgentResponse {
node_id: number;
}
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
interface PermissionCatalogue {
all: string[];
default: string[];
}
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
@ -103,6 +118,12 @@ export function CreateAgentForm({
EMPTY_INFERENCE_DRAFT,
);
const inferenceProblem = draftProblem(inference);
// `null` until `/api/permissions` answers; `selected` starts on its default.
const [catalogue, setCatalogue] = useState<PermissionCatalogue | null>(null);
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
null,
);
const [selected, setSelected] = useState<string[]>([]);
// Loaded once, not re-fetched on submit: the roster changes rarely
// enough (a nix-level swarm config change) that staleness within one
@ -124,6 +145,19 @@ export function CreateAgentForm({
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []);
useEffect(() => {
(async () => {
const r = await fetch("/api/permissions");
if (!r.ok) {
setCatalogueError(await readApiError(r));
return;
}
const data = (await r.json()) as PermissionCatalogue;
setCatalogue(data);
setSelected(data.default);
})().catch((e: unknown) => setCatalogueError({ detail: String(e) }));
}, []);
const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({
value: h.name,
label: h.name,
@ -146,6 +180,9 @@ export function CreateAgentForm({
hive,
...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
}),
});
if (!r.ok) {
@ -155,6 +192,7 @@ export function CreateAgentForm({
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: "done", nodeId: data.node_id });
setName("");
if (catalogue) setSelected(catalogue.default);
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
@ -176,6 +214,12 @@ export function CreateAgentForm({
problem={hivesError}
/>
)}
{catalogueError && (
<ApiErrorPanel
context="failed to load the permission list"
problem={catalogueError}
/>
)}
<form class="create-agent-form" onSubmit={submit}>
<TextField
id="agent-name"
@ -202,6 +246,19 @@ export function CreateAgentForm({
: "select a hive"
}
/>
<FormField label="permissions" htmlFor="agent-permissions">
<div id="agent-permissions">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
/>
) : (
<span>loading…</span>
)}
</div>
</FormField>
<details class="create-agent-section">
<summary>resource caps</summary>
<div class="create-agent-section-body">
@ -230,6 +287,7 @@ export function CreateAgentForm({
disabled={
result.status === "submitting" ||
!hivesReady ||
!catalogue ||
inferenceProblem !== null
}
>

View file

@ -0,0 +1,24 @@
// The create-agent form's permissions logic, kept out of the component so
// `npm test` can run it.
/** `selected` with `value` added, or removed when it is there. */
export function togglePermission(selected: string[], value: string): string[] {
return selected.includes(value)
? selected.filter((p) => p !== value)
: [...selected, value];
}
/**
* The `permissions` to send with a create: `undefined` while `selected` is
* the default set (in any order), so the agent's key stays unset and follows
* the default; otherwise the list, which may be empty.
*/
export function requestPermissions(
selected: string[],
defaults: string[],
): string[] | undefined {
const same =
selected.length === defaults.length &&
selected.every((p) => defaults.includes(p));
return same ? undefined : selected;
}

View file

@ -0,0 +1,31 @@
// The create-agent form's permissions field: what a toggle does and what the
// create request carries. Run by `npm test` with Node's type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
requestPermissions,
togglePermission,
} from "../src/pages/CreateAgentPermissions.ts";
const defaults = ["messaging", "meta", "inbox", "execution"];
test("a toggle adds a missing permission and removes a present one", () => {
assert.deepEqual(togglePermission(["meta"], "inbox"), ["meta", "inbox"]);
assert.deepEqual(togglePermission(["meta", "inbox"], "meta"), ["inbox"]);
});
test("the default set, in any order, sends no permissions", () => {
assert.equal(
requestPermissions([...defaults].reverse(), defaults),
undefined,
);
});
test("a changed set is sent, an empty one included", () => {
assert.deepEqual(requestPermissions(["messaging"], defaults), ["messaging"]);
assert.deepEqual(requestPermissions([], defaults), []);
assert.deepEqual(requestPermissions([...defaults, "web_tools"], defaults), [
...defaults,
"web_tools",
]);
});