Watch
0
0
Fork
You've already forked hyperhive
0

forge: external forge accounts live in swarm bao; the agent fetches them itself

An operator now links an agent's external forge account (label, base URL,
token) in the swarm UI. swarm-controller stores it at
swarm/agents/<agent>/forge/<label>. There is no index: the store's
listing of the agent's forge/ directory is the set of accounts.

In the agent, hive-agent-forge-accounts (oneshot + 2-minute timer, as
the agent user, under its own store certificate) lists
swarm/agents/<agent>/forge/ with the `list` #4866 grants an agent on its
own metadata subtree, reads each account, and writes
<state>/forge-<label>-token and forge-<label>.json in the names and shape
hive-forge -f already reads. An empty listing (a 404, which `bao kv list
-format=json` answers with `{}` and an empty stderr) is zero accounts; a
denial or an unreachable store fails the unit. It never deletes: files
for labels not listed, including ones the hive wrote, stay as they are.

Removed: the dashboard FORGES tab (credentials.js/html section and its
CSS), hive-c0re's extra_forges.rs and its routes, priv_client's
extra-forge calls, and hive-priv's WriteAgentExtraForgeAccount /
DeleteAgentExtraForgeAccount with their helpers. The GITHUB tab and
WriteAgentGithubToken stay.

Also: persistence.md's matrix avatar note names the exit-75 restart on a
changed account listing, not the dashboard, as what brings a linked
account up.

Refs #4348
This commit is contained in:
atlas 2026-10-01 17:50:24 +02:00
commit 2c7e586f47
27 changed files with 815 additions and 748 deletions

View file

@ -1,25 +1,17 @@
// CR3D3NTIALS page entry (/credentials.html).
//
// Operator surface to provision per-agent credentials without editing the
// agent's config repo. Two sub-tabs, sharing one agent picker:
// agent's config repo. One sub-tab and an agent picker:
// GITHUB — single-account PAT paste against /api/github-account
// (GET -> {present}, POST form-encoded {agent, token} ->
// {ok:true}; error_response shape on failure).
// No account name / homeserver / login mode, and no
// live/heartbeat concept for a static PAT — just present/absent.
// FORGES — external forge accounts, entirely dashboard-provisioned (no
// host-side config): GET /api/extra-forges?agent= lists the
// agent's stored {label, base_url} pairs, POST
// /api/extra-forge-account (form agent/label/base_url/token/
// action=add|remove) stores or removes one. No remote account
// creation — the operator makes the token on the external forge
// themselves and pastes it in, same trust model as GITHUB.
// Per-tab detail comments live next to their section below.
import { $, esc, renderServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import "@hive/shared/hive-tab-strip.js";
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
let agents = [];
@ -185,179 +177,10 @@ async function submitGithub(e) {
}
}
// ─── FORGES tab ─────────────────────────────────────────────────────────
// Entirely dashboard-provisioned, no host-side nix config: per-agent list
// (GET /api/extra-forges?agent=, derived from the agent's own
// forge-<label>-token files) + an add form (POST /api/extra-forge-account,
// form label/base_url/token, action=add) and a remove button per row
// (same POST, action=remove). No remote account creation — purely local
// bookkeeping for a token the operator already created on the external
// forge themselves.
async function loadForgeAccounts(agent) {
const list = $("ef-list");
if (!agent) {
list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its forge accounts."),
);
return;
}
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let forges;
try {
const resp = await fetch(
"/api/extra-forges?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
forges = (await resp.json()).forges || [];
} catch (err) {
list.replaceChildren(
el(
"p",
{ class: "err" },
"could not load forge accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
list.replaceChildren();
if (!forges.length) {
list.replaceChildren(
el("p", { class: "meta" }, "no forge accounts stored for this agent."),
);
return;
}
const ul = el("ul", { class: "ma-accounts" });
for (const forge of forges) {
const btn = el("button", { class: "btn", type: "button" }, "remove");
btn.addEventListener("click", () => onForgeRemoveClick(agent, forge, btn));
ul.append(
el(
"li",
{ class: "ma-account" },
el("span", { class: "ma-dot ok" }),
el("span", { class: "ma-name" }, forge.label),
el("span", { class: "ma-hs" }, forge.base_url || "—"),
el("span", { class: "ma-status ok" }, "token stored ✓"),
btn,
),
);
}
list.append(ul);
}
async function onForgeRemoveClick(agent, forge, btn) {
const r = await themedConfirm({
message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`,
danger: true,
confirmLabel: "⊘ remove",
});
if (!r) return;
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = "removing…";
try {
const resp = await fetch("/api/extra-forge-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
agent,
label: forge.label,
action: "remove",
}),
});
if (resp.ok) {
loadForgeAccounts(agent);
return;
}
const msg = problemMessage(await readApiError(resp));
btn.textContent = orig;
btn.disabled = false;
themedToast("✗ " + (msg || "remove failed (HTTP " + resp.status + ")"), {
type: "error",
});
} catch (err) {
btn.textContent = orig;
btn.disabled = false;
themedToast("✗ request failed: " + String(err), { type: "error" });
}
}
async function submitForgeAccount(e) {
e.preventDefault();
const formEl = e.target;
const out = $("ef-result");
out.className = "ma-result";
out.textContent = "";
const agent = $("ma-agent").value;
if (!agent) {
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set("agent", agent);
fd.set("action", "add");
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = "storing…";
try {
const resp = await fetch("/api/extra-forge-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
let body = {};
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = "ma-result ok";
out.textContent = "✓ forge account stored.";
clearSecrets(formEl);
loadForgeAccounts(agent);
} else {
out.className = "ma-result err";
out.textContent = "✗ store failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
// ─── init ─────────────────────────────────────────────────────────────
async function onAgentChange(agent) {
loadGithubStatus(agent);
loadForgeAccounts(agent);
}
async function init() {
@ -367,13 +190,9 @@ async function init() {
onAgentChange(e.target.value),
);
$("gh-form").addEventListener("submit", submitGithub);
$("ef-form").addEventListener("submit", submitForgeAccount);
document.getElementById("cred-tabbar").configure({
tabs: [
{ id: "github", label: "GITHUB" },
{ id: "forges", label: "FORGES" },
],
tabs: [{ id: "github", label: "GITHUB" }],
defaultId: "github",
});