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
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,
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
@ -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
of its own.
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
settings.
5. **summary**: every setting the request will carry, and what stays at its

View file

@ -23,12 +23,18 @@ import {
requestPermissions,
togglePermission,
} from "../../pages/CreateAgentPermissions.js";
import { editorSelection, grantSummary } from "./permissions.js";
import {
editorSelection,
grantSummary,
internetBody,
internetSummary,
} from "./permissions.js";
import "./AgentLimits.css";
// The stored grant as the route carries it; the field is absent when unset.
interface Grant {
permissions?: string[];
unrestricted_internet?: boolean;
}
// 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.
const [reload, setReload] = useState(0);
const [editing, setEditing] = useState(false);
const [flagBusy, setFlagBusy] = useState(false);
useEffect(() => {
let cancelled = false;
@ -68,6 +75,28 @@ export function AgentPermissions({ agent }: { agent: string }) {
};
}, [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
// worked is not hidden by a read that did not.
const errorPanel = error ? (
@ -99,6 +128,22 @@ export function AgentPermissions({ agent }: { agent: string }) {
))}
</ul>
) : 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
variant="quiet"
value="edit"

View file

@ -21,3 +21,27 @@ export function grantSummary(stored: string[] | undefined): string {
if (stored.length === 0) return "none";
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 {
initialSelection,
requestInternet,
requestPermissions,
togglePermission,
} from "./CreateAgentPermissions.js";
@ -132,6 +133,8 @@ export function CreateAgentForm({
null,
);
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
// `archetypeKey`, `""` for none.
const [archetypes, setArchetypes] = useState<ArchetypeList | null>(null);
@ -231,6 +234,7 @@ export function CreateAgentForm({
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
...requestInternet(internet),
archetype: requestArchetype(picked),
...agentsMdBody(agentsMd),
}),
@ -434,6 +438,14 @@ export function CreateAgentForm({
)}
</div>
</FormField>
<label>
<input
type="checkbox"
checked={internet}
onChange={() => setInternet((v) => !v)}
/>{" "}
Unrestricted internet
</label>
</div>
)}
{!done && step === "resources" && (

View file

@ -38,3 +38,13 @@ export function initialSelection(
...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 {
editorSelection,
grantSummary,
internetBody,
internetSummary,
} from "../src/agent/detail/permissions.ts";
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(["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 {
initialSelection,
requestInternet,
requestPermissions,
togglePermission,
} from "../src/pages/CreateAgentPermissions.ts";
@ -44,3 +45,11 @@ test("the starting selection is the default plus the known extras", () => {
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 });
});