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:
parent
eaf26aaadd
commit
78c48272ee
5 changed files with 339 additions and 3 deletions
|
|
@ -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/<name>/terminal` | one agent's live terminal |
|
||||
| `/agents/<name>/subagents/<subagent>/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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
248
frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx
Normal file
248
frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/packages/swarm-ui/src/agent/detail/permissions.ts
Normal file
23
frontend/packages/swarm-ui/src/agent/detail/permissions.ts
Normal 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`;
|
||||
}
|
||||
35
frontend/packages/swarm-ui/test/agent-permissions.test.mjs
Normal file
35
frontend/packages/swarm-ui/test/agent-permissions.test.mjs
Normal 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");
|
||||
});
|
||||
Loading…
Reference in a new issue