swarm-ui: show and toggle an agent's unrestricted internet flag, add it to the create dialog
This commit is contained in:
parent
24beb167d8
commit
10508895c4
7 changed files with 136 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue