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
202 lines
6.2 KiB
JavaScript
202 lines
6.2 KiB
JavaScript
// CR3D3NTIALS page entry (/credentials.html).
|
|
//
|
|
// Operator surface to provision per-agent credentials without editing the
|
|
// 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.
|
|
// 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 { readApiError, problemMessage } from "@hive/shared/api-error.js";
|
|
|
|
let agents = [];
|
|
|
|
async function loadState() {
|
|
try {
|
|
const resp = await fetch("/api/state");
|
|
if (!resp.ok) return;
|
|
const s = await resp.json();
|
|
renderServerWarnings(s.server_warnings);
|
|
// `/api/state` exposes the live roster under `containers` (each entry an
|
|
// object carrying `.name` + `.running`); there is no top-level `agents`
|
|
// field, so the picker stays compatible with both string + object shapes.
|
|
const containers = (s.containers || [])
|
|
.map((a) => (typeof a === "string" ? { name: a } : a))
|
|
.filter((c) => c && c.name);
|
|
agents = containers.map((c) => c.name).sort();
|
|
} catch {
|
|
// best-effort: on a failed state read the picker renders empty
|
|
// ("— no agents —") and the submit guard blocks until an agent is
|
|
// selected, rather than guessing a roster.
|
|
}
|
|
}
|
|
|
|
function renderAgentPicker() {
|
|
const sel = $("ma-agent");
|
|
sel.replaceChildren();
|
|
if (!agents.length) {
|
|
sel.append(el("option", { value: "" }, "— no agents —"));
|
|
return;
|
|
}
|
|
sel.append(el("option", { value: "" }, "— select agent —"));
|
|
for (const a of agents) sel.append(el("option", { value: a }, a));
|
|
}
|
|
|
|
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
|
|
// lives in `@hive/shared/api-error.js` now — `readApiError` +
|
|
// `problemMessage` (this page only needs the one-line message, not the
|
|
// full `ApiErrorPanel`; its result lines are single-line `aria-live`
|
|
// regions, not a swap-in-a-panel context). Was a local function here
|
|
// originally; promoted so swarm-ui shares the same
|
|
// shape-agnostic reader instead of each side maintaining its own copy.
|
|
|
|
function clearSecrets(formEl) {
|
|
formEl
|
|
.querySelectorAll('input[type="password"], input[name="token"]')
|
|
.forEach((i) => {
|
|
i.value = "";
|
|
});
|
|
}
|
|
|
|
// ─── GITHUB tab ─────────────────────────────────────────────────────────
|
|
|
|
async function loadGithubStatus(agent) {
|
|
const status = $("gh-status");
|
|
if (!agent) {
|
|
status.replaceChildren(
|
|
el(
|
|
"p",
|
|
{ class: "meta" },
|
|
"select an agent to see its github credential status.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
status.replaceChildren(el("p", { class: "meta" }, "loading…"));
|
|
let data;
|
|
try {
|
|
const resp = await fetch(
|
|
"/api/github-account?agent=" + encodeURIComponent(agent),
|
|
);
|
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
data = await resp.json();
|
|
} catch (err) {
|
|
status.replaceChildren(
|
|
el(
|
|
"p",
|
|
{ class: "err" },
|
|
"could not load status: " +
|
|
esc(String(err)) +
|
|
" (the backend endpoint may not be deployed yet).",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const present = !!data.present;
|
|
status.replaceChildren(
|
|
el(
|
|
"div",
|
|
{ class: "gh-status-line" },
|
|
el("span", { class: "gh-dot " + (present ? "present" : "absent") }),
|
|
el(
|
|
"span",
|
|
{ class: "gh-status-text " + (present ? "present" : "absent") },
|
|
present ? "token stored ✓" : "not set",
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function submitGithub(e) {
|
|
e.preventDefault();
|
|
const formEl = e.target;
|
|
const out = $("gh-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);
|
|
|
|
const btn = formEl.querySelector('button[type="submit"]');
|
|
const orig = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = "storing…";
|
|
|
|
try {
|
|
const resp = await fetch("/api/github-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 = "✓ token stored.";
|
|
clearSecrets(formEl);
|
|
loadGithubStatus(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);
|
|
}
|
|
|
|
async function init() {
|
|
await loadState();
|
|
renderAgentPicker();
|
|
$("ma-agent").addEventListener("change", (e) =>
|
|
onAgentChange(e.target.value),
|
|
);
|
|
$("gh-form").addEventListener("submit", submitGithub);
|
|
|
|
document.getElementById("cred-tabbar").configure({
|
|
tabs: [{ id: "github", label: "GITHUB" }],
|
|
defaultId: "github",
|
|
});
|
|
|
|
onAgentChange("");
|
|
}
|
|
|
|
init();
|