diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md
index a0f39470..feaedec1 100644
--- a/docs/swarm/ui.md
+++ b/docs/swarm/ui.md
@@ -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
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx
index 883030c9..03de607a 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx
@@ -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 }) {
))}
) : null}
+
+
+ void setInternet(grant, grant.unrestricted_internet === false)
+ }
+ />
([]);
+ // 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(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({
)}
+
)}
{!done && step === "resources" && (
diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPermissions.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentPermissions.ts
index be62c338..9520538d 100644
--- a/frontend/packages/swarm-ui/src/pages/CreateAgentPermissions.ts
+++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPermissions.ts
@@ -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 } : {};
+}
diff --git a/frontend/packages/swarm-ui/test/agent-permissions.test.mjs b/frontend/packages/swarm-ui/test/agent-permissions.test.mjs
index a607d8d6..56a6a96e 100644
--- a/frontend/packages/swarm-ui/test/agent-permissions.test.mjs
+++ b/frontend/packages/swarm-ui/test/agent-permissions.test.mjs
@@ -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,
+ });
+});
diff --git a/frontend/packages/swarm-ui/test/create-agent-permissions.test.mjs b/frontend/packages/swarm-ui/test/create-agent-permissions.test.mjs
index 07109b23..f7ec493b 100644
--- a/frontend/packages/swarm-ui/test/create-agent-permissions.test.mjs
+++ b/frontend/packages/swarm-ui/test/create-agent-permissions.test.mjs
@@ -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 });
+});