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:
parent
97fb76ce99
commit
2c7e586f47
27 changed files with 815 additions and 748 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1966,7 +1966,6 @@ dependencies = [
|
|||
"anyhow",
|
||||
"hive-priv-sock",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
skips the upload when unchanged, because every upload mints a fresh
|
||||
`mxc://` URI that emits a profile state event in every joined room —
|
||||
re-uploading identical bytes is timeline spam. A dashboard-provisioned
|
||||
account gets its avatar when the `systemd.paths.hive-matrix-daemon` token
|
||||
watcher restarts the daemon (which re-runs the per-account bring-up), so
|
||||
no separate avatar trigger is needed. The daemon swallows avatar failures
|
||||
re-uploading identical bytes is timeline spam. A swarm-UI-linked
|
||||
account gets its avatar when the daemon restarts on a changed account
|
||||
listing (exit 75, which re-runs the per-account bring-up), so no separate
|
||||
avatar trigger is needed. The daemon swallows avatar failures
|
||||
(logged, non-fatal) so they never break account bring-up or sync.
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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>/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/<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/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 |
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ Forgejo CLI wrapper for hyperhive
|
|||
###### **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
|
||||
* `-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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ for every operation that genuinely requires root.
|
|||
known operations; there is no arbitrary command pass-through:
|
||||
|
||||
| Operation | What it runs |
|
||||
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
|
||||
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `WriteAgentGithubToken` | write `0600` `github-token` into agent state dir (same semantics as the extra-forge account writes) |
|
||||
| `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 |
|
||||
| `WriteAgentGithubToken` | write `0600` `github-token` into agent 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 |
|
||||
| `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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(`credentials.js`); no SSE — it reads `/api/state` once for the (shared)
|
||||
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.
|
||||
|
||||
|
|
@ -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
|
||||
echoed back in either direction.
|
||||
|
||||
### FORGES tab
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## P3RM1SS10NS tab
|
||||
|
||||
|
|
|
|||
|
|
@ -81,43 +81,6 @@ body.cred-shell {
|
|||
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 {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
</head>
|
||||
<body class="cred-shell">
|
||||
<!-- 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 /). -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
|
|
@ -25,8 +25,7 @@
|
|||
</header>
|
||||
|
||||
<main class="cred-main">
|
||||
<!-- Agent picker: shared across the tabs (one agent selected at a
|
||||
time drives both the github status and the forge list). -->
|
||||
<!-- Agent picker: the selected agent drives the github status. -->
|
||||
<h3>◇ agent</h3>
|
||||
<label class="ma-field">
|
||||
<span>agent</span>
|
||||
|
|
@ -81,61 +80,6 @@
|
|||
<p id="gh-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
|
||||
Entirely dashboard-provisioned, no host-side nix config — 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 — 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 — 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>
|
||||
|
||||
<script type="module" src="/static/credentials.js" defer></script>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
// CR3D3NTIALS page entry (/credentials.html).
|
||||
//
|
||||
// Operator surface to provision per-agent credentials without editing the
|
||||
// agent's config repo. Two sub-tabs, sharing one agent picker:
|
||||
// agent's config repo. One sub-tab and an agent picker:
|
||||
// GITHUB — single-account PAT paste against /api/github-account
|
||||
// (GET -> {present}, POST form-encoded {agent, token} ->
|
||||
// {ok:true}; error_response shape on failure).
|
||||
// No account name / homeserver / login mode, and no
|
||||
// live/heartbeat concept for a static PAT — just present/absent.
|
||||
// FORGES — external forge accounts, entirely dashboard-provisioned (no
|
||||
// host-side config): GET /api/extra-forges?agent= lists the
|
||||
// agent's stored {label, base_url} pairs, POST
|
||||
// /api/extra-forge-account (form agent/label/base_url/token/
|
||||
// action=add|remove) stores or removes one. No remote account
|
||||
// creation — the operator makes the token on the external forge
|
||||
// themselves and pastes it in, same trust model as GITHUB.
|
||||
// Per-tab detail comments live next to their section below.
|
||||
|
||||
import { $, esc, renderServerWarnings } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import "@hive/shared/hive-tab-strip.js";
|
||||
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
|
||||
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
|
||||
|
||||
let agents = [];
|
||||
|
|
@ -185,179 +177,10 @@ async function submitGithub(e) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── FORGES tab ─────────────────────────────────────────────────────────
|
||||
// Entirely dashboard-provisioned, no host-side nix config: per-agent list
|
||||
// (GET /api/extra-forges?agent=, derived from the agent's own
|
||||
// forge-<label>-token files) + an add form (POST /api/extra-forge-account,
|
||||
// form label/base_url/token, action=add) and a remove button per row
|
||||
// (same POST, action=remove). No remote account creation — purely local
|
||||
// bookkeeping for a token the operator already created on the external
|
||||
// forge themselves.
|
||||
|
||||
async function loadForgeAccounts(agent) {
|
||||
const list = $("ef-list");
|
||||
if (!agent) {
|
||||
list.replaceChildren(
|
||||
el("p", { class: "meta" }, "select an agent to see its forge accounts."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
|
||||
let forges;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/extra-forges?agent=" + encodeURIComponent(agent),
|
||||
);
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
forges = (await resp.json()).forges || [];
|
||||
} catch (err) {
|
||||
list.replaceChildren(
|
||||
el(
|
||||
"p",
|
||||
{ class: "err" },
|
||||
"could not load forge accounts: " +
|
||||
esc(String(err)) +
|
||||
" (the backend endpoint may not be deployed yet).",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
list.replaceChildren();
|
||||
if (!forges.length) {
|
||||
list.replaceChildren(
|
||||
el("p", { class: "meta" }, "no forge accounts stored for this agent."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ul = el("ul", { class: "ma-accounts" });
|
||||
for (const forge of forges) {
|
||||
const btn = el("button", { class: "btn", type: "button" }, "remove");
|
||||
btn.addEventListener("click", () => onForgeRemoveClick(agent, forge, btn));
|
||||
ul.append(
|
||||
el(
|
||||
"li",
|
||||
{ class: "ma-account" },
|
||||
el("span", { class: "ma-dot ok" }),
|
||||
el("span", { class: "ma-name" }, forge.label),
|
||||
el("span", { class: "ma-hs" }, forge.base_url || "—"),
|
||||
el("span", { class: "ma-status ok" }, "token stored ✓"),
|
||||
btn,
|
||||
),
|
||||
);
|
||||
}
|
||||
list.append(ul);
|
||||
}
|
||||
|
||||
async function onForgeRemoveClick(agent, forge, btn) {
|
||||
const r = await themedConfirm({
|
||||
message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`,
|
||||
danger: true,
|
||||
confirmLabel: "⊘ remove",
|
||||
});
|
||||
if (!r) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = "removing…";
|
||||
try {
|
||||
const resp = await fetch("/api/extra-forge-account", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
agent,
|
||||
label: forge.label,
|
||||
action: "remove",
|
||||
}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
loadForgeAccounts(agent);
|
||||
return;
|
||||
}
|
||||
const msg = problemMessage(await readApiError(resp));
|
||||
btn.textContent = orig;
|
||||
btn.disabled = false;
|
||||
themedToast("✗ " + (msg || "remove failed (HTTP " + resp.status + ")"), {
|
||||
type: "error",
|
||||
});
|
||||
} catch (err) {
|
||||
btn.textContent = orig;
|
||||
btn.disabled = false;
|
||||
themedToast("✗ request failed: " + String(err), { type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForgeAccount(e) {
|
||||
e.preventDefault();
|
||||
const formEl = e.target;
|
||||
const out = $("ef-result");
|
||||
out.className = "ma-result";
|
||||
out.textContent = "";
|
||||
|
||||
const agent = $("ma-agent").value;
|
||||
if (!agent) {
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "select an agent first.";
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData(formEl);
|
||||
fd.set("agent", agent);
|
||||
fd.set("action", "add");
|
||||
|
||||
const btn = formEl.querySelector('button[type="submit"]');
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "storing…";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/extra-forge-account", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(fd),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
let body = {};
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
/* tolerate odd 2xx body */
|
||||
}
|
||||
if (body.ok) {
|
||||
out.className = "ma-result ok";
|
||||
out.textContent = "✓ forge account stored.";
|
||||
clearSecrets(formEl);
|
||||
loadForgeAccounts(agent);
|
||||
} else {
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "✗ store failed (unexpected response).";
|
||||
clearSecrets(formEl);
|
||||
}
|
||||
} else {
|
||||
const msg = problemMessage(await readApiError(resp));
|
||||
out.className = "ma-result err";
|
||||
out.textContent =
|
||||
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
|
||||
clearSecrets(formEl);
|
||||
}
|
||||
} catch (err) {
|
||||
out.className = "ma-result err";
|
||||
out.textContent =
|
||||
"✗ request failed: " +
|
||||
String(err) +
|
||||
" (the backend endpoint may not be deployed yet).";
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── init ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function onAgentChange(agent) {
|
||||
loadGithubStatus(agent);
|
||||
loadForgeAccounts(agent);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
|
|
@ -367,13 +190,9 @@ async function init() {
|
|||
onAgentChange(e.target.value),
|
||||
);
|
||||
$("gh-form").addEventListener("submit", submitGithub);
|
||||
$("ef-form").addEventListener("submit", submitForgeAccount);
|
||||
|
||||
document.getElementById("cred-tabbar").configure({
|
||||
tabs: [
|
||||
{ id: "github", label: "GITHUB" },
|
||||
{ id: "forges", label: "FORGES" },
|
||||
],
|
||||
tabs: [{ id: "github", label: "GITHUB" }],
|
||||
defaultId: "github",
|
||||
});
|
||||
|
||||
|
|
|
|||
128
frontend/packages/swarm-ui/src/pages/LinkForgeAccountForm.tsx
Normal file
128
frontend/packages/swarm-ui/src/pages/LinkForgeAccountForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import { SplitView } from "../../ui/split-view/SplitView.js";
|
|||
import { type TableColumn } from "../../ui/table/Table.js";
|
||||
import { CreateAgentForm } from "../CreateAgentForm.js";
|
||||
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
|
||||
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./AgentsPage.css";
|
||||
|
||||
|
|
@ -152,6 +153,8 @@ export function AgentsPage() {
|
|||
// confirmation of a `declareState` call, it's an unrelated action with
|
||||
// its own form (`LinkMatrixAccountForm`).
|
||||
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`
|
||||
// object itself. Storing the row would snapshot it at selection time;
|
||||
// `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"
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
{/* MVP scope per mara's own ruling: a small read-only
|
||||
preview, no header/no input — the full terminal
|
||||
|
|
@ -618,6 +637,20 @@ export function AgentsPage() {
|
|||
/>
|
||||
) : null}
|
||||
</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
|
||||
open={confirmTarget !== null}
|
||||
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@ use crate::lifecycle;
|
|||
(name = "journal", description = "container + host journal reads"),
|
||||
(name = "approvals", description = "approve/deny pending approval rows"),
|
||||
(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 = "matrix_accounts", description = "github account provisioning for agents"),
|
||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
|
|
@ -54,7 +53,6 @@ struct ApiDoc;
|
|||
|
||||
mod approvals;
|
||||
mod build_logs;
|
||||
mod extra_forges;
|
||||
// The single validated identifier type — homed in `hive-host-sock` (the crate
|
||||
// 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
|
||||
|
|
@ -143,8 +141,6 @@ pub async fn serve(
|
|||
matrix_accounts::post_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_stats_hive))
|
||||
.routes(routes!(misc_api::api_container_resources))
|
||||
|
|
@ -379,8 +375,6 @@ mod router_build_probe {
|
|||
matrix_accounts::post_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_stats_hive))
|
||||
.routes(routes!(misc_api::api_container_resources))
|
||||
|
|
|
|||
|
|
@ -388,37 +388,6 @@ pub async fn write_agent_github_token(agent_name: &str, token: &str) -> Result<(
|
|||
.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
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ never `curl` it directly.
|
|||
|
||||
Reads credentials from the environment (`HIVE_FORGE_URL`,
|
||||
`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 >
|
||||
`HIVE_FORGE_REPO` (last-resort override, unset by default) > a hard
|
||||
error — see `client::Client::from_env`.
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ impl Client {
|
|||
/// `json_mode` comes from the global `--json` flag — per-verb
|
||||
/// output formatters key off it via `Client::json_mode`.
|
||||
/// `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`].
|
||||
pub fn from_env(
|
||||
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.
|
||||
/// `None` (the default) resolves the internal forge exactly as before:
|
||||
/// `HIVE_FORGE_URL` (default [`DEFAULT_URL`]) + `read_token()`.
|
||||
/// `Some(label)` (from `-f/--forge <label>`) instead resolves a
|
||||
/// dashboard-provisioned external forge account: the token comes from
|
||||
/// `Some(label)` (from `-f/--forge <label>`) instead resolves an
|
||||
/// external forge account linked in the swarm UI: the token comes from
|
||||
/// `${HYPERHIVE_STATE_DIR}/forge-<label>-token` and the base URL from
|
||||
/// the `base_url` key of the sibling `forge-<label>.json` sidecar —
|
||||
/// the exact same two files `dashboard/extra_forges.rs` writes, so the
|
||||
/// read side can't drift from the write side. An unknown label (either
|
||||
/// file missing) is a clear error listing the labels actually found in
|
||||
/// the state dir, not a raw file-not-found. A label outside the plain
|
||||
/// identifier charset the dashboard accepts (e.g. a typo containing
|
||||
/// `/` or `..`) is rejected up front with the same charset spelled out,
|
||||
/// rather than silently building a nonsense/traversing path and
|
||||
/// surfacing a confusing file error later.
|
||||
/// the two files `nix/agent-modules/forge-accounts.nix` writes. An
|
||||
/// unknown label (either file missing) is a clear error listing the
|
||||
/// labels actually found in the state dir, not a raw file-not-found. A
|
||||
/// label outside the plain identifier charset the swarm controller
|
||||
/// accepts (e.g. a typo containing `/` or `..`) is rejected up front
|
||||
/// with the same charset spelled out, rather than silently building a
|
||||
/// nonsense/traversing path and surfacing a confusing file error later.
|
||||
pub(crate) fn resolve_credentials(forge_label: Option<&str>) -> Result<(String, String)> {
|
||||
let Some(label) = forge_label else {
|
||||
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) {
|
||||
bail!(
|
||||
"hive-forge: invalid --forge label {label:?} — must be lowercase \
|
||||
letters, digits, and hyphens only (same rule the dashboard's \
|
||||
FORGES tab enforces)"
|
||||
letters, digits, and hyphens only (same rule the swarm UI's \
|
||||
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}");
|
||||
}
|
||||
|
||||
/// Plain-identifier check matching `dashboard/extra_forges.rs`'s
|
||||
/// `is_plain_ident` (itself matching hive-priv's `validate_name_chars`)
|
||||
/// — lowercase ascii + digits + hyphens only. Rejecting anything else
|
||||
/// up front (rather than just letting a weird label fail to resolve a
|
||||
/// file) turns a confusing "no such forge" surprise into a precise
|
||||
/// "that's not a valid label" one, and incidentally means a label like
|
||||
/// `../../etc` can't be used to build a path outside the state dir.
|
||||
/// Plain-identifier check matching the label rule of swarm-controller's
|
||||
/// `put_forge_account` (`hive_types::Ident`) — lowercase ascii, digits and
|
||||
/// hyphens only. Rejecting anything else up front (rather than just
|
||||
/// letting a weird label fail to resolve a file) turns a confusing "no
|
||||
/// such forge" surprise into a precise "that's not a valid label" one,
|
||||
/// and incidentally means a label like `../../etc` can't be used to
|
||||
/// build a path outside the state dir.
|
||||
fn is_plain_ident(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
/// Sidecar shape `dashboard/extra_forges.rs` writes alongside each
|
||||
/// `forge-<label>-token` file: just the base URL, pinned to the same
|
||||
/// `base_url` JSON key the write side uses.
|
||||
/// Sidecar shape `nix/agent-modules/forge-accounts.nix` writes alongside
|
||||
/// each `forge-<label>-token` file: just the base URL, under the
|
||||
/// `base_url` key that unit's `jq` filter spells.
|
||||
#[derive(Deserialize)]
|
||||
struct ForgeSidecar {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
/// Scan the state dir for every `forge-<label>-token` file (mirroring
|
||||
/// `dashboard/extra_forges.rs`'s own listing logic) and return the
|
||||
/// labels found, sorted. Used to build a helpful "did you mean one of
|
||||
/// Scan the state dir for every `forge-<label>-token` file and return
|
||||
/// the labels found, sorted. Used to build a helpful "did you mean one of
|
||||
/// these" error when `--forge <label>` doesn't resolve. Best-effort:
|
||||
/// an unreadable state dir yields an empty list rather than erroring
|
||||
/// (the caller already has its own error to report).
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
//! cwd's git checkout > `HIVE_FORGE_REPO` (last-resort override, unset
|
||||
//! 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` +
|
||||
//! `forge-<label>.json` (base URL) from the state dir rather than
|
||||
//! `HIVE_FORGE_URL`/`forge-token`. See `client::Client::from_env`.
|
||||
|
|
@ -45,9 +45,8 @@ struct Cli {
|
|||
/// any verb.
|
||||
#[arg(short = 'r', long, global = true)]
|
||||
repo: Option<String>,
|
||||
/// Act as a dashboard-provisioned external forge account (by its
|
||||
/// FORGES-tab label) instead of the internal forge. Independent of
|
||||
/// `-r/--repo`.
|
||||
/// Act as an external forge account linked in the swarm UI (by its
|
||||
/// label) instead of the internal forge. Independent of `-r/--repo`.
|
||||
#[arg(short = 'f', long, global = true)]
|
||||
forge: Option<String>,
|
||||
/// Emit JSON instead of the default human-readable output (for verbs
|
||||
|
|
|
|||
|
|
@ -494,43 +494,6 @@ pub enum PrivRequest {
|
|||
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
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ workspace = true
|
|||
anyhow.workspace = true
|
||||
hive-priv-sock.workspace = true
|
||||
libc.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ use hive_priv_sock::{
|
|||
PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
|
||||
PrivStreamLine, SIBLING_CONTAINERS,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
use tokio::net::unix::OwnedWriteHalf;
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
|
@ -413,18 +412,6 @@ async fn exec(
|
|||
ref 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::ControlInfraContainer { container, action } => {
|
||||
|
|
@ -523,29 +510,6 @@ async fn exec_forge_admin(args: &[String]) -> Result<(String, String)> {
|
|||
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`.
|
||||
async fn stop_container(name: &str) -> Result<(String, String)> {
|
||||
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"))
|
||||
}
|
||||
|
||||
/// `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`.
|
||||
async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
|
|
@ -1531,18 +1486,6 @@ fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)
|
|||
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.
|
||||
/// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E;
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ in
|
|||
./dashboard-links.nix
|
||||
./docs.nix
|
||||
./forge.nix
|
||||
./forge-accounts.nix
|
||||
./forge-token.nix
|
||||
./frontend.nix
|
||||
./github.nix
|
||||
|
|
|
|||
191
nix/agent-modules/forge-accounts.nix
Normal file
191
nix/agent-modules/forge-accounts.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -112,15 +112,6 @@ in
|
|||
# `/var/lib/nixos-containers/hive-forge/var/lib/forgejo/` survives
|
||||
# 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 ];
|
||||
|
||||
# What ./service.nix declares is what the forge IS from any hive's point of
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ let
|
|||
};
|
||||
|
||||
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;
|
||||
in
|
||||
let
|
||||
|
|
@ -164,6 +165,60 @@ let
|
|||
&& !(agentForgeNoBao.systemd.services ? tea-login)
|
||||
&& !(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
|
||||
runGroup "agent-forge-bao" cases
|
||||
|
|
|
|||
200
swarm-controller/src/forge_account.rs
Normal file
200
swarm-controller/src/forge_account.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ mod agent_status;
|
|||
mod auth;
|
||||
mod config_pr;
|
||||
mod forge;
|
||||
mod forge_account;
|
||||
mod issue_report;
|
||||
mod matrix_account;
|
||||
mod otel_http_client;
|
||||
|
|
@ -2888,6 +2889,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(get_agents_status))
|
||||
.routes(routes!(set_agent_state))
|
||||
.routes(routes!(matrix_account::put_matrix_account))
|
||||
.routes(routes!(forge_account::put_forge_account))
|
||||
.routes(routes!(get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
//! The forge agreement: where an agent's own forge token lives in the store,
|
||||
//! and what the object at that path holds.
|
||||
//! The forge agreement: where an agent's forge tokens live in the store, and
|
||||
//! what the objects at those paths hold.
|
||||
//!
|
||||
//! `swarm-controller` mints the token with the forge's admin API and writes it
|
||||
//! here; the agent container reads it back under its own certificate
|
||||
//! (`nix/agent-modules/forge-token.nix`). Neither end is senior, so both halves
|
||||
//! are stated once, here, beside [`crate::matrix`].
|
||||
//! Two kinds. The agent's own token on the swarm's forge
|
||||
//! ([`agent_token_path`]): `swarm-controller` mints it with the forge's admin
|
||||
//! API, and `nix/agent-modules/forge-token.nix` reads it back under the agent's
|
||||
//! 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 crate::{
|
||||
Error,
|
||||
path::{Kind, principal_prefix},
|
||||
path::{Kind, checked_segment, principal_prefix},
|
||||
};
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn the_token_lands_under_the_agent_prefix() {
|
||||
// Spelled out rather than rebuilt from the pieces the code uses: the
|
||||
|
|
|
|||
Loading…
Reference in a new issue