Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: edit an agent's permissions on its detail panel

The detail panel had editors for inference, instructions, resource caps and
accounts, but nothing for the permissions the swarm grants, which could only
be set at create time or with swarmctl. A permissions row shows the grant
(the default set, none, or the list) and an edit dialog ticks the catalogue
from GET /api/permissions and saves with PUT /api/agents/{agent}/permissions.
Ticking exactly the default set sends the DELETE, as the create form sends no
list for it.
This commit is contained in:
iris 2026-10-24 23:25:31 +02:00 • committed by mara
commit 78c48272ee
5 changed files with 339 additions and 3 deletions

View file

@ -13,6 +13,7 @@ import type { AgentActions } from "../roster/AgentActions.js";
import { AgentInference } from "./AgentInference.js";
import { AgentInstructions } from "./AgentInstructions.js";
import { AgentLimits } from "./AgentLimits.js";
import { AgentPermissions } from "./AgentPermissions.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
@ -112,6 +113,10 @@ export function AgentDetail({
<dd>
<AgentLimits key={row.name} agent={row.name} />
</dd>
<dt>permissions</dt>
<dd>
<AgentPermissions key={row.name} agent={row.name} />
</dd>
<dt>accounts</dt>
<dd>
{row.hive ? (

View file

@ -0,0 +1,248 @@
// <AgentPermissions> — one agent's permissions on the detail panel: what the
// swarm grants it, and an editor for that.
//
// Reads `GET /api/agents/{name}/permissions`, which answers the stored list as
// `{ permissions? }` (`{}` when the swarm sets none, so the agent runs on the
// default set). The editor offers the catalogue from `GET /api/permissions` as
// checkboxes and PUTs the ticked ones, a PUT replacing the stored value whole.
// A selection equal to the default set DELETEs instead, as the create form
// sends nothing for it: the agent then follows the default if that changes.
// The route's refusal shows as it arrives, next to the boxes.
//
// Its state belongs to one agent: callers key it by agent name, so switching
// agents remounts it instead of showing the previous agent's grant.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js";
import { Panel } from "../../ui/panel/Panel.js";
import {
requestPermissions,
togglePermission,
} from "../../pages/CreateAgentPermissions.js";
import { editorSelection, grantSummary } from "./permissions.js";
import "./AgentLimits.css";
// The stored grant as the route carries it; the field is absent when unset.
interface Grant {
permissions?: string[];
}
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
interface Catalogue {
all: string[];
default: string[];
}
function permissionsUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/permissions`;
}
export function AgentPermissions({ agent }: { agent: string }) {
const [grant, setGrant] = useState<Grant | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after a save or a clear, so the grant is fetched again.
const [reload, setReload] = useState(0);
const [editing, setEditing] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(permissionsUrl(agent));
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const body = (await r.json()) as Grant;
if (cancelled) return;
setGrant(body);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [agent, reload]);
// A grant already loaded stays under a failed refetch, so a save that
// worked is not hidden by a read that did not.
const errorPanel = error ? (
<>
<ApiErrorPanel context="reading the permissions failed" problem={error} />
<Button onClick={() => setReload((n) => n + 1)}>retry</Button>
</>
) : null;
if (grant === null) {
return (
errorPanel ?? (
<span class="ui-agent-limits-muted" role="status">
loading the permissions…
</span>
)
);
}
return (
<div class="ui-agent-limits">
{errorPanel}
<Badge label="permissions" value={grantSummary(grant.permissions)} />
{grant.permissions && grant.permissions.length > 0 ? (
<ul class="ui-agent-limits-list">
{grant.permissions.map((p) => (
<li key={p}>
<Badge value={p} />
</li>
))}
</ul>
) : null}
<Badge
variant="quiet"
value="edit"
title={`set the permissions the swarm grants ${agent}`}
onClick={() => setEditing(true)}
/>
<Dialog
open={editing}
onClose={() => setEditing(false)}
label="edit permissions"
plain
>
{/* Remounted per open, so a cancelled edit leaves nothing behind. */}
{editing ? (
<PermissionsForm
agent={agent}
grant={grant}
onClose={() => setEditing(false)}
onSaved={() => {
setEditing(false);
setReload((n) => n + 1);
}}
/>
) : null}
</Dialog>
</div>
);
}
function PermissionsForm({
agent,
grant,
onClose,
onSaved,
}: {
agent: string;
grant: Grant;
onClose: () => void;
onSaved: () => void;
}) {
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch("/api/permissions");
if (!r.ok) {
if (!cancelled) setProblem(await readApiError(r));
return;
}
const data = (await r.json()) as Catalogue;
if (cancelled) return;
setCatalogue(data);
setSelected(editorSelection(grant.permissions, data.all, data.default));
})().catch((e: unknown) => {
if (!cancelled) setProblem({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [grant]);
async function send(init: RequestInit) {
setBusy(true);
setProblem(null);
try {
const r = await fetch(permissionsUrl(agent), init);
if (!r.ok) {
// The boxes stay as ticked, so the operator can fix it and save again.
setProblem(await readApiError(r));
return;
}
onSaved();
} catch (e: unknown) {
setProblem({ detail: String(e) });
} finally {
setBusy(false);
}
}
function save(e: Event) {
e.preventDefault();
if (!catalogue) return;
// The default set sends no list, so the agent follows the default.
const list = requestPermissions(selected, catalogue.default);
void send(
list === undefined
? { method: "DELETE" }
: {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ permissions: list }),
},
);
}
return (
<Panel title={`permissions — ${agent}`} icon="🔑" onClose={onClose}>
<p>
What the swarm lets {agent} do. A tick grants that permission; leaving
the default set ticked keeps {agent} on the default if it changes. The
hive reads a change within about 30 seconds, and the agent takes it at
its next turn. Changing <code>manage_root_agent</code> restarts it after
its current turn.
</p>
<form class="ui-agent-limits-form" onSubmit={save}>
<div role="group" aria-label="permissions">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
/>
) : (
<span class="ui-agent-limits-muted" role="status">
{problem ? "could not load the permissions" : "loading…"}
</span>
)}
</div>
{problem ? (
<ApiErrorPanel
context="the permissions were not saved"
problem={problem}
/>
) : null}
<div class="ui-agent-limits-actions">
<Button
variant="primary"
type="submit"
disabled={busy || catalogue === null}
>
{busy ? "saving…" : "save"}
</Button>
<Button
disabled={busy || grant.permissions === undefined}
onClick={() => void send({ method: "DELETE" })}
>
use the default
</Button>
</div>
</form>
</Panel>
);
}

View file

@ -0,0 +1,23 @@
// The detail panel's permissions editor: what it starts on and how a granted
// set reads. Kept out of the component so `npm test` can run it.
/**
* The editor's starting selection: the swarm's stored list when it sets one,
* else the default set. Names the catalogue (`all`) does not list are dropped,
* since the route refuses an unknown one, and the operator would be unable to
* clear a box they cannot see.
*/
export function editorSelection(
stored: string[] | undefined,
all: string[],
defaults: string[],
): string[] {
return (stored ?? defaults).filter((p) => all.includes(p));
}
/** What the panel says about a stored `permissions` field. */
export function grantSummary(stored: string[] | undefined): string {
if (stored === undefined) return "the default set";
if (stored.length === 0) return "none";
return `${stored.length} granted`;
}

View file

@ -0,0 +1,35 @@
// The detail panel's permissions editor: its starting selection and the
// summary line. Run by `npm test` with Node's type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
editorSelection,
grantSummary,
} from "../src/agent/detail/permissions.ts";
const all = ["messaging", "meta", "inbox", "execution", "manage_root_agent"];
const defaults = ["messaging", "meta", "inbox", "execution"];
test("an agent the swarm sets nothing for starts on the default set", () => {
assert.deepEqual(editorSelection(undefined, all, defaults), defaults);
});
test("a stored list is the starting selection, empty included", () => {
assert.deepEqual(
editorSelection(["meta", "manage_root_agent"], all, defaults),
["meta", "manage_root_agent"],
);
assert.deepEqual(editorSelection([], all, defaults), []);
});
test("a stored name the catalogue does not list is not offered", () => {
assert.deepEqual(editorSelection(["meta", "retired"], all, defaults), [
"meta",
]);
});
test("the summary tells unset, empty and a list apart", () => {
assert.equal(grantSummary(undefined), "the default set");
assert.equal(grantSummary([]), "none");
assert.equal(grantSummary(["meta", "inbox"]), "2 granted");
});