diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 6119df4c..fc92ba5a 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -11,7 +11,7 @@ domain, covers host-level detail for one hive. | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here | | `/hives` | the hive directory, each hive with its last reported status | -| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts | +| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts | | `/agents//terminal` | one agent's live terminal | | `/agents//subagents//terminal` | one of that agent's subagents' live terminal | | `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token | @@ -77,7 +77,7 @@ with no icon keeps the hyperhive mark. The tab also has an agent badge, with the agent's icon and name, ahead of the connection badge. It opens the agent's details in a popover under the badge: -the same status, wanted state, inference settings, resource caps and linked accounts as the +the same status, wanted state, inference settings, resource caps, permissions and linked accounts as the panel on `/agents`, without the terminal preview. The popover doesn't dim the page. Selecting outside it, or Escape, closes it. From the keyboard, opening a popover moves focus into it, and Escape puts focus back on the badge. The filter @@ -503,6 +503,30 @@ row shows the new value before the agent runs under it. The panel reads reason and a **retry** button. [hivectl](../tools/hivectl.md#per-agent-resource-limits) sets the hive's own limits. +### Permissions + +The agent's detail panel shows what the swarm grants it under **permissions**: +the default set, none, or a count with one badge per permission. A swarm +grant is the agent's whole set, so it follows the agent to whichever hive runs +it. **edit** opens a dialog with one checkbox per permission the controller +knows (`GET /api/permissions`), ticked as the grant stands, or on the default +set when the swarm sets none. + +Saving does a `PUT /api/agents/{agent}/permissions` of the ticked boxes. A +`PUT` replaces the stored list whole, so an unticked box revokes that +permission, and ticking nothing grants nothing. Ticking exactly the default set sends the +`DELETE` instead, as the create form sends no list for it, so the agent follows +the default if it changes. **use the default** sends the `DELETE` too. The +dialog shows a refusal's reason under the boxes and leaves them as you ticked +them. + +A saved change isn't instant. The hive reads the grant about every 30 seconds +and the agent takes it at its next turn. A change to `manage_root_agent` +restarts the agent after its current turn. The panel reads +`GET /api/agents/{agent}/permissions`; when that read fails, the panel shows +the reason and a **retry** button. `swarmctl agent permissions set` sets the +same grant from a shell. + ### Settings at creation The **+ agent** dialog has two collapsed sections, **resource caps** and @@ -549,7 +573,8 @@ The agent's detail panel lists its linked accounts under **accounts**, one row per account: kind, name and host. Opening an agent makes one request, `GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which returns every account of that agent as names and hosts and never a credential. When the panel -fails to read the accounts, the inference settings or the resource caps, it +fails to read the accounts, the inference settings, the resource caps or the +permissions, it shows the reason as text. A list the panel already loaded stays on screen beneath the error. diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx index 808afa77..6b17964d 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx @@ -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({
+
permissions
+
+ +
accounts
{row.hive ? ( diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx new file mode 100644 index 00000000..883030c9 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx @@ -0,0 +1,248 @@ +// — 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(null); + const [error, setError] = useState(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 ? ( + <> + + + + ) : null; + if (grant === null) { + return ( + errorPanel ?? ( + + loading the permissions… + + ) + ); + } + + return ( +
+ {errorPanel} + + {grant.permissions && grant.permissions.length > 0 ? ( +
    + {grant.permissions.map((p) => ( +
  • + +
  • + ))} +
+ ) : null} + setEditing(true)} + /> + setEditing(false)} + label="edit permissions" + plain + > + {/* Remounted per open, so a cancelled edit leaves nothing behind. */} + {editing ? ( + setEditing(false)} + onSaved={() => { + setEditing(false); + setReload((n) => n + 1); + }} + /> + ) : null} + +
+ ); +} + +function PermissionsForm({ + agent, + grant, + onClose, + onSaved, +}: { + agent: string; + grant: Grant; + onClose: () => void; + onSaved: () => void; +}) { + const [catalogue, setCatalogue] = useState(null); + const [selected, setSelected] = useState([]); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(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 ( + +

+ 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 manage_root_agent restarts it after + its current turn. +

+
+
+ {catalogue ? ( + setSelected((s) => togglePermission(s, v))} + /> + ) : ( + + {problem ? "could not load the permissions" : "loading…"} + + )} +
+ {problem ? ( + + ) : null} +
+ + +
+ +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/agent/detail/permissions.ts b/frontend/packages/swarm-ui/src/agent/detail/permissions.ts new file mode 100644 index 00000000..655a3d81 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/permissions.ts @@ -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`; +} diff --git a/frontend/packages/swarm-ui/test/agent-permissions.test.mjs b/frontend/packages/swarm-ui/test/agent-permissions.test.mjs new file mode 100644 index 00000000..a607d8d6 --- /dev/null +++ b/frontend/packages/swarm-ui/test/agent-permissions.test.mjs @@ -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"); +});