Watch
0
0
Fork
You've already forked hyperhive
0

github: PATs live in swarm bao; the agent fetches them itself

An operator links an agent's GitHub personal access token in the swarm UI
(LinkGithubAccountForm, "link github account" on /agents). swarm-controller's
PUT /api/hives/{hive}/agents/{agent}/github-account stores it at
swarm/agents/<agent>/github-token (swarm_secret_client::github), a flat leaf
under the agent's prefix that the agent's existing read grant already covers:
no policy change, and no list grant, since there is one token per agent.

In the agent, hive-agent-github-token (oneshot + 2-minute timer, as the agent
user, under its own store certificate, ordered before hive-github-notify)
reads that path and writes <state>/github-token, 0600 and agent-owned, the
file the gh wrapper, git credential helper and hive-github-notify already
read. It replaces the file by rename only when the bytes changed and never
deletes it: a hive-written github-token stays until a token is linked in the
swarm UI. It is installed only with a store address and
services.hyperhive.agent.github.enable.

Removed: the dashboard's CR3D3NTIALS page (credentials.html/js/css, its
build entries and H0M3 tile; GITHUB was its only tab), hive-c0re's
dashboard/matrix_accounts.rs with GET/POST /api/github-account,
priv_client::write_agent_github_token, the host socket's
SetAgentGithubToken and `hivectl github set-token`, and hive-priv's
WriteAgentGithubToken with write_agent_state_file, its only caller gone.

Docs: integrations/github.md and swarm/ui.md describe the swarm path,
swarm/credentials.md gains the store-path row, and the hive UI docs,
hivectl docs and security.md's hive-priv table drop the removed pieces.

Closes #4347
This commit is contained in:
atlas 2026-10-02 17:48:27 +02:00
commit 8e23feb01b
41 changed files with 804 additions and 846 deletions

View file

@ -1,5 +1,5 @@
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (H0M3 at
// `/`, dashboard/flow/logs/core/builds/credentials at their own
// `/`, dashboard/flow/logs/core/builds at their own
// `.html`), each named after its own entry point below — see the
// `entryPoints`/`for` lists for the exact map, not repeated here to
// avoid this comment drifting out of sync with the real build steps.
@ -40,7 +40,6 @@ await build({
src("stats.js"),
src("core.js"),
src("builds.js"),
src("credentials.js"),
],
outdir: staticDir(""),
bundle: true,
@ -107,7 +106,6 @@ for (const entry of [
"stats.css",
"core.css",
"builds.css",
"credentials.css",
]) {
await build({
entryPoints: [src(entry)],
@ -126,7 +124,6 @@ for (const html of [
"stats.html",
"core.html",
"builds.html",
"credentials.html",
]) {
copyFileSync(src(html), dist(html));
}

View file

@ -1,94 +0,0 @@
/* CR3D3NTIALS page (/credentials.html) only. Page chrome
(.page-header / .page-back / .page-title) comes from the shared
chrome.css imported by common.css; base tab styling lives in
@hive/shared/tabs.css (.hive-tab*) same as /logs.html. This file holds
the account-list + provision-form styling specific to this surface
(`.ma-*` classes) plus the tab-strip layout delta + github-tab
additions (`.cred-*`). */
body.cred-shell {
margin: 0;
padding: 0;
}
/* Same layout delta as .logs-tabbar: fill the header row next to the
← home back-link, and let the flex:1 nav shrink below its intrinsic
width instead of wrapping onto its own row. */
.cred-tabbar {
flex: 1;
min-width: 0;
}
.cred-main {
max-width: 720px;
margin: 0 auto;
padding: 1.2em 1.25rem 3rem;
}
.cred-pane[hidden] {
display: none;
}
.gh-status {
margin: 0.5rem 0 1.2rem;
}
.gh-status-line {
display: flex;
align-items: center;
gap: 0.6rem;
}
.gh-dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
flex: none;
}
.gh-dot.present {
background: var(--green);
}
.gh-dot.absent {
background: var(--muted);
}
.gh-status-text.present {
color: var(--green);
}
.gh-status-text.absent {
color: var(--muted);
}
.ma-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0.55rem 0;
}
.ma-field > span {
font-size: 0.8rem;
color: var(--muted);
}
.ma-field input,
.ma-field select {
padding: 0.4rem 0.5rem;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
}
.ma-field input:focus,
.ma-field select:focus {
outline: none;
border-color: var(--purple);
}
.ma-result {
margin-top: 0.7rem;
font-size: 0.9rem;
min-height: 1.2em;
}
.ma-result.ok {
color: var(--green);
}
.ma-result.err {
color: var(--red);
}

View file

@ -1,87 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // CR3D3NTIALS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/credentials.css" />
</head>
<body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
logs.html (GITHUB instead of AGENT/INFRA/SYSTEM). Back
link points to the H0M3 hub (served at /). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar cred-tabbar"
id="cred-tabbar"
prefix="cred"
role="tablist"
></hive-tab-strip>
</header>
<main class="cred-main">
<!-- Agent picker: the selected agent drives the github status. -->
<h3>◇ agent</h3>
<label class="ma-field">
<span>agent</span>
<select id="ma-agent"></select>
</label>
<!-- GITHUB tab: single-account PAT paste. No login flow — the
operator pastes an existing PAT for a dedicated bot account.
Security-warning banner + a link to generate a PAT. -->
<section
class="cred-pane"
id="cred-pane-github"
data-tab-pane="github"
role="tabpanel"
aria-labelledby="cred-tab-github"
>
<hive-warn level="warning">
⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash;
and a <strong>minimally-scoped</strong> personal access token (only
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
<code>workflow</code>). the container boundary is the enforcement:
anything within the token's scopes is reachable if the agent is ever
compromised. the token is injected into the agent's state dir and is
<strong>never displayed back</strong> on this page.
</hive-warn>
<h3>◇ status</h3>
<div id="gh-status" class="gh-status">
<p class="meta">
select an agent to see its github credential status.
</p>
</div>
<h3>◇ provision</h3>
<p class="meta">
generate a token at
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener"
>github.com/settings/tokens</a
>
and paste it below. one account per agent &mdash; pasting a new token
replaces the stored one.
</p>
<form id="gh-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>personal access token</span>
<input type="password" name="token" autocomplete="off" required />
</label>
<button type="submit" class="btn btn-spawn">store token</button>
<p id="gh-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
</main>
<script type="module" src="/static/credentials.js" defer></script>
</body>
</html>

View file

@ -1,202 +0,0 @@
// 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();

View file

@ -90,16 +90,6 @@
<span class="home-tile-desc">kept state · container load</span>
</a>
<a class="home-tile" href="/credentials.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔑</span>
<span class="home-tile-label">Credentials</span>
</span>
<span class="home-tile-desc"
>provision per-agent github + forge accounts</span
>
</a>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
endpoints), so this tile is never gated/hidden. -->