let the operator set, replace and remove an agent's backend env from the swarm ui
This commit is contained in:
parent
398b2af44b
commit
57b04d6c49
15 changed files with 795 additions and 201 deletions
|
|
@ -16,45 +16,45 @@ the rest of the cell says how.
|
|||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| store path | minter | reader | automatic re-mint | automatic re-pull |
|
||||
| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or rejected by the homeserver | ✅ the daemon restarts within 5 minutes of a rejection |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller`, from a credential you hand it | the agent | must be stated | ✅ retried within 2 to 16 minutes after a rejection |
|
||||
| `swarm/agents/<agent>/matrix-recovery/<account>` | `swarm-controller`, a random passphrase per account | the agent | ❌ never replaced: the account's key backup opens only with this passphrase | ❌ read when the account comes up |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl appservice sync` in `hive-matrix`, from the registration's `as_token` | `swarm-controller` | ❌ follows the registration below | ✅ read every 5 minutes |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-registration` | `swarm-matrix-ctl appservice sync` in `hive-matrix`: seeded from the container's own tokens when the store holds none, a new pair only when the container has none either | `swarm-matrix-ctl` in `hive-matrix`; the controller has no grant to it | ❌ minted once; an operator who replaces it makes `sync` write the new pair into the container and restart the homeserver | ✅ `sync` re-reads it every 10 minutes |
|
||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, first boot | `swarm-controller` | ❌ minted once; `swarm-secret-publish` republishes a re-mint | ✅ re-read when authelia refuses it |
|
||||
| `swarm/controller/swarm-controller/forge/token` | the forge, once | `swarm-controller` | ❌ minted once; delete `/var/lib/forgejo/swarm-controller-token-name` in the forge container to mint a new one | ✅ re-read on a 401 |
|
||||
| `swarm/controller/swarm-controller/grafana/token` | `swarm-bao-grafana-controller-token` on Grafana's host | `swarm-controller` | ✅ every 10 minutes, when Grafana refuses it or none is stored | ✅ re-read on a 401 |
|
||||
| `swarm/controller/swarm-controller/forge/webhook-secret` | `swarm-controller`, at start, from its old `webhook-secret` file; a new one only when there is no file | `swarm-controller` | ❌ minted once; a rotated secret fails every delivery until the forge hooks are re-registered | ❌ read at start |
|
||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), at agent creation | `hive-c0re`, then the agent | ✅ re-issued every 5 minutes once past half its 7-day validity | ✅ the agent re-reads every 10 minutes; it renews only while its current leaf is valid |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent; the queue, log and metrics auth services | ✅ re-minted at 45 days; skipped for `Destroyed` agents, whose path is deleted | ✅ read on every reconnect; an open connection keeps the old secret for up to an hour; the log and metrics stores honour a replaced secret for up to `deploy.victorialogs.authVerdictTtl` and `deploy.victoriametrics.agentAuthVerdictTtl` (60 s by default) |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or no longer matching the forge | ✅ every 10 minutes |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when you link an external forge account in the swarm UI | the agent | ❌ your token; delete it and link the label again | ✅ every 2 minutes |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when you link a GitHub account in the swarm UI | the agent | ❌ your token; delete it and link again | ✅ every 2 minutes |
|
||||
| `swarm/agents/<agent>/backend-env` | `hive-agent-backend-env`, in the agent's container, from the agent's `backendEnvironmentFile` when the leaf holds nothing (field `env`, the file's exact text); or you, in the store's UI | the agent | ❌ nothing replaces it; the file stays as it was | ❌ the file in `/run` refreshes every 2 minutes, and the harness and the subagent daemon load it at their next start |
|
||||
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or rejected by the homeserver | ✅ the hive re-reads on every sweep |
|
||||
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or no longer matching the forge | ✅ read on every forge call |
|
||||
| `swarm/hives/<hive>/oidc/client` | authelia, first boot; `swarm-secret-publish` copies it in | the hive | ❌ minted once; `swarm-secret-publish` republishes a re-mint | ✅ re-read when authelia refuses it |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | that service | must be stated | must be stated |
|
||||
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge, at start and every 5 minutes | `swarm-bao-ci-runner` on the runner's host | ❌ the forge keeps one token until an admin resets it; the controller then writes the new one within 5 minutes | ✅ every 10 minutes; restarts the runner on a change |
|
||||
| `swarm/services/<grafana client id>/metrics/bearer` | `swarm-bao-grafana-metrics-bearer` on Grafana's host, when none is stored | `swarm-metrics-auth` and `swarm-logs-grafana-auth` | ❌ delete it and restart `swarm-bao-grafana-metrics-bearer` | ✅ the auth services re-read within a minute; ❌ Grafana restarts when its copy changes |
|
||||
| `swarm/services/<grafana client id>/secret-key` | `swarm-bao-grafana-secret-key` on Grafana's host, from the key an earlier release generated; a new one only when there is none | Grafana | ❌ minted once; a different key leaves Grafana unable to read its stored datasource secrets | ❌ Grafana reads it at start; the unit restarts Grafana when the file changes |
|
||||
| `swarm/services/swarm-authelia/keys/<name>`, for `jwt`, `session`, `storage-encryption`, `oidc-hmac` and `oidc-issuer` | authelia's `authelia-swarm-secrets` unit in its container, under the secret publisher's identity, from the key file an earlier release generated; a new one only when there is none | authelia | ❌ minted once; a different storage-encryption key leaves authelia's database unreadable and a different issuer key invalidates every issued token | ❌ read when the unit runs; the unit restarts authelia when the file changes |
|
||||
| `swarm/services/review-classifier/anthropic-api-key` | you, in the store's UI, field `api_key` | `swarm-review-classifier` | ❌ write a new key | ✅ read on every run |
|
||||
| `swarm/services/review-classifier/acp-provider` | you, in the store's UI, field `api_key` | `swarm-review-classifier` | ❌ write a new key | ✅ read on every run |
|
||||
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every review |
|
||||
| `swarm/services/builder/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-builder`, `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every build |
|
||||
| `swarm/services/attic/push-token` | `swarm-bao-attic-tokens` on the cache's host, when none is stored; with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the token on stdin | `swarm-builder` | ❌ write a new token; a token the cache refuses fails the push, and the build stays green with `cached` false | ✅ read on every push; with both `swarm.attic` options null, nothing reads it |
|
||||
| `swarm/services/attic/pull-token` | `swarm-bao-attic-tokens` on the cache's host, when none is stored; read-only, ten years; with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin | `swarm-controller`, which copies it to each hive | ❌ write a new token | ✅ copied to the hives every 5 minutes |
|
||||
| `swarm/services/attic/url` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (`https://<swarm.attic.domain>`); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin (scheme included, no trailing slash) | `swarm-controller`, which copies it to each hive | ❌ delete it and restart `swarm-bao-attic-tokens`, or write the new value | ✅ copied to the hives every 5 minutes |
|
||||
| `swarm/services/attic/cache` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (`swarm.attic.cache`); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin | `swarm-controller`, which copies it to each hive | ❌ as the URL | ✅ copied to the hives every 5 minutes |
|
||||
| `swarm/services/attic/public-key` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (the key `attic cache info` prints); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin (`<name>:<base64>`) | `swarm-controller`, which copies it to each hive | ❌ as the URL. A cache recreated with a new key leaves the stored one stale: every hive then reports `bad-signature` | ✅ copied to the hives every 5 minutes |
|
||||
| `swarm/hives/<hive>/attic/{pull-token,url,cache,public-key}` | `swarm-controller`, from the four `swarm/services/attic/` leaves, every 5 minutes; it skips a leaf whose source holds nothing, and a copy whose source is gone stays | the hive (`hive-c0re`) | ✅ a changed source is written over the copy in the next pass | ✅ read every 5 minutes |
|
||||
| `swarm/services/attic/jwt-secret` | `swarm-bao-attic-secret` on the cache's host, when none is stored | `swarm-bao-attic-secret`, `swarm-bao-attic-tokens` | ❌ a new key invalidates both tokens | ❌ atticd restarts when the file changes |
|
||||
| `swarm/services/ci-metrics/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-ci-metrics`, `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every pass |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's PKI, or you by hand | the hive itself, from disk | must be stated | must be stated |
|
||||
| store path | minter | reader | automatic re-mint | automatic re-pull |
|
||||
| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or rejected by the homeserver | ✅ the daemon restarts within 5 minutes of a rejection |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller`, from a credential you hand it | the agent | must be stated | ✅ retried within 2 to 16 minutes after a rejection |
|
||||
| `swarm/agents/<agent>/matrix-recovery/<account>` | `swarm-controller`, a random passphrase per account | the agent | ❌ never replaced: the account's key backup opens only with this passphrase | ❌ read when the account comes up |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl appservice sync` in `hive-matrix`, from the registration's `as_token` | `swarm-controller` | ❌ follows the registration below | ✅ read every 5 minutes |
|
||||
| `swarm/controller/swarm-controller/matrix/appservice-registration` | `swarm-matrix-ctl appservice sync` in `hive-matrix`: seeded from the container's own tokens when the store holds none, a new pair only when the container has none either | `swarm-matrix-ctl` in `hive-matrix`; the controller has no grant to it | ❌ minted once; an operator who replaces it makes `sync` write the new pair into the container and restart the homeserver | ✅ `sync` re-reads it every 10 minutes |
|
||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, first boot | `swarm-controller` | ❌ minted once; `swarm-secret-publish` republishes a re-mint | ✅ re-read when authelia refuses it |
|
||||
| `swarm/controller/swarm-controller/forge/token` | the forge, once | `swarm-controller` | ❌ minted once; delete `/var/lib/forgejo/swarm-controller-token-name` in the forge container to mint a new one | ✅ re-read on a 401 |
|
||||
| `swarm/controller/swarm-controller/grafana/token` | `swarm-bao-grafana-controller-token` on Grafana's host | `swarm-controller` | ✅ every 10 minutes, when Grafana refuses it or none is stored | ✅ re-read on a 401 |
|
||||
| `swarm/controller/swarm-controller/forge/webhook-secret` | `swarm-controller`, at start, from its old `webhook-secret` file; a new one only when there is no file | `swarm-controller` | ❌ minted once; a rotated secret fails every delivery until the forge hooks are re-registered | ❌ read at start |
|
||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), at agent creation | `hive-c0re`, then the agent | ✅ re-issued every 5 minutes once past half its 7-day validity | ✅ the agent re-reads every 10 minutes; it renews only while its current leaf is valid |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent; the queue, log and metrics auth services | ✅ re-minted at 45 days; skipped for `Destroyed` agents, whose path is deleted | ✅ read on every reconnect; an open connection keeps the old secret for up to an hour; the log and metrics stores honour a replaced secret for up to `deploy.victorialogs.authVerdictTtl` and `deploy.victoriametrics.agentAuthVerdictTtl` (60 s by default) |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or no longer matching the forge | ✅ every 10 minutes |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when you link an external forge account in the swarm UI | the agent | ❌ your token; delete it and link the label again | ✅ every 2 minutes |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when you link a GitHub account in the swarm UI | the agent | ❌ your token; delete it and link again | ✅ every 2 minutes |
|
||||
| `swarm/agents/<agent>/backend-env` | `hive-agent-backend-env`, in the agent's container, from the agent's `backendEnvironmentFile` when the leaf holds nothing (field `env`, the file's exact text); or `swarm-controller`, when you set the API-key environment in the swarm UI | the agent | ❌ nothing replaces it without you; a save in the swarm UI overwrites it, including text an agent wrote into an empty leaf; the agent's file stays as it was | ❌ the file in `/run` refreshes every 2 minutes, and the harness and the subagent daemon load it at their next start |
|
||||
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or rejected by the homeserver | ✅ the hive re-reads on every sweep |
|
||||
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or no longer matching the forge | ✅ read on every forge call |
|
||||
| `swarm/hives/<hive>/oidc/client` | authelia, first boot; `swarm-secret-publish` copies it in | the hive | ❌ minted once; `swarm-secret-publish` republishes a re-mint | ✅ re-read when authelia refuses it |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | that service | must be stated | must be stated |
|
||||
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge, at start and every 5 minutes | `swarm-bao-ci-runner` on the runner's host | ❌ the forge keeps one token until an admin resets it; the controller then writes the new one within 5 minutes | ✅ every 10 minutes; restarts the runner on a change |
|
||||
| `swarm/services/<grafana client id>/metrics/bearer` | `swarm-bao-grafana-metrics-bearer` on Grafana's host, when none is stored | `swarm-metrics-auth` and `swarm-logs-grafana-auth` | ❌ delete it and restart `swarm-bao-grafana-metrics-bearer` | ✅ the auth services re-read within a minute; ❌ Grafana restarts when its copy changes |
|
||||
| `swarm/services/<grafana client id>/secret-key` | `swarm-bao-grafana-secret-key` on Grafana's host, from the key an earlier release generated; a new one only when there is none | Grafana | ❌ minted once; a different key leaves Grafana unable to read its stored datasource secrets | ❌ Grafana reads it at start; the unit restarts Grafana when the file changes |
|
||||
| `swarm/services/swarm-authelia/keys/<name>`, for `jwt`, `session`, `storage-encryption`, `oidc-hmac` and `oidc-issuer` | authelia's `authelia-swarm-secrets` unit in its container, under the secret publisher's identity, from the key file an earlier release generated; a new one only when there is none | authelia | ❌ minted once; a different storage-encryption key leaves authelia's database unreadable and a different issuer key invalidates every issued token | ❌ read when the unit runs; the unit restarts authelia when the file changes |
|
||||
| `swarm/services/review-classifier/anthropic-api-key` | you, in the store's UI, field `api_key` | `swarm-review-classifier` | ❌ write a new key | ✅ read on every run |
|
||||
| `swarm/services/review-classifier/acp-provider` | you, in the store's UI, field `api_key` | `swarm-review-classifier` | ❌ write a new key | ✅ read on every run |
|
||||
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every review |
|
||||
| `swarm/services/builder/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-builder`, `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every build |
|
||||
| `swarm/services/attic/push-token` | `swarm-bao-attic-tokens` on the cache's host, when none is stored; with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the token on stdin | `swarm-builder` | ❌ write a new token; a token the cache refuses fails the push, and the build stays green with `cached` false | ✅ read on every push; with both `swarm.attic` options null, nothing reads it |
|
||||
| `swarm/services/attic/pull-token` | `swarm-bao-attic-tokens` on the cache's host, when none is stored; read-only, ten years; with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin | `swarm-controller`, which copies it to each hive | ❌ write a new token | ✅ copied to the hives every 5 minutes |
|
||||
| `swarm/services/attic/url` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (`https://<swarm.attic.domain>`); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin (scheme included, no trailing slash) | `swarm-controller`, which copies it to each hive | ❌ delete it and restart `swarm-bao-attic-tokens`, or write the new value | ✅ copied to the hives every 5 minutes |
|
||||
| `swarm/services/attic/cache` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (`swarm.attic.cache`); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin | `swarm-controller`, which copies it to each hive | ❌ as the URL | ✅ copied to the hives every 5 minutes |
|
||||
| `swarm/services/attic/public-key` | `swarm-bao-attic-tokens` on the cache's host, when none is stored (the key `attic cache info` prints); with an external cache, you, in the store's UI (field `value`), or `bao kv put` with `value=-` and the value on stdin (`<name>:<base64>`) | `swarm-controller`, which copies it to each hive | ❌ as the URL. A cache recreated with a new key leaves the stored one stale: every hive then reports `bad-signature` | ✅ copied to the hives every 5 minutes |
|
||||
| `swarm/hives/<hive>/attic/{pull-token,url,cache,public-key}` | `swarm-controller`, from the four `swarm/services/attic/` leaves, every 5 minutes; it skips a leaf whose source holds nothing, and a copy whose source is gone stays | the hive (`hive-c0re`) | ✅ a changed source is written over the copy in the next pass | ✅ read every 5 minutes |
|
||||
| `swarm/services/attic/jwt-secret` | `swarm-bao-attic-secret` on the cache's host, when none is stored | `swarm-bao-attic-secret`, `swarm-bao-attic-tokens` | ❌ a new key invalidates both tokens | ❌ atticd restarts when the file changes |
|
||||
| `swarm/services/ci-metrics/forge/token` | `swarm-controller`, at start and every 5 minutes | `swarm-ci-metrics`, `swarm-controller` | ✅ when missing or no longer matching the forge | ✅ read on every pass |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's PKI, or you by hand | the hive itself, from disk | must be stated | must be stated |
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
@ -106,43 +106,43 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
|
|||
|
||||
### Swarm-level — one of each per swarm
|
||||
|
||||
| secret | generated by | lives at | hive elsewhere |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| swarm-services root CA (cert + key) | `swarm-bao.nix`'s granting unit, inside the store's `pki` mount | the **key** never leaves the mount; the **cert** lands at `/var/lib/swarm-bao-services-pki/services-root.pem` (0644) and at `/var/lib/hive-tls/swarm-services-root.pem` on every hive | nothing to copy — every hive receives the cert with the leaf it asks the store for, or from `pki/ca/pem` where it fronts no swarm name and needs a store identity |
|
||||
| authelia session, JWT, storage-encryption and OIDC HMAC keys | `authelia-swarm-secrets` in authelia's container. It stores each in the swarm secret store at `swarm/services/swarm-authelia/keys/<name>` when the store holds none: the value of the key an earlier release generated, or a new one when there is no such file. The files stay on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs on the files. A container with no secret publisher identity generates the files, as before | `/var/lib/authelia-swarm/{session,jwt,storage-encryption,oidc-hmac}.key` | nothing to place: the publisher's store leaf is the one in the row below. A store that stays unreachable on a start with no key file keeps authelia from starting |
|
||||
| authelia OIDC issuer key (RSA) | same unit, same store path (`oidc-issuer`) | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
|
||||
| swarm appservice registration (`as_token`, `hs_token`) | `swarm-matrix-ctl appservice sync` in `hive-matrix`. The store's pair wins; a store holding none takes the container's own tokens, so the value doesn't change by moving. Only `appservice render` on a container with neither mints a new pair | `/var/lib/swarm-matrix-appservice/{as-token,hs-token,swarm.yaml}` in `hive-matrix`, the last copy `sync` took, which tuwunel starts from; the store path is `swarm/controller/swarm-controller/matrix/appservice-registration` | nothing to place: matrix-ctl's store leaf is the one the container binds. A store that's down leaves the homeserver running on the files |
|
||||
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service; the forge and, by default, the hive collector need none — `swarm-bao-forge-oidc` and `swarm-bao-hive-otel-oidc` fetch it from the store ([how](credentials.md#hive-level-secrets)) |
|
||||
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
|
||||
| the swarm collector's copy of its OIDC secret | `swarm-bao-otel-oidc.service` reads it out of the swarm secret store, **on every host that runs the collector and holds a store identity** | `/run/swarm-otel-oidc/<id>.secret` on the host's tmpfs, bind-mounted read-only into the `swarm-otel` container — never on persistent disk, so a reboot fetches it again | same unit, same path — one route, co-located or not. A collector with no store identity has `clientSecretFile == null`, its already-supported unauthenticated-push degrade — see [swarm-level secrets](credentials.md#swarm-level-secrets) |
|
||||
| Grafana's copy of its OIDC secret | `swarm-bao-grafana-oidc.service` reads it out of the swarm secret store, **on every host that runs Grafana** | `/run/swarm-grafana-secrets/<id>.secret` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container — never on persistent disk, so a reboot fetches it again | same unit, same path — one route, co-located or not. Nothing for an operator to place beyond this host's store leaf, see [hive-level secrets](credentials.md#hive-level-secrets) |
|
||||
| Grafana's admin password | `swarm-bao-grafana-admin.service` creates it in the swarm secret store at `swarm/services/<grafana client id>/admin/password` when the store holds none, then copies it out, **on every host that runs Grafana**. Grafana's leaf may create that leaf but not update it, so the store keeps the first value, and the secret publisher can't write it. To rotate, run `bao kv metadata delete secret/swarm/services/<grafana client id>/admin/password` with a token that may, such as the root token (Grafana's, the publisher's and the controller's can't), then `systemctl restart swarm-bao-grafana-admin`; a plain `bao kv delete` isn't enough, see [the callout xkey's section](credentials.md#the-queues-callout-xkey) | `/run/swarm-grafana-secrets/admin-password` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container, never on persistent disk: `security.admin_password` for a new database, and `grafana cli admin reset-admin-password` in `grafana.service`'s `preStart` for an existing one, on every start | same unit, same path. After a reboot the unit fetches the file again, and Grafana doesn't start until it exists, so it stays down rather than falling back to the upstream default `admin` |
|
||||
| Grafana's metrics bearer | `swarm-bao-grafana-metrics-bearer.service` creates it in the swarm secret store at `swarm/services/<grafana client id>/metrics/bearer` when the store holds none, then copies it out, **on every host that runs Grafana**. Same grant as the admin password, create without update. To rotate, run `bao kv metadata delete secret/swarm/services/<grafana client id>/metrics/bearer` with a token that may, then `systemctl restart swarm-bao-grafana-metrics-bearer` | `/run/swarm-grafana-secrets/metrics-bearer` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container, holding `Bearer <value>`: the metrics datasource's `Authorization` header. The logs datasource sends the same header. `swarm-metrics-auth` on the metrics store's host, and a second instance on the log store's host (`swarm-logs-grafana-auth`), read the stored value themselves, each under its own leaf | same unit, same path. The unit restarts Grafana when the file changes |
|
||||
| Grafana's datasource encryption key | `swarm-bao-grafana-secret-key.service` stores it in the swarm secret store at `swarm/services/<grafana client id>/secret-key` when the store holds none, then copies it out, **on every host that runs Grafana**. It stores the key an earlier release generated at `/var/lib/grafana-secret/secret_key` in the container, so the key doesn't change, and mints a new one only on a host with no such file. Same grant as the admin password, create without update. The earlier file stays on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs Grafana on that file | `/run/swarm-grafana-secrets/secret-key` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container: `security.secret_key` | same unit, same path. Grafana doesn't start until the file exists. Changing the key leaves Grafana unable to read the datasource secrets it stored, so never delete the store path to rotate it |
|
||||
| the swarm controller's webhook secret | `swarm-controller` stores it in the swarm secret store at `swarm/controller/swarm-controller/forge/webhook-secret` when the store holds none: the value of `webhook-secret` in its state directory, or a new one when there is no such file. The state directory file stays on disk and is the fallback while the store is unreachable or its policy doesn't grant the path yet | the swarm secret store; the controller holds it in memory and registers the same value with the forge's hooks | nothing to place: the controller's store leaf already exists. A store that stays unreachable on a start with no state directory file leaves the webhook endpoint answering 503 |
|
||||
| the metrics store responder's own store leaf (cert + key) | `signLeaf` in `glue-bao-tls.nix` (`swarm-bao-pki.service`), on the host that runs the secret store, once, subject `deploy.bao.metricsAuthCommonName`. `swarm-bao-metrics-auth-policy.service` writes the `swarm-metrics-auth` role and its read grants on the bearer leaf and on the metrics store forwarder's client secret, nothing else | `/var/lib/swarm-bao-pki/metrics-auth.pem` and `metrics-auth-key.pem` on the store's host, handed to `swarm-metrics-auth` through `LoadCredential` (paths in argv, never values) and read by path by `swarm-victoriametrics-forwarder-oidc` | **the operator** places a leaf with that subject on the metrics store's host and names it in `deploy.victoriametrics.authBaoClientCertFile` / `authBaoClientKeyFile`; evaluation fails naming both until then. If the store is on another host, deploy that host first: the role must exist before the responder's login succeeds, and until it does the gateway refuses every Grafana query (fail closed) |
|
||||
| the secret publisher's store leaf (cert + key) | `signLeaf` in `glue-bao-tls.nix` (`swarm-bao-pki.service`), on the host that runs the secret store, once, subject `deploy.bao.secretPublisherCommonName`. `swarm-bao-secret-publisher-policy.service` writes the `swarm-secret-publisher` role: create and update under `swarm/hives/*`, `swarm/services/*` and the controller's client, and read on authelia's forwarder's client secret and authelia's five keys alone | `/var/lib/swarm-bao-pki/secret-publisher.pem` and `secret-publisher-key.pem` on the host, handed into authelia's container as nspawn system credentials, which `swarm-secret-publish` and the forwarder's `swarm-journal-forwarder-oidc` load with `LoadCredential`. nspawn won't start authelia's container while a named file is missing | **the operator** copies the leaf to authelia's host, beside the hive's own store leaf, where `glue-secret-publisher-bao-identity.nix` looks, or names it in `deploy.swarm-secret-publisher.baoClientCertFile` / `baoClientKeyFile` |
|
||||
| the swarm controller's copy of its OIDC secret | `swarm-controller` reads it out of the swarm secret store at start, under its own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the controller's store leaf (`deploy.swarm-controller.baoClientCertFile`) |
|
||||
| a hive's copy of its own OIDC secret | hive-c0re reads it out of the swarm secret store when it first connects to the queue, under the hive's own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
|
||||
| the hive collector's copy of its hive's OIDC secret | `swarm-bao-hive-otel-oidc.service` reads it out of the swarm secret store under the hive's own leaf, **on every hive that runs the collector and holds that leaf**; the leaf is the source | `/run/hive-otel-oidc/hive-<name>.secret` on the hive's host tmpfs, 0400 root, loaded by `LoadCredential` — never on persistent disk, so a reboot fetches it again | same unit, same path, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`). Naming a secret file with `otel.clientSecretFile` instead is legacy for a host without a leaf: the build warns, and the fix is to place the leaf from `/var/lib/swarm-bao-pki/hives/<hive>/` and drop the setting |
|
||||
| the swarm controller's forge token | the forge's `forgejo-swarm-controller-account`, in-container, once | the swarm secret store at `swarm/controller/swarm-controller/forge/token`, written by the mint under the forge publisher's leaf, which the forge container loads as a credential; the controller holds it in memory only. `/var/lib/forgejo/swarm-controller-token-name` in the forge container holds its name, the minter's record that it has minted | nothing to place beyond the controller's store leaf. A forge host without the store needs the publisher's leaf placed (`deploy.bao.forgePublisherClientCertFile`) |
|
||||
| the swarm controller's Grafana token | `swarm-bao-grafana-controller-token.service`, **on the host that runs Grafana**, as Grafana's built-in admin over its socket: it creates the `swarm-controller` service account as a Viewer (and sets it back to Viewer on every run), mints a token for it and writes it to the swarm secret store at `swarm/controller/swarm-controller/grafana/token` under Grafana's leaf. Re-run every ten minutes: it keeps a stored token Grafana accepts, and replaces one Grafana refuses with 401 (or a missing one), deleting every other token of that account first | the swarm secret store only; the controller reads it for `GET /api/alerts` and holds it in memory, re-reading the store once when Grafana answers 401 | nothing to place: Grafana's leaf is already required to run Grafana. Until the unit has run, `/api/alerts` answers 503 naming the empty path |
|
||||
| an agent's API-key environment (`ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, an ACP provider key) | `hive-agent-backend-env.service`, in the agent's container, on an agent with a store that uses `useApiKey` or the `acp` runtime. When the store holds nothing at `swarm/agents/<agent>/backend-env` and `backendEnvironmentFile` is a non-empty file, it creates the leaf with the file's exact bytes and `-cas=0`, under the agent's own leaf, which may create that path and not update it. It never rotates a value and never rewrites or deletes the file. You can also enter the value in the store's UI | the swarm secret store at that path; the unit writes it to `/run/hive-agent-backend-env/env` (tmpfs, `0600`, root), and `hive-agent` and `hive-subagent-daemon` load that after `backendEnvironmentFile` as `EnvironmentFile=` | the same unit, same path. A variable in the store wins over the same one in the file. With the store down at a start, the file alone carries the agent, as it did before the store held a copy; with an older hive build the agent ignores the leaf and keeps reading the file |
|
||||
| an agent's claude login (`.credentials.json`) | `swarm-claude-login.service`, in its container, when an operator completes `claude auth login` from the agent's card in the swarm UI; it stores the file as a new version at `swarm/agents/<agent>/claude-credentials` under its own leaf, which may create and update that path and read nothing under an agent | the swarm secret store at that path; the agent's `hive-agent` reads it under the agent's own leaf every 30 seconds while it waits for a login and writes it to `~/.claude/.credentials.json` | the same read, co-located or not; nothing for an operator to place beyond the agent's store leaf. When the store's copy and the agent's differ the KV version decides: the agent installs only a version newer than the last it installed (`claude-credentials-version` under the harness dir), because claude rotates the refresh token in its own copy and the stored one goes stale. The agent ignores a stale or equal version, and a relogin writes a newer one — [claude-login.md](claude-login.md) |
|
||||
| the queue responder's copy of its OIDC secret | the responder reads it out of the swarm secret store itself as it starts, under its own leaf, and again when authelia refuses it | the daemon's memory only; never on disk | same read, wherever authelia runs. Nothing for an operator to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| the queue container's journal forwarder's copy of the queue's OIDC secret | `swarm-journal-forwarder-oidc.service` inside the `swarm-nats` container reads it out of the swarm secret store under the responder's store leaf, and again every 10 minutes | `/run/swarm-journal-forwarder-oidc/client-secret` on the container's tmpfs, root `0400`, never on persistent disk; `LoadCredential` hands it to the collector | same unit, same path. After a reboot the collector restarts until the file exists, then resumes from its saved journal cursor. Nothing for an operator to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| the CI runner, Grafana, log store, cache, forge and homeserver containers' journal forwarders' OIDC secrets | `swarm-journal-forwarder-oidc.service` inside each container reads its forwarder client's secret (`swarm.<service>.otel.clientId`) out of the swarm secret store under the store leaf the host already holds for that service, handed in as a system credential (bound in read-only, for the homeserver's), and again every 10 minutes | `/run/swarm-journal-forwarder-oidc/client-secret` on the container's tmpfs, root `0400`, never on persistent disk; `LoadCredential` hands it to the collector | same unit, same path. Nothing for an operator to place beyond those leaves (`deploy.bao.ciRunnerClientCertFile`, `deploy.bao.grafanaOidcClientCertFile`, `deploy.victorialogs.authBaoClientCertFile`, `deploy.bao.forgePublisherClientCertFile`, `deploy.bao.atticClientCertFile`, `deploy.matrix.ctlBaoClientCertFile`) |
|
||||
| the metrics store container's journal forwarder's OIDC secret | `swarm-victoriametrics-forwarder-oidc.service` on the store's host reads its forwarder client's secret (`swarm.victoriametrics.otel.clientId`) out of the swarm secret store under the metrics store responder's leaf, and again every 10 minutes: the container has no network to reach the store | `/run/swarm-victoriametrics-forwarder-oidc/<id>.secret` on the host (tmpfs), root `0400`, never on persistent disk; bound into the container read-only, where `LoadCredential` hands it to the collector | same unit, same path. Nothing for an operator to place beyond that leaf (`deploy.victoriametrics.authBaoClientCertFile`) |
|
||||
| the CI run poller's forge token | `swarm-controller`, with the forge's admin API, for its `ci-metrics` account, at start and in a five-minute pass | the swarm secret store at `swarm/services/ci-metrics/forge/token`; `swarm-ci-metrics` reads it once per pass under its own leaf and holds it in memory for that pass. When the store's token no longer matches the forge's, the controller's next pass mints a new one; until then the poller's forge calls answer 401, the unit fails and no cursor moves | nothing to place beyond the poller's leaf (`deploy.bao.ciMetricsClientCertFile`), on a host without the store |
|
||||
| the CI run poller's copy of its OIDC secret | authelia; `swarm-secret-publish` copies it into the swarm secret store | `swarm-ci-metrics` reads `swarm/services/<swarm.ci-metrics.clientId>/oidc/client` once per pass under its own leaf, and again when authelia refuses it; memory only. While it differs from authelia's, the token request fails, nothing gets pushed and no cursor moves | same leaf, nothing else |
|
||||
| authelia's container's journal forwarder's OIDC secret | authelia's mint, in its container, like every client's plaintext half (`swarm.authelia.otel.clientId`). `swarm-secret-publish` copies it to `swarm/services/<id>/oidc/client`, and `swarm-journal-forwarder-oidc.service` in the same container reads it back under the secret publisher's leaf, at start and every 10 minutes. The two copies can differ while the publisher hasn't caught up, for example while the store is down after a re-mint. Authelia then refuses the forwarder's token request, and the collector retries until the next read lands the new value and restarts it | `/run/swarm-journal-forwarder-oidc/client-secret` on the container's tmpfs, root `0400`; `LoadCredential` hands it to the collector. The mint's own copy stays in `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | none: the forwarder runs only in authelia's container, and is off there without the publisher's leaf |
|
||||
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
|
||||
| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
|
||||
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-auth ensure-callout-keys`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed; the verb stores a seed it finds at `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed` as is, then removes the file | the swarm secret store: the seeds at `swarm/services/swarm-nats/callout/{user,issuer}-seed`, read by the `swarm-nats-auth` role alone; the public halves at `swarm/public/swarm-nats/callout-{user,issuer}` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| queue auth-callout xkey (curve seed) | `swarm-nats-auth ensure-callout-keys`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed | the swarm secret store: the seed at `swarm/services/swarm-nats/callout/xkey-seed`, read by the `swarm-nats-auth` role alone; the public half at `swarm/public/swarm-nats/callout-xkey` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| the queue's TLS key, copied for the gateway's `/metrics` vhost | `swarm-nats-gateway-cert.service` on the queue's host, where the gateway uses self-signed TLS: a copy of the queue's bao-PKI TLS leaf (`/var/lib/swarm-nats-tls/`) once `swarm-bao-nats-tls` has issued it, else a throwaway 30-day self-signed pair for the queue's name until then | `/var/lib/swarm-nats-gateway-tls/{cert,key}.pem`; the key is `0640` group `nginx`, read by nginx for the queue's `/metrics` vhost. The queue's own key stays `0600` root | the copy is redone on each leaf renewal (`swarm-bao-nats-tls` starts the unit, which queues a non-blocking nginx reload when nginx is running); a restart before any leaf exists keeps the pair already there. Until `swarm-bao-nats-tls` issues the leaf the vhost serves the throwaway cert, so the collector's scrape fails TLS. Nothing for an operator to place |
|
||||
| the secret store's own contents | openbao, on first `bao operator init` — **an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a _client_ of it and holds none of this |
|
||||
| the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it's stated rather than inferred | n/a — only the host running the store seals anything |
|
||||
| secret | generated by | lives at | hive elsewhere |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| swarm-services root CA (cert + key) | `swarm-bao.nix`'s granting unit, inside the store's `pki` mount | the **key** never leaves the mount; the **cert** lands at `/var/lib/swarm-bao-services-pki/services-root.pem` (0644) and at `/var/lib/hive-tls/swarm-services-root.pem` on every hive | nothing to copy — every hive receives the cert with the leaf it asks the store for, or from `pki/ca/pem` where it fronts no swarm name and needs a store identity |
|
||||
| authelia session, JWT, storage-encryption and OIDC HMAC keys | `authelia-swarm-secrets` in authelia's container. It stores each in the swarm secret store at `swarm/services/swarm-authelia/keys/<name>` when the store holds none: the value of the key an earlier release generated, or a new one when there is no such file. The files stay on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs on the files. A container with no secret publisher identity generates the files, as before | `/var/lib/authelia-swarm/{session,jwt,storage-encryption,oidc-hmac}.key` | nothing to place: the publisher's store leaf is the one in the row below. A store that stays unreachable on a start with no key file keeps authelia from starting |
|
||||
| authelia OIDC issuer key (RSA) | same unit, same store path (`oidc-issuer`) | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
|
||||
| swarm appservice registration (`as_token`, `hs_token`) | `swarm-matrix-ctl appservice sync` in `hive-matrix`. The store's pair wins; a store holding none takes the container's own tokens, so the value doesn't change by moving. Only `appservice render` on a container with neither mints a new pair | `/var/lib/swarm-matrix-appservice/{as-token,hs-token,swarm.yaml}` in `hive-matrix`, the last copy `sync` took, which tuwunel starts from; the store path is `swarm/controller/swarm-controller/matrix/appservice-registration` | nothing to place: matrix-ctl's store leaf is the one the container binds. A store that's down leaves the homeserver running on the files |
|
||||
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service; the forge and, by default, the hive collector need none — `swarm-bao-forge-oidc` and `swarm-bao-hive-otel-oidc` fetch it from the store ([how](credentials.md#hive-level-secrets)) |
|
||||
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
|
||||
| the swarm collector's copy of its OIDC secret | `swarm-bao-otel-oidc.service` reads it out of the swarm secret store, **on every host that runs the collector and holds a store identity** | `/run/swarm-otel-oidc/<id>.secret` on the host's tmpfs, bind-mounted read-only into the `swarm-otel` container — never on persistent disk, so a reboot fetches it again | same unit, same path — one route, co-located or not. A collector with no store identity has `clientSecretFile == null`, its already-supported unauthenticated-push degrade — see [swarm-level secrets](credentials.md#swarm-level-secrets) |
|
||||
| Grafana's copy of its OIDC secret | `swarm-bao-grafana-oidc.service` reads it out of the swarm secret store, **on every host that runs Grafana** | `/run/swarm-grafana-secrets/<id>.secret` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container — never on persistent disk, so a reboot fetches it again | same unit, same path — one route, co-located or not. Nothing for an operator to place beyond this host's store leaf, see [hive-level secrets](credentials.md#hive-level-secrets) |
|
||||
| Grafana's admin password | `swarm-bao-grafana-admin.service` creates it in the swarm secret store at `swarm/services/<grafana client id>/admin/password` when the store holds none, then copies it out, **on every host that runs Grafana**. Grafana's leaf may create that leaf but not update it, so the store keeps the first value, and the secret publisher can't write it. To rotate, run `bao kv metadata delete secret/swarm/services/<grafana client id>/admin/password` with a token that may, such as the root token (Grafana's, the publisher's and the controller's can't), then `systemctl restart swarm-bao-grafana-admin`; a plain `bao kv delete` isn't enough, see [the callout xkey's section](credentials.md#the-queues-callout-xkey) | `/run/swarm-grafana-secrets/admin-password` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container, never on persistent disk: `security.admin_password` for a new database, and `grafana cli admin reset-admin-password` in `grafana.service`'s `preStart` for an existing one, on every start | same unit, same path. After a reboot the unit fetches the file again, and Grafana doesn't start until it exists, so it stays down rather than falling back to the upstream default `admin` |
|
||||
| Grafana's metrics bearer | `swarm-bao-grafana-metrics-bearer.service` creates it in the swarm secret store at `swarm/services/<grafana client id>/metrics/bearer` when the store holds none, then copies it out, **on every host that runs Grafana**. Same grant as the admin password, create without update. To rotate, run `bao kv metadata delete secret/swarm/services/<grafana client id>/metrics/bearer` with a token that may, then `systemctl restart swarm-bao-grafana-metrics-bearer` | `/run/swarm-grafana-secrets/metrics-bearer` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container, holding `Bearer <value>`: the metrics datasource's `Authorization` header. The logs datasource sends the same header. `swarm-metrics-auth` on the metrics store's host, and a second instance on the log store's host (`swarm-logs-grafana-auth`), read the stored value themselves, each under its own leaf | same unit, same path. The unit restarts Grafana when the file changes |
|
||||
| Grafana's datasource encryption key | `swarm-bao-grafana-secret-key.service` stores it in the swarm secret store at `swarm/services/<grafana client id>/secret-key` when the store holds none, then copies it out, **on every host that runs Grafana**. It stores the key an earlier release generated at `/var/lib/grafana-secret/secret_key` in the container, so the key doesn't change, and mints a new one only on a host with no such file. Same grant as the admin password, create without update. The earlier file stays on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs Grafana on that file | `/run/swarm-grafana-secrets/secret-key` on the host's tmpfs, bind-mounted read-only into the `swarm-grafana` container: `security.secret_key` | same unit, same path. Grafana doesn't start until the file exists. Changing the key leaves Grafana unable to read the datasource secrets it stored, so never delete the store path to rotate it |
|
||||
| the swarm controller's webhook secret | `swarm-controller` stores it in the swarm secret store at `swarm/controller/swarm-controller/forge/webhook-secret` when the store holds none: the value of `webhook-secret` in its state directory, or a new one when there is no such file. The state directory file stays on disk and is the fallback while the store is unreachable or its policy doesn't grant the path yet | the swarm secret store; the controller holds it in memory and registers the same value with the forge's hooks | nothing to place: the controller's store leaf already exists. A store that stays unreachable on a start with no state directory file leaves the webhook endpoint answering 503 |
|
||||
| the metrics store responder's own store leaf (cert + key) | `signLeaf` in `glue-bao-tls.nix` (`swarm-bao-pki.service`), on the host that runs the secret store, once, subject `deploy.bao.metricsAuthCommonName`. `swarm-bao-metrics-auth-policy.service` writes the `swarm-metrics-auth` role and its read grants on the bearer leaf and on the metrics store forwarder's client secret, nothing else | `/var/lib/swarm-bao-pki/metrics-auth.pem` and `metrics-auth-key.pem` on the store's host, handed to `swarm-metrics-auth` through `LoadCredential` (paths in argv, never values) and read by path by `swarm-victoriametrics-forwarder-oidc` | **the operator** places a leaf with that subject on the metrics store's host and names it in `deploy.victoriametrics.authBaoClientCertFile` / `authBaoClientKeyFile`; evaluation fails naming both until then. If the store is on another host, deploy that host first: the role must exist before the responder's login succeeds, and until it does the gateway refuses every Grafana query (fail closed) |
|
||||
| the secret publisher's store leaf (cert + key) | `signLeaf` in `glue-bao-tls.nix` (`swarm-bao-pki.service`), on the host that runs the secret store, once, subject `deploy.bao.secretPublisherCommonName`. `swarm-bao-secret-publisher-policy.service` writes the `swarm-secret-publisher` role: create and update under `swarm/hives/*`, `swarm/services/*` and the controller's client, and read on authelia's forwarder's client secret and authelia's five keys alone | `/var/lib/swarm-bao-pki/secret-publisher.pem` and `secret-publisher-key.pem` on the host, handed into authelia's container as nspawn system credentials, which `swarm-secret-publish` and the forwarder's `swarm-journal-forwarder-oidc` load with `LoadCredential`. nspawn won't start authelia's container while a named file is missing | **the operator** copies the leaf to authelia's host, beside the hive's own store leaf, where `glue-secret-publisher-bao-identity.nix` looks, or names it in `deploy.swarm-secret-publisher.baoClientCertFile` / `baoClientKeyFile` |
|
||||
| the swarm controller's copy of its OIDC secret | `swarm-controller` reads it out of the swarm secret store at start, under its own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the controller's store leaf (`deploy.swarm-controller.baoClientCertFile`) |
|
||||
| a hive's copy of its own OIDC secret | hive-c0re reads it out of the swarm secret store when it first connects to the queue, under the hive's own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
|
||||
| the hive collector's copy of its hive's OIDC secret | `swarm-bao-hive-otel-oidc.service` reads it out of the swarm secret store under the hive's own leaf, **on every hive that runs the collector and holds that leaf**; the leaf is the source | `/run/hive-otel-oidc/hive-<name>.secret` on the hive's host tmpfs, 0400 root, loaded by `LoadCredential` — never on persistent disk, so a reboot fetches it again | same unit, same path, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`). Naming a secret file with `otel.clientSecretFile` instead is legacy for a host without a leaf: the build warns, and the fix is to place the leaf from `/var/lib/swarm-bao-pki/hives/<hive>/` and drop the setting |
|
||||
| the swarm controller's forge token | the forge's `forgejo-swarm-controller-account`, in-container, once | the swarm secret store at `swarm/controller/swarm-controller/forge/token`, written by the mint under the forge publisher's leaf, which the forge container loads as a credential; the controller holds it in memory only. `/var/lib/forgejo/swarm-controller-token-name` in the forge container holds its name, the minter's record that it has minted | nothing to place beyond the controller's store leaf. A forge host without the store needs the publisher's leaf placed (`deploy.bao.forgePublisherClientCertFile`) |
|
||||
| the swarm controller's Grafana token | `swarm-bao-grafana-controller-token.service`, **on the host that runs Grafana**, as Grafana's built-in admin over its socket: it creates the `swarm-controller` service account as a Viewer (and sets it back to Viewer on every run), mints a token for it and writes it to the swarm secret store at `swarm/controller/swarm-controller/grafana/token` under Grafana's leaf. Re-run every ten minutes: it keeps a stored token Grafana accepts, and replaces one Grafana refuses with 401 (or a missing one), deleting every other token of that account first | the swarm secret store only; the controller reads it for `GET /api/alerts` and holds it in memory, re-reading the store once when Grafana answers 401 | nothing to place: Grafana's leaf is already required to run Grafana. Until the unit has run, `/api/alerts` answers 503 naming the empty path |
|
||||
| an agent's API-key environment (`ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, an ACP provider key) | `hive-agent-backend-env.service`, in the agent's container, on an agent with a store that uses `useApiKey` or the `acp` runtime. When the store holds nothing at `swarm/agents/<agent>/backend-env` and `backendEnvironmentFile` is a non-empty file, it creates the leaf with the file's exact bytes and `-cas=0`, under the agent's own leaf, which may create that path and not update it. It never rotates a value and never rewrites or deletes the file. To set or replace a key, use **set the API-key environment** in the swarm UI (`PUT /api/hives/{hive}/agents/{agent}/backend-env`): `swarm-controller` overwrites the leaf with its own grant, and the store's value then wins over the file. Its **remove** (`DELETE` on the same path) deletes the leaf, and an agent whose file has content then creates it from that file again | the swarm secret store at that path; the unit writes it to `/run/hive-agent-backend-env/env` (tmpfs, `0600`, root), and `hive-agent` and `hive-subagent-daemon` load that after `backendEnvironmentFile` as `EnvironmentFile=` | the same unit, same path. A variable in the store wins over the same one in the file. With the store down at a start, the file alone carries the agent, as it did before the store held a copy; with an older hive build the agent ignores the leaf and keeps reading the file |
|
||||
| an agent's claude login (`.credentials.json`) | `swarm-claude-login.service`, in its container, when an operator completes `claude auth login` from the agent's card in the swarm UI; it stores the file as a new version at `swarm/agents/<agent>/claude-credentials` under its own leaf, which may create and update that path and read nothing under an agent | the swarm secret store at that path; the agent's `hive-agent` reads it under the agent's own leaf every 30 seconds while it waits for a login and writes it to `~/.claude/.credentials.json` | the same read, co-located or not; nothing for an operator to place beyond the agent's store leaf. When the store's copy and the agent's differ the KV version decides: the agent installs only a version newer than the last it installed (`claude-credentials-version` under the harness dir), because claude rotates the refresh token in its own copy and the stored one goes stale. The agent ignores a stale or equal version, and a relogin writes a newer one — [claude-login.md](claude-login.md) |
|
||||
| the queue responder's copy of its OIDC secret | the responder reads it out of the swarm secret store itself as it starts, under its own leaf, and again when authelia refuses it | the daemon's memory only; never on disk | same read, wherever authelia runs. Nothing for an operator to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| the queue container's journal forwarder's copy of the queue's OIDC secret | `swarm-journal-forwarder-oidc.service` inside the `swarm-nats` container reads it out of the swarm secret store under the responder's store leaf, and again every 10 minutes | `/run/swarm-journal-forwarder-oidc/client-secret` on the container's tmpfs, root `0400`, never on persistent disk; `LoadCredential` hands it to the collector | same unit, same path. After a reboot the collector restarts until the file exists, then resumes from its saved journal cursor. Nothing for an operator to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| the CI runner, Grafana, log store, cache, forge and homeserver containers' journal forwarders' OIDC secrets | `swarm-journal-forwarder-oidc.service` inside each container reads its forwarder client's secret (`swarm.<service>.otel.clientId`) out of the swarm secret store under the store leaf the host already holds for that service, handed in as a system credential (bound in read-only, for the homeserver's), and again every 10 minutes | `/run/swarm-journal-forwarder-oidc/client-secret` on the container's tmpfs, root `0400`, never on persistent disk; `LoadCredential` hands it to the collector | same unit, same path. Nothing for an operator to place beyond those leaves (`deploy.bao.ciRunnerClientCertFile`, `deploy.bao.grafanaOidcClientCertFile`, `deploy.victorialogs.authBaoClientCertFile`, `deploy.bao.forgePublisherClientCertFile`, `deploy.bao.atticClientCertFile`, `deploy.matrix.ctlBaoClientCertFile`) |
|
||||
| the metrics store container's journal forwarder's OIDC secret | `swarm-victoriametrics-forwarder-oidc.service` on the store's host reads its forwarder client's secret (`swarm.victoriametrics.otel.clientId`) out of the swarm secret store under the metrics store responder's leaf, and again every 10 minutes: the container has no network to reach the store | `/run/swarm-victoriametrics-forwarder-oidc/<id>.secret` on the host (tmpfs), root `0400`, never on persistent disk; bound into the container read-only, where `LoadCredential` hands it to the collector | same unit, same path. Nothing for an operator to place beyond that leaf (`deploy.victoriametrics.authBaoClientCertFile`) |
|
||||
| the CI run poller's forge token | `swarm-controller`, with the forge's admin API, for its `ci-metrics` account, at start and in a five-minute pass | the swarm secret store at `swarm/services/ci-metrics/forge/token`; `swarm-ci-metrics` reads it once per pass under its own leaf and holds it in memory for that pass. When the store's token no longer matches the forge's, the controller's next pass mints a new one; until then the poller's forge calls answer 401, the unit fails and no cursor moves | nothing to place beyond the poller's leaf (`deploy.bao.ciMetricsClientCertFile`), on a host without the store |
|
||||
| the CI run poller's copy of its OIDC secret | authelia; `swarm-secret-publish` copies it into the swarm secret store | `swarm-ci-metrics` reads `swarm/services/<swarm.ci-metrics.clientId>/oidc/client` once per pass under its own leaf, and again when authelia refuses it; memory only. While it differs from authelia's, the token request fails, nothing gets pushed and no cursor moves | same leaf, nothing else |
|
||||
| authelia's container's journal forwarder's OIDC secret | authelia's mint, in its container, like every client's plaintext half (`swarm.authelia.otel.clientId`). `swarm-secret-publish` copies it to `swarm/services/<id>/oidc/client`, and `swarm-journal-forwarder-oidc.service` in the same container reads it back under the secret publisher's leaf, at start and every 10 minutes. The two copies can differ while the publisher hasn't caught up, for example while the store is down after a re-mint. Authelia then refuses the forwarder's token request, and the collector retries until the next read lands the new value and restarts it | `/run/swarm-journal-forwarder-oidc/client-secret` on the container's tmpfs, root `0400`; `LoadCredential` hands it to the collector. The mint's own copy stays in `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | none: the forwarder runs only in authelia's container, and is off there without the publisher's leaf |
|
||||
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
|
||||
| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
|
||||
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-auth ensure-callout-keys`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed; the verb stores a seed it finds at `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed` as is, then removes the file | the swarm secret store: the seeds at `swarm/services/swarm-nats/callout/{user,issuer}-seed`, read by the `swarm-nats-auth` role alone; the public halves at `swarm/public/swarm-nats/callout-{user,issuer}` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| queue auth-callout xkey (curve seed) | `swarm-nats-auth ensure-callout-keys`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed | the swarm secret store: the seed at `swarm/services/swarm-nats/callout/xkey-seed`, read by the `swarm-nats-auth` role alone; the public half at `swarm/public/swarm-nats/callout-xkey` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| the queue's TLS key, copied for the gateway's `/metrics` vhost | `swarm-nats-gateway-cert.service` on the queue's host, where the gateway uses self-signed TLS: a copy of the queue's bao-PKI TLS leaf (`/var/lib/swarm-nats-tls/`) once `swarm-bao-nats-tls` has issued it, else a throwaway 30-day self-signed pair for the queue's name until then | `/var/lib/swarm-nats-gateway-tls/{cert,key}.pem`; the key is `0640` group `nginx`, read by nginx for the queue's `/metrics` vhost. The queue's own key stays `0600` root | the copy is redone on each leaf renewal (`swarm-bao-nats-tls` starts the unit, which queues a non-blocking nginx reload when nginx is running); a restart before any leaf exists keeps the pair already there. Until `swarm-bao-nats-tls` issues the leaf the vhost serves the throwaway cert, so the collector's scrape fails TLS. Nothing for an operator to place |
|
||||
| the secret store's own contents | openbao, on first `bao operator init` — **an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a _client_ of it and holds none of this |
|
||||
| the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it's stated rather than inferred | n/a — only the host running the store seals anything |
|
||||
|
||||
### Hive-level — one of each per hive
|
||||
|
||||
|
|
|
|||
|
|
@ -663,10 +663,12 @@ The [swarm README](README.md#an-agents-agentsmd) has the wire details.
|
|||
|
||||
### Linking external accounts
|
||||
|
||||
Each agent on `/agents` opens three dialogs that write a credential for it
|
||||
into the swarm secret store through swarm-controller. All three are blind
|
||||
Each agent on `/agents` opens four dialogs that write a credential for it
|
||||
into the swarm secret store through swarm-controller. All four are blind
|
||||
set actions: no route hands a token back. swarm-controller refuses to link a
|
||||
name that already holds an account; delete that account first to replace it.
|
||||
The fourth, the API-key environment, is the exception: a save overwrites what the
|
||||
store holds.
|
||||
|
||||
The agent's detail panel lists its linked accounts under **accounts**, one row
|
||||
per account: kind, name and host. Opening an agent makes one request,
|
||||
|
|
@ -677,11 +679,12 @@ permissions, it
|
|||
shows the reason as text. A list the panel already loaded stays on screen
|
||||
beneath the error.
|
||||
|
||||
| kind | one row per | name | host |
|
||||
| ------- | ------------------------------------------- | ----------- | ------------------------ |
|
||||
| matrix | `swarm/agents/<agent>/matrix/<account>` | the account | its `homeserver`, if set |
|
||||
| forgejo | `swarm/agents/<agent>/forge/<label>` | the label | its `url` |
|
||||
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` |
|
||||
| kind | one row per | name | host |
|
||||
| --------- | ------------------------------------------- | ------------- | ------------------------ |
|
||||
| matrix | `swarm/agents/<agent>/matrix/<account>` | the account | its `homeserver`, if set |
|
||||
| forgejo | `swarm/agents/<agent>/forge/<label>` | the label | its `url` |
|
||||
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` |
|
||||
| `backend` | `swarm/agents/<agent>/backend-env`, if any | `backend-env` | none |
|
||||
|
||||
The route names the second kind `forgejo`; the panel shows it as **forge**,
|
||||
the word the link menu uses.
|
||||
|
|
@ -711,6 +714,14 @@ a link dialog closes.
|
|||
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).
|
||||
- **set the API-key environment** — `PUT /api/hives/{hive}/agents/{agent}/backend-env`
|
||||
with the text of a systemd `EnvironmentFile` (`NAME=VALUE` lines, at most
|
||||
16 KiB), stored at `swarm/agents/<agent>/backend-env`. A save replaces the
|
||||
stored text, including text the agent wrote into an empty leaf. The box is
|
||||
write-only and starts empty; **remove** sends the `DELETE` below. A
|
||||
swarm-controller without the route answers 404, and the dialog says so.
|
||||
What the agent does with it:
|
||||
[API-key provider](../turn-loop/config.md#api-key-provider-useapikey--backendenvironmentfile).
|
||||
|
||||
#### Deleting a linked account
|
||||
|
||||
|
|
@ -718,11 +729,12 @@ Every row except the matrix `main` row has a **delete** action. Its
|
|||
confirmation names the account and its host, and confirming sends one
|
||||
request:
|
||||
|
||||
| kind | route |
|
||||
| ------- | ------------------------------------------------------------------- |
|
||||
| matrix | `DELETE /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` |
|
||||
| forgejo | `DELETE /api/hives/{hive}/agents/{agent}/forge-accounts/{label}` |
|
||||
| github | `DELETE /api/hives/{hive}/agents/{agent}/github-account` |
|
||||
| kind | route |
|
||||
| --------- | ------------------------------------------------------------------- |
|
||||
| matrix | `DELETE /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` |
|
||||
| forgejo | `DELETE /api/hives/{hive}/agents/{agent}/forge-accounts/{label}` |
|
||||
| github | `DELETE /api/hives/{hive}/agents/{agent}/github-account` |
|
||||
| `backend` | `DELETE /api/hives/{hive}/agents/{agent}/backend-env` |
|
||||
|
||||
swarm-controller removes every version of the entry from the swarm secret
|
||||
store, and answers 404 when the store holds nothing there. It refuses `main`: the
|
||||
|
|
|
|||
|
|
@ -289,6 +289,16 @@ the store down at a start the file alone carries the agent. Both read it at
|
|||
their own start, so a changed value reaches a running agent at its next
|
||||
restart.
|
||||
|
||||
To set or replace a key, use **set the API-key environment** on the agent in the swarm UI:
|
||||
it sends `PUT /api/hives/{hive}/agents/{agent}/backend-env` with the file text,
|
||||
which must be blank lines, `#` or `;` comments, and `NAME=VALUE` lines of at
|
||||
most 16 KiB. A save overwrites the stored text, including text the agent wrote
|
||||
into an empty leaf, and the store's value wins over the file. The box never
|
||||
shows the stored text. **Remove** (`DELETE` on the same path) deletes the stored
|
||||
text. An agent whose file has content creates the leaf from it again within 2
|
||||
minutes; one without a file keeps its copy in `/run` until the container
|
||||
restarts.
|
||||
|
||||
Provision the file once, out of band (never through nix — an API key in
|
||||
the store is world-readable and travels with the flake closure):
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// <LinkAccountMenu> — one "link account" badge that opens a `Dropdown` with
|
||||
// the three kinds of account an agent can be linked to, in place of one
|
||||
// the kinds of account and credential an agent can be linked to, in place of one
|
||||
// badge per kind: three side-by-side badges were wider than the detail
|
||||
// panel and spilled out of it. Presentational only, same split as
|
||||
// `WantedMenu`: the caller owns what each selection opens.
|
||||
|
|
@ -8,12 +8,13 @@ import { Badge } from "@hive/shared/badge.js";
|
|||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
|
||||
export type LinkKind = "matrix" | "forge" | "github";
|
||||
export type LinkKind = "matrix" | "forge" | "github" | "backend";
|
||||
|
||||
const OPTIONS: DropdownOption[] = [
|
||||
{ value: "matrix", label: "matrix account" },
|
||||
{ value: "forge", label: "forge account" },
|
||||
{ value: "github", label: "github account" },
|
||||
{ value: "backend", label: "API-key environment" },
|
||||
];
|
||||
|
||||
export function LinkAccountMenu({
|
||||
|
|
@ -40,7 +41,7 @@ export function LinkAccountMenu({
|
|||
disabled={!hasHive}
|
||||
title={
|
||||
hasHive
|
||||
? `link a matrix, forge or github account to ${agent}`
|
||||
? `link a matrix, forge or github account, or set the API-key environment, for ${agent}`
|
||||
: "no hive on record for this agent — nothing to link against"
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
|||
import { accountKindLabel } from "./account-kind.js";
|
||||
import "./LinkedAccounts.css";
|
||||
|
||||
type AccountKind = "matrix" | "forgejo" | "github";
|
||||
type AccountKind = "matrix" | "forgejo" | "github" | "backend";
|
||||
|
||||
// Mirrors `linked_accounts::LinkedAccount`.
|
||||
interface LinkedAccount {
|
||||
|
|
@ -38,6 +38,8 @@ function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
|
|||
return `${base}/forge-accounts/${encodeURIComponent(a.name)}`;
|
||||
case "github":
|
||||
return `${base}/github-account`;
|
||||
case "backend":
|
||||
return `${base}/backend-env`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
|||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { LinkMatrixAccountForm } from "../../pages/LinkMatrixAccountForm.js";
|
||||
import { LinkForgeAccountForm } from "../../pages/LinkForgeAccountForm.js";
|
||||
import { LinkBackendEnvForm } from "../../pages/LinkBackendEnvForm.js";
|
||||
import { LinkGithubAccountForm } from "../../pages/LinkGithubAccountForm.js";
|
||||
import type { AgentRow } from "../AgentTypes.js";
|
||||
import type { LinkKind } from "../detail/LinkAccountMenu.js";
|
||||
|
|
@ -96,6 +97,7 @@ export function useAgentActions(
|
|||
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
|
||||
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
|
||||
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
||||
const [backendTarget, setBackendTarget] = useState<AgentRow | null>(null);
|
||||
const [linkVersion, setLinkVersion] = useState(0);
|
||||
|
||||
const linkClosed = () => setLinkVersion((v) => v + 1);
|
||||
|
|
@ -111,6 +113,10 @@ export function useAgentActions(
|
|||
setGithubTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
const closeBackend = () => {
|
||||
setBackendTarget(null);
|
||||
linkClosed();
|
||||
};
|
||||
|
||||
// The one PUT every wanted-state selection ends at, whether or not it went
|
||||
// through a `ConfirmDialog` first: PUT a new `wanted` declaration, track
|
||||
|
|
@ -213,6 +219,20 @@ export function useAgentActions(
|
|||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={backendTarget !== null}
|
||||
onClose={closeBackend}
|
||||
label="set the API-key environment"
|
||||
plain
|
||||
>
|
||||
{backendTarget?.hive ? (
|
||||
<LinkBackendEnvForm
|
||||
hive={backendTarget.hive}
|
||||
agent={backendTarget.name}
|
||||
onClose={closeBackend}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirmTarget !== null}
|
||||
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
|
||||
|
|
@ -259,7 +279,8 @@ export function useAgentActions(
|
|||
openLink: (row, kind) => {
|
||||
if (kind === "matrix") setMatrixTarget(row);
|
||||
else if (kind === "forge") setForgeTarget(row);
|
||||
else setGithubTarget(row);
|
||||
else if (kind === "github") setGithubTarget(row);
|
||||
else setBackendTarget(row);
|
||||
},
|
||||
dialogs,
|
||||
};
|
||||
|
|
|
|||
130
frontend/packages/swarm-ui/src/pages/LinkBackendEnvForm.tsx
Normal file
130
frontend/packages/swarm-ui/src/pages/LinkBackendEnvForm.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// <LinkBackendEnvForm> — writes an agent's backend environment (the text of
|
||||
// a systemd `EnvironmentFile`) into the swarm secret store, or removes it.
|
||||
// PUTs and DELETEs `/api/hives/{hive}/agents/{agent}/backend-env` — 204 on
|
||||
// success, 400/404/500 as `problem+json`, shown via `ApiErrorPanel` like
|
||||
// `LinkGithubAccountForm`.
|
||||
//
|
||||
// A blind set: no route hands the text back, so the box never starts with the
|
||||
// stored value. A save replaces whatever is stored. Whether one is stored
|
||||
// shows on the agent panel (`LinkedAccounts`).
|
||||
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 { TextAreaField } from "../ui/text-area-field/TextAreaField.js";
|
||||
import { Button } from "../ui/button/Button.js";
|
||||
import "./LinkMatrixAccountForm.css";
|
||||
|
||||
type SubmitState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "done"; message: string }
|
||||
| { status: "error"; problem: ProblemDetails };
|
||||
|
||||
// A swarm-controller without the route answers 404 with no problem body.
|
||||
const NO_ROUTE =
|
||||
"this swarm-controller has no backend-env route, or nothing is stored";
|
||||
|
||||
export function LinkBackendEnvForm({
|
||||
hive,
|
||||
agent,
|
||||
onClose,
|
||||
}: {
|
||||
hive: string;
|
||||
agent: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [env, setEnv] = useState("");
|
||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||
|
||||
async function send(method: "PUT" | "DELETE", done: string) {
|
||||
setResult({ status: "submitting" });
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/backend-env`,
|
||||
method === "PUT"
|
||||
? {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
}
|
||||
: { method },
|
||||
);
|
||||
if (!r.ok) {
|
||||
const problem = await readApiError(r);
|
||||
setResult({
|
||||
status: "error",
|
||||
problem:
|
||||
r.status === 404 && !problem.detail
|
||||
? { detail: NO_ROUTE }
|
||||
: problem,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setResult({ status: "done", message: done });
|
||||
// The store holds the text; nothing here needs it.
|
||||
setEnv("");
|
||||
} catch (err) {
|
||||
setResult({ status: "error", problem: { detail: String(err) } });
|
||||
}
|
||||
}
|
||||
|
||||
function save(e: Event) {
|
||||
e.preventDefault();
|
||||
void send("PUT", "stored the API-key environment for");
|
||||
}
|
||||
|
||||
const busy = result.status === "submitting";
|
||||
return (
|
||||
<Panel
|
||||
title={`set the API-key environment — ${agent}`}
|
||||
icon="🔗"
|
||||
onClose={onClose}
|
||||
>
|
||||
<p>
|
||||
Writes <code>NAME=VALUE</code> lines (for example{" "}
|
||||
<code>ANTHROPIC_API_KEY</code> and <code>ANTHROPIC_BASE_URL</code>) to
|
||||
the swarm secret store. Saving replaces whatever is stored, including
|
||||
text the agent wrote itself. The agent fetches it within two minutes and
|
||||
loads it at its next restart; it wins over the agent's{" "}
|
||||
<code>backendEnvironmentFile</code>. Remove deletes the stored text; an
|
||||
agent whose <code>backendEnvironmentFile</code> has content stores that
|
||||
file again.
|
||||
</p>
|
||||
<form class="link-matrix-account-form" onSubmit={save}>
|
||||
<TextAreaField
|
||||
id="backend-env-text"
|
||||
label="environment file text"
|
||||
value={env}
|
||||
required
|
||||
rows={6}
|
||||
placeholder="ANTHROPIC_API_KEY=…"
|
||||
onInput={setEnv}
|
||||
/>
|
||||
<Button variant="primary" type="submit" disabled={busy}>
|
||||
{busy ? "saving…" : "save"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void send("DELETE", "removed the API-key environment for")
|
||||
}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
</form>
|
||||
{result.status === "done" && (
|
||||
<p class="link-matrix-account-result-ok">
|
||||
{result.message} <strong>{agent}</strong>
|
||||
</p>
|
||||
)}
|
||||
{result.status === "error" && (
|
||||
<ApiErrorPanel
|
||||
context="failed to update the API-key environment"
|
||||
problem={result.problem}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,4 +12,5 @@ test("a forgejo account is called a forge account, like the link menu does", ()
|
|||
test("the other kinds keep their names", () => {
|
||||
assert.equal(accountKindLabel("matrix"), "matrix");
|
||||
assert.equal(accountKindLabel("github"), "github");
|
||||
assert.equal(accountKindLabel("backend"), "backend");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -474,6 +474,10 @@ let
|
|||
capabilities = ["delete"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/metadata/swarm/agents/+/backend-env" {
|
||||
capabilities = ["delete"]
|
||||
}
|
||||
|
||||
path "${credentialMountPath}/data/swarm/hives/+/matrix/sender-token" {
|
||||
capabilities = ["create", "read", "update"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2265,11 +2265,12 @@ let
|
|||
in
|
||||
lib.hasInfix "path \"secret/metadata/swarm/agents/+/matrix/\" {\n capabilities = [\"list\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/forge/\" {\n capabilities = [\"list\"]\n}" s
|
||||
&& stanzas == 6;
|
||||
&& stanzas == 7;
|
||||
}
|
||||
{
|
||||
# `linked_accounts` deletes one matrix account, forge account or GitHub
|
||||
# token through `delete_all_versions`, which addresses its metadata path.
|
||||
# `linked_accounts` deletes one matrix account, forge account, GitHub
|
||||
# token or backend environment through `delete_all_versions`, which
|
||||
# addresses its metadata path.
|
||||
# A trailing `+` is one segment, so each stanza reaches the accounts
|
||||
# directly in that directory and nothing deeper. Pinned as whole stanzas,
|
||||
# so `list` or `read` here, which would expose version history, fails.
|
||||
|
|
@ -2280,7 +2281,8 @@ let
|
|||
in
|
||||
lib.hasInfix "path \"secret/metadata/swarm/agents/+/matrix/+\" {\n capabilities = [\"delete\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/forge/+\" {\n capabilities = [\"delete\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/github-token\" {\n capabilities = [\"delete\"]\n}" s;
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/github-token\" {\n capabilities = [\"delete\"]\n}" s
|
||||
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/backend-env\" {\n capabilities = [\"delete\"]\n}" s;
|
||||
}
|
||||
{
|
||||
# The swarm appservice token is a homeserver-admin credential. The
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
|||
agent's forge token and matrix account, and renews store certificates and
|
||||
queue secrets as they age.
|
||||
- **Linked external accounts** — an operator-supplied matrix, forge or
|
||||
GitHub account for one agent, stored in the swarm secret store
|
||||
GitHub account, or backend environment, for one agent, stored in the swarm secret store
|
||||
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
||||
`.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted
|
||||
`.../forge-accounts/{label}`, `.../github-account`, `.../backend-env`, whose save replaces what is stored); distinct from the agent's own swarm-minted
|
||||
accounts above. `GET .../linked-accounts` lists them, plus the agent's own
|
||||
`main` matrix account, by kind, name and host, never with a credential.
|
||||
Each of the three also takes a `DELETE`; matrix's also takes
|
||||
Each of the four also takes a `DELETE`; matrix's also takes
|
||||
`?revoke=true`, which logs the stored token out at its homeserver first and
|
||||
leaves the account in place if that fails.
|
||||
- **Config PR status** — each agent's open config-repo PR, cached from forge
|
||||
|
|
|
|||
296
swarm-controller/src/backend_env.rs
Normal file
296
swarm-controller/src/backend_env.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
//! An agent's backend environment: an operator hands us the text of a systemd
|
||||
//! `EnvironmentFile`, and we put it in the swarm's secret store.
|
||||
//!
|
||||
//! The agent end is `nix/agent-modules/backend-env.nix`, which reads it under
|
||||
//! the agent's own certificate into the file its harness and subagent daemon
|
||||
//! load as `EnvironmentFile=`. This is the operator's path: it replaces
|
||||
//! whatever the leaf holds, including a value the agent wrote itself while the
|
||||
//! leaf was empty, and the store's value wins over the agent's
|
||||
//! `backendEnvironmentFile`.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use swarm_secret_client::backend_env::{self, BackendEnv};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::linked_accounts::AccountStore;
|
||||
use crate::routes::agents::swarm_hive;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The most text one agent's environment may hold.
|
||||
const MAX_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// The text to store for one agent.
|
||||
///
|
||||
/// No `Debug` derive: this carries live credentials.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct PutBackendEnvRequest {
|
||||
/// The file text, `NAME=VALUE` per line; blank lines and lines starting
|
||||
/// with `#` or `;` are allowed. Never logged, and never returned by this
|
||||
/// route.
|
||||
env: String,
|
||||
}
|
||||
|
||||
/// Store an agent's backend environment, replacing any stored one.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/hives/{hive}/agents/{agent}/backend-env",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the environment belongs to"),
|
||||
),
|
||||
request_body = PutBackendEnvRequest,
|
||||
responses(
|
||||
(status = 204, description = "stored"),
|
||||
(status = 400, description = "the agent is not an identifier, the text is empty, too large or not `NAME=VALUE` lines, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 500, description = "the store could not be written (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn put_backend_env(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
||||
Json(req): Json<PutBackendEnvRequest>,
|
||||
) -> 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 path = backend_env::env_path(&agent)
|
||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
||||
let stored = BackendEnv {
|
||||
env: validated(&req.env).map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?,
|
||||
};
|
||||
|
||||
let store = state.accounts.open().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
// A plain write, not a create: the operator is authoritative over a value
|
||||
// the agent planted in the empty leaf.
|
||||
store.write(&path, &stored).await.map_err(|e| {
|
||||
// The path names the agent; the value is not in it.
|
||||
tracing::warn!(%path, error = %e, "storing the backend env failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!(%hive, %agent, "backend env stored");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// The text as it is stored, or why it cannot be.
|
||||
///
|
||||
/// systemd skips a line it cannot parse and goes on, so a typo would load as
|
||||
/// a missing key. A quote that is not closed would swallow the lines after it
|
||||
/// into one value. The reasons name a line number and never its text.
|
||||
fn validated(text: &str) -> Result<String, String> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("env is required".to_owned());
|
||||
}
|
||||
if text.len() > MAX_BYTES {
|
||||
return Err(format!("env is over {MAX_BYTES} bytes"));
|
||||
}
|
||||
if text
|
||||
.chars()
|
||||
.any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
|
||||
{
|
||||
return Err("env holds a control character".to_owned());
|
||||
}
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
if !valid_line(line.trim()) {
|
||||
return Err(format!("line {} is not NAME=VALUE", n + 1));
|
||||
}
|
||||
}
|
||||
Ok(format!("{text}\n"))
|
||||
}
|
||||
|
||||
/// A blank line, a comment, or `NAME=VALUE` with a closed quote and no
|
||||
/// trailing backslash.
|
||||
fn valid_line(line: &str) -> bool {
|
||||
if line.is_empty() || line.starts_with(['#', ';']) {
|
||||
return true;
|
||||
}
|
||||
let Some((name, value)) = line.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
let mut chars = name.chars();
|
||||
let name_ok = chars
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
let value = value.trim();
|
||||
let closed = match value.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => value.len() > 1 && value.ends_with(q),
|
||||
_ => true,
|
||||
};
|
||||
name_ok && closed && !value.ends_with('\\')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use swarm_secret_client::backend_env::BackendEnv;
|
||||
|
||||
use super::super::linked_accounts::{AccountStore, Accounts, tests::FakeStore};
|
||||
use super::{MAX_BYTES, PutBackendEnvRequest, validated};
|
||||
|
||||
const SECRET: &str = "sk-live-0123";
|
||||
|
||||
/// Bare-minimum `AppState`, as `github_account`'s tests build it.
|
||||
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
|
||||
crate::routes::AppState {
|
||||
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
dashboard: "pr1ma.hives.example".to_owned(),
|
||||
}]),
|
||||
links: std::sync::Arc::new(Vec::new()),
|
||||
status: None,
|
||||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
agents_md: None,
|
||||
reported_inference: None,
|
||||
agent_login: 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: std::sync::Arc::default(),
|
||||
swarm_name: None,
|
||||
auth: None,
|
||||
forge: std::sync::Arc::default(),
|
||||
create_gate: std::sync::Arc::default(),
|
||||
accounts: Accounts::Fake(Arc::clone(store)),
|
||||
try_outs: super::super::review_classifier::TryOuts::default(),
|
||||
reviews: std::sync::Arc::default(),
|
||||
runs: crate::review_classifier::runs::Runs::Live(None),
|
||||
builds: crate::builder::runs::Builds::Live(None),
|
||||
alerts: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(
|
||||
store: &Arc<FakeStore>,
|
||||
agent: &str,
|
||||
env: &str,
|
||||
) -> Result<StatusCode, problem_details::ProblemDetails> {
|
||||
super::put_backend_env(
|
||||
axum::extract::State(state(store)),
|
||||
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
|
||||
axum::Json(PutBackendEnvRequest {
|
||||
env: env.to_owned(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stored(store: &FakeStore, agent: &str) -> Option<String> {
|
||||
store
|
||||
.read_optional::<BackendEnv>(&format!("swarm/agents/{agent}/backend-env"))
|
||||
.await
|
||||
.expect("store answers")
|
||||
.map(|b| b.env)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_lines_comments_and_quoted_values_are_accepted() {
|
||||
let text = "# openrouter\nANTHROPIC_BASE_URL=https://openrouter.ai/api/v1\n\n\
|
||||
; key\n_K9=\"a b\"\nEMPTY=\nS='x'\n";
|
||||
let kept = validated(text).expect("valid");
|
||||
assert_eq!(kept, text.trim().to_owned() + "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_systemd_would_skip_or_misread_is_refused() {
|
||||
for bad in [
|
||||
"",
|
||||
" \n ",
|
||||
"KEY",
|
||||
"=value",
|
||||
"1KEY=v",
|
||||
"KE Y=v",
|
||||
"KEY=\"open",
|
||||
"KEY='",
|
||||
"KEY=trailing\\",
|
||||
"A=1\nnot a line\nB=2",
|
||||
"A=b\0c",
|
||||
] {
|
||||
assert!(validated(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(validated(&format!("A={}", "x".repeat(MAX_BYTES))).is_err());
|
||||
// The control: the size cap is the only difference.
|
||||
assert!(validated(&format!("A={}", "x".repeat(MAX_BYTES - 2))).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_names_the_line_and_not_its_text() {
|
||||
let reason = validated(&format!("A=1\n{SECRET}\n")).expect_err("no `=`");
|
||||
assert_eq!(reason, "line 2 is not NAME=VALUE");
|
||||
assert!(!reason.contains(SECRET), "{reason}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_plain_request_is_stored_under_the_agent() {
|
||||
let store = Arc::default();
|
||||
let status = put(&store, "atlas", &format!("ANTHROPIC_API_KEY={SECRET}"))
|
||||
.await
|
||||
.expect("stored");
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
assert_eq!(store.written(), ["swarm/agents/atlas/backend-env"]);
|
||||
assert_eq!(
|
||||
stored(&store, "atlas").await.as_deref(),
|
||||
Some("ANTHROPIC_API_KEY=sk-live-0123\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The operator's write replaces a value an agent planted in the leaf.
|
||||
#[tokio::test]
|
||||
async fn a_value_already_in_the_leaf_is_overwritten() {
|
||||
let store = Arc::new(FakeStore::default().with(
|
||||
"swarm/agents/atlas/backend-env",
|
||||
json!({"env": "PLANTED=1\n"}),
|
||||
));
|
||||
put(&store, "atlas", "ANTHROPIC_API_KEY=operator")
|
||||
.await
|
||||
.expect("overwritten");
|
||||
assert_eq!(
|
||||
stored(&store, "atlas").await.as_deref(),
|
||||
Some("ANTHROPIC_API_KEY=operator\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bad_agent_or_body_is_refused_before_the_store() {
|
||||
let store = Arc::default();
|
||||
let leaky = format!("{SECRET}-no-equals");
|
||||
for (agent, env) in [
|
||||
("Atlas", "A=1"),
|
||||
("../x", "A=1"),
|
||||
("atlas", " "),
|
||||
("atlas", leaky.as_str()),
|
||||
] {
|
||||
let problem = put(&store, agent, env).await.expect_err("refused");
|
||||
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{problem:?}");
|
||||
assert!(!format!("{problem:?}").contains(SECRET), "{problem:?}");
|
||||
}
|
||||
assert!(store.written().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_store_failure_does_not_echo_the_value() {
|
||||
let store = Arc::new(FakeStore::denying());
|
||||
let problem = put(&store, "atlas", &format!("ANTHROPIC_API_KEY={SECRET}"))
|
||||
.await
|
||||
.expect_err("the store refuses");
|
||||
assert_eq!(problem.status, Some(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
assert!(!format!("{problem:?}").contains(SECRET), "{problem:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,15 @@
|
|||
//! UI's agent panel lists, and deletes.
|
||||
//!
|
||||
//! [`link`] is the write the three link routes share: it refuses a path that
|
||||
//! already holds an account, so replacing one takes a delete first.
|
||||
//! already holds an account, so replacing one takes a delete first. The
|
||||
//! backend environment route is the exception: it overwrites.
|
||||
//!
|
||||
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`] and
|
||||
//! [`crate::github_account`] write, plus the agent's own `main` matrix account,
|
||||
//! which `matrix_account::agent_token` mints into the same directory. Each
|
||||
//! entry is read for its host and nothing else leaves this module:
|
||||
//! [`LinkedAccount`] has no field a credential could land in.
|
||||
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`],
|
||||
//! [`crate::github_account`] and [`crate::backend_env`] write, plus the
|
||||
//! agent's own `main` matrix account, which `matrix_account::agent_token`
|
||||
//! mints into the same directory. Each entry is read for its host and nothing
|
||||
//! else leaves this module: [`LinkedAccount`] has no field a credential could
|
||||
//! land in. A backend environment is listed as set, with no host.
|
||||
//!
|
||||
//! Naming the matrix accounts and forge labels takes `list` on each agent's
|
||||
//! `matrix` and `forge` metadata directories, and deleting an account takes
|
||||
|
|
@ -18,6 +20,9 @@
|
|||
//! A delete removes the store entry only, and the agent is not told. Its
|
||||
//! GitHub and forge token files stay in place, since the units that write them
|
||||
//! never delete one; its matrix daemon drops the account when it next re-lists.
|
||||
//! A deleted backend environment leaves the agent's copy in `/run` until its
|
||||
//! next boot, and an agent with a non-empty `backendEnvironmentFile` creates
|
||||
//! the leaf again from that file.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
|
|
@ -26,7 +31,7 @@ use axum::extract::State;
|
|||
use axum::http::StatusCode;
|
||||
use serde::de::{DeserializeOwned, IgnoredAny};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
|
||||
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, matrix};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::routes::agents::swarm_hive;
|
||||
|
|
@ -43,13 +48,14 @@ pub enum AccountKind {
|
|||
Matrix,
|
||||
Forgejo,
|
||||
Github,
|
||||
Backend,
|
||||
}
|
||||
|
||||
/// One account linked to an agent. Never carries the credential.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct LinkedAccount {
|
||||
kind: AccountKind,
|
||||
/// The matrix account name, the forge label, or `github`.
|
||||
/// The matrix account name, the forge label, `github`, or `backend-env`.
|
||||
#[schema(example = "main")]
|
||||
name: String,
|
||||
/// The matrix homeserver, the forge base URL, or `github.com`. `None` for a
|
||||
|
|
@ -185,7 +191,7 @@ async fn objects(store: &impl AccountStore, dir: &str) -> Result<Vec<String>, Er
|
|||
}
|
||||
|
||||
/// Every account the store holds for `agent`: matrix, then forgejo, then
|
||||
/// github.
|
||||
/// github, then the backend environment.
|
||||
///
|
||||
/// A missing directory lists nothing, and a listed name whose object is gone
|
||||
/// gets no entry.
|
||||
|
|
@ -238,6 +244,16 @@ pub(crate) async fn linked_accounts(
|
|||
});
|
||||
}
|
||||
|
||||
let path = backend_env::env_path(agent)?;
|
||||
if store.read_optional::<IgnoredAny>(&path).await?.is_some() {
|
||||
out.push(LinkedAccount {
|
||||
kind: AccountKind::Backend,
|
||||
name: "backend-env".to_owned(),
|
||||
host: None,
|
||||
reserved: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -567,13 +583,55 @@ pub async fn delete_github_account(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Delete an agent's backend environment from the store, every version of it.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/hives/{hive}/agents/{agent}/backend-env",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the environment belongs to"),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "deleted"),
|
||||
(status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 404, description = "no backend environment is stored (problem+json)", body = String),
|
||||
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn delete_backend_env(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
||||
) -> 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 path = backend_env::env_path(&agent)
|
||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
||||
|
||||
let store = state.accounts.open().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
remove_plain::<IgnoredAny>(&store, &path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(%hive, %agent, error = ?e, "deleting the backend env failed");
|
||||
e.problem()
|
||||
})?;
|
||||
|
||||
tracing::info!(%hive, %agent, "backend env deleted");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::de::{DeserializeOwned, IgnoredAny};
|
||||
use serde_json::{Value, json};
|
||||
use swarm_secret_client::Error;
|
||||
|
||||
|
|
@ -596,7 +654,15 @@ pub(crate) mod tests {
|
|||
}
|
||||
|
||||
impl FakeStore {
|
||||
fn with(self, path: &str, object: Value) -> Self {
|
||||
/// A store whose every call fails.
|
||||
pub(crate) fn denying() -> Self {
|
||||
Self {
|
||||
denied: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with(self, path: &str, object: Value) -> Self {
|
||||
self.objects
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
|
|
@ -705,6 +771,10 @@ pub(crate) mod tests {
|
|||
"swarm/agents/atlas/github-token",
|
||||
json!({"value": "t0k3n-gh"}),
|
||||
)
|
||||
.with(
|
||||
"swarm/agents/atlas/backend-env",
|
||||
json!({"env": "ANTHROPIC_API_KEY=t0k3n-env\n"}),
|
||||
)
|
||||
// Another agent's accounts, and the agent's own forge token beside
|
||||
// its `forge/` directory: neither is a linked account of atlas.
|
||||
.with(
|
||||
|
|
@ -745,6 +815,7 @@ pub(crate) mod tests {
|
|||
false
|
||||
),
|
||||
row(AccountKind::Github, "github", Some("github.com"), false),
|
||||
row(AccountKind::Backend, "backend-env", None, false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -813,12 +884,16 @@ pub(crate) mod tests {
|
|||
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
|
||||
.await
|
||||
.expect("stored");
|
||||
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env")
|
||||
.await
|
||||
.expect("stored");
|
||||
assert_eq!(
|
||||
deleted(&store),
|
||||
[
|
||||
"swarm/agents/atlas/matrix/catgirl",
|
||||
"swarm/agents/atlas/forge/myforge",
|
||||
"swarm/agents/atlas/github-token",
|
||||
"swarm/agents/atlas/backend-env",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -835,6 +910,7 @@ pub(crate) mod tests {
|
|||
.await,
|
||||
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge").await,
|
||||
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token").await,
|
||||
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env").await,
|
||||
];
|
||||
for result in results {
|
||||
let removal = result.expect_err("nothing is stored");
|
||||
|
|
@ -1023,4 +1099,31 @@ pub(crate) mod tests {
|
|||
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
|
||||
assert_eq!(deleted(&store), ["swarm/agents/atlas/matrix/catgirl"]);
|
||||
}
|
||||
|
||||
async fn delete_backend(
|
||||
store: &Arc<FakeStore>,
|
||||
agent: &str,
|
||||
) -> Result<axum::http::StatusCode, problem_details::ProblemDetails> {
|
||||
super::delete_backend_env(
|
||||
axum::extract::State(state(store)),
|
||||
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_backend_env_removes_that_agents_leaf_only() {
|
||||
let store = Arc::new(every_kind());
|
||||
let status = delete_backend(&store, "atlas").await.expect("stored");
|
||||
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
|
||||
assert_eq!(deleted(&store), ["swarm/agents/atlas/backend-env"]);
|
||||
|
||||
let problem = delete_backend(&store, "red").await.expect_err("none");
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::NOT_FOUND));
|
||||
for bad in ["../x", "Atlas"] {
|
||||
let problem = delete_backend(&store, bad).await.expect_err("refused");
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::BAD_REQUEST));
|
||||
}
|
||||
assert_eq!(deleted(&store).len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ mod alerts;
|
|||
mod archetypes;
|
||||
mod attic_copy;
|
||||
mod auth;
|
||||
mod backend_env;
|
||||
mod builder;
|
||||
mod claude_login;
|
||||
mod config_pr;
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ use crate::routes::hives::{HiveEntry, ServiceLink};
|
|||
use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph};
|
||||
use crate::{
|
||||
agent_control, agent_icon, agent_input, agent_limits, agent_login, agent_permissions,
|
||||
agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth, builder,
|
||||
claude_login, config_pr, forge, forge_account, github_account, inference, issue_report,
|
||||
linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api, pulls,
|
||||
queue_identity, review_classifier, schedules, status, subagent_term, term_stream, wanted,
|
||||
webhook,
|
||||
agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth,
|
||||
backend_env, builder, claude_login, config_pr, forge, forge_account, github_account, inference,
|
||||
issue_report, linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api,
|
||||
pulls, queue_identity, review_classifier, schedules, status, subagent_term, term_stream,
|
||||
wanted, webhook,
|
||||
};
|
||||
|
||||
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
||||
|
|
@ -552,49 +552,10 @@ pub(crate) async fn start_forge(
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Every route this daemon serves, wired to its state.
|
||||
///
|
||||
/// Split out of `main` because the route list is the part that grows, and
|
||||
/// `main` sat exactly on the `too_many_lines` limit — adding an endpoint
|
||||
/// tripped a lint about the startup sequence, which is not where the change
|
||||
/// was. A new route now costs one line here and none there.
|
||||
pub(crate) fn build_app(state: AppState) -> axum::Router {
|
||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(routes::health))
|
||||
.routes(routes!(alerts::get_alerts))
|
||||
.routes(routes!(hives::get_hives))
|
||||
.routes(routes!(hives::get_hives_status))
|
||||
.routes(routes!(hives::get_links))
|
||||
.routes(routes!(hives::get_swarm_info))
|
||||
.routes(routes!(jobq_graph::get_jobq_graph))
|
||||
.routes(routes!(jobq_graph::get_jobq_rollup))
|
||||
.routes(routes!(config_prs::get_agent_config_pr))
|
||||
.routes(routes!(agents::get_agent_icon))
|
||||
.routes(routes!(agents_md::get_agents_md, agents_md::put_agents_md))
|
||||
.routes(routes!(
|
||||
inference::get_agent_inference,
|
||||
inference::put_agent_inference,
|
||||
inference::delete_agent_inference
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_limits::get_agent_limits,
|
||||
agent_limits::put_agent_limits,
|
||||
agent_limits::delete_agent_limits
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_permissions::get_agent_permissions,
|
||||
agent_permissions::put_agent_permissions,
|
||||
agent_permissions::delete_agent_permissions
|
||||
))
|
||||
.routes(routes!(agent_permissions::get_permission_catalogue))
|
||||
.routes(routes!(archetypes::get_archetypes))
|
||||
.routes(routes!(config_prs::get_config_prs))
|
||||
.routes(routes!(agents::create_agent))
|
||||
.routes(routes!(forge_admin::mint_agent_forge_token))
|
||||
.routes(routes!(forge_admin::make_forge_admin))
|
||||
.routes(routes!(agents::get_agents))
|
||||
.routes(routes!(agents::get_agents_status))
|
||||
.routes(routes!(agents::set_agent_state))
|
||||
/// The routes that write, delete and list an agent's linked accounts and
|
||||
/// backend environment, kept apart so `build_app` stays under the line limit.
|
||||
fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppState> {
|
||||
router
|
||||
.routes(routes!(
|
||||
matrix_account::put_matrix_account,
|
||||
linked_accounts::delete_matrix_account
|
||||
|
|
@ -607,50 +568,100 @@ pub(crate) fn build_app(state: AppState) -> axum::Router {
|
|||
github_account::put_github_account,
|
||||
linked_accounts::delete_github_account
|
||||
))
|
||||
.routes(routes!(
|
||||
backend_env::put_backend_env,
|
||||
linked_accounts::delete_backend_env
|
||||
))
|
||||
.routes(routes!(linked_accounts::get_linked_accounts))
|
||||
.routes(routes!(agents::get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(message_stream::stream_messages))
|
||||
.routes(routes!(subagent_term::list_subagents))
|
||||
.routes(routes!(subagent_term::stream_subagent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
.routes(routes!(agent_input::post_agent_input))
|
||||
.routes(routes!(agent_input::post_agents_broadcast))
|
||||
.routes(routes!(agent_control::post_agent_cancel))
|
||||
.routes(routes!(
|
||||
claude_login::post_claude_login,
|
||||
claude_login::delete_claude_login
|
||||
))
|
||||
.routes(routes!(claude_login::post_claude_login_code))
|
||||
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
|
||||
.routes(routes!(
|
||||
schedules::edit_schedule,
|
||||
schedules::remove_schedule
|
||||
))
|
||||
.routes(routes!(issue_report::get_repos))
|
||||
.routes(routes!(issue_report::get_issue_report_all))
|
||||
.routes(routes!(issue_report::get_issue_report))
|
||||
.routes(routes!(pulls::get_pull_repos))
|
||||
.routes(routes!(pulls::get_pulls))
|
||||
.routes(routes!(review_classifier::post_try_out))
|
||||
.routes(routes!(review_classifier::get_try_out))
|
||||
.routes(routes!(review_classifier::get_rules))
|
||||
.routes(routes!(review_classifier::open_pr::post_open_pr))
|
||||
.routes(routes!(review_classifier::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_run))
|
||||
.routes(routes!(
|
||||
review_classifier::settings::get_settings,
|
||||
review_classifier::settings::put_settings
|
||||
))
|
||||
.routes(routes!(notify_flows::get_flows, notify_flows::put_flows))
|
||||
.routes(routes!(notify_router_api::get_deliveries))
|
||||
.routes(routes!(
|
||||
notify_router_api::get_dry_run,
|
||||
notify_router_api::post_dry_run
|
||||
))
|
||||
.routes(routes!(webhook::post_webhook_forge))
|
||||
.split_for_parts();
|
||||
}
|
||||
|
||||
/// Every route this daemon serves, wired to its state.
|
||||
///
|
||||
/// Split out of `main` because the route list is the part that grows, and
|
||||
/// `main` sat exactly on the `too_many_lines` limit — adding an endpoint
|
||||
/// tripped a lint about the startup sequence, which is not where the change
|
||||
/// was. A new route now costs one line here and none there.
|
||||
pub(crate) fn build_app(state: AppState) -> axum::Router {
|
||||
let (router, api) =
|
||||
linked_account_routes(OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi()))
|
||||
.routes(routes!(routes::health))
|
||||
.routes(routes!(alerts::get_alerts))
|
||||
.routes(routes!(hives::get_hives))
|
||||
.routes(routes!(hives::get_hives_status))
|
||||
.routes(routes!(hives::get_links))
|
||||
.routes(routes!(hives::get_swarm_info))
|
||||
.routes(routes!(jobq_graph::get_jobq_graph))
|
||||
.routes(routes!(jobq_graph::get_jobq_rollup))
|
||||
.routes(routes!(config_prs::get_agent_config_pr))
|
||||
.routes(routes!(agents::get_agent_icon))
|
||||
.routes(routes!(agents_md::get_agents_md, agents_md::put_agents_md))
|
||||
.routes(routes!(
|
||||
inference::get_agent_inference,
|
||||
inference::put_agent_inference,
|
||||
inference::delete_agent_inference
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_limits::get_agent_limits,
|
||||
agent_limits::put_agent_limits,
|
||||
agent_limits::delete_agent_limits
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_permissions::get_agent_permissions,
|
||||
agent_permissions::put_agent_permissions,
|
||||
agent_permissions::delete_agent_permissions
|
||||
))
|
||||
.routes(routes!(agent_permissions::get_permission_catalogue))
|
||||
.routes(routes!(archetypes::get_archetypes))
|
||||
.routes(routes!(config_prs::get_config_prs))
|
||||
.routes(routes!(agents::create_agent))
|
||||
.routes(routes!(forge_admin::mint_agent_forge_token))
|
||||
.routes(routes!(forge_admin::make_forge_admin))
|
||||
.routes(routes!(agents::get_agents))
|
||||
.routes(routes!(agents::get_agents_status))
|
||||
.routes(routes!(agents::set_agent_state))
|
||||
.routes(routes!(agents::get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(message_stream::stream_messages))
|
||||
.routes(routes!(subagent_term::list_subagents))
|
||||
.routes(routes!(subagent_term::stream_subagent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
.routes(routes!(agent_input::post_agent_input))
|
||||
.routes(routes!(agent_input::post_agents_broadcast))
|
||||
.routes(routes!(agent_control::post_agent_cancel))
|
||||
.routes(routes!(
|
||||
claude_login::post_claude_login,
|
||||
claude_login::delete_claude_login
|
||||
))
|
||||
.routes(routes!(claude_login::post_claude_login_code))
|
||||
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
|
||||
.routes(routes!(
|
||||
schedules::edit_schedule,
|
||||
schedules::remove_schedule
|
||||
))
|
||||
.routes(routes!(issue_report::get_repos))
|
||||
.routes(routes!(issue_report::get_issue_report_all))
|
||||
.routes(routes!(issue_report::get_issue_report))
|
||||
.routes(routes!(pulls::get_pull_repos))
|
||||
.routes(routes!(pulls::get_pulls))
|
||||
.routes(routes!(review_classifier::post_try_out))
|
||||
.routes(routes!(review_classifier::get_try_out))
|
||||
.routes(routes!(review_classifier::get_rules))
|
||||
.routes(routes!(review_classifier::open_pr::post_open_pr))
|
||||
.routes(routes!(review_classifier::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_run))
|
||||
.routes(routes!(
|
||||
review_classifier::settings::get_settings,
|
||||
review_classifier::settings::put_settings
|
||||
))
|
||||
.routes(routes!(notify_flows::get_flows, notify_flows::put_flows))
|
||||
.routes(routes!(notify_router_api::get_deliveries))
|
||||
.routes(routes!(
|
||||
notify_router_api::get_dry_run,
|
||||
notify_router_api::post_dry_run
|
||||
))
|
||||
.routes(routes!(webhook::post_webhook_forge))
|
||||
.split_for_parts();
|
||||
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
|
||||
// the nix store (see the module doc comment above). `api` is
|
||||
// `Clone`; each request gets its own owned copy for `Json` to
|
||||
|
|
|
|||
Loading…
Reference in a new issue