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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" && (
|
||||||
|
|
|
||||||
|
|
@ -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 } : {};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue