Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: read the agent's grant once for the internet and permissions controls, and save one permission change at a time

This commit is contained in:
iris 2026-11-04 00:23:56 +01:00 • committed by mara
commit 9afe21819f
3 changed files with 73 additions and 36 deletions

View file

@ -17,7 +17,11 @@ import type { AgentActions } from "../roster/AgentActions.js";
import { AgentInference } from "./AgentInference.js"; import { AgentInference } from "./AgentInference.js";
import { AgentInstructions } from "./AgentInstructions.js"; import { AgentInstructions } from "./AgentInstructions.js";
import { AgentLimits } from "./AgentLimits.js"; import { AgentLimits } from "./AgentLimits.js";
import { AgentInternet, AgentPermissions } from "./AgentPermissions.js"; import {
AgentInternet,
AgentPermissions,
useGrant,
} from "./AgentPermissions.js";
import { AllowedDomains } from "./AllowedDomains.js"; import { AllowedDomains } from "./AllowedDomains.js";
import { AgentStateRow } from "./AgentStateRow.js"; import { AgentStateRow } from "./AgentStateRow.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js"; import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
@ -184,9 +188,21 @@ function SettingsRows({ agent }: { agent: string }) {
<dd> <dd>
<AgentLimits key={agent} agent={agent} /> <AgentLimits key={agent} agent={agent} />
</dd> </dd>
<GrantRows key={agent} agent={agent} />
</>
);
}
// The internet and permissions rows read and write the one stored grant, so
// they share one read of it: a save in one is seen by the other. Keyed by
// agent, so switching agents remounts it instead of showing the last grant.
function GrantRows({ agent }: { agent: string }) {
const g = useGrant(agent);
return (
<>
<dt>internet</dt> <dt>internet</dt>
<dd> <dd>
<AgentInternet key={agent} agent={agent} /> {g.grant === null ? g.loading : <AgentInternet agent={agent} g={g} />}
</dd> </dd>
<dt>allowed domains</dt> <dt>allowed domains</dt>
<dd> <dd>
@ -194,7 +210,14 @@ function SettingsRows({ agent }: { agent: string }) {
</dd> </dd>
<dt>permissions</dt> <dt>permissions</dt>
<dd> <dd>
<AgentPermissions key={agent} agent={agent} /> {g.grant === null ? (
<span class="ui-agent-limits-muted">—</span>
) : (
<>
{g.readFailure}
<AgentPermissions agent={agent} g={g} />
</>
)}
</dd> </dd>
</> </>
); );

View file

@ -60,38 +60,48 @@ function permissionsUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/permissions`; return `/api/agents/${encodeURIComponent(agent)}/permissions`;
} }
// The stored grant of one agent, and what changes it. // The stored grant of one agent, and what changes it. One instance serves
function useGrant(agent: string) { // every control of the grant, so a save from one is seen by the others.
export function useGrant(agent: string) {
const [grant, setGrant] = useState<Grant | null>(null); const [grant, setGrant] = useState<Grant | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after a save or a clear, so the grant is fetched again. // Bumped by a retry, so the grant is fetched again.
const [reload, setReload] = useState(0); const [reload, setReload] = useState(0);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// The refusal of the last save, kept apart from a failed read. // The refusal of the last save, kept apart from a failed read.
const [saveError, setSaveError] = useState<ProblemDetails | null>(null); const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
useEffect(() => { // Reads the grant; false when the read failed.
let cancelled = false; async function load(isCancelled: () => boolean): Promise<boolean> {
(async () => { try {
const r = await fetch(permissionsUrl(agent)); const r = await fetch(permissionsUrl(agent));
if (!r.ok) { if (!r.ok) {
if (!cancelled) setError(await readApiError(r)); if (!isCancelled()) setError(await readApiError(r));
return; return false;
} }
const body = (await r.json()) as Grant; const body = (await r.json()) as Grant;
if (cancelled) return; if (isCancelled()) return false;
setGrant(body); setGrant(body);
setError(null); setError(null);
})().catch((e: unknown) => { return true;
if (!cancelled) setError({ detail: String(e) }); } catch (e: unknown) {
}); if (!isCancelled()) setError({ detail: String(e) });
return false;
}
}
useEffect(() => {
let cancelled = false;
void load(() => cancelled);
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [agent, reload]); }, [agent, reload]);
// Sends one change and reads the grant again; true when the route took it. // Sends one change and reads the grant again before it lets go of `busy`,
// so the next change builds on what was stored; true when the route took it.
async function send(init: RequestInit): Promise<boolean> { async function send(init: RequestInit): Promise<boolean> {
if (busy) return false;
setBusy(true); setBusy(true);
setSaveError(null); setSaveError(null);
try { try {
@ -100,7 +110,7 @@ function useGrant(agent: string) {
setSaveError(await readApiError(r)); setSaveError(await readApiError(r));
return false; return false;
} }
setReload((n) => n + 1); await load(() => false);
return true; return true;
} catch (e: unknown) { } catch (e: unknown) {
setSaveError({ detail: String(e) }); setSaveError({ detail: String(e) });
@ -129,14 +139,21 @@ function useGrant(agent: string) {
return { grant, busy, saveError, readFailure, loading, send }; return { grant, busy, saveError, readFailure, loading, send };
} }
export function AgentPermissions({ agent }: { agent: string }) { export type GrantState = ReturnType<typeof useGrant>;
const { grant, busy, saveError, readFailure, loading, send } =
useGrant(agent); export function AgentPermissions({
agent,
g,
}: {
agent: string;
g: GrantState;
}) {
const { grant, busy, saveError, loading, send } = g;
const [catalogue, setCatalogue] = useState<Catalogue | null>(null); const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>( const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
null, null,
); );
// The boxes as ticked; they follow the stored grant once it is read again. // The boxes as ticked while a change is being saved; null shows the stored grant.
const [selected, setSelected] = useState<string[] | null>(null); const [selected, setSelected] = useState<string[] | null>(null);
useEffect(() => { useEffect(() => {
@ -157,14 +174,9 @@ export function AgentPermissions({ agent }: { agent: string }) {
}; };
}, []); }, []);
// The stored grant changing (a save went through) resets the boxes to it.
const stored = grant?.permissions;
useEffect(() => {
setSelected(null);
}, [stored]);
if (grant === null) return loading; if (grant === null) return loading;
const stored = grant.permissions;
const ticked = const ticked =
selected ?? selected ??
(catalogue (catalogue
@ -172,12 +184,12 @@ export function AgentPermissions({ agent }: { agent: string }) {
: []); : []);
async function toggle(value: string) { async function toggle(value: string) {
if (!catalogue) return; if (!catalogue || busy) return;
const next = togglePermission(ticked, value); const next = togglePermission(ticked, value);
setSelected(next); setSelected(next);
// The default set sends no list, so the agent follows the default. // The default set sends no list, so the agent follows the default.
const list = requestPermissions(next, catalogue.default); const list = requestPermissions(next, catalogue.default);
const ok = await send( await send(
list === undefined list === undefined
? { method: "DELETE" } ? { method: "DELETE" }
: { : {
@ -186,13 +198,12 @@ export function AgentPermissions({ agent }: { agent: string }) {
body: JSON.stringify({ permissions: list }), body: JSON.stringify({ permissions: list }),
}, },
); );
// A refused change puts the boxes back as stored. // The grant was read again by now, or the change refused: show what is stored.
if (!ok) setSelected(null); setSelected(null);
} }
return ( return (
<div class="ui-agent-limits"> <div class="ui-agent-limits">
{readFailure}
<BadgePopover <BadgePopover
label="permissions" label="permissions"
value={grantSummary(grant.permissions)} value={grantSummary(grant.permissions)}
@ -212,6 +223,7 @@ export function AgentPermissions({ agent }: { agent: string }) {
options={catalogue.all} options={catalogue.all}
filter={{ values: ticked, negate: false }} filter={{ values: ticked, negate: false }}
onToggle={(v) => void toggle(v)} onToggle={(v) => void toggle(v)}
disabled={busy}
/> />
</div> </div>
) : ( ) : (
@ -236,15 +248,13 @@ export function AgentPermissions({ agent }: { agent: string }) {
); );
} }
export function AgentInternet({ agent }: { agent: string }) { export function AgentInternet({ agent, g }: { agent: string; g: GrantState }) {
const { grant, busy, saveError, readFailure, loading, send } = const { grant, busy, saveError, loading, send } = g;
useGrant(agent);
if (grant === null) return loading; if (grant === null) return loading;
const mode = internetMode(grant.unrestricted_internet, grant.internet_log); const mode = internetMode(grant.unrestricted_internet, grant.internet_log);
return ( return (
<div class="ui-agent-limits"> <div class="ui-agent-limits">
{readFailure}
<BadgePopover <BadgePopover
label="internet" label="internet"
value={internetSummary(grant.unrestricted_internet, grant.internet_log)} value={internetSummary(grant.unrestricted_internet, grant.internet_log)}

View file

@ -30,10 +30,13 @@ export function MultiselectFilterOptions({
options, options,
filter, filter,
onToggle, onToggle,
disabled = false,
}: { }: {
options: string[]; options: string[];
filter: MultiselectFilterState; filter: MultiselectFilterState;
onToggle: (value: string) => void; onToggle: (value: string) => void;
/** Greys the boxes out, e.g. while a change is being saved. */
disabled?: boolean;
}) { }) {
return ( return (
<div class="ui-multiselect-filter-options"> <div class="ui-multiselect-filter-options">
@ -41,6 +44,7 @@ export function MultiselectFilterOptions({
<label key={v} class="ui-multiselect-filter-checkbox"> <label key={v} class="ui-multiselect-filter-checkbox">
<input <input
type="checkbox" type="checkbox"
disabled={disabled}
checked={filter.values.includes(v)} checked={filter.values.includes(v)}
onChange={() => onToggle(v)} onChange={() => onToggle(v)}
/> />