Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: show and toggle an agent's unrestricted internet flag, add it to the create dialog

This commit is contained in:
atlas 2026-10-27 13:38:58 +01:00 • committed by mara
commit 10508895c4
7 changed files with 136 additions and 2 deletions

View file

@ -562,6 +562,14 @@ ignores a value it can't parse and the agent would run uncapped, and it
refuses, for example, a percentage with a leading zero. The dialog shows the refuses, for example, a percentage with a leading zero. The dialog shows the
refusal's reason under the fields and leaves them as you typed them. refusal's reason under the fields and leaves them as you typed them.
Below the list, **internet** shows whether the agent's traffic may leave the
host unfiltered: `unrestricted`, `restricted`, or `unrestricted (legacy)` when
the swarm never set the flag, which counts as on. The **restrict** or
**allow unrestricted** badge flips it with a `PUT` that carries the stored
permission list along with the flag, because a `PUT` without the list resets a
stored list to the default. An agent with no stored list sends the flag alone.
Nothing acts on the flag yet.
A saved change isn't instant. The hive reads the caps about every 30 seconds, A saved change isn't instant. The hive reads the caps about every 30 seconds,
and a running agent takes a changed cap at its next spawn or rebuild, so the and a running agent takes a changed cap at its next spawn or rebuild, so the
row shows the new value before the agent runs under it. The panel reads row shows the new value before the agent runs under it. The panel reads
@ -605,7 +613,8 @@ order with **next** and can step back through with **back** or the step tabs:
2. **instructions**: the text the agent's `AGENTS.md` starts with, in a box 2. **instructions**: the text the agent's `AGENTS.md` starts with, in a box
of its own. of its own.
3. **permissions**: one checkbox per permission, starting from the default set 3. **permissions**: one checkbox per permission, starting from the default set
or the archetype's. or the archetype's, and an **Unrestricted internet** checkbox, unticked by
default. Ticking it adds `unrestricted_internet` to the request.
4. **resources and inference**: the optional resource caps and inference 4. **resources and inference**: the optional resource caps and inference
settings. settings.
5. **summary**: every setting the request will carry, and what stays at its 5. **summary**: every setting the request will carry, and what stays at its

View file

@ -23,12 +23,18 @@ import {
requestPermissions, requestPermissions,
togglePermission, togglePermission,
} from "../../pages/CreateAgentPermissions.js"; } from "../../pages/CreateAgentPermissions.js";
import { editorSelection, grantSummary } from "./permissions.js"; import {
editorSelection,
grantSummary,
internetBody,
internetSummary,
} from "./permissions.js";
import "./AgentLimits.css"; import "./AgentLimits.css";
// The stored grant as the route carries it; the field is absent when unset. // The stored grant as the route carries it; the field is absent when unset.
interface Grant { interface Grant {
permissions?: string[]; permissions?: string[];
unrestricted_internet?: boolean;
} }
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`). // Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
@ -47,6 +53,7 @@ export function AgentPermissions({ agent }: { agent: string }) {
// Bumped after a save or a clear, so the grant is fetched again. // Bumped after a save or a clear, so the grant is fetched again.
const [reload, setReload] = useState(0); const [reload, setReload] = useState(0);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [flagBusy, setFlagBusy] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -68,6 +75,28 @@ export function AgentPermissions({ agent }: { agent: string }) {
}; };
}, [agent, reload]); }, [agent, reload]);
// Flips the internet flag, sending the stored list with it.
async function setInternet(g: Grant, unrestricted: boolean) {
if (flagBusy) return;
setFlagBusy(true);
try {
const r = await fetch(permissionsUrl(agent), {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(internetBody(g.permissions, unrestricted)),
});
if (!r.ok) {
setError(await readApiError(r));
return;
}
setReload((n) => n + 1);
} catch (e: unknown) {
setError({ detail: String(e) });
} finally {
setFlagBusy(false);
}
}
// A grant already loaded stays under a failed refetch, so a save that // A grant already loaded stays under a failed refetch, so a save that
// worked is not hidden by a read that did not. // worked is not hidden by a read that did not.
const errorPanel = error ? ( const errorPanel = error ? (
@ -99,6 +128,22 @@ export function AgentPermissions({ agent }: { agent: string }) {
))} ))}
</ul> </ul>
) : null} ) : null}
<Badge
label="internet"
value={internetSummary(grant.unrestricted_internet)}
/>
<Badge
variant="quiet"
value={
grant.unrestricted_internet === false
? "allow unrestricted"
: "restrict"
}
title={`set whether ${agent}'s traffic may leave the host unfiltered`}
onClick={() =>
void setInternet(grant, grant.unrestricted_internet === false)
}
/>
<Badge <Badge
variant="quiet" variant="quiet"
value="edit" value="edit"

View file

@ -21,3 +21,27 @@ export function grantSummary(stored: string[] | undefined): string {
if (stored.length === 0) return "none"; if (stored.length === 0) return "none";
return `${stored.length} granted`; return `${stored.length} granted`;
} }
/**
* How the panel reads the stored `unrestricted_internet`. An absent value
* counts as on, and says "legacy" because the swarm never set it.
*/
export function internetSummary(stored: boolean | undefined): string {
if (stored === undefined) return "unrestricted (legacy)";
return stored ? "unrestricted" : "restricted";
}
/**
* The `PUT` body that sets the internet flag. A `PUT` without `permissions`
* resets a stored list to the default, so the stored list goes along; an
* agent with none stored sends the flag alone, which leaves it unset.
*/
export function internetBody(
stored: string[] | undefined,
unrestricted: boolean,
): { permissions?: string[]; unrestricted_internet: boolean } {
return {
...(stored === undefined ? {} : { permissions: stored }),
unrestricted_internet: unrestricted,
};
}

View file

@ -49,6 +49,7 @@ import { FormField } from "../ui/form-field/FormField.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js"; import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import { import {
initialSelection, initialSelection,
requestInternet,
requestPermissions, requestPermissions,
togglePermission, togglePermission,
} from "./CreateAgentPermissions.js"; } from "./CreateAgentPermissions.js";
@ -132,6 +133,8 @@ export function CreateAgentForm({
null, null,
); );
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
// Unticked: the server writes `false`, so the agent starts restricted.
const [internet, setInternet] = useState(false);
// `null` until `/api/archetypes` answers; `archetype` is the picked one's // `null` until `/api/archetypes` answers; `archetype` is the picked one's
// `archetypeKey`, `""` for none. // `archetypeKey`, `""` for none.
const [archetypes, setArchetypes] = useState<ArchetypeList | null>(null); const [archetypes, setArchetypes] = useState<ArchetypeList | null>(null);
@ -231,6 +234,7 @@ export function CreateAgentForm({
permissions: catalogue permissions: catalogue
? requestPermissions(selected, catalogue.default) ? requestPermissions(selected, catalogue.default)
: undefined, : undefined,
...requestInternet(internet),
archetype: requestArchetype(picked), archetype: requestArchetype(picked),
...agentsMdBody(agentsMd), ...agentsMdBody(agentsMd),
}), }),
@ -434,6 +438,14 @@ export function CreateAgentForm({
)} )}
</div> </div>
</FormField> </FormField>
<label>
<input
type="checkbox"
checked={internet}
onChange={() => setInternet((v) => !v)}
/>{" "}
Unrestricted internet
</label>
</div> </div>
)} )}
{!done && step === "resources" && ( {!done && step === "resources" && (

View file

@ -38,3 +38,13 @@ export function initialSelection(
...extra.filter((p) => all.includes(p) && !defaults.includes(p)), ...extra.filter((p) => all.includes(p) && !defaults.includes(p)),
]; ];
} }
/**
* The create request's internet field: `unrestricted_internet: true` while
* the box is ticked, nothing otherwise, as the server writes `false` for it.
*/
export function requestInternet(ticked: boolean): {
unrestricted_internet?: true;
} {
return ticked ? { unrestricted_internet: true } : {};
}

View file

@ -5,6 +5,8 @@ import { test } from "node:test";
import { import {
editorSelection, editorSelection,
grantSummary, grantSummary,
internetBody,
internetSummary,
} from "../src/agent/detail/permissions.ts"; } from "../src/agent/detail/permissions.ts";
const all = ["messaging", "meta", "inbox", "execution", "manage_root_agent"]; const all = ["messaging", "meta", "inbox", "execution", "manage_root_agent"];
@ -33,3 +35,26 @@ test("the summary tells unset, empty and a list apart", () => {
assert.equal(grantSummary([]), "none"); assert.equal(grantSummary([]), "none");
assert.equal(grantSummary(["meta", "inbox"]), "2 granted"); assert.equal(grantSummary(["meta", "inbox"]), "2 granted");
}); });
test("an absent internet flag reads as unrestricted, marked legacy", () => {
assert.equal(internetSummary(undefined), "unrestricted (legacy)");
assert.equal(internetSummary(true), "unrestricted");
assert.equal(internetSummary(false), "restricted");
});
test("the internet toggle sends the stored list with the flag", () => {
assert.deepEqual(internetBody(["meta", "inbox"], false), {
permissions: ["meta", "inbox"],
unrestricted_internet: false,
});
assert.deepEqual(internetBody([], true), {
permissions: [],
unrestricted_internet: true,
});
});
test("with no stored list the toggle leaves the list unset", () => {
assert.deepEqual(internetBody(undefined, false), {
unrestricted_internet: false,
});
});

View file

@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
initialSelection, initialSelection,
requestInternet,
requestPermissions, requestPermissions,
togglePermission, togglePermission,
} from "../src/pages/CreateAgentPermissions.ts"; } from "../src/pages/CreateAgentPermissions.ts";
@ -44,3 +45,11 @@ test("the starting selection is the default plus the known extras", () => {
defaults, defaults,
); );
}); });
test("the create box is unticked by default and sends nothing", () => {
assert.deepEqual(requestInternet(false), {});
});
test("a ticked create box sends the flag", () => {
assert.deepEqual(requestInternet(true), { unrestricted_internet: true });
});