Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
34bde7a5af nix: allSwarmServices turns on the review classifier
deploy.review-classifier.enable now defaults to allSwarmServices, as the
other shared services do, but only where the host has the classifier's
store identity (deploy.bao.reviewClassifierClient{Cert,Key}File). The
classifier module asserts that pair; glue-bao-tls.nix mints it only on the
host that runs the store, so a services host with the store placed
elsewhere would otherwise fail eval. An explicit value still wins.

The operator still writes the Anthropic key by hand; until then each run
fails with "no API key…" while the service runs.
2026-10-12 13:11:21 +02:00
atlas
ed15105e6f swarm-controller: document every try-out status and row shape in the OpenAPI
The routes' annotations now list every 400 reason (including the JSON
shape rejections), the 503 causes, the 31-minute running window, the
404 after a controller restart, and that an errored row reads
not-applicable with certainty 0 and an empty reason, so the page keys
off error, and that overall can be approve beside errored rows.
2026-10-12 13:11:21 +02:00
atlas
af1495731e swarm-review-classifier: claude's environment is an allowlist, not the daemon's
hive-claude adds its variables on top of the parent's environment, so
claude inherited the daemon's store identity paths, CREDENTIALS_DIRECTORY
and queue coordinates. claude now starts as this same binary with
SWARM_REVIEW_CLASSIFIER_EXEC_CLAUDE naming the real one; main execs it
before the runtime starts, with the environment cleared down to the API
key, PATH, HOME, CLAUDE_CONFIG_DIR and claude's telemetry settings. The
exec keeps the pid, so hive-claude's cancel and idle timeout still reach
it. An integration test runs the built binary as the trampoline and
checks that a BAO_* variable does not reach the program it execs.
2026-10-12 13:11:21 +02:00
atlas
8e33de6fb7 nix: hand the review classifier its store leaf as an nspawn credential
The leaf's key was copied by a host unit into the container's root on
host disk. nspawn now reads the cert, key and store CA as root at
container start (--load-credential) and passes them in as system
credentials; the daemon's unit loads them by bare id, as an agent's
container does with its own leaf (nix/agent-modules/bao.nix). The copy
unit is gone. Where this host mints the leaf, the container starts after
swarm-bao-pki, since nspawn refuses a missing credential source. An
assertion refuses identity paths with whitespace, which nixos-container's
unquoted extra flags would split.
2026-10-12 13:11:21 +02:00
atlas
c0fd4e9848 module-eval: hosting the swarm's shared services leaves the review classifier off
Pins that deploy.review-classifier.enable is not derived from
allSwarmServices: the same fixture turns the queue on and leaves the
classifier and its container absent.
2026-10-12 13:11:21 +02:00
atlas
6c9cb66dce docs: the review classifier's try-out service, its key and its subject
- review-classifier.md: claude runs spawn through hive-claude directly
  with no tools and no session (hive-runtime holds only TurnStop); the
  answer's fields, the 500-character reason cap and what a failure does;
  how the try-out page reaches the classifier; the settled parts of the
  open list (API key path, try-out subject, --tools "" on claude).
  The banner stays.
- services.md: a Review classifier section with the two operator steps.
- secrets.md, credentials.md: the classifier's leaf and the key row.
- network.md: the container's row. CLAUDE.md: the crate.
2026-10-12 13:11:21 +02:00
atlas
5d8ab675cd nix: run the review classifier in a container of its own when a host enables it
deploy.review-classifier.enable (off by default, not derived from
allSwarmServices) puts swarm-review-classifier in its own network
namespace on the bridge, with claude-code and the daemon. A host unit
copies the classifier's store leaf into the container; the daemon reads
its queue client secret and the API key from the store with it.

- glue-bao-tls mints the review-classifier leaf; swarm-bao writes the
  swarm-review-classifier role, reading exactly the API key path and the
  classifier's queue client secret; swarm.nix reserves its CN.
- glue-review-classifier-oidc-client registers the machine client where
  authelia runs, and the publisher carries its secret to the store.
- swarm-nats passes the client id to the responder as
  --review-classifier-client.
- Where the host runs a hive-tier collector, claude exports its metrics
  there; the daemon adds the repo and the PR to the resource attributes.
- module-eval: the new grant and leaf (bao-grants, now sixteen granting
  units), the container's presence and its unit (core-toggle), and the
  client registration and publishing (secret-publisher).
2026-10-12 13:11:21 +02:00
atlas
d70ed41537 swarm-controller: start a review try-out on a PR and read its result
POST /api/review-classifier/try-out validates the run (owner/name repo,
a PR number, one to ten classifications with distinct names, thresholds
up to 100), reads the PR's head commit, commits and diff with the
controller's forge token, sends one request to the review classifier on
the queue and answers 202 with an id. GET .../try-out/{id} reports the
run as running, done (rows and overall verdict) or failed (the error).
Results live in memory for an hour after the reply; there is no run
store. POST then GET because a run can outlast the gateway's 60-second
proxy read timeout. Both routes are utoipa-annotated, so they show on
/api/docs/.
2026-10-12 13:11:21 +02:00
atlas
88a55f136e swarm-review-classifier: answer a try-out with one claude run per classification
A new service crate. It reads its queue client secret from the store at
start, takes try-outs on the review classifier subject in its queue
group, and for each run reads the Anthropic API key from the store,
builds the shared context (commits with full messages, diff with
lockfiles collapsed), runs one claude --print per classification with no
session, no tools, no MCP server and the key in the child's environment
only, validates the JSON answer and aggregates the rows. A missing key or
a run where no classification got an answer fails the run; one failed
classification is an excluded row with its error. One journal line per
run names the operator, repo, PR, head SHA, rows and overall verdict.

swarm-secret-client gains the two paths the service reads.
2026-10-12 13:11:21 +02:00
atlas
98c873965b swarm-nats-auth: a review classifier hears try-outs and answers by reply only
swarm-queue-client gains review_classifier: the try-out subject, the
request and reply that travel on it, and the deadlines both ends share.
The controller's reader grant may publish the subject; a client named
with --review-classifier-client may subscribe to it and its inbox, and
answers under NATS allow_responses (resp in the user JWT) within the
run deadline. Every other client id is still denied.
2026-10-12 13:11:21 +02:00
50 changed files with 3188 additions and 97 deletions

View file

@ -187,6 +187,12 @@ hand-maintained per-file tree drifts out of sync with the code.
defaults look for `VAULT_ADDR`/`VAULT_CLIENT_CERT`/`VAULT_CLIENT_KEY`, which
no unit in this tree sets, so falling through to them yields a client with
**no identity** and a TLS handshake failure that names no cause.
- **`swarm-review-classifier/`** — the review classifier's try-out daemon,
in its own container where `deploy.review-classifier.enable` is set. Takes
a PR and classifications from `swarm-controller` over the queue, runs one
`claude --print` per classification (no tools, no session) and replies
with the rows and the overall verdict. Holds no forge credential. Design:
[`docs/swarm/review-classifier.md`](docs/swarm/review-classifier.md).
- **`swarmctl/`** — swarm-level operator CLI, installed by the
swarm-controller module on the host that runs the daemon. Runs as
**root and acts directly** — it serves no socket, publishes no HTTP

19
Cargo.lock generated
View file

@ -4850,6 +4850,7 @@ dependencies = [
"swarm-queue-client",
"swarm-secret-client",
"tokio",
"tower",
"tracing",
"url",
"utoipa",
@ -4928,6 +4929,24 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"utoipa",
]
[[package]]
name = "swarm-review-classifier"
version = "0.1.0"
dependencies = [
"anyhow",
"async-nats",
"futures-util",
"hive-claude",
"hive-log",
"serde",
"serde_json",
"swarm-queue-client",
"swarm-secret-client",
"tokio",
"tracing",
]
[[package]]

View file

@ -32,6 +32,7 @@ members = [
"swarm-matrix-ctl",
"swarm-nats-auth",
"swarm-queue-client",
"swarm-review-classifier",
"swarm-logs",
"swarm-secret-client",
"swarmctl",

View file

@ -43,11 +43,12 @@ that touches it.
```
| container | netns | IPv4 | listens / reached via |
| ----------------- | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
| ------------------------- | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
| gateway (host) | host | host addresses | nginx `:80`/`:443` (every vhost); dnsmasq `bridgeIp:53` + DHCP `:67` on the bridge |
| `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.<swarm-domain>` vhost |
| `hive-matrix` | host (shared) | host addresses | tuwunel `:8008` (+ optional federation port); fronted by the matrix vhost |
| `swarm-ci-runner` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface |
| `swarm-review-classifier` | private, veth on bridge | DHCP pool | outbound only (classifier → queue, secret store, hive collector); no inbound surface |
| `h-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → nginx sub-path; in-container UI port hashed 8100–8999 |
The flows, end to end:

View file

@ -56,7 +56,7 @@ of the cell says how.
<!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated |
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
@ -71,8 +71,9 @@ of the cell says how.
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, with the forge's admin API, 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 `forge::reader_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ hive-c0re reads the store on every forge read |
| `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>/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | hive-c0re, under the hive's own certificate, when it first connects to the queue | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read until the first connect succeeds, retried with a backoff of up to five minutes while it's missing; then held until hive-c0re restarts |
| `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate — the queue responder's under `swarm-nats-auth`'s, the homeserver's under `swarm-matrix-ctl`'s, the forge's under `swarm-forge-publisher`'s; the bao UI's by the store's granter | must be stated | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate — the queue responder's under `swarm-nats-auth`'s, the homeserver's under `swarm-matrix-ctl`'s, the forge's under `swarm-forge-publisher`'s, the review classifier's under `swarm-review-classifier`'s; the bao UI's by the store's granter | must be stated | must be stated |
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge's admin API, at start and in a five-minute pass; its grant on the path is write-only | `swarm-bao-ci-runner` on the runner's host, under the runner's own certificate, into a file only `swarm-ci-runner-register` in the runner's container loads; no other principal's policy reads it | ❌ the forge hands out one token until an admin resets it; the controller writes a reset token within five minutes | ❌ `swarm-bao-ci-runner` reads it when it starts, at boot or deploy, and restarts the runner when the value changed |
| `swarm/services/review-classifier/anthropic-api-key` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every run; held for that run and handed to `claude`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
<!-- vale write-good.Passive = YES -->

View file

@ -98,14 +98,16 @@ It looks like an agent from the inside, with three things missing: it has no
full agent identity, no state directory and no tools. Every model session is
ephemeral, and nothing carries over from one run to the next.
The model sessions run through [`hive-runtime`](../../hive-runtime/README.md),
the crate `hive-agent` and `hive-subagent-mcp` already drive their turns
through: the `Runtime` trait in `hive-runtime/src/lib.rs`, with
`ClaudeRuntime` and `AcpRuntime` behind it. That trait runs turns on a durable
session, and the subagent daemon's claude path
(`hive-subagent-mcp/src/session.rs`) spawns claude directly. A refactor
first moves the cancellable per-turn claude session into `hive-runtime` and
moves the subagent daemon onto it; the classifier builds on that shared seam.
On the claude runtimes, each classification is one `claude --print` the
service spawns through the `hive-claude` crate directly, with no session to
resume, no tools (`--tools ""`), no MCP server (`--strict-mcp-config` and no
config) and an empty directory of its own. A classification needs no durable
session, so the service doesn't use [`hive-runtime`](../../hive-runtime/README.md),
whose `Runtime` trait drives the agents' durable sessions.
The service is `swarm-review-classifier`, and its container is the one
[`services.md`](services.md#review-classifier) describes. It holds no forge
credential: `swarm-controller` reads the PR and sends it along.
### Swarm-level settings
@ -147,9 +149,9 @@ The model answers each classification in JSON. The answer holds:
- a **verdict**, one of _match_, _no match_ or _not applicable to this PR_
- a **certainty** from 0 to 100, how sure the model is of that verdict
- a **reason**, limited in length
- a **reason**, at most 500 characters
An illustrative answer, with placeholder field names:
An answer:
```json
{
@ -159,6 +161,13 @@ An illustrative answer, with placeholder field names:
}
```
The verdict field holds `match`, `no-match` or `not-applicable`, and the
certainty field a whole number. A runtime error, a timeout, or an answer that
isn't that JSON object leaves the classification without a verdict: it's listed
with its error, and doesn't count. A run with no API key, or in which no
classification got an answer, fails as a whole, and shows its error instead of a
verdict.
### Aggregation into the verdict
The classifier collects the answers into one result, keyed by
@ -224,6 +233,14 @@ repo and a PR, write or edit a classification, and run it against that PR;
change the prompt and run it again until it answers the way you want.
Nothing reaches the forge while you iterate.
The page posts the run to `swarm-controller` (`POST
/api/review-classifier/try-out`), which reads the PR with its own forge token
and sends it to the classifier as one queue request on
`$SWARM.review-classifier.try-out`. The page then reads the result back (`GET
/api/review-classifier/try-out/{id}`): a run can take longer than a request
stays open. The controller keeps the result in memory for an hour; there is no
run store.
When it's ready, **open a PR** has the service account push a branch that
adds the classification to the repo's config, with you, the swarm UI user,
as commit author. The page then links you to the forge's new-PR page for
@ -234,7 +251,8 @@ that branch, where you log in as usual and open the PR yourself.
The swarm UI shows no token counts, cache counts or cost, on any page. On the
claude runtimes, every classifier run starts its `claude` process with
`OTEL_RESOURCE_ATTRIBUTES` naming the repo and the PR, so the cost reads from
the metrics store by repo and PR. On a subscription, cost is notional.
the metrics store by repo and PR. The process exports to the hive-tier collector on
the classifier's host, where that host runs one. On a subscription, cost is notional.
On the ACP runtime, the harness counts the agent's `usage_update` running
totals and exports them as `hyperhive.agent.cost.usage`, labelled with only
@ -255,13 +273,8 @@ These get settled, and filled in here, as the work reaches them:
token counts before choosing.
- **Run store and retention.** Where run records live, how long they stay,
and how the recent-runs page reads them.
- **Failure handling.** What a runtime error, malformed JSON, a timeout or a
rate limit does to a classification and to the verdict.
- **JSON validation and the reason cap.** The exact answer schema, and the
reason's length limit.
- **Oversized diffs.** What happens when the diff doesn't fit the model's
context, and whether lockfiles collapse as in `hive-forge diff`
(`hive-forge/src/verbs/diff.rs`).
context. Lockfiles already collapse to one line each, as in `hive-forge diff`.
- **Stale reviews.** Whether the repo's stale-review dismissal setting
retires the bot's review on an older commit, or the service dismisses its
own earlier review when it posts a new one.
@ -272,16 +285,19 @@ These get settled, and filled in here, as the work reaches them:
whether anything gets redacted.
- **Prompt injection.** A PR's diff and commit messages are model input, so
a PR can try to talk the classifier into approving it.
- **The "no tools" mechanism.** How each runtime runs a session with no
tools at all.
- **Credential paths.** Where in bao the runtime credentials and the service
account's forge token live, and which component holds the token.
- **The "no tools" mechanism on ACP.** How the ACP runtime runs a session with
no tools at all. The claude runtimes pass `--tools ""`.
- **Credential paths.** Where in bao the ACP and subscription runtimes'
credentials and the service account's forge token live, and which component
holds the token. The API key is at
`secret/swarm/services/review-classifier/anthropic-api-key`, field `api_key`.
- **Model naming across runtimes.** How the swarm-level model setting
handles runtime-specific model names.
- **ACP cost labels.** How an ACP run's cost gets a repo and PR label, so
it reads from the metrics store like the claude runtimes' cost.
- **Async message medium.** Which queue subjects or streams carry the
messages between `swarm-controller` and the classifier.
- **Async message medium for review runs.** Which queue subjects or streams
carry a webhook-started run. A try-out is one request on
`$SWARM.review-classifier.try-out`, answered on its reply subject.
- **Reviewer handoff.** How a later reviewer, human or agent, learns the
classifier passed a PR before it starts its own review.
- **Opt-in details.** Beyond the config file: the service account's access
@ -289,5 +305,3 @@ These get settled, and filled in here, as the work reaches them:
branches.
- **Commit author for try-out PRs.** How the classifier derives the commit
author's name and email from the swarm UI login.
- **Refactor scope.** How much of `hive-agent`'s and `hive-subagent-mcp`'s
runtime code moves into `hive-runtime` before the classifier uses it.

View file

@ -390,43 +390,46 @@ next controller restart.
### Per-principal identities
Bao matches a cert-auth role on the certificate's subject, so a certificate is an
identity and sharing one merges the identities. Each of these six leaves has a
role and a policy of its own, and reaches one path, except the forge host's,
which reaches two:
identity and sharing one merges the identities. Each of these seven leaves has
a role and a policy of its own, and reaches one path, except the review
classifier's and the forge host's, which reach two:
| unit | option pair under `deploy.bao.` | reads, or writes |
| --------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| --------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `swarm-bao-queue-agent` | `queueAgentClientCertFile` / `queueAgentClientKeyFile` | `swarm/hives/<hive>/queue/agent` |
| `swarm-bao-grafana-oidc` | `grafanaOidcClientCertFile` / `grafanaOidcClientKeyFile` | `swarm/services/<grafana client id>/oidc/client` |
| `swarm-bao-otel-oidc` | `otelOidcClientCertFile` / `otelOidcClientKeyFile` | `swarm/services/<collector client id>/oidc/client` |
| `swarm-bao-forwarder-oidc` | `forwarderOidcClientCertFile` / `forwarderOidcClientKeyFile` | `swarm/services/<store forwarder client id>/oidc/client` |
| `swarm-bao-ci-runner` | `ciRunnerClientCertFile` / `ciRunnerClientKeyFile` | `swarm/services/ci-runner/registration-token` |
| `swarm-review-classifier`, in its container | `reviewClassifierClientCertFile` / `reviewClassifierClientKeyFile` | `swarm/services/review-classifier/anthropic-api-key` and `swarm/services/<classifier client id>/oidc/client` |
| `swarm-bao-forge-publisher`, `swarm-bao-forge-oidc` | `forgePublisherClientCertFile` / `forgePublisherClientKeyFile` | writes `swarm/controller/swarm-controller/forge/token`; reads `swarm/services/<forge client id>/oidc/client` |
The first exists **per hive**, because the path it reads carries a hive name
and every hive runs its own reader. Its subject is
`<deploy.bao.queueAgentCommonNamePrefix>-<hive>`; `swarm.nix` reserves that
composed spelling as a hive name, so nobody can name a hive into another hive's
role. The other five reach a path that names a swarm service rather than a hive,
role. The other six reach a path that names a swarm service rather than a hive,
so one role each is enough and their subjects are the flat
`deploy.bao.grafanaOidcCommonName`, `deploy.bao.otelOidcCommonName`,
`deploy.bao.forwarderOidcCommonName`, `deploy.bao.ciRunnerCommonName` and
`deploy.bao.forwarderOidcCommonName`, `deploy.bao.ciRunnerCommonName`,
`deploy.bao.reviewClassifierCommonName` and
`deploy.bao.forgePublisherCommonName`. The last is the only writer, and the one
leaf two units share: the forge's host holds one store identity for the token it
publishes and the OIDC client secret it reads.
On a host that mints its own PKI, `glue-bao-tls.nix` signs all six and defaults
all twelve options, and there is nothing to do. Elsewhere you issue each leaf from
On a host that mints its own PKI, `glue-bao-tls.nix` signs all seven and defaults
all fourteen options, and there is nothing to do. Elsewhere you issue each leaf from
that CA out of band and name it here — one file per principal rather than one file
shared by six, which is the whole of what this buys. `swarm-ci-runner.nix`
shared by seven, which is the whole of what this buys. `swarm-ci-runner.nix`
refuses the build wherever the runner runs without its pair, as
`swarm-grafana.nix` does for Grafana. The forge's pair refuses nothing: without
`swarm-review-classifier.nix` does for the classifier and `swarm-grafana.nix`
for Grafana. The forge's pair refuses nothing: without
it `swarm-bao-forge-publisher` and `swarm-bao-forge-oidc` render, fail at start
naming both options, and the controller runs without forge access and the forge
without a login source until an operator places the leaf.
On a host without the store, where the forwarder never runs, forgetting one of
the other six isn't a quiet degrade. A hand-configured remote-store hive that
the other seven isn't a quiet degrade. A hand-configured remote-store hive that
names the hive's own `clientCertFile` and misses a principal's pair has the
build refused, by the module of the reader it missed, naming that pair — see
[what a missing leaf costs](#what-a-missing-leaf-costs) below.

View file

@ -232,3 +232,25 @@ publishes to the secret store, which it reads under its own `ci-runner` leaf.
On a host without the store, that leaf is the one file to copy. Registration,
the container and the threat model are in
[`../scheduler/ci.md`](../scheduler/ci.md).
### Review classifier
The review classifier's try-out service, in a `swarm-review-classifier`
container on the host that sets `deploy.review-classifier.enable`.
`allSwarmServices` defaults it on wherever the host has the classifier's store
identity: the host that runs the store has it, and a services host with the
store elsewhere gets the classifier only once it holds a copy of the pair (see
below). Set `deploy.review-classifier.enable` directly to override either way.
The operator writes the Anthropic key by hand, in the secret store's UI at
`secret/swarm/services/review-classifier/anthropic-api-key`, field `api_key`.
Nothing writes it for you: until it exists the service runs and every run fails
with `no API key…`. The service reads the key on every run, so writing or
removing it takes effect on the next run, with no restart. Every run spends
money on that key.
It needs neither the forge nor a hive on its host. It reaches the queue and the
secret store by name, under its own `review-classifier` leaf. On a host
without the store, that leaf is the one file to copy, as for the CI runner.
The swarm UI's try-out page drives it through swarm-controller; what a run is
and what it costs are in [`review-classifier.md`](review-classifier.md).

View file

@ -160,6 +160,9 @@
services.hyperhive.deploy.matrix.ctlPackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-ctl;
services.hyperhive.deploy.review-classifier.package =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-review-classifier;
services.hyperhive.deploy.authelia.bridgePackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;

View file

@ -39,6 +39,7 @@
./glue-nats-bao-identity.nix
./glue-nats-oidc-client.nix
./glue-queue-agent-credential.nix
./glue-review-classifier-oidc-client.nix
./glue-secret-publisher-bao-identity.nix
./glue-swarm-bao-otel-oidc-client.nix
./glue-swarm-otel-oidc-client.nix
@ -47,6 +48,7 @@
./swarm-bao-service.nix
./swarm-bao.nix
./swarm-ci-runner.nix
./swarm-review-classifier.nix
./swarm-secret-publisher.nix
./swarm-nats-service.nix
./swarm-nats.nix

View file

@ -642,6 +642,27 @@ in
'';
};
review-classifier.enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Run the review classifier in a `swarm-review-classifier` container on
this host. It answers the swarm UI's review try-outs with `claude` on
the Anthropic API key the operator writes to the secret store at
`secret/swarm/services/review-classifier/anthropic-api-key`, field
`api_key`.
On by default where
{option}`services.hyperhive.deploy.allSwarmServices` is set and this
host has the classifier's store identity (the pair
{option}`services.hyperhive.deploy.bao.reviewClassifierClientCertFile`
and `reviewClassifierClientKeyFile`, minted on the store's host). The
key is never written for you: until the operator writes it the service
runs and every run fails with "no API key…".
'';
};
hive-controller.enable = lib.mkOption {
type = lib.types.bool;
default = false;

View file

@ -107,6 +107,8 @@ in
forwarderOidcClientKeyFile = lib.mkDefault "${pkiDir}/forwarder-oidc-key.pem";
ciRunnerClientCertFile = lib.mkDefault "${pkiDir}/ci-runner.pem";
ciRunnerClientKeyFile = lib.mkDefault "${pkiDir}/ci-runner-key.pem";
reviewClassifierClientCertFile = lib.mkDefault "${pkiDir}/review-classifier.pem";
reviewClassifierClientKeyFile = lib.mkDefault "${pkiDir}/review-classifier-key.pem";
forgePublisherClientCertFile = lib.mkDefault "${pkiDir}/forge-publisher.pem";
forgePublisherClientKeyFile = lib.mkDefault "${pkiDir}/forge-publisher-key.pem";
servicesIssuerClientCertFile = lib.mkDefault "${pkiDir}/services-issuer.pem";
@ -268,6 +270,13 @@ in
[ -s ${pkiDir}/ci-runner.pem ] || ${signLeaf} ${pkiDir} ci-runner \
${lib.escapeShellArg deployCfg.bao.ciRunnerCommonName} "" clientAuth
# The review classifier's, which reads its API key and queue client
# secret. Minted here for the matrix-ctl leaf's reason: the classifier
# is a swarm service, so on a classifier host without the store this is
# the file an operator copies.
[ -s ${pkiDir}/review-classifier.pem ] || ${signLeaf} ${pkiDir} review-classifier \
${lib.escapeShellArg deployCfg.bao.reviewClassifierCommonName} "" clientAuth
# The forge's token publisher. Minted here for the matrix-ctl leaf's
# reason: the forge is a swarm singleton, so on a forge host without the
# store this is the file an operator copies.

View file

@ -0,0 +1,35 @@
# Glue: register the review classifier as an OIDC client wherever authelia
# runs.
#
# ONE PAIRING PER FILE — swarm-review-classifier ← authelia, and nothing else.
# Deleting this leaves a classifier whose token requests authelia answers with
# a 401, and no minted secret for swarm-secret-publish to put in the store.
#
# ⚠️ Gated on authelia being HERE, and NOT on this host running the
# classifier, for ./glue-controller-oidc-client.nix's reason: a client is a row
# in THIS host's provider config. Registered whether or not any host enables
# the classifier: nothing in `swarm.*` records that, so a swarm without one
# carries one unused client and one unused minted secret.
{
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
deployCfg = hyperhiveCfg.deploy;
clientId = hyperhiveCfg.swarm.review-classifier.clientId;
in
{
config = lib.mkIf deployCfg.authelia.enable {
services.hyperhive.swarm.authelia.oidc.clients = [
{
id = clientId;
description = "HyperHive review classifier";
# `client_credentials`: a daemon authenticating as itself.
kind = "machine";
audience = [ clientId ];
}
];
};
}

View file

@ -932,6 +932,19 @@ let
}
];
# The review classifier: the API key the operator writes, read once per run,
# and its own queue client secret, read at start. Both by the daemon inside
# its container.
reviewClassifierReaders = [
{
name = "swarm-review-classifier";
cn = baoDeploy.reviewClassifierCommonName;
policyText =
readStanza "${credentialMountPath}/data/swarm/services/review-classifier/anthropic-api-key"
+ readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.review-classifier.clientId}/oidc/client";
}
];
# The forge host's one store identity. `swarm-bao-forge-publisher` writes
# the controller's forge token under it, the one writer of that path and
# write-only: it copies a file in and never reads one back.
@ -1754,6 +1767,20 @@ in
'';
};
reviewClassifierCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-review-classifier";
description = ''
Subject the store's `swarm-review-classifier` cert-auth role accepts:
the identity the review classifier presents from inside its
container. Its grant is two paths, read only:
`swarm/services/review-classifier/anthropic-api-key` and its own
queue client secret.
⚠️ Reserved as a hive name by ./swarm.nix, like its siblings.
'';
};
forgePublisherCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-forge-publisher";
@ -2003,6 +2030,32 @@ in
'';
};
reviewClassifierClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-bao-pki/review-classifier.pem";
description = ''
Certificate the review classifier presents to the store. Its subject
must be
{option}`services.hyperhive.deploy.bao.reviewClassifierCommonName`.
./glue-bao-tls.nix points it at the leaf it mints on the store's host.
On a classifier host without the store, it is a file the operator
copies from the store's host. A path, never a value.
'';
};
reviewClassifierClientKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-bao-pki/review-classifier-key.pem";
description = ''
Private key for
{option}`services.hyperhive.deploy.bao.reviewClassifierClientCertFile`.
A path, never a value.
'';
};
forgePublisherClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -3074,6 +3127,8 @@ in
systemd.services.swarm-bao-ci-runner-policy = readerPolicyUnit "write the CI runner's registration-token-reader bao policy and cert-auth role" ciRunnerReaders;
systemd.services.swarm-bao-review-classifier-policy = readerPolicyUnit "write the review classifier's API-key-reader bao policy and cert-auth role" reviewClassifierReaders;
systemd.services.swarm-bao-forge-publisher-policy = readerPolicyUnit "write the forge's controller-token-publisher bao policy and cert-auth role" forgePublisherWriters;
# A FOURTH sibling, same shape and same reasons as the two above. This

View file

@ -708,6 +708,8 @@ in
"--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}"
"--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}"
"--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}"
# Hears try-outs and answers each by reply, and nothing else.
"--review-classifier-client ${lib.escapeShellArg config.services.hyperhive.swarm.review-classifier.clientId}"
# What an agent may publish to, keyed on its own name: its
# terminal rows and its turn-state header
# (`hive-agent::swarm_agent_state`), two subject families so a

View file

@ -105,4 +105,16 @@ in
# `singleHostSwarm` box, which gets here through `allSwarmServices`, would
# have no forge at all.
config.services.hyperhive.deploy.forgejo.enable = lib.mkDefault deployCfg.allSwarmServices;
# The review classifier. Its module asserts a store identity, which
# glue-bao-tls.nix mints only on the host that runs the store; with the store
# placed elsewhere both files stay null and an unconditional `true` here would
# fail that host's eval. So the default follows the identity, and a host
# holding a copy of the pair (`deploy.bao.reviewClassifierClient{Cert,Key}File`)
# gets the classifier too.
config.services.hyperhive.deploy.review-classifier.enable = lib.mkDefault (
deployCfg.allSwarmServices
&& deployCfg.bao.reviewClassifierClientCertFile != null
&& deployCfg.bao.reviewClassifierClientKeyFile != null
);
}

View file

@ -0,0 +1,213 @@
# The review classifier, in a `swarm-review-classifier` container on the host
# that sets `deploy.review-classifier.enable`, which `deploy.allSwarmServices`
# defaults on where the host has the classifier's store identity. It answers the
# swarm UI's review try-outs: swarm-controller sends a PR and the operator's classifications over
# the queue, and the daemon runs `claude` once per classification and replies.
# docs/swarm/review-classifier.md has the design.
#
# ⚠️ Its own network namespace on the bridge, as ./swarm-ci-runner.nix has: a
# PR's diff and commit messages are model input, so the container reaches the
# swarm services by name and nothing on the host's loopback.
#
# The container holds one credential, its own store leaf, which nspawn hands in
# as a system credential with no copy on disk. The daemon reads everything else
# from the store with it: its queue client secret at start and the API key on
# every run.
{
pkgs,
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
cfg = hyperhiveCfg.deploy.review-classifier;
baoCfg = hyperhiveCfg.swarm.bao;
baoDeploy = hyperhiveCfg.deploy.bao;
natsCfg = hyperhiveCfg.swarm.nats;
autheliaCfg = hyperhiveCfg.swarm.authelia;
gatewayCfg = hyperhiveCfg.gateway;
networkCfg = hyperhiveCfg.network;
otelCfg = hyperhiveCfg.otel;
tlsCfg = hyperhiveCfg.deploy.hive-controller.tls;
machine = "swarm-review-classifier";
unit = "swarm-review-classifier";
clientId = hyperhiveCfg.swarm.review-classifier.clientId;
# The daemon reaches authelia, the store and the queue by name over TLS, and
# in self-signed mode their leaves chain to the hive CA.
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
caBundleModule = caTrust.trustBundle {
inherit pkgs;
name = machine;
consumers = [ unit ];
};
haveClientIdentity =
baoDeploy.reviewClassifierClientCertFile != null && baoDeploy.reviewClassifierClientKeyFile != null;
# Credential id → host source. nspawn reads each source as root when the
# container starts and hands it in as a system credential, held in memory;
# the daemon's unit alone loads them. No copy lands on disk, as for an
# agent's own leaf (`nix/agent-modules/bao.nix`). Ids carry no dot, the
# spelling `--load-credential` is safe with.
storeFiles = lib.filterAttrs (_: source: source != null) {
review-classifier-bao-cert = baoDeploy.reviewClassifierClientCertFile;
review-classifier-bao-key = baoDeploy.reviewClassifierClientKeyFile;
review-classifier-bao-server-ca = baoDeploy.serverCaFile;
};
# nixos-container expands `extraFlags` unquoted, so a source path must be
# one word.
oneWord = path: builtins.match ".*[[:space:]].*" path == null;
credential = id: "/run/credentials/${unit}.service/${id}";
# What `claude` exports its metrics with, where this host runs a hive-tier
# collector on the bridge. The daemon adds the repo and the PR to the
# resource attributes for each run.
otelEnv = lib.optionalAttrs otelCfg.enable {
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
OTEL_METRICS_EXPORTER = "otlp";
OTEL_EXPORTER_OTLP_ENDPOINT = "http://${networkCfg.bridgeIp}:${toString otelCfg.collector.port}";
OTEL_EXPORTER_OTLP_PROTOCOL = "http/protobuf";
# Claude Code defaults to delta, which the metrics store drops.
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
OTEL_RESOURCE_ATTRIBUTES = "service.name=hyperhive-review-classifier,swarm=${
if hyperhiveCfg.swarm.name == null then "unknown" else hyperhiveCfg.swarm.name
}";
};
privateNetwork = true;
in
{
options.services.hyperhive.swarm.review-classifier.clientId = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "swarm-review-classifier";
description = ''
The OAuth2 client id the review classifier presents to the swarm queue.
Read-only: the host running authelia registers it, the queue's
auth-callout responder grants it the try-out subject, and the secret
publisher carries its secret to the store, all from this one value.
'';
};
options.services.hyperhive.deploy.review-classifier.package = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-review-classifier";
description = "The review classifier daemon package.";
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = haveClientIdentity;
message = ''
services.hyperhive.deploy.review-classifier.enable requires the
classifier's swarm-secret-store client identity: set both
services.hyperhive.deploy.bao.reviewClassifierClientCertFile
services.hyperhive.deploy.bao.reviewClassifierClientKeyFile
The classifier reads its queue client secret and the API key with it.
Both default to the pair glue-bao-tls.nix mints when
services.hyperhive.deploy.bao.enable is true (the host that runs the
store). Otherwise copy review-classifier.pem and
review-classifier-key.pem from the store host's /var/lib/swarm-bao-pki/
and name them in those two options.
'';
}
{
assertion = lib.all oneWord (lib.attrValues storeFiles);
message = ''
The review classifier's store identity paths must not contain
whitespace: nspawn's --load-credential receives them through
nixos-container's unquoted extra flags.
'';
}
];
# The container hangs off the bridge, takes its address from the gateway's
# DHCP and resolves through its dnsmasq.
services.hyperhive.network.enable = lib.mkDefault true;
services.hyperhive.gateway.enable = lib.mkDefault true;
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
# nspawn fails to start the container when a credential source is
# missing, so where this host mints the leaf the container waits for it.
systemd.services."container@${machine}" = lib.mkMerge [
caTrust.containerOrdering
{ after = [ "swarm-bao-pki.service" ]; }
];
containers.${machine} = {
autoStart = true;
# The daemon keeps no state; its scratch directories are under PrivateTmp.
ephemeral = false;
# Journal files on the host, not inside the container: nixpkgs hardcodes
# --link-journal=try-guest, and EXTRA_NSPAWN_FLAGS expands after it.
extraFlags = [
"--link-journal=host"
]
++ lib.mapAttrsToList (id: source: "--load-credential=${id}:${source}") storeFiles;
inherit privateNetwork;
hostBridge = networkCfg.bridgeName;
bindMounts = caTrust.bindMount;
config =
{ pkgs, lib, ... }:
{
imports = [
../container-modules/swarm-container.nix
caBundleModule
];
services.hyperhive.swarmContainer = {
inherit privateNetwork;
writesOwnResolvConf = false;
};
networking.nameservers = [ networkCfg.bridgeIp ];
networking.interfaces.eth0.useDHCP = true;
# `claude-code` is unfree, and this container evaluates its own
# nixpkgs, so the host's allowance does not reach here.
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "claude-code" ];
systemd.services.${unit} = {
description = "answer the swarm UI's review try-outs";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = credential "review-classifier-bao-cert";
BAO_CLIENT_KEY = credential "review-classifier-bao-key";
SWARM_REVIEW_CLASSIFIER_NATS_URL = "tls://${natsCfg.domain}:${toString natsCfg.port}";
SWARM_REVIEW_CLASSIFIER_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token";
SWARM_REVIEW_CLASSIFIER_OIDC_CLIENT_ID = clientId;
SWARM_REVIEW_CLASSIFIER_CLAUDE = "${pkgs.claude-code}/bin/claude";
}
// lib.optionalAttrs (storeFiles ? review-classifier-bao-server-ca) {
BAO_CACERT = credential "review-classifier-bao-server-ca";
}
// lib.optionalAttrs caTrust.useSelfSigned {
SWARM_REVIEW_CLASSIFIER_OIDC_CA_FILE = caTrust.bundlePathFor machine;
}
// otelEnv;
serviceConfig = {
ExecStart = "${cfg.package}/bin/swarm-review-classifier";
DynamicUser = true;
# Each classification's scratch directory, gone with the unit.
PrivateTmp = true;
# Bare ids: the system credentials nspawn handed in.
LoadCredential = lib.attrNames storeFiles;
Restart = "on-failure";
RestartSec = "10s";
SyslogIdentifier = unit;
};
};
};
};
};
}

View file

@ -90,6 +90,8 @@ let
hyperhiveCfg.swarm.matrix.sso.clientId
# The forge's login source, read by `swarm-bao-forge-oidc` on its host.
hyperhiveCfg.swarm.forge.sso.clientId
# The review classifier, which reads it itself inside its container.
hyperhiveCfg.swarm.review-classifier.clientId
];
# The swarm controller's OIDC client, which it reads back from the store

View file

@ -55,6 +55,7 @@ let
deployCfg.bao.granterCommonName
deployCfg.bao.natsAuthCommonName
deployCfg.bao.ciRunnerCommonName
deployCfg.bao.reviewClassifierCommonName
deployCfg.bao.forgePublisherCommonName
]
# The per-hive subjects, spelled out per hive rather than as the prefix.

View file

@ -170,7 +170,7 @@ let
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
) baoGrantWithConsumers.systemd.services;
# The fifteen units that write a `swarm-*` grant, by name, for the discovery
# The sixteen units that write a `swarm-*` grant, by name, for the discovery
# control below.
grantingUnitNames = [
"swarm-bao-controller-policy"
@ -188,6 +188,7 @@ let
"swarm-bao-hive-leaf-policy"
"swarm-bao-ci-runner-policy"
"swarm-bao-forge-publisher-policy"
"swarm-bao-review-classifier-policy"
];
# Comment lines dropped first: both the HCL and the scripts explain
@ -852,24 +853,24 @@ let
}
{
# A store host without the granter's pair writes its grants some other
# way, so none of the fifteen units may exist. Without this arm
# way, so none of the sixteen units may exist. Without this arm
# `lib.mkIf haveGranter` could be dropped from any of them and every other
# case here would still pass.
name = "without the granter's pair none of the fifteen granting units render";
name = "without the granter's pair none of the sixteen granting units render";
ok =
let
s = baoGranterOptOut.systemd.services;
in
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
# The control: the same store with the pair renders all fifteen.
# The control: the same store with the pair renders all sixteen.
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
}
{
# 🩸 What replaced the silent skip. With no bootstrap token the fifteen still
# 🩸 What replaced the silent skip. With no bootstrap token the sixteen still
# render, and a refused granter fails them with the step that fixes it.
# A store host that never named a token is told to name one, since the
# unit that sets the granter up renders only where it has.
name = "a store host without a bootstrap token renders the fifteen, each failing loudly with the one-time step";
name = "a store host without a bootstrap token renders the sixteen, each failing loudly with the one-time step";
ok =
let
s = baoGranterNoToken.systemd.services;
@ -1372,6 +1373,34 @@ let
in
lib.hasInfix "/ci-runner.pem ]" s && lib.hasInfix "swarm-ci-runner \"\" clientAuth" s;
}
{
# The classifier reads the key the operator writes and its own queue
# client secret, each read-only and spelled to the leaf: `services/*`
# would reach every swarm service's secret.
name = "the review classifier's grant is its API key and its queue client secret, and nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-review-classifier-policy.script;
in
lib.hasInfix "path \"secret/data/swarm/services/review-classifier/anthropic-api-key\" {\n capabilities = [\"read\"]\n}" s
&& lib.hasInfix "path \"secret/data/swarm/services/swarm-review-classifier/oidc/client\" {\n capabilities = [\"read\"]\n}" s
&& lib.length (lib.filter lib.isList (builtins.split "path \"" s)) == 2
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
&& !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives" s)
&& lib.hasInfix "auth/cert/certs/swarm-review-classifier" s
&& lib.hasInfix "allowed_common_names=swarm-review-classifier" s
&& lib.hasInfix "token_policies=swarm-review-classifier" s;
}
{
name = "the PKI unit signs the review classifier's leaf under its own subject";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-pki.script;
in
lib.hasInfix "/review-classifier.pem ]" s
&& lib.hasInfix "swarm-review-classifier \"\" clientAuth" s;
}
{
# The forge's host is the one writer of the controller's forge token,
# and reads the forge's own OIDC client secret under the same leaf.
@ -1566,8 +1595,8 @@ let
}
{
# What makes the case above mean something: discovery by the granter's
# certificate reaches all fifteen units, and each yields calls.
name = "the granter-policy check sees all fifteen granting units, and parses calls from each";
# certificate reaches all sixteen units, and each yields calls.
name = "the granter-policy check sees all sixteen granting units, and parses calls from each";
ok =
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)

Some files were not shown because too many files have changed in this diff Show more