Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: show the accounts linked to each agent

GET /api/hives/{hive}/agents/{agent}/linked-accounts returns one row per
account linked to the agent, as kind, name and host: each matrix account
under swarm/agents/<agent>/matrix (with its homeserver, and the agent's own
`main` marked reserved), each forge label under swarm/agents/<agent>/forge
(with its url), and github when swarm/agents/<agent>/github-token exists
(host github.com, which is not stored). No credential field is in the
response type.

Listing those two directories needs a new controller grant: `list` on
secret/metadata/swarm/agents/+/matrix and .../+/forge only, pinned in
bao-grants.nix as the only metadata stanzas under agents/ beside the queue
revocation. Checked against a dev OpenBao 2.6.3: the grant lists those two
directories and is refused on agents/, agents/<agent>/, and a leaf.

The swarm UI agent detail panel shows all rows under "accounts"; the table
view's matrix column shows the matrix rows. The link badges stay.

Refs #4855
This commit is contained in:
atlas 2026-10-02 20:02:03 +02:00
commit 3380c1915f
13 changed files with 577 additions and 30 deletions

View file

@ -0,0 +1,92 @@
// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
// name, host. Reads `GET /api/hives/{hive}/agents/{agent}/linked-accounts`,
// which carries names and hosts only, never a credential.
//
// Fetched on mount and again whenever `version` changes; `AgentsPage` bumps
// it when a link dialog closes, so an account linked there shows up without
// waiting for a reload.
import { useEffect, useState } from "preact/hooks";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import "./LinkedAccounts.css";
export type AccountKind = "matrix" | "forgejo" | "github";
// Mirrors `linked_accounts::LinkedAccount`.
interface LinkedAccount {
kind: AccountKind;
name: string;
host: string | null;
reserved: boolean;
}
export function LinkedAccounts({
hive,
agent,
version,
kinds,
}: {
hive: string;
agent: string;
version: number;
/** Only these kinds; all of them when omitted. */
kinds?: AccountKind[];
}) {
const [accounts, setAccounts] = useState<LinkedAccount[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const data = (await r.json()) as LinkedAccount[];
if (cancelled) return;
setAccounts(data);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [hive, agent, version]);
if (error) {
return (
<Badge
tone="negative"
value="accounts unavailable"
title={error.detail ?? "listing linked accounts failed"}
/>
);
}
if (accounts === null) return <span class="ui-linked-accounts-muted">…</span>;
const shown = kinds
? accounts.filter((a) => kinds.includes(a.kind))
: accounts;
if (shown.length === 0) {
return <span class="ui-linked-accounts-muted">none linked</span>;
}
return (
<ul class="ui-linked-accounts">
{shown.map((a) => (
<li key={`${a.kind}/${a.name}`}>
<Badge label={a.kind} value={a.name} />
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
{a.reserved ? (
<Badge
value="own account"
title="the agent's own account, which the swarm mints"
/>
) : null}
</li>
))}
</ul>
);
}