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 { AgentInstructions } from "./AgentInstructions.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 { AgentStateRow } from "./AgentStateRow.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
@ -184,9 +188,21 @@ function SettingsRows({ agent }: { agent: string }) {
<dd>
<AgentLimits key={agent} agent={agent} />
</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>
<dd>
<AgentInternet key={agent} agent={agent} />
{g.grant === null ? g.loading : <AgentInternet agent={agent} g={g} />}
</dd>
<dt>allowed domains</dt>
<dd>
@ -194,7 +210,14 @@ function SettingsRows({ agent }: { agent: string }) {
</dd>
<dt>permissions</dt>
<dd>
<AgentPermissions key={agent} agent={agent} />
{g.grant === null ? (
<span class="ui-agent-limits-muted">—</span>
) : (
<>
{g.readFailure}
<AgentPermissions agent={agent} g={g} />
</>
)}
</dd>
</>
);

View file

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

View file

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