Watch
0
0
Fork
You've already forked hyperhive
0

make squid's allowed domains editable at runtime in the swarm ui

This commit is contained in:
atlas 2026-10-28 20:39:22 +01:00 • committed by mara
commit c9abc2a42b
3 changed files with 2307 additions and 35 deletions

View file

@ -14,6 +14,7 @@ import { AgentInference } from "./AgentInference.js";
import { AgentInstructions } from "./AgentInstructions.js";
import { AgentLimits } from "./AgentLimits.js";
import { AgentPermissions } from "./AgentPermissions.js";
import { AllowedDomains } from "./AllowedDomains.js";
import { AgentStateRow } from "./AgentStateRow.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
@ -122,6 +123,10 @@ export function AgentDetail({
<dd>
<AgentPermissions key={row.name} agent={row.name} />
</dd>
<dt>allowed domains</dt>
<dd>
<AllowedDomains key={row.name} agent={row.name} />
</dd>
<dt>accounts</dt>
<dd>
{row.hive ? (

View file

@ -0,0 +1,243 @@
// <AllowedDomains> — the destinations the swarm lets one agent reach through
// its hive's egress proxy, beyond the base list every agent has: the entries
// that hold for every agent and the ones for this agent alone, with an editor
// to add and remove them.
//
// Reads `GET /api/egress/allowed-domains`, which answers every entry, and
// shows the ones that apply to this agent. Adding POSTs one entry and removing
// DELETEs one, each with the entry as the body, each answering the whole list.
// The route's refusal (an IP address, a wildcard, a name the proxy already
// allows everyone) shows as it arrives, next to the box. The hive reads a
// change within about 30 seconds.
//
// The first entry makes the swarm's list replace the names the hive's own
// configuration allows, so a name the hive allowed and the swarm's list lacks
// stops being allowed once hives read it. An agent that holds the unrestricted
// internet flag reaches any name the proxy's private-range rules leave, so an
// entry matters only to a restricted one.
//
// Its state belongs to one agent: callers key it by agent name.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { Button } from "../../ui/button/Button.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import {
ALLOWED_DOMAINS_URL,
allowedSummary,
entriesFor,
entryBody,
type AllowedDomain,
} from "./allowed-domains.js";
import "./AgentLimits.css";
interface Listing {
entries: AllowedDomain[];
}
export function AllowedDomains({ agent }: { agent: string }) {
const [entries, setEntries] = useState<AllowedDomain[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after an add or a removal, so the list is fetched again.
const [reload, setReload] = useState(0);
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(ALLOWED_DOMAINS_URL);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const body = (await r.json()) as Listing;
if (cancelled) return;
setEntries(body.entries);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [reload]);
async function remove(entry: AllowedDomain) {
if (busy) return;
setBusy(true);
try {
const r = await fetch(ALLOWED_DOMAINS_URL, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(entry),
});
if (!r.ok) {
setError(await readApiError(r));
return;
}
setReload((n) => n + 1);
} catch (e: unknown) {
setError({ detail: String(e) });
} finally {
setBusy(false);
}
}
// A list already loaded stays under a failed refetch, so a change that
// worked is not hidden by a read that did not.
const errorPanel = error ? (
<>
<ApiErrorPanel
context="the allowed domains were not read or changed"
problem={error}
/>
<Button onClick={() => setReload((n) => n + 1)}>retry</Button>
</>
) : null;
if (entries === null) {
return (
errorPanel ?? (
<span class="ui-agent-limits-muted" role="status">
loading the allowed domains…
</span>
)
);
}
const shown = entriesFor(entries, agent);
return (
<div class="ui-agent-limits">
{errorPanel}
<Badge label="allowed domains" value={allowedSummary(shown)} />
{shown.length > 0 ? (
<ul class="ui-agent-limits-list">
{shown.map((e) => (
<li key={`${e.agent ?? "*"}/${e.domain}`}>
<Badge
label={e.agent === undefined ? "swarm-wide" : agent}
value={e.domain}
/>{" "}
<Badge
variant="quiet"
value="remove"
title={`stop allowing ${e.domain} for ${
e.agent === undefined ? "every agent" : agent
}`}
onClick={() => void remove(e)}
/>
</li>
))}
</ul>
) : null}
<Badge
variant="quiet"
value="add"
title={`allow a domain for ${agent} or for every agent`}
onClick={() => setAdding(true)}
/>
<Dialog
open={adding}
onClose={() => setAdding(false)}
label="add an allowed domain"
plain
>
{/* Remounted per open, so a cancelled add leaves nothing behind. */}
{adding ? (
<AddForm
agent={agent}
onClose={() => setAdding(false)}
onAdded={() => {
setAdding(false);
setReload((n) => n + 1);
}}
/>
) : null}
</Dialog>
</div>
);
}
function AddForm({
agent,
onClose,
onAdded,
}: {
agent: string;
onClose: () => void;
onAdded: () => void;
}) {
const [domain, setDomain] = useState("");
const [forSwarm, setForSwarm] = useState(false);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
async function save(e: Event) {
e.preventDefault();
setBusy(true);
setProblem(null);
try {
const r = await fetch(ALLOWED_DOMAINS_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(entryBody(domain, forSwarm, agent)),
});
if (!r.ok) {
// The box stays as typed, so the operator can fix it and add again.
setProblem(await readApiError(r));
return;
}
onAdded();
} catch (err: unknown) {
setProblem({ detail: String(err) });
} finally {
setBusy(false);
}
}
return (
<Panel title={`allowed domain — ${agent}`} icon="🌐" onClose={onClose}>
<p>
Lets {agent} reach one more name through its hive's proxy, or every
agent when the box below is ticked. A name needs at least two labels,
such as <code>github.com</code>; a leading dot, as in{" "}
<code>.githubusercontent.com</code>, also allows every subdomain. IP
addresses and wildcards are refused. A name that resolves to a private
address stays blocked. The first entry makes the swarm's list replace
the names each hive's own configuration allows. The hive reads a change
within about 30 seconds.
</p>
<form class="ui-agent-limits-form" onSubmit={(e) => void save(e)}>
<TextField
id="allowed-domain"
label="domain"
value={domain}
placeholder="e.g. github.com"
onInput={setDomain}
/>
<CheckboxField
id="allowed-domain-swarm"
label="for every agent in the swarm"
checked={forSwarm}
onChange={setForSwarm}
/>
{problem ? (
<ApiErrorPanel context="the domain was not added" problem={problem} />
) : null}
<div class="ui-agent-limits-actions">
<Button
variant="primary"
type="submit"
disabled={busy || domain.trim() === ""}
>
{busy ? "adding…" : "add"}
</Button>
</div>
</form>
</Panel>
);
}

View file

@ -0,0 +1,52 @@
// The detail panel's allowed-domains editor: which entries one agent's panel
// shows and the bodies it sends. Kept out of the component so `npm test` can
// run it.
/** One entry as `GET /api/egress/allowed-domains` carries it. */
export interface AllowedDomain {
domain: string;
/** Absent for an entry that holds for every agent in the swarm. */
agent?: string;
}
export const ALLOWED_DOMAINS_URL = "/api/egress/allowed-domains";
/**
* The entries that apply to `agent`: the swarm-wide ones first, then the ones
* for this agent alone. Entries for other agents are not shown on this panel.
*/
export function entriesFor(
entries: AllowedDomain[],
agent: string,
): AllowedDomain[] {
const swarmWide = entries.filter((e) => e.agent === undefined);
const own = entries.filter((e) => e.agent === agent);
return [...swarmWide, ...own];
}
/** What the panel says about a list of entries. */
export function allowedSummary(shown: AllowedDomain[]): string {
if (shown.length === 0) return "none added";
const swarmWide = shown.filter((e) => e.agent === undefined).length;
const own = shown.length - swarmWide;
return [
swarmWide > 0 ? `${swarmWide} swarm-wide` : null,
own > 0 ? `${own} for this agent` : null,
]
.filter((part) => part !== null)
.join(", ");
}
/**
* The body that adds or removes `domain`, as typed: the route lower-cases it
* and refuses what is not a name. `forSwarm` makes it hold for every agent;
* otherwise it is for `agent` alone.
*/
export function entryBody(
domain: string,
forSwarm: boolean,
agent: string,
): AllowedDomain {
const name = domain.trim();
return forSwarm ? { domain: name } : { domain: name, agent };
}