Watch
0
0
Fork
You've already forked hyperhive
0

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

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

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

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

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

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

View file

@ -5,9 +5,9 @@ HTTPS, both authenticated by an operator-supplied personal access token
(PAT) — so it can run GitHub API calls and push commits without any manual
`gh auth login`.
Provisioning is UI-driven: paste a PAT into the agent's credentials tab
and it works. No per-agent nix declaration, no rebuild — hive-c0re
injects the token into the agent's state dir out of band.
Provisioning is UI-driven: link a PAT to the agent in the swarm UI and it
works. No per-agent nix declaration, no rebuild — the agent fetches the token
from the swarm secret store itself.
## Enabling
@ -15,8 +15,7 @@ injects the token into the agent's state dir out of band.
The integration is **on by default** for every agent (`services.hyperhive.agent.github.enable
= true`), inert until the operator provisions a PAT. No per-agent declaration is
needed — an agent gains GitHub by having a PAT written to its token
file.
needed — an agent gains GitHub by having a PAT stored for it.
<!-- vale write-good.Passive = YES -->
@ -27,29 +26,31 @@ services.hyperhive.github.enable = false;
```
hive-c0re's meta-flake renderer then injects `services.hyperhive.agent.github.enable = false`
into every agent, so no agent ships the `gh` wrapper or credential helper.
into every agent, so no agent ships the `gh` wrapper, the credential helper
or the token fetch.
(`services.hyperhive.agent.github.enable` also exists per-agent for completeness, but the
hive-wide host switch is the intended control.)
github.com only. The token **value** never touches nix — it's written to
`<state>/github-token` separately (see [Provisioning](#provisioning)).
github.com only. The token **value** never touches nix — the agent writes it
to `<state>/github-token` at runtime (see [Provisioning](#provisioning)).
## Provisioning
The PAT is operator-supplied. The primary path is the **dashboard
credentials tab** (github sub-tab): paste the PAT for an agent and submit
(`POST /api/github-account`). A CLI path also exists for
recovery/scripting:
The PAT is operator-supplied. In the [swarm UI](../swarm/ui.md#linking-external-accounts),
open the agent on `/agents`, choose **link github account** and paste the PAT
(`PUT /api/hives/{hive}/agents/{agent}/github-account`). swarm-controller
stores it at `swarm/agents/<agent>/github-token` in the swarm secret store;
no hive writes it. One token per agent: linking again replaces it,
and no route hands it back.
```sh
hivectl github set-token <agent> --token-stdin # paste the PAT on stdin (preferred)
hivectl github set-token <agent> --token <pat> # inline (visible in shell history)
```
Either path has hive-c0re delegate the write to hive-priv, which stores the
file `0600` owned by the agent (so the container can read it) — the same
credential-injection path as forge/matrix tokens. See
[hivectl → GitHub](../tools/hivectl.md#github).
The agent's `hive-agent-github-token` unit reads that path under the
agent's own store certificate and writes `<state>/github-token` (`0600`,
owned by the agent), on boot and every two minutes, replacing the file only
when the token changed. It needs a store identity
(`services.hyperhive.agent.bao.addr`); an agent without one gets no token.
The unit never deletes the file: a `github-token` already in place stays
when the store holds none or doesn't answer. Where the token lives and who
reads it: [credentials.md](../swarm/credentials.md).
## Security

View file

@ -69,6 +69,7 @@ of the cell says how.
| `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/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by linking it 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 |

View file

@ -8,9 +8,9 @@ domain, covers host-level detail for one hive.
## What it shows
| route | what |
| ------------------------- | ------------------------------------------------------------------------------------------- |
| ------------------------- | --------------------------------------------------------------------------------------------------- |
| `/` | the hive directory, each hive with its last reported status |
| `/agents` | every agent: status, config PR, wanted state; create agents, link forge and matrix accounts |
| `/agents` | every agent: status, config PR, wanted state; create agents, link forge, matrix and GitHub accounts |
| `/agents/<name>/terminal` | one agent's live terminal |
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/issues` | a cross-repo issue report |
@ -21,8 +21,8 @@ An agent created here or with `swarmctl agent create` starts `paused`; set it
### Linking external accounts
Each agent on `/agents` opens two dialogs that write a credential for it
into the swarm secret store through swarm-controller. Both are blind
Each agent on `/agents` opens three dialogs that write a credential for it
into the swarm secret store through swarm-controller. All three are blind
set/update actions: no route lists linked accounts or hands a token back.
- **link a matrix account** — `PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
@ -36,6 +36,14 @@ set/update actions: no route lists linked accounts or hands a token back.
`hive-forge -f <label>` reads. The unit never deletes a pair: linking
the same label again overwrites both files, and a pair whose label the
store doesn't list stays untouched.
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
with a personal access token, stored at `swarm/agents/<agent>/github-token`.
One token per agent: linking again replaces it. The agent's
`hive-agent-github-token` unit fetches it into `<state>/github-token`,
the file its `gh` wrapper, git credential helper and GitHub notification
poller read. A `github-token` already in place stays when the store holds
none. What the token needs and how the agent uses it:
[GitHub accounts](../integrations/github.md).
Where each credential lives and who reads it:
[`credentials.md`](credentials.md).

View file

@ -9,8 +9,6 @@ This document contains the help content for the `hivectl` command-line program.
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
* [`hivectl matrix`↴](#hivectl-matrix)
* [`hivectl matrix invite`↴](#hivectl-matrix-invite)
* [`hivectl github`↴](#hivectl-github)
* [`hivectl github set-token`↴](#hivectl-github-set-token)
* [`hivectl gateway`↴](#hivectl-gateway)
* [`hivectl gateway create-user`↴](#hivectl-gateway-create-user)
* [`hivectl gateway delete-user`↴](#hivectl-gateway-delete-user)
@ -64,7 +62,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
* `forge` — Reconcile an agent's config between this hive and the forge
* `matrix` — matrix-tuwunel invites
* `github` — GitHub account provisioning
* `gateway` — Gateway htpasswd user management
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
* `list-agents` — Show all managed agents with their status and technical state
@ -156,39 +153,6 @@ Invite a matrix user to the hive Space, or a specific room with `--room`. Idempo
## `hivectl github`
GitHub account provisioning.
Store an operator-supplied personal access token (PAT) for an agent so its `gh` and git can authenticate. Creates no account — the PAT is for an existing GitHub account.
**Usage:** `hivectl github <COMMAND>`
###### **Subcommands:**
* `set-token` — Store a GitHub PAT for `<agent>` so its `gh` and git can authenticate
## `hivectl github set-token`
Store a GitHub PAT for `<agent>` so its `gh` and git can authenticate.
Prefer `--token-stdin` — an inline `--token` is visible in shell history.
**Usage:** `hivectl github set-token [OPTIONS] <AGENT>`
###### **Arguments:**
* `<AGENT>` — Logical agent name (the container/agent name)
###### **Options:**
* `--token <TOKEN>` — The PAT value inline. Mutually exclusive with `--token-stdin`
* `--token-stdin` — Read the PAT from stdin (trailing newline stripped). Mutually exclusive with `--token`
## `hivectl gateway`
Gateway htpasswd user management.

View file

@ -65,30 +65,6 @@ group; nobody has built that sync yet.
invite power (it owns the hive Space, so that case always works).
Idempotent — already-member / already-invited is a no-op.
## GitHub
<!-- vale write-good.Passive = NO -->
Write an operator-supplied GitHub personal access token (PAT) into an
agent's token file so its `gh` wrapper + git credential helper can act as
the bot account. Unlike matrix there is no account creation — the PAT
is for an existing GitHub account. A CLI alternative to the dashboard
credentials tab; the [GitHub integration](../integrations/github.md) is on by default
(`services.hyperhive.agent.github.enable`), so no per-agent config is needed.
<!-- vale write-good.Passive = YES -->
```bash
hivectl github set-token damocles --token-stdin # paste the PAT on stdin (preferred)
hivectl github set-token damocles --token <pat> # inline (visible in shell history)
```
- `set-token`: writes `<state>/github-token` (`0600`, agent-owned) via
hive-priv — the same credential-injection path as forge/matrix tokens.
The `gh` wrapper / git credential helper read it live, so a freshly set
or rotated PAT takes effect with no rebuild or restart. Refuses an empty
token. See [github.md](../integrations/github.md) for the full flow + security notes.
## Gateway
Manage users in the gateway's HTTP Basic auth htpasswd file

View file

@ -295,7 +295,6 @@ 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` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` |
| `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 |
| `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 |

View file

@ -29,11 +29,8 @@ Everything else lives on its own page instead, all reachable from the
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
the whole swarm), **Logs** (`/logs.html`, per-agent and host
journals), **Stats** (`/stats.html`, swarm-wide usage stats),
**Builds** (`/builds.html`, the rebuild queue and build history),
**Core** (`/core.html`, tombstones and container resource use), and
**Credentials** (`/credentials.html`, pasting a GitHub PAT for an
agent's dedicated bot account — matrix and external-forge accounts are
swarm identities, linked from the swarm UI). Your local
**Builds** (`/builds.html`, the rebuild queue and build history), and
**Core** (`/core.html`, tombstones and container resource use). Your local
browser preferences (notifications) live in the dashboard's Y3R C4LL
tab.
@ -83,11 +80,6 @@ journald view for any agent + service; the per-agent `⋮` menu's
recurring or one-shot prompts you schedule for one or more agents, and
reminders agents have set for themselves.
**Set an agent's GitHub token.** The Credentials page pastes a PAT for
the agent's dedicated bot account, without editing that agent's config
repo. Link matrix and external-forge accounts from the swarm UI
instead.
## More depth
Both the dashboard and the per-agent pages are SPAs sharing one

View file

@ -30,7 +30,7 @@ from the dashboard tab strip.
— sits below the tab strip.
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
on **every** page (dashboard + every stand-alone page — FL0W, L0GS,
H0M3, C0R3, BU1LDS, CR3D3NTIALS, ST4TS), injected at the top
H0M3, C0R3, BU1LDS, ST4TS), injected at the top
of `<body>` by `renderServerWarnings` in `common.js`. Driven by
`state.server_warnings` — a list of `{ kind, level, message }` — and
coloured by `level` (`warn` amber / `crit` red). hive-c0re owns the
@ -283,35 +283,6 @@ badge with a ticking elapsed-time chip; expanding streams output via
(suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the
entry with that id pre-expanded.
## CR3D3NTIALS page (`/credentials.html`)
Operator surface to provision per-agent credentials without editing the
agent's config repo. Standalone page reached from the **Credentials** tile
on the H0M3 hub, same minimal chrome as `/logs.html` (a `← home` back-link
+ a sub-tab strip, via the shared `@hive/shared/tabs.js` tab strip) rather
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.
One sub-tab, GITHUB.
### GITHUB tab
Provision a single per-agent GitHub personal access token (see
[`docs/integrations/github.md`](../integrations/github.md) for the injection + `gh`/git-push
mechanics). No login flow — the operator pastes an existing PAT for a
dedicated bot account, with a security-warning banner (dedicated account +
minimally scoped token) and a link to
[github.com/settings/tokens](https://github.com/settings/tokens).
Status reads `GET /api/github-account?agent=<name>` →
`{ present: bool }` — whether the agent's `github-token` file exists.
There's no live/heartbeat concept for a static PAT, so this is just a
"token stored ✓" / "not set" line.
Provisioning posts `POST /api/github-account` (form-encoded `agent`,
`token`) → `200 { ok: true }` on success; failures come back as RFC 9457
`application/problem+json` with the message in `detail`. The token is never
echoed back in either direction.
## P3RM1SS10NS tab
Per-agent permission configuration. Two sections, each rendered as a
@ -575,7 +546,7 @@ re-renders the terminal row. The operator addresses the root agent as `@root`.
The H0M3 hub is the primary landing page (served at `/` by default). A
responsive grid of link tiles — Dashboard, Flow, Logs, Builds, Stats,
Core, Credentials, API — each pointing to their respective
Core, API — each pointing to their respective
surfaces, all unconditionally shown (no gating). The page is a pure
portal with no tab-bar or SSE subscriptions. Typography + colours inherit
from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`).

View file

@ -20,8 +20,8 @@
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
loaded by every page, plus a page-specific bundle —
`dashboard.css` / `flow.css` / `logs.css` / `home.css` /
`stats.css` / `core.css` / `builds.css` /
`credentials.css`); `common.css` inlines `@hive/shared`'s
`stats.css` / `core.css` / `builds.css`);
`common.css` inlines `@hive/shared`'s
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
`pill.css` via esbuild's `@import` resolution.
`terminal.js` exports `{ create, linkify }` as ES module

View file

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

View file

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

View file

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

View file

@ -1,202 +0,0 @@
// CR3D3NTIALS page entry (/credentials.html).
//
// Operator surface to provision per-agent credentials without editing the
// agent's config repo. One sub-tab and an agent picker:
// GITHUB — single-account PAT paste against /api/github-account
// (GET -> {present}, POST form-encoded {agent, token} ->
// {ok:true}; error_response shape on failure).
// No account name / homeserver / login mode, and no
// live/heartbeat concept for a static PAT — just present/absent.
// Per-tab detail comments live next to their section below.
import { $, esc, renderServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import "@hive/shared/hive-tab-strip.js";
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
let agents = [];
async function loadState() {
try {
const resp = await fetch("/api/state");
if (!resp.ok) return;
const s = await resp.json();
renderServerWarnings(s.server_warnings);
// `/api/state` exposes the live roster under `containers` (each entry an
// object carrying `.name` + `.running`); there is no top-level `agents`
// field, so the picker stays compatible with both string + object shapes.
const containers = (s.containers || [])
.map((a) => (typeof a === "string" ? { name: a } : a))
.filter((c) => c && c.name);
agents = containers.map((c) => c.name).sort();
} catch {
// best-effort: on a failed state read the picker renders empty
// ("— no agents —") and the submit guard blocks until an agent is
// selected, rather than guessing a roster.
}
}
function renderAgentPicker() {
const sel = $("ma-agent");
sel.replaceChildren();
if (!agents.length) {
sel.append(el("option", { value: "" }, "— no agents —"));
return;
}
sel.append(el("option", { value: "" }, "— select agent —"));
for (const a of agents) sel.append(el("option", { value: a }, a));
}
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
// lives in `@hive/shared/api-error.js` now — `readApiError` +
// `problemMessage` (this page only needs the one-line message, not the
// full `ApiErrorPanel`; its result lines are single-line `aria-live`
// regions, not a swap-in-a-panel context). Was a local function here
// originally; promoted so swarm-ui shares the same
// shape-agnostic reader instead of each side maintaining its own copy.
function clearSecrets(formEl) {
formEl
.querySelectorAll('input[type="password"], input[name="token"]')
.forEach((i) => {
i.value = "";
});
}
// ─── GITHUB tab ─────────────────────────────────────────────────────────
async function loadGithubStatus(agent) {
const status = $("gh-status");
if (!agent) {
status.replaceChildren(
el(
"p",
{ class: "meta" },
"select an agent to see its github credential status.",
),
);
return;
}
status.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data;
try {
const resp = await fetch(
"/api/github-account?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json();
} catch (err) {
status.replaceChildren(
el(
"p",
{ class: "err" },
"could not load status: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
const present = !!data.present;
status.replaceChildren(
el(
"div",
{ class: "gh-status-line" },
el("span", { class: "gh-dot " + (present ? "present" : "absent") }),
el(
"span",
{ class: "gh-status-text " + (present ? "present" : "absent") },
present ? "token stored ✓" : "not set",
),
),
);
}
async function submitGithub(e) {
e.preventDefault();
const formEl = e.target;
const out = $("gh-result");
out.className = "ma-result";
out.textContent = "";
const agent = $("ma-agent").value;
if (!agent) {
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set("agent", agent);
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = "storing…";
try {
const resp = await fetch("/api/github-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
let body = {};
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = "ma-result ok";
out.textContent = "✓ token stored.";
clearSecrets(formEl);
loadGithubStatus(agent);
} else {
out.className = "ma-result err";
out.textContent = "✗ store failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
// ─── init ─────────────────────────────────────────────────────────────
async function onAgentChange(agent) {
loadGithubStatus(agent);
}
async function init() {
await loadState();
renderAgentPicker();
$("ma-agent").addEventListener("change", (e) =>
onAgentChange(e.target.value),
);
$("gh-form").addEventListener("submit", submitGithub);
document.getElementById("cred-tabbar").configure({
tabs: [{ id: "github", label: "GITHUB" }],
defaultId: "github",
});
onAgentChange("");
}
init();

View file

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

View file

@ -1,5 +1,5 @@
// hive-tab-strip.js — <hive-tab-strip>, the markup-owning tab-strip custom
// element behind the logs/credentials/core/builds sub-page tabbars. Owns
// element behind the logs/core/builds sub-page tabbars. Owns
// rendering the <a class="hive-tab"> markup from a declarative `tabs` list
// instead of every page hand-writing the same <nav><a>...</a></nav>
// boilerplate, then wires the existing createTabStrip() behaviour

View file

@ -0,0 +1,109 @@
// <LinkGithubAccountForm> — writes a GitHub personal access token for one
// agent into the swarm secret store.
// PUTs `/api/hives/{hive}/agents/{agent}/github-account` — 204 on success,
// 400/500 as `problem+json`, shown via `ApiErrorPanel` like
// `LinkForgeAccountForm`.
//
// One token per agent. A blind set/update: no route says whether a token is
// stored, and none hands one 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";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "done" }
| { status: "error"; problem: ProblemDetails };
export function LinkGithubAccountForm({
hive,
agent,
onClose,
}: {
hive: string;
agent: string;
onClose?: () => void;
}) {
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)}/github-account`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
},
);
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
setResult({ status: "done" });
// 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 github account — ${agent}`}
icon="🔗"
onClose={onClose}
>
<p>
Writes the token to the swarm secret store. The agent fetches it within
two minutes, and its <code>gh</code> and <code>git push</code> to
github.com then authenticate with it. Use a dedicated bot account and a
minimally scoped token, created at{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
>
github.com/settings/tokens
</a>
; GitHub notifications also need the <code>notifications</code> scope.
</p>
<form class="link-matrix-account-form" onSubmit={submit}>
<TextField
id="github-account-token"
label="personal 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">
stored a github token for <strong>{agent}</strong>
</p>
)}
{result.status === "error" && (
<ApiErrorPanel
context="failed to link the account"
problem={result.problem}
/>
)}
</Panel>
);
}

View file

@ -49,6 +49,7 @@ import { type TableColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.js";
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentsPage.css";
@ -98,6 +99,8 @@ const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
// The row showing the "link a github account" dialog, same shape.
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -600,6 +603,22 @@ export function AgentsPage() {
: "no hive on record for this agent — nothing to link against"
}
/>
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link github account"
onClick={
detailTarget.hive
? () => setGithubTarget(detailTarget)
: undefined
}
disabled={!detailTarget.hive}
title={
detailTarget.hive
? `link a github 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
@ -651,6 +670,20 @@ export function AgentsPage() {
/>
) : null}
</Dialog>
<Dialog
open={githubTarget !== null}
onClose={() => setGithubTarget(null)}
label="link a github account"
plain
>
{githubTarget?.hive ? (
<LinkGithubAccountForm
hive={githubTarget.hive}
agent={githubTarget.name}
onClose={() => setGithubTarget(null)}
/>
) : null}
</Dialog>
<ConfirmDialog
open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}

View file

@ -1,100 +0,0 @@
//! Per-agent GitHub PAT provisioning for the dashboard's GITHUB tab:
//! `POST /api/github-account` stores it, `GET /api/github-account` reports
//! whether one is stored.
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;
/// Form body for `POST /api/github-account` (urlencoded, the dashboard's
/// mutation convention). Writes the operator-supplied PAT to the agent's
/// `github-token` file. No account creation and no login modes — the
/// operator pastes a PAT for an existing account.
#[derive(Deserialize, ToSchema)]
pub(super) struct GithubAccountForm {
agent: String,
token: String,
}
#[derive(Serialize, ToSchema)]
struct GithubAccountResult {
ok: bool,
}
/// Provision (or refresh) an agent's GitHub PAT from the dashboard
/// credentials tab.
///
/// Validates the agent name, then writes the PAT to
/// `<state>/github-token` (`0600`, agent-owned) via hive-priv. No account
/// creation and no daemon to kick — the agent's `gh` wrapper / git credential
/// helper read the file live, so the new token takes effect immediately.
/// Operator-authenticated (dashboard). Never echoes the token back — only
/// `{ ok: true }`.
#[utoipa::path(
post,
path = "/api/github-account",
request_body(content = GithubAccountForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "PAT provisioned", body = GithubAccountResult),
(status = 500, description = "invalid agent name, empty token, or the write failed"),
),
tag = "matrix_accounts"
)]
pub(super) async fn post_github_account(Form(f): Form<GithubAccountForm>) -> Response {
let agent = f.agent.trim();
let token = f.token.trim();
let Ok(agent) = Ident::parse(agent) else {
return error_response(&format!("github-account: invalid agent {agent:?}"));
};
if token.is_empty() {
return error_response("github-account: token is required");
}
if let Err(e) = crate::priv_client::write_agent_github_token(agent.as_str(), token).await {
return error_response(&format!("github-account: write token failed: {e:#}"));
}
tracing::info!(%agent, "github-account: provisioned github PAT");
axum::Json(GithubAccountResult { ok: true }).into_response()
}
#[derive(Deserialize, IntoParams)]
pub(super) struct GithubAccountQuery {
agent: String,
}
#[derive(Serialize, ToSchema)]
struct GithubAccountStatus {
/// A `github-token` file exists in the agent's state dir (a PAT has been
/// provisioned). A static PAT has no live/heartbeat concept, so this is
/// the only status the credentials tab needs.
present: bool,
}
/// Whether the agent has a GitHub
/// PAT provisioned (its `github-token` file exists).
///
/// Lets the credentials tab show "token stored" vs "not set" instead of a
/// black-hole paste field. Never returns the token itself.
#[utoipa::path(
get,
path = "/api/github-account",
params(GithubAccountQuery),
responses(
(status = 200, description = "whether a github PAT is provisioned", body = GithubAccountStatus),
(status = 500, description = "invalid agent name"),
),
tag = "matrix_accounts"
)]
pub(super) async fn get_github_account(Query(q): Query<GithubAccountQuery>) -> Response {
let agent = q.agent.trim();
let Ok(agent) = Ident::parse(agent) else {
return error_response(&format!("github-account: invalid agent {agent:?}"));
};
let present = Coordinator::agent_notes_dir(&agent)
.join("github-token")
.exists();
axum::Json(GithubAccountStatus { present }).into_response()
}

View file

@ -38,7 +38,6 @@ use crate::lifecycle;
(name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(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"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
@ -61,7 +60,6 @@ pub(crate) use hive_types::Ident;
mod health;
mod journal;
mod lifecycle_ops;
mod matrix_accounts;
mod meta_inputs;
mod misc_api;
pub(crate) mod permissions;
@ -126,8 +124,7 @@ pub async fn serve(
// call below is therefore scoped to exactly one path — two handlers
// in the same call only when they genuinely share a path with
// different methods (`schedules::api_schedules`/`post_schedule_new`
// on `/api/schedules`, `matrix_accounts::get_github_account`/
// `post_github_account` on `/api/github-account`) — chained via
// on `/api/schedules`) — chained via
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
// with everything in it.
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
@ -137,10 +134,6 @@ pub async fn serve(
.routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(
matrix_accounts::post_github_account,
matrix_accounts::get_github_account
))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
@ -371,10 +364,6 @@ mod router_build_probe {
.routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(
matrix_accounts::post_github_account,
matrix_accounts::get_github_account
))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))

View file

@ -369,25 +369,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
.await?)
}
/// Write a GitHub personal access token (PAT) for `agent_name` via hive-priv
/// (running as root). Writes `<state>/github-token` 0600, chowned to the agent
/// user so the `gh` wrapper / git credential helper can read it from inside the
/// container. Single account per agent — no account suffix. The token value is
/// operator-supplied (for the agent's GitHub integration, `services.hyperhive.agent.github.enable`).
///
/// # Errors
///
/// Returns an error if the hive-priv call fails — the socket is unreachable,
/// `agent_name` is rejected by the root-side validation, or the file
/// write/chown fails.
pub async fn write_agent_github_token(agent_name: &str, token: &str) -> Result<()> {
ok(call(&PrivRequest::WriteAgentGithubToken {
agent_name: agent_name.to_owned(),
token: token.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.

View file

@ -224,9 +224,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostRequest::GatewayListUsers => {
HostResponse::messages(crate::gateway_nginx::list_users()?)
}
HostRequest::SetAgentGithubToken { agent, token } => {
handle_set_agent_github_token(agent.as_str(), token).await?
}
HostRequest::QuotaEnable => handle_quota_enable().await?,
HostRequest::QuotaLimit { name, limit } => {
handle_quota_limit(name.as_str(), *limit).await?
@ -468,16 +465,6 @@ fn require_matrix_present() -> Result<()> {
)
}
async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result<HostResponse> {
crate::priv_client::write_agent_github_token(agent, token)
.await
.with_context(|| format!("write github-token for agent {agent}"))?;
Ok(HostResponse::messages(vec![format!(
"wrote github-token for agent '{agent}' \
(read live by the gh wrapper / git credential helper — no rebuild needed)"
)]))
}
async fn handle_quota_enable() -> Result<HostResponse> {
crate::priv_client::ensure_btrfs_quota()
.await

View file

@ -4,8 +4,9 @@
//! mark read on the source.
//!
//! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT
//! (`github-token`, provisioned from the dashboard credentials tab — see
//! `docs/integrations/github.md`) and `HIVE_AGENT_SOCKET` is the harness's todo socket.
//! (`github-token`, fetched from the swarm secret store by
//! `nix/agent-modules/github-token.nix` — see `docs/integrations/github.md`)
//! and `HIVE_AGENT_SOCKET` is the harness's todo socket.
//! Having a PAT *is* the opt-in: with no token the poller logs why and
//! exits 0, so deploying this unit to an agent that never gets one costs a
//! settled process rather than a restart loop.
@ -39,9 +40,9 @@ async fn main() {
github_loop(hive_forge_notify::state_dir(), socket).await;
}
/// Waits for the PAT to appear: the token is written out of band from the
/// dashboard and takes effect without a rebuild, so an agent that gains a
/// PAT mid-session starts getting notifications on the next tick rather
/// Waits for the PAT to appear: the agent's store fetch writes the token
/// out of band and it takes effect without a rebuild, so an agent that gains
/// a PAT mid-session starts getting notifications on the next tick rather
/// than after a restart. Gives up — returning, so the process exits 0
/// rather than restart-looping — when no PAT ever arrives, which is the
/// common case for an agent that has the unit but no account.

View file

@ -315,12 +315,6 @@ pub enum HostRequest {
/// Daemon-side equivalent of `hivectl gateway list-users`; the usernames
/// come back in [`HostResponse::messages`], one per line.
GatewayListUsers,
/// Write (or overwrite) an agent's GitHub PAT under its state dir, via
/// the privileged helper. Daemon-side equivalent of `hivectl github
/// set-token`. `token` is resolved + non-empty-validated client-side
/// (inline flag or stdin); the daemon just persists it. Read live by
/// the agent's `gh` wrapper / git credential helper — no rebuild needed.
SetAgentGithubToken { agent: Ident, token: String },
/// Turn on btrfs qgroup accounting on the agent-state filesystem, via
/// the privileged helper. Daemon-side equivalent of `hivectl
/// quota-enable`. Returns advisory lines in [`HostResponse::messages`].

View file

@ -478,22 +478,6 @@ pub enum PrivRequest {
paused: bool,
},
// --- Agent credential writes ---
/// Write `github-token` into `AGENT_STATE_ROOT/<agent_name>/state/github-token`.
///
/// The operator-supplied GitHub personal access token (PAT) for the
/// agent's GitHub integration (`services.hyperhive.agent.github.enable`).
/// hive-priv validates `agent_name`, creates the state dir
/// if absent, writes the file 0600, and chowns it to the agent so the
/// `gh` wrapper / git credential helper can read it. No account suffix
/// (single GitHub account per agent).
WriteAgentGithubToken {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// PAT value. hive-priv appends a trailing newline before writing.
token: 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

View file

@ -407,11 +407,6 @@ async fn exec(
paused,
} => exec_set_agent_paused(agent_name, paused),
PrivRequest::WriteAgentGithubToken {
ref agent_name,
ref token,
} => write_github_token(agent_name, token),
PrivRequest::RegisterCiRunner { ref token } => register_ci_runner(token).await,
PrivRequest::ControlInfraContainer { container, action } => {
@ -547,12 +542,6 @@ fn exec_set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, Stri
set_agent_paused(agent_name, paused)
}
/// `WriteAgentGithubToken`.
fn write_github_token(agent_name: &str, token: &str) -> Result<(String, String)> {
validate_agent_name(agent_name)?;
write_agent_state_file(agent_name, "github-token", &format!("{token}\n"))
}
/// `EnsureAgentSubvolume`.
async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> {
validate_agent_name(agent_name)?;
@ -1486,22 +1475,6 @@ fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)
staged.publish()
}
/// 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),
/// and chmods 0600. Running as root (hive-priv), so this succeeds
/// regardless of the file's prior owner/permissions.
fn write_agent_state_file(
agent_name: &str,
filename: &str,
content: &str,
) -> Result<(String, String)> {
let state_dir = PathBuf::from(AGENT_STATE_ROOT)
.join(agent_name)
.join("state");
write_agent_dir_file(agent_name, &state_dir, filename, content)
}
/// Create or remove an agent's pause marker under its harness dir. The
/// marker is written empty and chowned to the harness dir's owner (the
/// agent), matching how the harness itself would have created it.
@ -1536,10 +1509,9 @@ fn remove_marker_in(dir: &Path, filename: &str) -> Result<()> {
}
/// Write `content` to `dir/filename` as root, chowning the result to `dir`'s
/// owner so the agent process can read it back. Shared by the credential
/// writes (which target `state/`) and the pause marker (which targets
/// `harness/`) — both write into a directory owned by the agent, which is
/// precisely why they need hive-priv at all.
/// owner so the agent process can read it back. Its caller is the pause
/// marker, in `harness/`, a directory owned by the agent, which is precisely
/// why it needs hive-priv at all.
///
/// The file is published through a [`StagedFile`], so a reader woken by its
/// appearance cannot catch it empty or half-written: several of these paths
@ -1563,8 +1535,7 @@ fn write_agent_dir_file(
// yet (container being provisioned for the first time), the newly created dir
// is root:root. The `stat state_dir` chown below will then see uid=0 and
// leave the file root-owned (0600). The agent won't be able to read it until
// its lifecycle completes. If that happens, a `systemctl restart hive-c0re`
// after provisioning will re-mint and re-write the token correctly.
// its lifecycle completes.
std::fs::create_dir_all(&state_dir)
.with_context(|| format!("create state dir {}", state_dir.display()))?;
@ -1592,7 +1563,7 @@ fn write_agent_dir_file(
agent = %agent_name,
path = %path.display(),
error = %e,
"write_agent_state_file: fchown failed"
"write_agent_dir_file: fchown failed"
);
}
}
@ -1600,7 +1571,7 @@ fn write_agent_dir_file(
tracing::warn!(
agent = %agent_name,
error = %e,
"write_agent_state_file: stat state_dir failed, leaving file root-owned"
"write_agent_dir_file: stat state_dir failed, leaving file root-owned"
);
}
}

View file

@ -3,7 +3,7 @@
The operator-facing host CLI. A thin client for the `hive-c0re`
daemon — speaks the host admin socket protocol (`hive-host-sock`) and
does not link the daemon crate. Container lifecycle, the approval
queue, and the matrix/github/gateway verbs all forward to the daemon
queue, and the matrix/gateway verbs all forward to the daemon
and need it running; a few (`wg`/`peer-config`, `choom`) work off local
host state instead.
@ -30,7 +30,6 @@ dispatch:
- **`forge.rs`** — reconciles an agent's config against its forge
repo.
- **`matrix.rs`** — invite a matrix user to the hive Space or a room.
- **`github.rs`** — per-agent GitHub PAT writes (`set-token`).
- **`gateway.rs`** — gateway Basic-auth user management (htpasswd).
- **`wg.rs`** — WireGuard mesh helpers.
- **`subvol.rs`** — btrfs state-subvolume ops.

View file

@ -45,15 +45,6 @@ pub enum Cmd {
#[command(subcommand)]
cmd: MatrixCmd,
},
/// GitHub account provisioning.
///
/// Store an operator-supplied personal access token (PAT) for an agent
/// so its `gh` and git can authenticate. Creates no account — the PAT
/// is for an existing GitHub account.
Github {
#[command(subcommand)]
cmd: GithubCmd,
},
/// Gateway htpasswd user management.
///
/// Add, remove, or list users for the gateway's HTTP Basic auth.
@ -299,26 +290,6 @@ pub enum MatrixCmd {
},
}
#[derive(Subcommand)]
pub enum GithubCmd {
/// Store a GitHub PAT for `<agent>` so its `gh` and git can
/// authenticate.
///
/// Prefer `--token-stdin` — an inline `--token` is visible in shell
/// history.
SetToken {
/// Logical agent name (the container/agent name).
agent: String,
/// The PAT value inline. Mutually exclusive with `--token-stdin`.
#[arg(long)]
token: Option<String>,
/// Read the PAT from stdin (trailing newline stripped). Mutually
/// exclusive with `--token`.
#[arg(long, conflicts_with = "token")]
token_stdin: bool,
},
}
#[derive(Subcommand)]
pub enum GatewayCmd {
/// Add a user or update an existing user's password in the gateway

View file

@ -1,48 +0,0 @@
//! `hivectl github set-token <agent>` — write an operator-supplied GitHub PAT
//! into an agent's `github-token` state file via the daemon's privileged
//! helper, so the agent's `gh` wrapper + git credential helper authenticate.
use std::path::Path;
use anyhow::{Result, bail};
use crate::util::daemon_request;
/// `hivectl github set-token <agent>`: write an operator-supplied GitHub PAT
/// into the agent's `github-token` state file (0600, agent-owned) via
/// hive-priv, so the agent's `gh` wrapper + git credential helper can
/// authenticate. Read live at invocation, so no rebuild/restart is needed.
pub(crate) async fn github_set_token(
socket: &Path,
agent: &str,
token: Option<String>,
token_stdin: bool,
) -> Result<()> {
// Resolve + validate the token client-side (inline flag or stdin read);
// the daemon never touches this process's stdin. Persistence happens
// daemon-side via the privileged helper.
let token = match (token, token_stdin) {
(Some(t), _) => t,
(None, true) => {
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s.trim_end_matches(['\n', '\r']).to_owned()
}
(None, false) => bail!(
"provide the PAT via --token <pat> or --token-stdin (stdin preferred — \
an inline token is visible in shell history + process listings)"
),
};
if token.is_empty() {
bail!("refusing to write an empty GitHub token for agent '{agent}'");
}
daemon_request(
socket,
hive_host_sock::HostRequest::SetAgentGithubToken {
agent: crate::util::parse_ident(agent)?,
token,
},
"github",
)
.await
}

View file

@ -5,7 +5,7 @@
//! Container lifecycle + the approval queue (`agent <name> <spawn|kill|
//! rebuild|restart|choom|…>`, `list-agents`, `approvals <pending|approve|
//! deny>`, `stop` / `start`) and provisioning (`forge` / `matrix` /
//! `github` / `gateway`) all forward to the daemon, which owns the broker,
//! `gateway`) all forward to the daemon, which owns the broker,
//! the credentials, and the provisioning logic — a running daemon is
//! required for those. A couple of verbs work off local host state
//! directly instead (`wg` / `peer-config` read the mesh key + TLS CA), so
@ -27,7 +27,7 @@ mod client;
/// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner /
/// plain renderers), split out to keep this file manageable.
mod dag_progress;
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, GithubCmd, WgCmd};
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, WgCmd};
mod completions;
mod quota;
mod util;
@ -41,10 +41,8 @@ use open::open_url;
mod wg;
use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status};
mod choom;
mod github;
mod watch;
use github::github_set_token;
mod forge;
mod watch;
use forge::forge_reconcile_config;
mod agents;
use agents::{agents_list, run_agent};
@ -73,13 +71,6 @@ async fn main() -> Result<()> {
} => forge_reconcile_config(&socket, &agent, from, verbose).await,
},
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await,
Cmd::Github { cmd } => match cmd {
GithubCmd::SetToken {
agent,
token,
token_stdin,
} => github_set_token(&socket, &agent, token, token_stdin).await,
},
Cmd::Gateway { cmd } => match cmd {
GatewayCmd::CreateUser {
username,

View file

@ -18,7 +18,7 @@ pub(crate) fn parse_ident(name: &str) -> Result<hive_types::Ident> {
/// Send a provisioning request to the daemon and print its result lines.
/// The daemon owns the provisioning logic; hivectl just relays the outcome,
/// prefixing any error with `label` (e.g. `forge` / `github`).
/// prefixing any error with `label` (e.g. `forge` / `gateway`).
pub(crate) async fn daemon_request(
socket: &Path,
req: hive_host_sock::HostRequest,

View file

@ -50,6 +50,7 @@ in
./forge-token.nix
./frontend.nix
./github.nix
./github-token.nix
./logs.nix
./matrix.nix
./mcp.nix

View file

@ -0,0 +1,154 @@
# This agent's GitHub personal access token, fetched from the swarm secret
# store by the agent itself, into the file ./github.nix's readers use.
#
# An operator links the token in the swarm UI; `swarm-controller` stores it at
# `swarm/agents/<agent>/github-token` (`swarm_secret_client::github`). The
# agent's own read grant covers that path, so this unit reads it and writes
# `<state>/github-token`, which the `gh` wrapper, the git credential helper and
# `hive-github-notify` read.
#
# It never deletes. A `github-token` already in place stays when the store has
# none or cannot be read. The 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-accounts.nix load.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-github-token";
# The nix half of `swarm_secret_client::github::account_path` plus
# `path::MOUNT`.
tokenPath = "secret/swarm/agents/${agentName}/github-token";
runtimeDir = unitName;
# The store's whole answer, token included: kept in the unit's own `0700`
# directory, never in the state dir.
rawFile = "/run/${runtimeDir}/account.json";
errFile = "/run/${runtimeDir}/bao.err";
tokenFile = "${stateDir}/github-token";
stagedFile = "${stateDir}/.github-token.new";
configured = cfg.addr != null && config.services.hyperhive.agent.github.enable;
storeRetry = import ../host-modules/lib/store-retry.nix { };
in
{
config = lib.mkIf configured {
systemd.services.${unitName} = {
description = "fetch this agent's GitHub token from the secret store";
after = [
"network.target"
"hive-agent-bao-identity.service"
];
# The poller reads the token once at start.
before = [ "hive-github-notify.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 = 30;
User = agentName;
Group = agentName;
RuntimeDirectory = runtimeDir;
RuntimeDirectoryMode = "0700";
# `0600`, the mode `github-token` has 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 GitHub token." >&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}
staged=${lib.escapeShellArg stagedFile}
token=${lib.escapeShellArg tokenFile}
trap 'rm -f "$err" "$raw" "$staged"' 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
# No token linked and a store that cannot answer look alike here, and
# either way the file in place, if any, is kept.
if ! bao kv get -format=json ${lib.escapeShellArg tokenPath} >"$raw" 2>"$err"; then
echo "no GitHub token read from ${tokenPath}; github-token left as it is:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 0
fi
# ⚠️ The token goes from the store's answer straight into a file; it is
# never in a variable or an argument. A malformed object exits 0:
# failing the unit would only restart it into the same answer.
rm -f "$staged"
if ! jq -er '.data.data.value | strings' "$raw" >"$staged"; then
echo "${tokenPath} holds no string value; github-token left as it is." >&2
exit 0
fi
if cmp -s "$staged" "$token"; then
echo "this agent's GitHub token at ${tokenPath} is unchanged."
exit 0
fi
mv -f "$staged" "$token"
echo "fetched this agent's GitHub token from ${tokenPath}."
'';
};
# The same cadence as ./forge-accounts.nix.
systemd.timers.${unitName} = {
description = "re-fetch this agent's GitHub token from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "2min";
RandomizedDelaySec = "20s";
};
};
};
}

View file

@ -1,7 +1,7 @@
# GitHub integration (services.hyperhive.agent.github.enable): a `gh` wrapper + a git
# credential helper, both reading the PAT from the agent's
# `github-token` state file at invocation, so a dashboard-pasted token
# takes effect with no rebuild. The token PATH is baked in at build
# `github-token` state file at invocation, so a token ./github-token.nix
# fetches takes effect with no rebuild. The token PATH is baked in at build
# time (nix knows `userName`) — NOT read from `$HIVE_GITHUB_TOKEN_FILE`,
# because claude's Bash tool runs `bash -c` in a minimal env that
# doesn't source `/etc/set-environment`, so the env var isn't present
@ -41,12 +41,13 @@ in
description = ''
Install the GitHub integration in this agent: a `gh` CLI wrapper and a
git credential helper for `https://github.com`, both authenticated from
an operator-supplied personal access token (PAT). The PAT is written to
`<state>/github-token` out of band --- the dashboard credentials tab or
`hivectl github set-token` --- so giving an agent GitHub is a runtime
paste, no per-agent config or rebuild. The wrappers read the token file
at invocation, so a freshly-pasted PAT takes effect immediately; until
one exists, `gh` / `git push` just fail unauthenticated.
an operator-supplied personal access token (PAT). The operator links the
PAT in the swarm UI, and the agent fetches it from the swarm secret store
into `<state>/github-token` (needs `services.hyperhive.agent.bao.addr`),
so giving an agent GitHub is a runtime action, no per-agent config or
rebuild. The wrappers read the token file at invocation, so a fetched
PAT takes effect immediately; until one exists, `gh` / `git push` just
fail unauthenticated.
github.com only. git authenticates as `x-access-token` + the PAT (GitHub
ignores the username for PAT auth); `gh` derives its identity from the

View file

@ -126,6 +126,10 @@ in
inherit pkgs self nixosSystem;
inherit (pkgs) lib;
};
module-eval-agent-github-bao = import ./module-eval/agent-github-bao.nix {
inherit pkgs self nixosSystem;
inherit (pkgs) lib;
};
module-eval-agent-memory = import ./module-eval/agent-memory.nix {
inherit pkgs self nixosSystem;
inherit (pkgs) lib;

View file

@ -219,9 +219,8 @@ in
description = ''
Hive-wide switch for the per-agent GitHub integration (the `gh` CLI
wrapper + git credential helper, per `hyperhive.github.enable`). On by
default: every agent gets the integration, inert until a PAT is
provisioned via the dashboard credentials tab or `hivectl github
set-token`. Set `false` to turn it off for the whole hive --- the
default: every agent gets the integration, inert until an operator links
a PAT for it in the swarm UI. Set `false` to turn it off for the whole hive --- the
meta-flake renderer (`hive-c0re/src/meta.rs`) then injects
`hyperhive.github.enable = false` into every agent. Exposed to hive-c0re
as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off).

View file

@ -0,0 +1,114 @@
# `checks.module-eval-agent-github-bao` — see ./lib.nix for the shared
# rationale (why this suite exists, naming convention, "evaluates
# not executes").
#
# The agent side of the swarm-stored GitHub token: ../agent-modules/github-token.nix
# fetches it into the file ../agent-modules/github.nix's readers use.
{
pkgs,
lib,
self,
nixosSystem,
}:
let
inherit
(import ./lib.nix {
inherit
pkgs
lib
self
nixosSystem
;
})
agentWith
runGroup
;
baoAddr = "https://bao.t.local:8200";
# A store, and the integration on by default.
agentGithubBao = agentWith {
services.hyperhive.agent.bao.addr = baoAddr;
};
# No store: the absence arm, and what makes the cases above able to fail.
agentGithubNoBao = agentWith { };
# A store, and the integration switched off.
agentGithubOff = agentWith {
services.hyperhive.agent.bao.addr = baoAddr;
services.hyperhive.agent.github.enable = false;
};
fetchUnit = machine: machine.systemd.services.hive-agent-github-token;
has = machine: machine.systemd.services ? hive-agent-github-token;
hasTimer = machine: machine.systemd.timers ? hive-agent-github-token;
in
let
cases = [
{
name = "an agent with a store address and the integration on fetches its github token";
ok = has agentGithubBao && hasTimer agentGithubBao;
}
{
name = "an agent with no store address, or with the integration off, fetches none";
ok =
!(has agentGithubNoBao)
&& !(hasTimer agentGithubNoBao)
&& !(has agentGithubOff)
&& !(hasTimer agentGithubOff);
}
{
# The nix half of `swarm_secret_client::github::account_path`, and the
# file ./github.nix's `gh` wrapper, credential helper and poller read.
name = "the fetch reads the agent's own github-token path into its state-dir file";
ok =
let
name = agentGithubBao.services.hyperhive.agent.user.name;
s = (fetchUnit agentGithubBao).script;
in
lib.hasInfix "bao kv get -format=json secret/swarm/agents/${name}/github-token >" s
&& lib.hasInfix "token=/agents/${name}/state/github-token" s
&& lib.hasInfix ".data.data.value | strings" s;
}
{
# The agent user owns its state dir (./user.nix), and the file keeps the
# `0600` it has always had.
name = "the fetch runs as the agent, with its own store identity";
ok =
let
u = fetchUnit agentGithubBao;
name = agentGithubBao.services.hyperhive.agent.user.name;
in
u.serviceConfig.User == name
&& u.serviceConfig.UMask == "0077"
&& builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
&& u.environment.BAO_ADDR == baoAddr
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
}
{
# A file a hive wrote keeps working until the operator links a token in
# the swarm UI.
name = "the fetch never deletes the state-dir token, and swaps it in only on a change";
ok =
let
s = (fetchUnit agentGithubBao).script;
in
!(lib.hasInfix "rm -f \"$token\"" s)
&& lib.hasInfix "cmp -s \"$staged\" \"$token\"" s
&& lib.hasInfix "mv -f \"$staged\" \"$token\"" s;
}
{
# The poller reads the token once at start.
name = "the fetch runs before the github poller starts";
ok = builtins.elem "hive-github-notify.service" (fetchUnit agentGithubBao).before;
}
{
name = "the fetch re-runs every two minutes";
ok = agentGithubBao.systemd.timers.hive-agent-github-token.timerConfig.OnUnitInactiveSec == "2min";
}
];
in
runGroup "agent-github-bao" cases

View file

@ -28,10 +28,10 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
- **Agent credentials** — at start and every five minutes it re-checks every
agent's forge token and matrix account, and renews store certificates and
queue secrets as they age.
- **Linked external accounts** — an operator-supplied matrix or forge
account for one agent, stored in the swarm secret store
- **Linked external accounts** — an operator-supplied matrix, forge or
GitHub account for one agent, stored in the swarm secret store
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
`.../forge-accounts/{label}`); distinct from the agent's own swarm-minted
`.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted
accounts above.
- **Config PR status** — each agent's open config-repo PR, cached from forge
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`).

View file

@ -0,0 +1,176 @@
//! An agent's GitHub personal access token: an operator hands us the token, we
//! put it in the swarm's secret store.
//!
//! The agent end is `nix/agent-modules/github-token.nix`, which reads it under
//! the agent's own certificate into the `github-token` file its `gh` wrapper,
//! git credential helper and `hive-github-notify` read. No hive is in the path.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::Deserialize;
use swarm_secret_client::github;
use utoipa::ToSchema;
use super::{AppState, error_problem, swarm_hive};
/// The token to store for one agent.
///
/// No `Debug` derive: this carries a token.
#[derive(Deserialize, ToSchema)]
pub struct PutGithubAccountRequest {
/// The personal access token. Never logged, and never returned by this
/// route.
token: String,
}
/// Store an agent's GitHub token.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
/// token the agent will next read.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/github-account",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the token belongs to"),
),
request_body = PutGithubAccountRequest,
responses(
(status = 204, description = "stored"),
(status = 400, description = "the agent is not an identifier, 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_github_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
Json(req): Json<PutGithubAccountRequest>,
) -> Result<StatusCode, 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();
let secret_path = github::account_path(&agent)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let credential = credential(&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, &credential).await.map_err(|e| {
// The path names the agent; the value is not in it.
tracing::warn!(path = %secret_path, error = %e, "writing the github token failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
tracing::info!(%hive, %agent, "github token stored");
Ok(StatusCode::NO_CONTENT)
}
/// The request as it is stored, or why it cannot be.
fn credential(req: &PutGithubAccountRequest) -> Result<github::Credential, &'static str> {
let token = req.token.trim();
if token.is_empty() {
return Err("token is required");
}
Ok(github::Credential {
value: token.to_owned(),
})
}
#[cfg(test)]
mod tests {
use super::{PutGithubAccountRequest, credential};
fn request(token: &str) -> PutGithubAccountRequest {
PutGithubAccountRequest {
token: token.to_owned(),
}
}
#[test]
fn the_token_is_kept_without_surrounding_whitespace() {
let c = credential(&request(" t0k3n\n")).expect("valid");
assert_eq!(c.value, "t0k3n");
}
#[test]
fn an_empty_token_is_refused() {
assert!(credential(&request(" ")).is_err());
assert!(credential(&request("")).is_err());
}
/// Bare-minimum `AppState`, as `forge_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(agent: &str, token: &str) -> problem_details::ProblemDetails {
super::put_github_account(
axum::extract::State(state()),
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
axum::Json(request(token)),
)
.await
.expect_err("no store is configured in a test")
}
fn assert_store_unset() {
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"
);
}
}
/// An agent name or an empty token is refused before the store: with
/// `BAO_*` unset a store connect would answer 500.
#[tokio::test]
async fn a_bad_agent_or_an_empty_token_is_refused_before_the_store() {
assert_store_unset();
for (agent, token) in [("Atlas", "t0k3n"), ("../x", "t0k3n"), ("atlas", " ")] {
let problem = put(agent, token).await;
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{agent:?}: {problem:?}"
);
}
}
/// The control: a plain agent and a token reach the store connect.
#[tokio::test]
async fn a_plain_request_reaches_the_store() {
assert_store_unset();
let problem = put("atlas", "t0k3n").await;
assert_eq!(
problem.status,
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
"{problem:?}"
);
}
}

View file

@ -49,6 +49,7 @@ mod auth;
mod config_pr;
mod forge;
mod forge_account;
mod github_account;
mod issue_report;
mod matrix_account;
mod otel_http_client;
@ -2890,6 +2891,7 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(set_agent_state))
.routes(routes!(matrix_account::put_matrix_account))
.routes(routes!(forge_account::put_forge_account))
.routes(routes!(github_account::put_github_account))
.routes(routes!(get_hive_wanted))
.routes(routes!(term_stream::stream_agent_term))
.routes(routes!(agent_state_stream::stream_agent_state))

View file

@ -0,0 +1,128 @@
//! The GitHub agreement: where an agent's GitHub personal access token lives in
//! the store, and what the object at that path holds.
//!
//! An operator hands `swarm-controller` the token, the controller stores it at
//! [`account_path`], and `nix/agent-modules/github-token.nix` reads it back
//! under the agent's own certificate. One token per agent, so one path and no
//! directory to list. Neither end is senior, so both halves are stated once,
//! here, beside [`crate::forge`].
use serde::{Deserialize, Serialize};
use crate::{
Error,
path::{Kind, principal_prefix},
};
/// The path holding `agent`'s GitHub token.
///
/// A flat leaf under the agent's prefix, like
/// [`crate::forge::agent_token_path`], so the agent's own read stanza
/// ([`crate::policy::render_agent`]) already covers it.
///
/// # Errors
/// [`Error::PathSegment`] when `agent` 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) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Agent, agent)?;
Ok(format!("{prefix}/github-token"))
}
/// What an [`account_path`] holds.
///
/// No `Debug` derive: `value` is a live GitHub credential, and a derived
/// `Debug` is one `{:?}` away from a log line.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Credential {
/// The token. `github-token.nix` reads the store with
/// `bao kv get -field=value`, so this name is load-bearing for a reader
/// this crate does not control.
pub value: String,
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credential")
.field("value", &"<redacted>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn covered_by(policy: &str, path: &str) -> bool {
policy.lines().any(|line| {
line.strip_prefix("path \"")
.and_then(|rest| rest.split_once("\" {"))
.and_then(|(p, _)| p.strip_suffix('*'))
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
})
}
#[test]
fn the_token_lands_under_the_agent_prefix() {
// Spelled out: `github-token.nix` spells the same string.
assert_eq!(
account_path("atlas").expect("a plain name is legal"),
"swarm/agents/atlas/github-token"
);
}
#[test]
fn a_traversal_in_the_agent_name_is_refused() {
for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] {
let e = account_path(bad).expect_err("a traversal is not legal");
assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}");
}
// The control: the legal charset stays reachable.
assert!(account_path("a-b_C9").is_ok());
}
#[test]
fn the_agents_own_read_grant_covers_the_path() {
let path = account_path("atlas").expect("legal");
let own = crate::policy::render_agent("atlas").expect("legal");
assert!(
covered_by(&own, &path),
"no stanza in\n{own}\ncovers {path}"
);
// The control: another agent's grant does not reach it.
let other = crate::policy::render_agent("argus").expect("legal");
assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
}
#[test]
fn no_github_key_is_also_a_directory() {
// KV-v2 cannot hold a key whose name is also a prefix of another key.
let key = account_path("atlas").expect("legal");
for other in [
crate::forge::agent_token_path("atlas").expect("legal"),
crate::forge::accounts_dir("atlas").expect("legal"),
] {
assert_ne!(key, other);
assert!(!other.starts_with(&format!("{key}/")), "{other}");
assert!(!key.starts_with(&format!("{other}/")), "{key}");
}
}
#[test]
fn the_value_field_matches_what_the_nix_reader_asks_for() {
let json = serde_json::to_string(&Credential {
value: "t".to_owned(),
})
.expect("a String serialises");
assert_eq!(json, r#"{"value":"t"}"#);
}
#[test]
fn debug_never_prints_the_value() {
let c = Credential {
value: "0123456789abcdef".to_owned(),
};
let shown = format!("{c:?}");
assert!(!shown.contains("0123456789abcdef"), "{shown}");
assert!(shown.contains("redacted"), "{shown}");
}
}

View file

@ -5,7 +5,7 @@
//! the rules every path obeys ([`path`]), the translation from this
//! deployment's environment into a logged-in client ([`client`]), and, per kind
//! of secret, the path it lives at together with the fields it holds
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`acp`]). Each of those is a thing the controller
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`acp`]). Each of those is a thing the controller
//! and a hive must say identically, so it is said once here.
//!
//! [`policy`] is the same kind of agreement seen from the other side: which of
@ -25,6 +25,7 @@
pub mod acp;
pub mod client;
pub mod forge;
pub mod github;
pub mod matrix;
pub mod mtls;
pub mod path;