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

1
Cargo.lock generated
View file

@ -1966,7 +1966,6 @@ dependencies = [
"anyhow", "anyhow",
"hive-priv-sock", "hive-priv-sock",
"libc", "libc",
"serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",

View file

@ -618,10 +618,10 @@ Idempotency is **per-account**: an `avatar-icon-hash` file in each
account's matrix-sdk `state_dir`. The daemon hashes the PNG bytes and account's matrix-sdk `state_dir`. The daemon hashes the PNG bytes and
skips the upload when unchanged, because every upload mints a fresh skips the upload when unchanged, because every upload mints a fresh
`mxc://` URI that emits a profile state event in every joined room — `mxc://` URI that emits a profile state event in every joined room —
re-uploading identical bytes is timeline spam. A dashboard-provisioned re-uploading identical bytes is timeline spam. A swarm-UI-linked
account gets its avatar when the `systemd.paths.hive-matrix-daemon` token account gets its avatar when the daemon restarts on a changed account
watcher restarts the daemon (which re-runs the per-account bring-up), so listing (exit 75, which re-runs the per-account bring-up), so no separate
no separate avatar trigger is needed. The daemon swallows avatar failures avatar trigger is needed. The daemon swallows avatar failures
(logged, non-fatal) so they never break account bring-up or sync. (logged, non-fatal) so they never break account bring-up or sync.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->

View file

@ -68,6 +68,7 @@ of the cell says how.
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires | | `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff | | `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer | | `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by linking the label again | ✅ the agent re-fetches on a 2-minute timer |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated | | `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs | | `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |

View file

@ -120,7 +120,7 @@ Forgejo CLI wrapper for hyperhive
###### **Options:** ###### **Options:**
* `-r`, `--repo <REPO>` — Repo to act on, as `owner/name` (default: inferred from the cwd's git `origin` remote, then `HIVE_FORGE_REPO`). Works with any verb * `-r`, `--repo <REPO>` — Repo to act on, as `owner/name` (default: inferred from the cwd's git `origin` remote, then `HIVE_FORGE_REPO`). Works with any verb
* `-f`, `--forge <FORGE>` — Act as a dashboard-provisioned external forge account (by its FORGES-tab label) instead of the internal forge. Independent of `-r/--repo` * `-f`, `--forge <FORGE>` — Act as an external forge account linked in the swarm UI (by its label) instead of the internal forge. Independent of `-r/--repo`
* `--json` — Emit JSON instead of the default human-readable output (for verbs that support both) * `--json` — Emit JSON instead of the default human-readable output (for verbs that support both)

View file

@ -281,7 +281,7 @@ for every operation that genuinely requires root.
known operations; there is no arbitrary command pass-through: known operations; there is no arbitrary command pass-through:
| Operation | What it runs | | Operation | What it runs |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` | | `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) | | `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
| `CreateContainer` / `UpdateContainer` | `nixos-container create/update <name> --flake <ref>` | | `CreateContainer` / `UpdateContainer` | `nixos-container create/update <name> --flake <ref>` |
@ -298,8 +298,7 @@ known operations; there is no arbitrary command pass-through:
| `ControlInfraContainer` | `systemctl <action> container@<container>.service` — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re` has no variant, so no request can name it) | | `ControlInfraContainer` | `systemctl <action> container@<container>.service` — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re` has no variant, so no request can name it) |
| `SyncAgentTmpfiles` | legacy: unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok`; kept one release for an older hive-c0re | | `SyncAgentTmpfiles` | legacy: unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok`; kept one release for an older hive-c0re |
| `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop | | `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop |
| `WriteAgentGithubToken` | write `0600` `github-token` into agent state dir (same semantics as the extra-forge account writes) | | `WriteAgentGithubToken` | write `0600` `github-token` into agent state dir |
| `WriteAgentExtraForgeAccount` / `DeleteAgentExtraForgeAccount` | write / remove `forge-<label>-token` + a `forge-<label>.json` base-URL sidecar, both `0600`. hive-priv validates `label` as a plain identifier before it reaches the filename — an unchecked one traverses out of the state dir |
| `RegisterCiRunner` | write `/run/hive-ci/runner-token` (host path, root-owned) then `systemctl --machine=hive-ci restart gitea-runner-hive.service`. Only the registration token crosses; the forge admin token never enters the container | | `RegisterCiRunner` | write `/run/hive-ci/runner-token` (host path, root-owned) then `systemctl --machine=hive-ci restart gitea-runner-hive.service`. Only the registration token crosses; the forge admin token never enters the container |
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs | | `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |
| `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first | | `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first |

View file

@ -296,7 +296,7 @@ on the H0M3 hub, same minimal chrome as `/logs.html` (a `← home` back-link
than `/core.html`'s plain title. Its own esbuild bundle than `/core.html`'s plain title. Its own esbuild bundle
(`credentials.js`); no SSE — it reads `/api/state` once for the (shared) (`credentials.js`); no SSE — it reads `/api/state` once for the (shared)
agent picker and otherwise works off purpose-built endpoints per tab. agent picker and otherwise works off purpose-built endpoints per tab.
Two sub-tabs: One sub-tab:
Link an external matrix account from the swarm UI (`LinkMatrixAccountForm` → swarm-controller). Accounts already linked through the old MATRIX tab keep working until the operator moves them to swarm level. Link an external matrix account from the swarm UI (`LinkMatrixAccountForm` → swarm-controller). Accounts already linked through the old MATRIX tab keep working until the operator moves them to swarm level.
@ -318,31 +318,7 @@ Provisioning posts `POST /api/github-account` (form-encoded `agent`,
`application/problem+json` with the message in `detail`. The token is never `application/problem+json` with the message in `detail`. The token is never
echoed back in either direction. echoed back in either direction.
### FORGES tab Link an external forge account from the swarm UI (`LinkForgeAccountForm` → swarm-controller); the agent fetches it into the files `hive-forge -f <label>` reads. Accounts already provisioned through the old FORGES tab keep working until the operator links them there.
Store a **label + base URL + access token** for an external Forgejo/Gitea/
Codeberg-compatible forge, per agent. Entirely dashboard-provisioned —
there is no host-side nix config for this (no `services.hyperhive.
extraForges` option). The operator creates the token on the external forge
themselves (however that forge lets them — PAT UI, a teammate with admin,
whatever) and pastes label/URL/token into the form; hive-c0re never talks
to the external forge's API and never creates an account there.
The selected agent's stored forges come from `GET /api/extra-forges?
agent=<name>` → `{ forges: [{ label, base_url }] }`, derived by scanning
the agent's state dir for `forge-<label>-token` files (mirrors the MATRIX
tab's filename-scan listing) with `base_url` backfilled from a sibling
`forge-<label>.json` sidecar. Submitting the add form posts `POST /api/
extra-forge-account` (form-encoded `agent, label, base_url, token,
action=add`) → `200 { ok: true }`, which writes both files through the
same privileged write path as the other tabs. Each row's `remove` button
opens a themed confirm dialog, then posts the same endpoint with
`action=remove`, deleting both local files — nothing changes on the
remote forge. The token is never echoed back in either direction.
A per-forge `hive-forge --forge <label>` CLI selector (to make `hive-forge`
target one of these accounts instead of the internal forge) is a
deliberate non-goal of this tab — tracked separately.
## P3RM1SS10NS tab ## P3RM1SS10NS tab

View file

@ -81,43 +81,6 @@ body.cred-shell {
border-color: var(--purple); border-color: var(--purple);
} }
.ma-accounts {
list-style: none;
padding: 0;
margin: 0;
}
.ma-account {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.2rem;
border-bottom: 1px solid var(--border);
}
.ma-dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
flex: none;
}
.ma-dot.ok {
background: var(--green);
}
.ma-name {
font-weight: 600;
color: var(--fg);
}
.ma-hs {
color: var(--muted);
font-size: 0.85rem;
}
.ma-status {
margin-left: auto;
font-size: 0.8rem;
}
.ma-status.ok {
color: var(--green);
}
.ma-result { .ma-result {
margin-top: 0.7rem; margin-top: 0.7rem;
font-size: 0.9rem; font-size: 0.9rem;

View file

@ -12,7 +12,7 @@
</head> </head>
<body class="cred-shell"> <body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as <!-- Minimal chrome: back link + sub-tab strip, same pattern as
logs.html (GITHUB / FORGES instead of AGENT/INFRA/SYSTEM). Back logs.html (GITHUB instead of AGENT/INFRA/SYSTEM). Back
link points to the H0M3 hub (served at /). --> link points to the H0M3 hub (served at /). -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
@ -25,8 +25,7 @@
</header> </header>
<main class="cred-main"> <main class="cred-main">
<!-- Agent picker: shared across the tabs (one agent selected at a <!-- Agent picker: the selected agent drives the github status. -->
time drives both the github status and the forge list). -->
<h3>◇ agent</h3> <h3>◇ agent</h3>
<label class="ma-field"> <label class="ma-field">
<span>agent</span> <span>agent</span>
@ -81,61 +80,6 @@
<p id="gh-result" class="ma-result" aria-live="polite"></p> <p id="gh-result" class="ma-result" aria-live="polite"></p>
</form> </form>
</section> </section>
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
Entirely dashboard-provisioned, no host-side nix config &mdash; same
shape as GITHUB plus a base-URL field.
The operator creates a token on the external forge themselves
(however that forge lets them) and pastes label + URL + token
below. No remote account minting/revoking &mdash; purely local. -->
<section
class="cred-pane"
id="cred-pane-forges"
data-tab-pane="forges"
role="tabpanel"
aria-labelledby="cred-tab-forges"
hidden
>
<p class="meta">
store a <strong>label + base URL + access token</strong> for an
external Forgejo/Gitea/Codeberg-compatible forge, per agent. no
account is created on the remote forge &mdash; create the token there
yourself first. the token is never displayed back on this page.
</p>
<h3>◇ provisioned forges</h3>
<div id="ef-list" class="ef-list">
<p class="meta">select an agent to see its forge accounts.</p>
</div>
<h3>◇ add forge account</h3>
<form id="ef-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>label</span>
<input
type="text"
name="label"
placeholder="e.g. codeberg"
required
/>
</label>
<label class="ma-field">
<span>base url</span>
<input
type="text"
name="base_url"
placeholder="https://codeberg.org"
required
/>
</label>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" required />
</label>
<button type="submit" class="btn btn-spawn">store account</button>
<p id="ef-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
</main> </main>
<script type="module" src="/static/credentials.js" defer></script> <script type="module" src="/static/credentials.js" defer></script>

View file

@ -1,25 +1,17 @@
// CR3D3NTIALS page entry (/credentials.html). // CR3D3NTIALS page entry (/credentials.html).
// //
// Operator surface to provision per-agent credentials without editing the // 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 // GITHUB — single-account PAT paste against /api/github-account
// (GET -> {present}, POST form-encoded {agent, token} -> // (GET -> {present}, POST form-encoded {agent, token} ->
// {ok:true}; error_response shape on failure). // {ok:true}; error_response shape on failure).
// No account name / homeserver / login mode, and no // No account name / homeserver / login mode, and no
// live/heartbeat concept for a static PAT — just present/absent. // 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. // Per-tab detail comments live next to their section below.
import { $, esc, renderServerWarnings } from "./common.js"; import { $, esc, renderServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js"; import { el } from "@hive/shared/dom.js";
import "@hive/shared/hive-tab-strip.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"; import { readApiError, problemMessage } from "@hive/shared/api-error.js";
let agents = []; 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 ───────────────────────────────────────────────────────────── // ─── init ─────────────────────────────────────────────────────────────
async function onAgentChange(agent) { async function onAgentChange(agent) {
loadGithubStatus(agent); loadGithubStatus(agent);
loadForgeAccounts(agent);
} }
async function init() { async function init() {
@ -367,13 +190,9 @@ async function init() {
onAgentChange(e.target.value), onAgentChange(e.target.value),
); );
$("gh-form").addEventListener("submit", submitGithub); $("gh-form").addEventListener("submit", submitGithub);
$("ef-form").addEventListener("submit", submitForgeAccount);
document.getElementById("cred-tabbar").configure({ document.getElementById("cred-tabbar").configure({
tabs: [ tabs: [{ id: "github", label: "GITHUB" }],
{ id: "github", label: "GITHUB" },
{ id: "forges", label: "FORGES" },
],
defaultId: "github", defaultId: "github",
}); });

View file

@ -0,0 +1,128 @@
// <LinkForgeAccountForm> — writes an external forge account (base URL +
// token) for one agent into the swarm secret store.
// PUTs `/api/hives/{hive}/agents/{agent}/forge-accounts/{label}` — 200
// (`{ url }`) on success, 400/500 as `problem+json`, shown via
// `ApiErrorPanel` like `LinkMatrixAccountForm`.
//
// The label is what the agent passes to `hive-forge -f <label>`. A blind
// set/update: no route lists linked accounts, and none hands a token back.
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import { Button } from "../ui/button/Button.js";
import "./LinkMatrixAccountForm.css";
// `hive_types::Ident`, which the server checks: `hive-forge -f` accepts
// nothing wider. A UX hint only; the server is the gate.
const LABEL_PATTERN = "[a-z0-9\\-]{1,63}";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "done"; url: string }
| { status: "error"; problem: ProblemDetails };
export function LinkForgeAccountForm({
hive,
agent,
onClose,
}: {
hive: string;
agent: string;
onClose?: () => void;
}) {
const [label, setLabel] = useState("");
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [result, setResult] = useState<SubmitState>({ status: "idle" });
async function submit(e: Event) {
e.preventDefault();
setResult({ status: "submitting" });
try {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/forge-accounts/${encodeURIComponent(label)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: url.trim(), token }),
},
);
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
const body = (await r.json().catch(() => ({}))) as { url?: string };
setResult({ status: "done", url: body.url ?? url.trim() });
// The store holds the token now; nothing here needs it.
setToken("");
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
}
return (
<Panel
title={`link a forge account — ${agent}`}
icon="🔗"
onClose={onClose}
>
<p>
Writes the account to the swarm secret store. The agent fetches it
within two minutes and can then use{" "}
<code>hive-forge -f {label || "<label>"}</code>. Create the token on the
external forge first; nothing is created there.
</p>
<form class="link-matrix-account-form" onSubmit={submit}>
<TextField
id="forge-account-label"
label="label"
value={label}
pattern={LABEL_PATTERN}
title="1-63 chars: lowercase letters, digits, hyphen"
placeholder="e.g. codeberg"
required
onInput={setLabel}
/>
<TextField
id="forge-account-url"
label="forge URL"
type="url"
value={url}
placeholder="https://codeberg.org"
required
onInput={setUrl}
/>
<TextField
id="forge-account-token"
label="access token"
type="password"
value={token}
required
onInput={setToken}
/>
<Button
variant="primary"
type="submit"
disabled={result.status === "submitting"}
>
{result.status === "submitting" ? "linking…" : "link account"}
</Button>
</form>
{result.status === "done" && (
<p class="link-matrix-account-result-ok">
linked <strong>{label}</strong> ({result.url}) to{" "}
<strong>{agent}</strong>
</p>
)}
{result.status === "error" && (
<ApiErrorPanel
context="failed to link the account"
problem={result.problem}
/>
)}
</Panel>
);
}

View file

@ -48,6 +48,7 @@ import { SplitView } from "../../ui/split-view/SplitView.js";
import { type TableColumn } from "../../ui/table/Table.js"; import { type TableColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.js"; import { CreateAgentForm } from "../CreateAgentForm.js";
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js"; import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
import { WantedMenu } from "./WantedMenu.js"; import { WantedMenu } from "./WantedMenu.js";
import "./AgentsPage.css"; import "./AgentsPage.css";
@ -152,6 +153,8 @@ export function AgentsPage() {
// confirmation of a `declareState` call, it's an unrelated action with // confirmation of a `declareState` call, it's an unrelated action with
// its own form (`LinkMatrixAccountForm`). // its own form (`LinkMatrixAccountForm`).
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null); const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
// The row showing the "link a forge account" dialog, same shape.
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
// Which agent the detail panel shows, *by name* — not the `AgentRow` // Which agent the detail panel shows, *by name* — not the `AgentRow`
// object itself. Storing the row would snapshot it at selection time; // object itself. Storing the row would snapshot it at selection time;
// `rows` replaces its whole array on every `refresh()` and every // `rows` replaces its whole array on every `refresh()` and every
@ -581,6 +584,22 @@ export function AgentsPage() {
: "no hive on record for this agent — nothing to link against" : "no hive on record for this agent — nothing to link against"
} }
/> />
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link forge account"
onClick={
detailTarget.hive
? () => setForgeTarget(detailTarget)
: undefined
}
disabled={!detailTarget.hive}
title={
detailTarget.hive
? `link an external forge account to ${detailTarget.name}`
: "no hive on record for this agent — nothing to link against"
}
/>
</div> </div>
{/* MVP scope per mara's own ruling: a small read-only {/* MVP scope per mara's own ruling: a small read-only
preview, no header/no input — the full terminal preview, no header/no input — the full terminal
@ -618,6 +637,20 @@ export function AgentsPage() {
/> />
) : null} ) : null}
</Dialog> </Dialog>
<Dialog
open={forgeTarget !== null}
onClose={() => setForgeTarget(null)}
label="link a forge account"
plain
>
{forgeTarget?.hive ? (
<LinkForgeAccountForm
hive={forgeTarget.hive}
agent={forgeTarget.name}
onClose={() => setForgeTarget(null)}
/>
) : null}
</Dialog>
<ConfirmDialog <ConfirmDialog
open={confirmTarget !== null} open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""} label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}

View file

@ -1,206 +0,0 @@
//! Dashboard-driven external (non-internal) Forgejo/Gitea/Codeberg-compatible
//! forge accounts, per agent. Entirely dashboard-provisioned — there is no
//! host-side nix config for these (see `nix/host-modules/hive-forge/`'s
//! removed `extraForges` option). The operator manually creates a token on
//! the external forge themselves (however that forge lets them: PAT UI, a
//! teammate with admin, whatever) and pastes a label + base URL + token into
//! the dashboard's FORGES tab, same shape as the GitHub PAT flow
//! (`post_github_account`) plus a base URL.
//!
//! No remote account minting, no admin API, no revoke-on-the-remote-side —
//! this module only ever touches the *local* agent state dir. hive-c0re
//! persists the token to `<state>/forge-<label>-token` (0600) and the base
//! URL to a `<state>/forge-<label>.json` sidecar (not secret, but kept next
//! to the token so both survive together) via hive-priv. Listing derives the
//! configured set from those files — there is no separate "catalog", since
//! there is no nix config to enumerate.
use std::path::Path;
use axum::extract::{Form, Query};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use super::{Ident, error_response};
use crate::coordinator::Coordinator;
#[derive(Deserialize)]
struct ForgeSidecar {
base_url: String,
}
/// Read the base URL an agent stashed for `label` from its
/// `forge-<label>.json` sidecar. `None` if the sidecar is missing or
/// unparseable (e.g. a token file left over from a partial/older write).
fn read_base_url(dir: &Path, label: &str) -> Option<String> {
let s = std::fs::read_to_string(dir.join(format!("forge-{label}.json"))).ok()?;
serde_json::from_str::<ForgeSidecar>(&s)
.ok()
.map(|s| s.base_url)
}
#[derive(Serialize, ToSchema)]
struct ExtraForgeAccount {
label: String,
base_url: Option<String>,
}
#[derive(Serialize, ToSchema)]
struct ExtraForgesResponse {
forges: Vec<ExtraForgeAccount>,
}
#[derive(Deserialize, IntoParams)]
pub(super) struct ExtraForgesQuery {
agent: String,
}
/// List the external forge
/// accounts currently provisioned for `agent`.
///
/// Derived from every `forge-<label>-token` file in its state dir. `base_url`
/// is backfilled from the matching `forge-<label>.json` sidecar when
/// present. Never returns a token.
#[utoipa::path(
get,
path = "/api/extra-forges",
params(ExtraForgesQuery),
responses(
(status = 200, description = "provisioned extra forge accounts for the agent", body = ExtraForgesResponse),
(status = 500, description = "invalid agent name, or a state-dir read failed"),
),
tag = "extra_forges"
)]
pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Response {
let agent = q.agent.trim();
let Ok(agent) = Ident::parse(agent) else {
return error_response(&format!("extra-forges: invalid agent {agent:?}"));
};
let dir = Coordinator::agent_notes_dir(&agent);
let mut forges = Vec::new();
match std::fs::read_dir(&dir) {
Ok(entries) => {
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_file()) {
continue;
}
let fname = entry.file_name();
let Some(fname) = fname.to_str() else {
continue;
};
// `forge-token` (no suffix) is the mandatory internal forge —
// not one of these dashboard-provisioned extra accounts.
let Some(label) = fname
.strip_prefix("forge-")
.and_then(|s| s.strip_suffix("-token"))
else {
continue;
};
if label.is_empty() {
continue;
}
forges.push(ExtraForgeAccount {
base_url: read_base_url(&dir, label),
label: label.to_owned(),
});
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return error_response(&format!("extra-forges: read {}: {e}", dir.display()));
}
}
forges.sort_by(|a, b| a.label.cmp(&b.label));
axum::Json(ExtraForgesResponse { forges }).into_response()
}
/// Form body for `POST /api/extra-forge-account` (urlencoded, the
/// dashboard's mutation convention). `action` is `"add"` (needs `base_url` +
/// `token`) or `"remove"`.
#[derive(Deserialize, ToSchema)]
pub(super) struct ExtraForgeAccountForm {
agent: String,
label: String,
action: String,
base_url: Option<String>,
token: Option<String>,
}
#[derive(Serialize, ToSchema)]
struct ExtraForgeAccountResult {
ok: bool,
}
/// Add persists the operator-pasted
/// label/base-URL/token to the agent's state dir via hive-priv; remove
/// deletes both files.
///
/// Purely local — no remote account creation or revocation, there is
/// no admin access assumed on the external forge.
/// Operator-authenticated (dashboard). Never echoes the token back.
#[utoipa::path(
post,
path = "/api/extra-forge-account",
request_body(content = ExtraForgeAccountForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "provisioned or removed", body = ExtraForgeAccountResult),
(status = 500, description = "invalid input, or the state-dir write/delete failed"),
),
tag = "extra_forges"
)]
pub(super) async fn post_extra_forge_account(Form(f): Form<ExtraForgeAccountForm>) -> Response {
let agent = f.agent.trim();
let label = f.label.trim();
let Ok(agent) = Ident::parse(agent) else {
return error_response(&format!("extra-forge-account: invalid agent {agent:?}"));
};
let Ok(label) = Ident::parse(label) else {
return error_response(&format!("extra-forge-account: invalid label {label:?}"));
};
match f.action.as_str() {
"add" => {
let base_url = f.base_url.as_deref().unwrap_or_default().trim();
let base_url = base_url.trim_end_matches('/');
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
return error_response(&format!(
"extra-forge-account: base_url must be an http(s) URL, got {base_url:?}"
));
}
let Some(token) = f.token.as_deref().filter(|t| !t.is_empty()) else {
return error_response("extra-forge-account: token is required");
};
if let Err(e) = crate::priv_client::write_agent_extra_forge_account(
agent.as_str(),
label.as_str(),
base_url,
token,
)
.await
{
return error_response(&format!(
"extra-forge-account: write account failed: {e:#}"
));
}
tracing::info!(%agent, %label, "extra-forge-account: provisioned");
}
"remove" => {
if let Err(e) =
crate::priv_client::delete_agent_extra_forge_account(agent.as_str(), label.as_str())
.await
{
return error_response(&format!(
"extra-forge-account: delete account failed: {e:#}"
));
}
tracing::info!(%agent, %label, "extra-forge-account: removed");
}
other => {
return error_response(&format!(
"extra-forge-account: unknown action {other:?} (want add|remove)"
));
}
}
axum::Json(ExtraForgeAccountResult { ok: true }).into_response()
}

View file

@ -37,7 +37,6 @@ use crate::lifecycle;
(name = "journal", description = "container + host journal reads"), (name = "journal", description = "container + host journal reads"),
(name = "approvals", description = "approve/deny pending approval rows"), (name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"), (name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "github account provisioning for agents"), (name = "matrix_accounts", description = "github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
@ -54,7 +53,6 @@ struct ApiDoc;
mod approvals; mod approvals;
mod build_logs; mod build_logs;
mod extra_forges;
// The single validated identifier type — homed in `hive-host-sock` (the crate // The single validated identifier type — homed in `hive-host-sock` (the crate
// owning agent-path facts) so every dashboard path-param validates through the // owning agent-path facts) so every dashboard path-param validates through the
// same type used to build agent paths. Re-exported so submodules + the socket // same type used to build agent paths. Re-exported so submodules + the socket
@ -143,8 +141,6 @@ pub async fn serve(
matrix_accounts::post_github_account, matrix_accounts::post_github_account,
matrix_accounts::get_github_account matrix_accounts::get_github_account
)) ))
.routes(routes!(extra_forges::get_extra_forges))
.routes(routes!(extra_forges::post_extra_forge_account))
.routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))
@ -379,8 +375,6 @@ mod router_build_probe {
matrix_accounts::post_github_account, matrix_accounts::post_github_account,
matrix_accounts::get_github_account matrix_accounts::get_github_account
)) ))
.routes(routes!(extra_forges::get_extra_forges))
.routes(routes!(extra_forges::post_extra_forge_account))
.routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))

View file

@ -388,37 +388,6 @@ pub async fn write_agent_github_token(agent_name: &str, token: &str) -> Result<(
.await?) .await?)
} }
/// Write a per-agent account for a dashboard-declared external forge —
/// label + base URL + token — to `<state>/forge-<label>-token` +
/// `<state>/forge-<label>.json` via hive-priv. Entirely dashboard-
/// provisioned, no host-side nix config; `label` is validated root-side as
/// a plain identifier before it reaches the filename.
pub async fn write_agent_extra_forge_account(
agent_name: &str,
label: &str,
base_url: &str,
token: &str,
) -> Result<()> {
ok(call(&PrivRequest::WriteAgentExtraForgeAccount {
agent_name: agent_name.to_owned(),
label: label.to_owned(),
base_url: base_url.to_owned(),
token: token.to_owned(),
})
.await?)
}
/// Remove a previously-added extra-forge account — the counterpart of
/// [`write_agent_extra_forge_account`]. Idempotent: missing files are not
/// an error.
pub async fn delete_agent_extra_forge_account(agent_name: &str, label: &str) -> Result<()> {
ok(call(&PrivRequest::DeleteAgentExtraForgeAccount {
agent_name: agent_name.to_owned(),
label: label.to_owned(),
})
.await?)
}
/// Register the hive-ci Forgejo Actions runner: hand the freshly-minted /// Register the hive-ci Forgejo Actions runner: hand the freshly-minted
/// registration token to hive-priv, which writes it to the host-side /// registration token to hive-priv, which writes it to the host-side
/// `/run/hive-ci/runner-token` env-file and restarts the in-container runner. /// `/run/hive-ci/runner-token` env-file and restarts the in-container runner.

View file

@ -9,7 +9,7 @@ never `curl` it directly.
Reads credentials from the environment (`HIVE_FORGE_URL`, Reads credentials from the environment (`HIVE_FORGE_URL`,
`HYPERHIVE_STATE_DIR`); `-f/--forge <label>` retargets a `HYPERHIVE_STATE_DIR`); `-f/--forge <label>` retargets a
dashboard-provisioned external forge account instead. The active repo swarm-UI-linked external forge account instead. The active repo
resolves `-r/--repo` > the `origin` remote of the cwd's git checkout > resolves `-r/--repo` > the `origin` remote of the cwd's git checkout >
`HIVE_FORGE_REPO` (last-resort override, unset by default) > a hard `HIVE_FORGE_REPO` (last-resort override, unset by default) > a hard
error — see `client::Client::from_env`. error — see `client::Client::from_env`.

View file

@ -84,7 +84,7 @@ impl Client {
/// `json_mode` comes from the global `--json` flag — per-verb /// `json_mode` comes from the global `--json` flag — per-verb
/// output formatters key off it via `Client::json_mode`. /// output formatters key off it via `Client::json_mode`.
/// `forge_label` (from the global `-f/--forge` flag) targets a /// `forge_label` (from the global `-f/--forge` flag) targets a
/// dashboard-provisioned external forge account instead of the /// swarm-UI-linked external forge account instead of the
/// internal forge — see [`resolve_credentials`]. /// internal forge — see [`resolve_credentials`].
pub fn from_env( pub fn from_env(
repo_override: Option<String>, repo_override: Option<String>,
@ -398,18 +398,17 @@ pub fn index(n: u64) -> Result<i64> {
/// Resolve the `(base_url, token)` pair the client authenticates with. /// Resolve the `(base_url, token)` pair the client authenticates with.
/// `None` (the default) resolves the internal forge exactly as before: /// `None` (the default) resolves the internal forge exactly as before:
/// `HIVE_FORGE_URL` (default [`DEFAULT_URL`]) + `read_token()`. /// `HIVE_FORGE_URL` (default [`DEFAULT_URL`]) + `read_token()`.
/// `Some(label)` (from `-f/--forge <label>`) instead resolves a /// `Some(label)` (from `-f/--forge <label>`) instead resolves an
/// dashboard-provisioned external forge account: the token comes from /// external forge account linked in the swarm UI: the token comes from
/// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` and the base URL from /// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` and the base URL from
/// the `base_url` key of the sibling `forge-<label>.json` sidecar — /// the `base_url` key of the sibling `forge-<label>.json` sidecar —
/// the exact same two files `dashboard/extra_forges.rs` writes, so the /// the two files `nix/agent-modules/forge-accounts.nix` writes. An
/// read side can't drift from the write side. An unknown label (either /// unknown label (either file missing) is a clear error listing the
/// file missing) is a clear error listing the labels actually found in /// labels actually found in the state dir, not a raw file-not-found. A
/// the state dir, not a raw file-not-found. A label outside the plain /// label outside the plain identifier charset the swarm controller
/// identifier charset the dashboard accepts (e.g. a typo containing /// accepts (e.g. a typo containing `/` or `..`) is rejected up front
/// `/` or `..`) is rejected up front with the same charset spelled out, /// with the same charset spelled out, rather than silently building a
/// rather than silently building a nonsense/traversing path and /// nonsense/traversing path and surfacing a confusing file error later.
/// surfacing a confusing file error later.
pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> { pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> {
let Some(label) = forge_label else { let Some(label) = forge_label else {
let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned()); let base = std::env::var("HIVE_FORGE_URL").unwrap_or_else(|_| DEFAULT_URL.to_owned());
@ -419,8 +418,8 @@ pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String,
if !is_plain_ident(label) { if !is_plain_ident(label) {
bail!( bail!(
"hive-forge: invalid --forge label {label:?} — must be lowercase \ "hive-forge: invalid --forge label {label:?} — must be lowercase \
letters, digits, and hyphens only (same rule the dashboard's \ letters, digits, and hyphens only (same rule the swarm UI's \
FORGES tab enforces)" link form enforces)"
); );
} }
@ -445,30 +444,29 @@ pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String,
bail!("hive-forge: no such forge {label:?} — provisioned forges: {known}"); bail!("hive-forge: no such forge {label:?} — provisioned forges: {known}");
} }
/// Plain-identifier check matching `dashboard/extra_forges.rs`'s /// Plain-identifier check matching the label rule of swarm-controller's
/// `is_plain_ident` (itself matching hive-priv's `validate_name_chars`) /// `put_forge_account` (`hive_types::Ident`) — lowercase ascii, digits and
/// — lowercase ascii + digits + hyphens only. Rejecting anything else /// hyphens only. Rejecting anything else up front (rather than just
/// up front (rather than just letting a weird label fail to resolve a /// letting a weird label fail to resolve a file) turns a confusing "no
/// file) turns a confusing "no such forge" surprise into a precise /// such forge" surprise into a precise "that's not a valid label" one,
/// "that's not a valid label" one, and incidentally means a label like /// and incidentally means a label like `../../etc` can't be used to
/// `../../etc` can't be used to build a path outside the state dir. /// build a path outside the state dir.
fn is_plain_ident(s: &str) -> bool { fn is_plain_ident(s: &str) -> bool {
!s.is_empty() !s.is_empty()
&& s.chars() && s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
} }
/// Sidecar shape `dashboard/extra_forges.rs` writes alongside each /// Sidecar shape `nix/agent-modules/forge-accounts.nix` writes alongside
/// `forge-<label>-token` file: just the base URL, pinned to the same /// each `forge-<label>-token` file: just the base URL, under the
/// `base_url` JSON key the write side uses. /// `base_url` key that unit's `jq` filter spells.
#[derive(Deserialize)] #[derive(Deserialize)]
struct ForgeSidecar { struct ForgeSidecar {
base_url: String, base_url: String,
} }
/// Scan the state dir for every `forge-<label>-token` file (mirroring /// Scan the state dir for every `forge-<label>-token` file and return
/// `dashboard/extra_forges.rs`'s own listing logic) and return the /// the labels found, sorted. Used to build a helpful "did you mean one of
/// labels found, sorted. Used to build a helpful "did you mean one of
/// these" error when `--forge <label>` doesn't resolve. Best-effort: /// these" error when `--forge <label>` doesn't resolve. Best-effort:
/// an unreadable state dir yields an empty list rather than erroring /// an unreadable state dir yields an empty list rather than erroring
/// (the caller already has its own error to report). /// (the caller already has its own error to report).

View file

@ -8,7 +8,7 @@
//! cwd's git checkout > `HIVE_FORGE_REPO` (last-resort override, unset //! cwd's git checkout > `HIVE_FORGE_REPO` (last-resort override, unset
//! by default) > a hard error — see `client::Client::from_env`. //! by default) > a hard error — see `client::Client::from_env`.
//! //!
//! The global `-f/--forge <label>` flag targets a dashboard-provisioned //! The global `-f/--forge <label>` flag targets a swarm-UI-linked
//! external forge account instead: it reads `forge-<label>-token` + //! external forge account instead: it reads `forge-<label>-token` +
//! `forge-<label>.json` (base URL) from the state dir rather than //! `forge-<label>.json` (base URL) from the state dir rather than
//! `HIVE_FORGE_URL`/`forge-token`. See `client::Client::from_env`. //! `HIVE_FORGE_URL`/`forge-token`. See `client::Client::from_env`.
@ -45,9 +45,8 @@ struct Cli {
/// any verb. /// any verb.
#[arg(short = 'r', long, global = true)] #[arg(short = 'r', long, global = true)]
repo: Option<String>, repo: Option<String>,
/// Act as a dashboard-provisioned external forge account (by its /// Act as an external forge account linked in the swarm UI (by its
/// FORGES-tab label) instead of the internal forge. Independent of /// label) instead of the internal forge. Independent of `-r/--repo`.
/// `-r/--repo`.
#[arg(short = 'f', long, global = true)] #[arg(short = 'f', long, global = true)]
forge: Option<String>, forge: Option<String>,
/// Emit JSON instead of the default human-readable output (for verbs /// Emit JSON instead of the default human-readable output (for verbs

View file

@ -494,43 +494,6 @@ pub enum PrivRequest {
token: String, token: String,
}, },
/// Write a per-agent account for an external, dashboard-declared forge:
/// the access token to
/// `AGENT_STATE_ROOT/<agent_name>/state/forge-<label>-token` (0600) and
/// a `forge-<label>.json` sidecar (`{"base_url": <base_url>}`, 0600) so
/// the base URL survives without any host-side nix config — the whole
/// account (label + URL + token) is operator-entered on the dashboard.
///
/// `label` MUST be validated as a plain identifier before it goes into
/// the filename — a crafted label could otherwise traverse out of the
/// state dir. Same write semantics as `WriteAgentGithubToken` — validates
/// `agent_name`, creates the state dir if absent, writes both files 0600,
/// chowns to the agent.
WriteAgentExtraForgeAccount {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Dashboard-chosen label for this external forge. Validated as a
/// plain identifier before use.
label: String,
/// Base HTTP(S) URL of the external forge, operator-entered on the
/// dashboard (no host-side config).
base_url: String,
/// Token value. hive-priv appends a trailing newline before writing.
token: String,
},
/// Remove a previously-written `forge-<label>-token` + `forge-<label>.
/// json` from an agent's state dir — the revoke half of
/// `WriteAgentExtraForgeAccount`. Missing files are not an error
/// (idempotent revoke).
DeleteAgentExtraForgeAccount {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// The forge label to revoke. Validated as a plain identifier
/// before use.
label: String,
},
/// Register the hive-ci Forgejo Actions runner: write the registration /// Register the hive-ci Forgejo Actions runner: write the registration
/// token to the host-side `/run/hive-ci/runner-token` env-file (root-owned, /// token to the host-side `/run/hive-ci/runner-token` env-file (root-owned,
/// bind-mounted read-only into the container) as `TOKEN=<token>`, then /// bind-mounted read-only into the container) as `TOKEN=<token>`, then

View file

@ -11,7 +11,6 @@ workspace = true
anyhow.workspace = true anyhow.workspace = true
hive-priv-sock.workspace = true hive-priv-sock.workspace = true
libc.workspace = true libc.workspace = true
serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true

View file

@ -27,7 +27,6 @@ use hive_priv_sock::{
PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
PrivStreamLine, SIBLING_CONTAINERS, PrivStreamLine, SIBLING_CONTAINERS,
}; };
use serde::Serialize;
use tokio::io::{AsyncWriteExt, BufReader}; use tokio::io::{AsyncWriteExt, BufReader};
use tokio::net::unix::OwnedWriteHalf; use tokio::net::unix::OwnedWriteHalf;
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
@ -413,18 +412,6 @@ async fn exec(
ref token, ref token,
} => write_github_token(agent_name, token), } => write_github_token(agent_name, token),
PrivRequest::WriteAgentExtraForgeAccount {
ref agent_name,
ref label,
ref base_url,
ref token,
} => write_extra_forge_account(agent_name, label, base_url, token),
PrivRequest::DeleteAgentExtraForgeAccount {
ref agent_name,
ref label,
} => delete_extra_forge_account(agent_name, label),
PrivRequest::RegisterCiRunner { ref token } => register_ci_runner(token).await, PrivRequest::RegisterCiRunner { ref token } => register_ci_runner(token).await,
PrivRequest::ControlInfraContainer { container, action } => { PrivRequest::ControlInfraContainer { container, action } => {
@ -523,29 +510,6 @@ async fn exec_forge_admin(args: &[String]) -> Result<(String, String)> {
run_forge_admin(args).await run_forge_admin(args).await
} }
/// `WriteAgentExtraForgeAccount`: writes the token, then a
/// `forge-<label>.json` sidecar carrying the base URL — there's no
/// host-side nix config for extra forges, so this is the only place it's
/// persisted.
fn write_extra_forge_account(
agent_name: &str,
label: &str,
base_url: &str,
token: &str,
) -> Result<(String, String)> {
validate_agent_name(agent_name)?;
validate_name_chars(label)?;
let res = write_agent_state_file(
agent_name,
&format!("forge-{label}-token"),
&format!("{token}\n"),
)?;
let meta = serde_json::to_string(&ForgeSidecar { base_url })
.context("serialize forge account sidecar")?;
write_agent_state_file(agent_name, &format!("forge-{label}.json"), &meta)?;
Ok(res)
}
/// `StopContainer`. /// `StopContainer`.
async fn stop_container(name: &str) -> Result<(String, String)> { async fn stop_container(name: &str) -> Result<(String, String)> {
validate_agent_name(name)?; validate_agent_name(name)?;
@ -589,15 +553,6 @@ fn write_github_token(agent_name: &str, token: &str) -> Result<(String, String)>
write_agent_state_file(agent_name, "github-token", &format!("{token}\n")) write_agent_state_file(agent_name, "github-token", &format!("{token}\n"))
} }
/// `DeleteAgentExtraForgeAccount`. Missing files are not an error
/// (idempotent revoke).
fn delete_extra_forge_account(agent_name: &str, label: &str) -> Result<(String, String)> {
validate_agent_name(agent_name)?;
validate_name_chars(label)?;
delete_agent_state_file(agent_name, &format!("forge-{label}-token"))?;
delete_agent_state_file(agent_name, &format!("forge-{label}.json"))
}
/// `EnsureAgentSubvolume`. /// `EnsureAgentSubvolume`.
async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> { async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> {
validate_agent_name(agent_name)?; validate_agent_name(agent_name)?;
@ -1531,18 +1486,6 @@ fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)
staged.publish() staged.publish()
} }
/// Sidecar written alongside a dashboard-provisioned extra forge
/// account's token (`forge-<label>.json`) so `hive-forge` can resolve
/// the account's base URL. Read side: `hive-forge/src/client.rs`'s own
/// (separately defined, deserialize-only) `ForgeSidecar` — same field
/// name (`base_url`), no shared crate between `hive-priv` and
/// `hive-forge` to hang a common type off, so the two structs are
/// pinned to the same JSON key by convention, not by the compiler.
#[derive(Serialize)]
struct ForgeSidecar<'a> {
base_url: &'a str,
}
/// Shared helper for the `WriteAgent*Token` requests. /// Shared helper for the `WriteAgent*Token` requests.
/// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`, /// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
/// chowns to the agent user (derived from the state dir's existing owner), /// chowns to the agent user (derived from the state dir's existing owner),
@ -1675,28 +1618,6 @@ fn write_agent_dir_file(
Ok((String::new(), String::new())) Ok((String::new(), String::new()))
} }
/// Remove `AGENT_STATE_ROOT/<agent_name>/state/<filename>` if present.
/// Idempotent revoke counterpart to [`write_agent_state_file`] — a
/// missing file is success, not an error. `filename` must be a single
/// plain component (no `/`, `.`, `..`); callers pass a pre-validated
/// label into a fixed `forge-<label>-token` shape, same as the write
/// side.
fn delete_agent_state_file(agent_name: &str, filename: &str) -> Result<(String, String)> {
ensure_plain_filename("delete_agent_state_file", filename)?;
let path = PathBuf::from(AGENT_STATE_ROOT)
.join(agent_name)
.join("state")
.join(filename);
match std::fs::remove_file(&path) {
Ok(()) => {
tracing::info!(agent = %agent_name, file = %filename, "removed agent state file");
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("remove {}", path.display())),
}
Ok((String::new(), String::new()))
}
/// btrfs superblock magic, as reported by `statfs(2)`'s `f_type`. /// btrfs superblock magic, as reported by `statfs(2)`'s `f_type`.
const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E; const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E;

View file

@ -46,6 +46,7 @@ in
./dashboard-links.nix ./dashboard-links.nix
./docs.nix ./docs.nix
./forge.nix ./forge.nix
./forge-accounts.nix
./forge-token.nix ./forge-token.nix
./frontend.nix ./frontend.nix
./github.nix ./github.nix

View file

@ -0,0 +1,191 @@
# This agent's accounts on external forges, fetched from the swarm secret store
# by the agent itself, into the files `hive-forge -f <label>` reads.
#
# An operator links an account in the swarm UI; `swarm-controller` stores it at
# `swarm/agents/<agent>/forge/<label>` (`swarm_secret_client::forge`). The
# agent's grant lists and reads its own subtree, so this unit lists
# `swarm/agents/<agent>/forge/`, reads each account, and writes
# `<state>/forge-<label>-token` and `<state>/forge-<label>.json`
# (`{"base_url":…}`), the two files `hive-forge`'s `resolve_credentials` reads.
#
# It never deletes. A `forge-<label>` pair for a label not listed is left
# as it is, whoever wrote it, and so is the pair of a label whose read fails.
# A file is replaced by rename, and only when its bytes changed.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.agent.bao;
agentName = config.services.hyperhive.agent.user.name;
stateDir = "/agents/${agentName}/state";
# The same three ids ./bao.nix and ./forge-token.nix load.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-forge-accounts";
# The nix half of `swarm_secret_client::forge::accounts_dir` plus
# `path::MOUNT`.
accountsDir = "secret/swarm/agents/${agentName}/forge";
runtimeDir = unitName;
# The store's whole answer for one account, token included: kept in the
# unit's own `0700` directory, never in the state dir.
rawFile = "/run/${runtimeDir}/account.json";
listFile = "/run/${runtimeDir}/list.json";
errFile = "/run/${runtimeDir}/bao.err";
configured = cfg.addr != null;
storeRetry = import ../host-modules/lib/store-retry.nix { };
in
{
config = lib.mkIf configured {
systemd.services.${unitName} = {
description = "fetch this agent's external forge accounts from the secret store";
after = [
"network.target"
"hive-agent-bao-identity.service"
];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.openbao
pkgs.coreutils
pkgs.diffutils
pkgs.jq
];
# ../host-modules/lib/store-retry.nix.
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
# Not `RemainAfterExit`, so the timer below can start it again.
RemainAfterExit = false;
TimeoutStartSec = 60;
User = agentName;
Group = agentName;
RuntimeDirectory = runtimeDir;
RuntimeDirectoryMode = "0700";
# `0600`, the mode the files in the state dir have always had.
UMask = "0077";
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
};
script = ''
set -euo pipefail
# No identity delivered: ./bao.nix's check reports that.
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then
echo "this agent has no store identity, so it cannot fetch its external forge accounts." >&2
exit 0
fi
done
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
fi
err=${lib.escapeShellArg errFile}
raw=${lib.escapeShellArg rawFile}
list=${lib.escapeShellArg listFile}
trap 'rm -f "$err" "$raw" "$list"' EXIT
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "the swarm secret store at $BAO_ADDR did not accept this agent's certificate login:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
export BAO_TOKEN
# An empty directory is a 404, which `bao` answers with `{}` on stdout
# and nothing on stderr; a denial or an unreachable store prints
# nothing on stdout.
if ! bao kv list -format=json ${lib.escapeShellArg accountsDir} >"$list" 2>"$err"; then
if [ ! -s "$err" ] && jq -e '. == {}' "$list" >/dev/null 2>&1; then
echo "no external forge accounts are linked to this agent (nothing under ${accountsDir})."
exit 0
fi
echo "could not list ${accountsDir}:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
if ! jq -e 'arrays' "$list" >/dev/null; then
echo "listing ${accountsDir} returned no array of names." >&2
exit 1
fi
# Moves $1 over $2 and succeeds when the bytes differ; drops $1 otherwise.
replace() {
if cmp -s "$1" "$2"; then
rm -f "$1"
return 1
fi
mv -f "$1" "$2"
}
# One account that cannot be read is logged and skipped. It does not
# stop the others, and failing the unit would only restart it into the
# same answer.
while IFS= read -r label; do
# The label becomes a file name, and `hive-forge -f` names only these.
# A key ending in `/` is a directory below this one, not an account.
if [[ ! "$label" =~ ^[a-z0-9-]+$ ]]; then
echo "skipping listed key $(printf '%q' "$label"): not a label hive-forge -f can name." >&2
continue
fi
path="${accountsDir}/$label"
if ! bao kv get -format=json "$path" >"$raw" 2>"$err"; then
echo "could not read $path; forge-$label files left as they are:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
continue
fi
# ⚠️ The token goes from the store's answer straight into a file;
# it is never in a variable or an argument.
token=${lib.escapeShellArg stateDir}/forge-$label-token
sidecar=${lib.escapeShellArg stateDir}/forge-$label.json
staged_token=${lib.escapeShellArg stateDir}/.forge-$label-token.new
staged_sidecar=${lib.escapeShellArg stateDir}/.forge-$label.json.new
rm -f "$staged_token" "$staged_sidecar"
if ! jq -er '.data.data.value | strings' "$raw" >"$staged_token" \
|| ! jq -cje '{base_url: (.data.data.url | strings)}' "$raw" >"$staged_sidecar"; then
echo "$path holds no string value and url; forge-$label files left as they are." >&2
rm -f "$staged_token" "$staged_sidecar"
continue
fi
changed=
replace "$staged_token" "$token" && changed=1
replace "$staged_sidecar" "$sidecar" && changed=1
if [ -n "$changed" ]; then
echo "fetched external forge account $label from $path."
fi
done < <(jq -r '.[]' "$list")
'';
};
# The same cadence as hive-matrix-daemon's re-listing of its own accounts.
systemd.timers.${unitName} = {
description = "re-fetch this agent's external forge accounts from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "2min";
RandomizedDelaySec = "20s";
};
};
};
}

View file

@ -112,15 +112,6 @@ in
# `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives # `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
# restart. See `docs/networking/gateway.md::hive-forge container shape`. # restart. See `docs/networking/gateway.md::hive-forge container shape`.
# External Forgejo/Gitea/Codeberg-compatible forges (beyond the mandatory
# internal one) are entirely dashboard-provisioned — no nix config here.
# An operator manually creates a token on the external forge (however
# that forge lets them) and pastes name + base URL + token into the
# dashboard's FORGES tab; hive-c0re just persists it to
# `<state>/forge-<label>-token` + a `<state>/forge-<label>.json` sidecar
# (base URL), the same shape as the GitHub PAT / matrix extra-account
# flows. See `hive-c0re/src/dashboard/extra_forges.rs`.
imports = [ ./service.nix ]; imports = [ ./service.nix ];
# What ./service.nix declares is what the forge IS from any hive's point of # What ./service.nix declares is what the forge IS from any hive's point of

View file

@ -42,6 +42,7 @@ let
}; };
fetchUnit = machine: machine.systemd.services.hive-agent-forge-token; fetchUnit = machine: machine.systemd.services.hive-agent-forge-token;
accountsUnit = machine: machine.systemd.services.hive-agent-forge-accounts;
tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile; tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile;
in in
let let
@ -164,6 +165,60 @@ let
&& !(agentForgeNoBao.systemd.services ? tea-login) && !(agentForgeNoBao.systemd.services ? tea-login)
&& !(builtins.elem pkgs.tea agentForgeBao.environment.systemPackages); && !(builtins.elem pkgs.tea agentForgeBao.environment.systemPackages);
} }
{
name = "an agent with a store address fetches its external forge accounts, and one without does not";
ok =
agentForgeBao.systemd.services ? hive-agent-forge-accounts
&& agentForgeBao.systemd.timers ? hive-agent-forge-accounts
&& !(agentForgeNoBao.systemd.services ? hive-agent-forge-accounts)
&& !(agentForgeNoBao.systemd.timers ? hive-agent-forge-accounts);
}
{
# The nix half of `swarm_secret_client::forge::{accounts_dir,account_path}`,
# and the two file names `hive-forge -f` reads.
name = "the account fetch lists the agent's own accounts and reads each into hive-forge's files";
ok =
let
name = agentForgeBao.services.hyperhive.agent.user.name;
s = (accountsUnit agentForgeBao).script;
in
lib.hasInfix "bao kv list -format=json secret/swarm/agents/${name}/forge >" s
&& !(lib.hasInfix "/index/" s)
&& lib.hasInfix "path=\"secret/swarm/agents/${name}/forge/$label\"" s
&& lib.hasInfix "/agents/${name}/state" s
&& lib.hasInfix "/forge-$label-token" s
&& lib.hasInfix "/forge-$label.json" s
&& lib.hasInfix "{base_url: (.data.data.url | strings)}" s;
}
{
# The agent user owns its state dir (./user.nix), and the files keep
# the `0600` they have always had.
name = "the account fetch runs as the agent, with its own store identity";
ok =
let
u = accountsUnit agentForgeBao;
name = agentForgeBao.services.hyperhive.agent.user.name;
in
u.serviceConfig.User == name
&& u.serviceConfig.UMask == "0077"
&& builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert";
}
{
# Files a hive wrote keep working until the operator re-links them.
name = "the account fetch never deletes a state-dir file it did not stage";
ok =
let
s = (accountsUnit agentForgeBao).script;
in
!(lib.hasInfix "rm -f \"$token\"" s)
&& !(lib.hasInfix "rm -f \"$sidecar\"" s)
&& !(lib.hasInfix "forge-*" s);
}
{
name = "the account fetch re-runs every two minutes";
ok = agentForgeBao.systemd.timers.hive-agent-forge-accounts.timerConfig.OnUnitInactiveSec == "2min";
}
]; ];
in in
runGroup "agent-forge-bao" cases runGroup "agent-forge-bao" cases

View file

@ -0,0 +1,200 @@
//! An agent's accounts on external forges: an operator hands us a base URL and
//! a token, we put them in the swarm's secret store.
//!
//! The agent end is `nix/agent-modules/forge-accounts.nix`, which lists the
//! agent's accounts and reads each under the agent's own certificate, and writes
//! the files `hive-forge -f <label>` reads. No hive is in the path.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use swarm_secret_client::forge;
use utoipa::ToSchema;
use super::{AppState, error_problem, swarm_hive};
/// The account to store for one agent's external forge.
///
/// No `Debug` derive: this carries a token.
#[derive(Deserialize, ToSchema)]
pub struct PutForgeAccountRequest {
/// The forge's base URL, `http://` or `https://`. A trailing slash is
/// dropped.
#[schema(example = "https://codeberg.org")]
url: String,
/// The access token. Never logged, and never returned by this route.
token: String,
}
/// `put_forge_account`'s success body.
#[derive(Debug, Serialize, ToSchema)]
pub struct PutForgeAccountResponse {
/// The base URL as stored.
url: String,
}
/// Store an agent's external forge account.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
/// account the agent will next read rather than adding a second one.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the account belongs to"),
("label" = String, Path, description = "the name the agent passes to `hive-forge -f`"),
),
request_body = PutForgeAccountRequest,
responses(
(status = 200, description = "stored", body = PutForgeAccountResponse),
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
(status = 500, description = "the store write failed (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn put_forge_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutForgeAccountRequest>,
) -> Result<Json<PutForgeAccountResponse>, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
// `hive-forge` accepts only `[a-z0-9-]` after `-f`, narrower than the
// store's charset, so a label it would refuse is refused here.
let label = hive_types::Ident::parse(&label)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let secret_path = forge::account_path(&agent, &label)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let account = account(req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, e))?;
let store = crate::store::connect().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
store.write(&secret_path, &account).await.map_err(|e| {
// The path names the agent and the label; the value is not in it.
tracing::warn!(path = %secret_path, error = %e, "writing the forge account failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
Ok(Json(PutForgeAccountResponse { url: account.url }))
}
/// The request as it is stored, or why it cannot be.
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
let url = req.url.trim().trim_end_matches('/');
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("url must start with http:// or https://");
}
if req.token.trim().is_empty() {
return Err("token is required");
}
Ok(forge::Account {
value: req.token,
url: url.to_owned(),
})
}
#[cfg(test)]
mod tests {
use super::{PutForgeAccountRequest, account};
fn request(url: &str, token: &str) -> PutForgeAccountRequest {
PutForgeAccountRequest {
url: url.to_owned(),
token: token.to_owned(),
}
}
#[test]
fn the_url_loses_its_trailing_slash_and_the_token_is_kept() {
let a = account(request("https://codeberg.org/ ", "t0k3n")).expect("valid");
assert_eq!(a.url, "https://codeberg.org");
assert_eq!(a.value, "t0k3n");
}
#[test]
fn a_url_that_is_not_http_is_refused() {
assert!(account(request("codeberg.org", "t")).is_err());
assert!(account(request("file:///etc/passwd", "t")).is_err());
// The control: plain http is accepted.
assert!(account(request("http://forge.lan", "t")).is_ok());
}
#[test]
fn an_empty_token_is_refused() {
assert!(account(request("https://codeberg.org", " ")).is_err());
}
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
fn state() -> super::super::AppState {
super::super::AppState {
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
wanted: None,
agent_status: None,
agent_icons: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
))),
webhook_secret: None,
config_prs: None,
swarm_name: None,
auth: None,
forge: None,
create_gate: std::sync::Arc::default(),
}
}
async fn put(label: &str) -> problem_details::ProblemDetails {
super::put_forge_account(
axum::extract::State(state()),
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), label.to_owned())),
axum::Json(request("https://codeberg.org", "t0k3n")),
)
.await
.expect_err("no store is configured in a test")
}
/// A label `hive-forge -f` could not name is refused before the store: with
/// `BAO_*` unset a store connect would answer 500.
#[tokio::test]
async fn a_label_hive_forge_cannot_name_is_refused_before_the_store() {
for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
assert!(
std::env::var(var).is_err(),
"{var} must be unset for this test to prove anything"
);
}
for bad in ["Codeberg", "code_berg", "../x"] {
let problem = put(bad).await;
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{bad}: {problem:?}"
);
}
}
/// The control: a plain label reaches the store connect.
#[tokio::test]
async fn a_plain_label_reaches_the_store() {
let problem = put("codeberg").await;
assert_eq!(
problem.status,
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
"{problem:?}"
);
}
}

View file

@ -48,6 +48,7 @@ mod agent_status;
mod auth; mod auth;
mod config_pr; mod config_pr;
mod forge; mod forge;
mod forge_account;
mod issue_report; mod issue_report;
mod matrix_account; mod matrix_account;
mod otel_http_client; mod otel_http_client;
@ -2888,6 +2889,7 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(get_agents_status)) .routes(routes!(get_agents_status))
.routes(routes!(set_agent_state)) .routes(routes!(set_agent_state))
.routes(routes!(matrix_account::put_matrix_account)) .routes(routes!(matrix_account::put_matrix_account))
.routes(routes!(forge_account::put_forge_account))
.routes(routes!(get_hive_wanted)) .routes(routes!(get_hive_wanted))
.routes(routes!(term_stream::stream_agent_term)) .routes(routes!(term_stream::stream_agent_term))
.routes(routes!(agent_state_stream::stream_agent_state)) .routes(routes!(agent_state_stream::stream_agent_state))

View file

@ -1,16 +1,20 @@
//! The forge agreement: where an agent's own forge token lives in the store, //! The forge agreement: where an agent's forge tokens live in the store, and
//! and what the object at that path holds. //! what the objects at those paths hold.
//! //!
//! `swarm-controller` mints the token with the forge's admin API and writes it //! Two kinds. The agent's own token on the swarm's forge
//! here; the agent container reads it back under its own certificate //! ([`agent_token_path`]): `swarm-controller` mints it with the forge's admin
//! (`nix/agent-modules/forge-token.nix`). Neither end is senior, so both halves //! API, and `nix/agent-modules/forge-token.nix` reads it back under the agent's
//! are stated once, here, beside [`crate::matrix`]. //! own certificate. And the agent's accounts on external forges
//! ([`account_path`]): an operator hands the controller a URL and a token, the
//! controller stores them there, and `nix/agent-modules/forge-accounts.nix`
//! lists [`accounts_dir`] and reads each account. Neither end is senior, so
//! both halves are stated once, here, beside [`crate::matrix`].
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
Error, Error,
path::{Kind, principal_prefix}, path::{Kind, checked_segment, principal_prefix},
}; };
/// The path holding `agent`'s own forge access token. /// The path holding `agent`'s own forge access token.
@ -53,10 +57,134 @@ impl std::fmt::Debug for Credential {
} }
} }
/// The path holding `agent`'s account on the external forge it calls `label`.
///
/// # Errors
/// [`Error::PathSegment`] when either name contains anything but
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
/// another agent's secret.
pub fn account_path(agent: &str, label: &str) -> Result<String, Error> {
checked_segment("label", label)?;
Ok(format!("{}/{label}", accounts_dir(agent)?))
}
/// The directory every one of `agent`'s [`account_path`]s is under, in the
/// form [`crate::SecretStore::list`] takes. Named apart from
/// [`agent_token_path`]'s `forge-token` key, so no key is also a directory.
///
/// # Errors
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`.
pub fn accounts_dir(agent: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Agent, agent)?;
Ok(format!("{prefix}/forge"))
}
/// What an [`account_path`] holds.
///
/// No `Debug` derive, for [`Credential`]'s reason.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
/// The token. `forge-accounts.nix` reads this field by name.
pub value: String,
/// The forge's base URL, without a trailing slash. Not secret.
/// `forge-accounts.nix` reads this field by name too.
pub url: String,
}
impl std::fmt::Debug for Account {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Account")
.field("value", &"<redacted>")
.field("url", &self.url)
.finish()
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn an_account_and_its_directory_land_under_the_agent_prefix() {
// Spelled out: `forge-accounts.nix` spells the same strings.
assert_eq!(
account_path("atlas", "codeberg").expect("both segments are legal"),
"swarm/agents/atlas/forge/codeberg"
);
assert_eq!(
accounts_dir("atlas").expect("legal"),
"swarm/agents/atlas/forge"
);
}
#[test]
fn no_forge_key_is_also_a_directory() {
// KV-v2 cannot hold a key whose name is also a prefix of another key.
let dir = accounts_dir("atlas").expect("legal");
let key = agent_token_path("atlas").expect("legal");
assert_ne!(key, dir);
assert!(!key.starts_with(&format!("{dir}/")), "{key}");
assert!(!dir.starts_with(&format!("{key}/")), "{dir}");
}
#[test]
fn a_label_that_could_escape_the_directory_is_refused() {
for bad in ["../argus", "a/b", "a.b", ""] {
assert!(account_path("atlas", bad).is_err(), "label {bad:?}");
assert!(account_path(bad, "codeberg").is_err(), "agent {bad:?}");
}
// The control: the legal charset stays reachable.
assert!(account_path("a-b_C9", "d-e0").is_ok());
}
#[test]
fn the_agents_own_grants_list_its_accounts_and_read_each() {
let policy = crate::policy::render_agent("atlas").expect("legal");
let covers = |path: &str| {
policy.lines().any(|line| {
line.strip_prefix("path \"")
.and_then(|rest| rest.split_once("\" {"))
.and_then(|(p, _)| p.strip_suffix('*'))
.is_some_and(|prefix| path.starts_with(prefix))
})
};
let dir = accounts_dir("atlas").expect("legal");
assert!(covers(&format!("secret/metadata/{dir}/")));
assert!(covers(&format!(
"secret/data/{}",
account_path("atlas", "codeberg").expect("legal")
)));
// The control: another agent's accounts are outside both grants.
let other = accounts_dir("argus").expect("legal");
assert!(!covers(&format!("secret/metadata/{other}/")));
assert!(!covers(&format!(
"secret/data/{}",
account_path("argus", "codeberg").expect("legal")
)));
}
#[test]
fn the_account_fields_match_what_the_nix_reader_asks_for() {
let json = serde_json::to_string(&Account {
value: "t".to_owned(),
url: "https://codeberg.org".to_owned(),
})
.expect("two Strings serialise");
assert_eq!(json, r#"{"value":"t","url":"https://codeberg.org"}"#);
}
#[test]
fn debug_never_prints_an_accounts_value() {
let a = Account {
value: "0123456789abcdef".to_owned(),
url: "https://codeberg.org".to_owned(),
};
let shown = format!("{a:?}");
assert!(!shown.contains("0123456789abcdef"), "{shown}");
assert!(shown.contains("codeberg.org"), "{shown}");
}
#[test] #[test]
fn the_token_lands_under_the_agent_prefix() { fn the_token_lands_under_the_agent_prefix() {
// Spelled out rather than rebuilt from the pieces the code uses: the // Spelled out rather than rebuilt from the pieces the code uses: the