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 (PAT) — so it can run GitHub API calls and push commits without any manual
`gh auth login`. `gh auth login`.
Provisioning is UI-driven: paste a PAT into the agent's credentials tab Provisioning is UI-driven: link a PAT to the agent in the swarm UI and it
and it works. No per-agent nix declaration, no rebuild — hive-c0re works. No per-agent nix declaration, no rebuild — the agent fetches the token
injects the token into the agent's state dir out of band. from the swarm secret store itself.
## Enabling ## 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 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 = 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 needed — an agent gains GitHub by having a PAT stored for it.
file.
<!-- vale write-good.Passive = YES --> <!-- 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` 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 (`services.hyperhive.agent.github.enable` also exists per-agent for completeness, but the
hive-wide host switch is the intended control.) hive-wide host switch is the intended control.)
github.com only. The token **value** never touches nix — it's written to github.com only. The token **value** never touches nix — the agent writes it
`<state>/github-token` separately (see [Provisioning](#provisioning)). to `<state>/github-token` at runtime (see [Provisioning](#provisioning)).
## Provisioning ## Provisioning
The PAT is operator-supplied. The primary path is the **dashboard The PAT is operator-supplied. In the [swarm UI](../swarm/ui.md#linking-external-accounts),
credentials tab** (github sub-tab): paste the PAT for an agent and submit open the agent on `/agents`, choose **link github account** and paste the PAT
(`POST /api/github-account`). A CLI path also exists for (`PUT /api/hives/{hive}/agents/{agent}/github-account`). swarm-controller
recovery/scripting: 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 The agent's `hive-agent-github-token` unit reads that path under the
hivectl github set-token <agent> --token-stdin # paste the PAT on stdin (preferred) agent's own store certificate and writes `<state>/github-token` (`0600`,
hivectl github set-token <agent> --token <pat> # inline (visible in shell history) 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.
Either path has hive-c0re delegate the write to hive-priv, which stores the The unit never deletes the file: a `github-token` already in place stays
file `0600` owned by the agent (so the container can read it) — the same when the store holds none or doesn't answer. Where the token lives and who
credential-injection path as forge/matrix tokens. See reads it: [credentials.md](../swarm/credentials.md).
[hivectl → GitHub](../tools/hivectl.md#github).
## Security ## 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>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer | | `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by linking the label again | ✅ the agent re-fetches on a 2-minute timer | | `swarm/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/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs | | `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |

View file

@ -8,9 +8,9 @@ domain, covers host-level detail for one hive.
## What it shows ## What it shows
| route | what | | route | what |
| ------------------------- | ------------------------------------------------------------------------------------------- | | ------------------------- | --------------------------------------------------------------------------------------------------- |
| `/` | the hive directory, each hive with its last reported status | | `/` | 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 | | `/agents/<name>/terminal` | one agent's live terminal |
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/issues` | a cross-repo issue report | | `/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 ### Linking external accounts
Each agent on `/agents` opens two dialogs that write a credential for it Each agent on `/agents` opens three dialogs that write a credential for it
into the swarm secret store through swarm-controller. Both are blind 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. 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}`, - **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 `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 the same label again overwrites both files, and a pair whose label the
store doesn't list stays untouched. 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: Where each credential lives and who reads it:
[`credentials.md`](credentials.md). [`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 forge reconcile-config`↴](#hivectl-forge-reconcile-config)
* [`hivectl matrix`↴](#hivectl-matrix) * [`hivectl matrix`↴](#hivectl-matrix)
* [`hivectl matrix invite`↴](#hivectl-matrix-invite) * [`hivectl matrix invite`↴](#hivectl-matrix-invite)
* [`hivectl github`↴](#hivectl-github)
* [`hivectl github set-token`↴](#hivectl-github-set-token)
* [`hivectl gateway`↴](#hivectl-gateway) * [`hivectl gateway`↴](#hivectl-gateway)
* [`hivectl gateway create-user`↴](#hivectl-gateway-create-user) * [`hivectl gateway create-user`↴](#hivectl-gateway-create-user)
* [`hivectl gateway delete-user`↴](#hivectl-gateway-delete-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 * `forge` — Reconcile an agent's config between this hive and the forge
* `matrix` — matrix-tuwunel invites * `matrix` — matrix-tuwunel invites
* `github` — GitHub account provisioning
* `gateway` — Gateway htpasswd user management * `gateway` — Gateway htpasswd user management
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running * `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 * `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` ## `hivectl gateway`
Gateway htpasswd user management. 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). invite power (it owns the hive Space, so that case always works).
Idempotent — already-member / already-invited is a no-op. 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 ## Gateway
Manage users in the gateway's HTTP Basic auth htpasswd file 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) | | `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` | | `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 | | `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 | | `RegisterCiRunner` | write `/run/hive-ci/runner-token` (host path, root-owned) then `systemctl --machine=hive-ci restart gitea-runner-hive.service`. Only the registration token crosses; the forge admin token never enters the container |
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs | | `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |
| `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first | | `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first |

View file

@ -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 H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
the whole swarm), **Logs** (`/logs.html`, per-agent and host the whole swarm), **Logs** (`/logs.html`, per-agent and host
journals), **Stats** (`/stats.html`, swarm-wide usage stats), journals), **Stats** (`/stats.html`, swarm-wide usage stats),
**Builds** (`/builds.html`, the rebuild queue and build history), **Builds** (`/builds.html`, the rebuild queue and build history), and
**Core** (`/core.html`, tombstones and container resource use), and **Core** (`/core.html`, tombstones and container resource use). Your local
**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
browser preferences (notifications) live in the dashboard's Y3R C4LL browser preferences (notifications) live in the dashboard's Y3R C4LL
tab. 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 recurring or one-shot prompts you schedule for one or more agents, and
reminders agents have set for themselves. 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 ## More depth
Both the dashboard and the per-agent pages are SPAs sharing one 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. — sits below the tab strip.
- **Server-warnings banner** — a generic, sticky top-of-page strip shown - **Server-warnings banner** — a generic, sticky top-of-page strip shown
on **every** page (dashboard + every stand-alone page — FL0W, L0GS, 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 of `<body>` by `renderServerWarnings` in `common.js`. Driven by
`state.server_warnings` — a list of `{ kind, level, message }` — and `state.server_warnings` — a list of `{ kind, level, message }` — and
coloured by `level` (`warn` amber / `crit` red). hive-c0re owns the 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 (suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the
entry with that id pre-expanded. 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 ## P3RM1SS10NS tab
Per-agent permission configuration. Two sections, each rendered as a 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 The H0M3 hub is the primary landing page (served at `/` by default). A
responsive grid of link tiles — Dashboard, Flow, Logs, Builds, Stats, 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 surfaces, all unconditionally shown (no gating). The page is a pure
portal with no tab-bar or SSE subscriptions. Typography + colours inherit portal with no tab-bar or SSE subscriptions. Typography + colours inherit
from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`). 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`, CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
loaded by every page, plus a page-specific bundle — loaded by every page, plus a page-specific bundle —
`dashboard.css` / `flow.css` / `logs.css` / `home.css` / `dashboard.css` / `flow.css` / `logs.css` / `home.css` /
`stats.css` / `core.css` / `builds.css` / `stats.css` / `core.css` / `builds.css`);
`credentials.css`); `common.css` inlines `@hive/shared`'s `common.css` inlines `@hive/shared`'s
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` + `base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
`pill.css` via esbuild's `@import` resolution. `pill.css` via esbuild's `@import` resolution.
`terminal.js` exports `{ create, linkify }` as ES module `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 // 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 // `.html`), each named after its own entry point below — see the
// `entryPoints`/`for` lists for the exact map, not repeated here to // `entryPoints`/`for` lists for the exact map, not repeated here to
// avoid this comment drifting out of sync with the real build steps. // avoid this comment drifting out of sync with the real build steps.
@ -40,7 +40,6 @@ await build({
src("stats.js"), src("stats.js"),
src("core.js"), src("core.js"),
src("builds.js"), src("builds.js"),
src("credentials.js"),
], ],
outdir: staticDir(""), outdir: staticDir(""),
bundle: true, bundle: true,
@ -107,7 +106,6 @@ for (const entry of [
"stats.css", "stats.css",
"core.css", "core.css",
"builds.css", "builds.css",
"credentials.css",
]) { ]) {
await build({ await build({
entryPoints: [src(entry)], entryPoints: [src(entry)],
@ -126,7 +124,6 @@ for (const html of [
"stats.html", "stats.html",
"core.html", "core.html",
"builds.html", "builds.html",
"credentials.html",
]) { ]) {
copyFileSync(src(html), dist(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> <span class="home-tile-desc">kept state · container load</span>
</a> </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 <!-- API tile: the OpenAPI spec + Swagger UI are always served by
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
endpoints), so this tile is never gated/hidden. --> 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 // 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 // rendering the <a class="hive-tab"> markup from a declarative `tabs` list
// instead of every page hand-writing the same <nav><a>...</a></nav> // instead of every page hand-writing the same <nav><a>...</a></nav>
// boilerplate, then wires the existing createTabStrip() behaviour // 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 { CreateAgentForm } from "../CreateAgentForm.js";
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js"; import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js"; import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js";
import { WantedMenu } from "./WantedMenu.js"; import { WantedMenu } from "./WantedMenu.js";
import "./AgentsPage.css"; import "./AgentsPage.css";
@ -98,6 +99,8 @@ const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
export function AgentsPage() { export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null); 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 [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -600,6 +603,22 @@ export function AgentsPage() {
: "no hive on record for this agent — nothing to link against" : "no hive on record for this agent — nothing to link against"
} }
/> />
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link 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> </div>
{/* MVP scope per mara's own ruling: a small read-only {/* MVP scope per mara's own ruling: a small read-only
preview, no header/no input — the full terminal preview, no header/no input — the full terminal
@ -651,6 +670,20 @@ export function AgentsPage() {
/> />
) : null} ) : null}
</Dialog> </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 <ConfirmDialog
open={confirmTarget !== null} open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""} 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 = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"), (name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"), (name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"), (name = "permissions", description = "tool-group + capability assignment for agents"),
@ -61,7 +60,6 @@ pub(crate) use hive_types::Ident;
mod health; mod health;
mod journal; mod journal;
mod lifecycle_ops; mod lifecycle_ops;
mod matrix_accounts;
mod meta_inputs; mod meta_inputs;
mod misc_api; mod misc_api;
pub(crate) mod permissions; pub(crate) mod permissions;
@ -126,8 +124,7 @@ pub async fn serve(
// call below is therefore scoped to exactly one path — two handlers // call below is therefore scoped to exactly one path — two handlers
// in the same call only when they genuinely share a path with // in the same call only when they genuinely share a path with
// different methods (`schedules::api_schedules`/`post_schedule_new` // different methods (`schedules::api_schedules`/`post_schedule_new`
// on `/api/schedules`, `matrix_accounts::get_github_account`/ // on `/api/schedules`) — chained via
// `post_github_account` on `/api/github-account`) — chained via
// repeated `.routes(...)` calls instead of one giant `routes!(...)` // repeated `.routes(...)` calls instead of one giant `routes!(...)`
// with everything in it. // with everything in it.
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi()) 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!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state)) .routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file)) .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_operator_inbox))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))
@ -371,10 +364,6 @@ mod router_build_probe {
.routes(routes!(journal::get_journal_host)) .routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state)) .routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file)) .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_operator_inbox))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))

View file

@ -369,25 +369,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
.await?) .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 /// Register the hive-ci Forgejo Actions runner: hand the freshly-minted
/// registration token to hive-priv, which writes it to the host-side /// registration token to hive-priv, which writes it to the host-side
/// `/run/hive-ci/runner-token` env-file and restarts the in-container runner. /// `/run/hive-ci/runner-token` env-file and restarts the in-container runner.

View file

@ -224,9 +224,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostRequest::GatewayListUsers => { HostRequest::GatewayListUsers => {
HostResponse::messages(crate::gateway_nginx::list_users()?) 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::QuotaEnable => handle_quota_enable().await?,
HostRequest::QuotaLimit { name, limit } => { HostRequest::QuotaLimit { name, limit } => {
handle_quota_limit(name.as_str(), *limit).await? 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> { async fn handle_quota_enable() -> Result<HostResponse> {
crate::priv_client::ensure_btrfs_quota() crate::priv_client::ensure_btrfs_quota()
.await .await

View file

@ -4,8 +4,9 @@
//! mark read on the source. //! mark read on the source.
//! //!
//! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT //! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT
//! (`github-token`, provisioned from the dashboard credentials tab — see //! (`github-token`, fetched from the swarm secret store by
//! `docs/integrations/github.md`) and `HIVE_AGENT_SOCKET` is the harness's todo socket. //! `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 //! 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 //! exits 0, so deploying this unit to an agent that never gets one costs a
//! settled process rather than a restart loop. //! settled process rather than a restart loop.
@ -39,9 +40,9 @@ async fn main() {
github_loop(hive_forge_notify::state_dir(), socket).await; github_loop(hive_forge_notify::state_dir(), socket).await;
} }
/// Waits for the PAT to appear: the token is written out of band from the /// Waits for the PAT to appear: the agent's store fetch writes the token
/// dashboard and takes effect without a rebuild, so an agent that gains a /// out of band and it takes effect without a rebuild, so an agent that gains
/// PAT mid-session starts getting notifications on the next tick rather /// a PAT mid-session starts getting notifications on the next tick rather
/// than after a restart. Gives up — returning, so the process exits 0 /// than after a restart. Gives up — returning, so the process exits 0
/// rather than restart-looping — when no PAT ever arrives, which is the /// rather than restart-looping — when no PAT ever arrives, which is the
/// common case for an agent that has the unit but no account. /// 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 /// Daemon-side equivalent of `hivectl gateway list-users`; the usernames
/// come back in [`HostResponse::messages`], one per line. /// come back in [`HostResponse::messages`], one per line.
GatewayListUsers, 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 /// Turn on btrfs qgroup accounting on the agent-state filesystem, via
/// the privileged helper. Daemon-side equivalent of `hivectl /// the privileged helper. Daemon-side equivalent of `hivectl
/// quota-enable`. Returns advisory lines in [`HostResponse::messages`]. /// quota-enable`. Returns advisory lines in [`HostResponse::messages`].

View file

@ -478,22 +478,6 @@ pub enum PrivRequest {
paused: bool, 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 /// Register the hive-ci Forgejo Actions runner: write the registration
/// token to the host-side `/run/hive-ci/runner-token` env-file (root-owned, /// token to the host-side `/run/hive-ci/runner-token` env-file (root-owned,
/// bind-mounted read-only into the container) as `TOKEN=<token>`, then /// bind-mounted read-only into the container) as `TOKEN=<token>`, then

View file

@ -407,11 +407,6 @@ async fn exec(
paused, paused,
} => exec_set_agent_paused(agent_name, 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::RegisterCiRunner { ref token } => register_ci_runner(token).await,
PrivRequest::ControlInfraContainer { container, action } => { 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) 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`. /// `EnsureAgentSubvolume`.
async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> { async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> {
validate_agent_name(agent_name)?; validate_agent_name(agent_name)?;
@ -1486,22 +1475,6 @@ fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)
staged.publish() 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 /// 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 /// marker is written empty and chowned to the harness dir's owner (the
/// agent), matching how the harness itself would have created it. /// 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 /// 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 /// owner so the agent process can read it back. Its caller is the pause
/// writes (which target `state/`) and the pause marker (which targets /// marker, in `harness/`, a directory owned by the agent, which is precisely
/// `harness/`) — both write into a directory owned by the agent, which is /// why it needs hive-priv at all.
/// precisely why they need hive-priv at all.
/// ///
/// The file is published through a [`StagedFile`], so a reader woken by its /// 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 /// 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 // 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 // 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 // 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` // its lifecycle completes.
// after provisioning will re-mint and re-write the token correctly.
std::fs::create_dir_all(&state_dir) std::fs::create_dir_all(&state_dir)
.with_context(|| format!("create state dir {}", state_dir.display()))?; .with_context(|| format!("create state dir {}", state_dir.display()))?;
@ -1592,7 +1563,7 @@ fn write_agent_dir_file(
agent = %agent_name, agent = %agent_name,
path = %path.display(), path = %path.display(),
error = %e, 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!( tracing::warn!(
agent = %agent_name, agent = %agent_name,
error = %e, 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` The operator-facing host CLI. A thin client for the `hive-c0re`
daemon — speaks the host admin socket protocol (`hive-host-sock`) and daemon — speaks the host admin socket protocol (`hive-host-sock`) and
does not link the daemon crate. Container lifecycle, the approval 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 and need it running; a few (`wg`/`peer-config`, `choom`) work off local
host state instead. host state instead.
@ -30,7 +30,6 @@ dispatch:
- **`forge.rs`** — reconciles an agent's config against its forge - **`forge.rs`** — reconciles an agent's config against its forge
repo. repo.
- **`matrix.rs`** — invite a matrix user to the hive Space or a room. - **`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). - **`gateway.rs`** — gateway Basic-auth user management (htpasswd).
- **`wg.rs`** — WireGuard mesh helpers. - **`wg.rs`** — WireGuard mesh helpers.
- **`subvol.rs`** — btrfs state-subvolume ops. - **`subvol.rs`** — btrfs state-subvolume ops.

View file

@ -45,15 +45,6 @@ pub enum Cmd {
#[command(subcommand)] #[command(subcommand)]
cmd: MatrixCmd, 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. /// Gateway htpasswd user management.
/// ///
/// Add, remove, or list users for the gateway's HTTP Basic auth. /// 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)] #[derive(Subcommand)]
pub enum GatewayCmd { pub enum GatewayCmd {
/// Add a user or update an existing user's password in the gateway /// 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| //! Container lifecycle + the approval queue (`agent <name> <spawn|kill|
//! rebuild|restart|choom|…>`, `list-agents`, `approvals <pending|approve| //! rebuild|restart|choom|…>`, `list-agents`, `approvals <pending|approve|
//! deny>`, `stop` / `start`) and provisioning (`forge` / `matrix` / //! 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 //! the credentials, and the provisioning logic — a running daemon is
//! required for those. A couple of verbs work off local host state //! required for those. A couple of verbs work off local host state
//! directly instead (`wg` / `peer-config` read the mesh key + TLS CA), so //! 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 / /// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner /
/// plain renderers), split out to keep this file manageable. /// plain renderers), split out to keep this file manageable.
mod dag_progress; mod dag_progress;
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, GithubCmd, WgCmd}; use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, WgCmd};
mod completions; mod completions;
mod quota; mod quota;
mod util; mod util;
@ -41,10 +41,8 @@ use open::open_url;
mod wg; mod wg;
use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status}; use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status};
mod choom; mod choom;
mod github;
mod watch;
use github::github_set_token;
mod forge; mod forge;
mod watch;
use forge::forge_reconcile_config; use forge::forge_reconcile_config;
mod agents; mod agents;
use agents::{agents_list, run_agent}; use agents::{agents_list, run_agent};
@ -73,13 +71,6 @@ async fn main() -> Result<()> {
} => forge_reconcile_config(&socket, &agent, from, verbose).await, } => forge_reconcile_config(&socket, &agent, from, verbose).await,
}, },
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).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 { Cmd::Gateway { cmd } => match cmd {
GatewayCmd::CreateUser { GatewayCmd::CreateUser {
username, 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. /// Send a provisioning request to the daemon and print its result lines.
/// The daemon owns the provisioning logic; hivectl just relays the outcome, /// 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( pub(crate) async fn daemon_request(
socket: &Path, socket: &Path,
req: hive_host_sock::HostRequest, req: hive_host_sock::HostRequest,

View file

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

View file

@ -219,9 +219,8 @@ in
description = '' description = ''
Hive-wide switch for the per-agent GitHub integration (the `gh` CLI Hive-wide switch for the per-agent GitHub integration (the `gh` CLI
wrapper + git credential helper, per `hyperhive.github.enable`). On by wrapper + git credential helper, per `hyperhive.github.enable`). On by
default: every agent gets the integration, inert until a PAT is default: every agent gets the integration, inert until an operator links
provisioned via the dashboard credentials tab or `hivectl github a PAT for it in the swarm UI. Set `false` to turn it off for the whole hive --- the
set-token`. Set `false` to turn it off for the whole hive --- the
meta-flake renderer (`hive-c0re/src/meta.rs`) then injects meta-flake renderer (`hive-c0re/src/meta.rs`) then injects
`hyperhive.github.enable = false` into every agent. Exposed to hive-c0re `hyperhive.github.enable = false` into every agent. Exposed to hive-c0re
as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off). 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 credentials** — at start and every five minutes it re-checks every
agent's forge token and matrix account, and renews store certificates and agent's forge token and matrix account, and renews store certificates and
queue secrets as they age. queue secrets as they age.
- **Linked external accounts** — an operator-supplied matrix or forge - **Linked external accounts** — an operator-supplied matrix, forge or
account for one agent, stored in the swarm secret store GitHub account for one agent, stored in the swarm secret store
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`, (`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. accounts above.
- **Config PR status** — each agent's open config-repo PR, cached from forge - **Config PR status** — each agent's open config-repo PR, cached from forge
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`). 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 config_pr;
mod forge; mod forge;
mod forge_account; mod forge_account;
mod github_account;
mod issue_report; mod issue_report;
mod matrix_account; mod matrix_account;
mod otel_http_client; mod otel_http_client;
@ -2890,6 +2891,7 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(set_agent_state)) .routes(routes!(set_agent_state))
.routes(routes!(matrix_account::put_matrix_account)) .routes(routes!(matrix_account::put_matrix_account))
.routes(routes!(forge_account::put_forge_account)) .routes(routes!(forge_account::put_forge_account))
.routes(routes!(github_account::put_github_account))
.routes(routes!(get_hive_wanted)) .routes(routes!(get_hive_wanted))
.routes(routes!(term_stream::stream_agent_term)) .routes(routes!(term_stream::stream_agent_term))
.routes(routes!(agent_state_stream::stream_agent_state)) .routes(routes!(agent_state_stream::stream_agent_state))

View file

@ -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 //! the rules every path obeys ([`path`]), the translation from this
//! deployment's environment into a logged-in client ([`client`]), and, per kind //! 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 //! 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. //! 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 //! [`policy`] is the same kind of agreement seen from the other side: which of
@ -25,6 +25,7 @@
pub mod acp; pub mod acp;
pub mod client; pub mod client;
pub mod forge; pub mod forge;
pub mod github;
pub mod matrix; pub mod matrix;
pub mod mtls; pub mod mtls;
pub mod path; pub mod path;