Watch
0
0
Fork
You've already forked hyperhive
0

let the operator set, replace and remove an agent's backend env from the swarm ui

This commit is contained in:
atlas 2026-10-28 01:33:52 +01:00 • committed by mara
commit 57b04d6c49
15 changed files with 795 additions and 201 deletions

View file

@ -17,7 +17,7 @@ the rest of the cell says how.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
| store path | minter | reader | automatic re-mint | automatic re-pull | | 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/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/<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/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 |
@ -32,7 +32,7 @@ the rest of the cell says how.
| `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-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>/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>/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>/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/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>/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>/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 |
@ -107,7 +107,7 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
### Swarm-level — one of each per swarm ### Swarm-level — one of each per swarm
| secret | generated by | lives at | hive elsewhere | | 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 | | 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 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` | | 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` |
@ -127,7 +127,7 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
| 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 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 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 | | 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 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) | | 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 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 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`) |

View file

@ -663,10 +663,12 @@ The [swarm README](README.md#an-agents-agentsmd) has the wire details.
### Linking external accounts ### Linking external accounts
Each agent on `/agents` opens three dialogs that write a credential for it Each agent on `/agents` opens four dialogs that write a credential for it
into the swarm secret store through swarm-controller. All three are blind 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 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. 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 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, per account: kind, name and host. Opening an agent makes one request,
@ -678,10 +680,11 @@ shows the reason as text. A list the panel already loaded stays on screen
beneath the error. beneath the error.
| kind | one row per | name | host | | kind | one row per | name | host |
| ------- | ------------------------------------------- | ----------- | ------------------------ | | --------- | ------------------------------------------- | ------------- | ------------------------ |
| matrix | `swarm/agents/<agent>/matrix/<account>` | the account | its `homeserver`, if set | | matrix | `swarm/agents/<agent>/matrix/<account>` | the account | its `homeserver`, if set |
| forgejo | `swarm/agents/<agent>/forge/<label>` | the label | its `url` | | forgejo | `swarm/agents/<agent>/forge/<label>` | the label | its `url` |
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` | | 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 route names the second kind `forgejo`; the panel shows it as **forge**,
the word the link menu uses. 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 poller read. A `github-token` already in place stays when the store holds
none. What the token needs and how the agent uses it: none. What the token needs and how the agent uses it:
[GitHub accounts](../integrations/github.md). [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 #### Deleting a linked account
@ -719,10 +730,11 @@ confirmation names the account and its host, and confirming sends one
request: request:
| kind | route | | kind | route |
| ------- | ------------------------------------------------------------------- | | --------- | ------------------------------------------------------------------- |
| matrix | `DELETE /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` | | matrix | `DELETE /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` |
| forgejo | `DELETE /api/hives/{hive}/agents/{agent}/forge-accounts/{label}` | | forgejo | `DELETE /api/hives/{hive}/agents/{agent}/forge-accounts/{label}` |
| github | `DELETE /api/hives/{hive}/agents/{agent}/github-account` | | 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 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 store, and answers 404 when the store holds nothing there. It refuses `main`: the

View file

@ -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 their own start, so a changed value reaches a running agent at its next
restart. 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 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): the store is world-readable and travels with the flake closure):

View file

@ -1,5 +1,5 @@
// <LinkAccountMenu> — one "link account" badge that opens a `Dropdown` with // <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 // badge per kind: three side-by-side badges were wider than the detail
// panel and spilled out of it. Presentational only, same split as // panel and spilled out of it. Presentational only, same split as
// `WantedMenu`: the caller owns what each selection opens. // `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 { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import { LinkIcon } from "@hive/shared/icons.js"; import { LinkIcon } from "@hive/shared/icons.js";
export type LinkKind = "matrix" | "forge" | "github"; export type LinkKind = "matrix" | "forge" | "github" | "backend";
const OPTIONS: DropdownOption[] = [ const OPTIONS: DropdownOption[] = [
{ value: "matrix", label: "matrix account" }, { value: "matrix", label: "matrix account" },
{ value: "forge", label: "forge account" }, { value: "forge", label: "forge account" },
{ value: "github", label: "github account" }, { value: "github", label: "github account" },
{ value: "backend", label: "API-key environment" },
]; ];
export function LinkAccountMenu({ export function LinkAccountMenu({
@ -40,7 +41,7 @@ export function LinkAccountMenu({
disabled={!hasHive} disabled={!hasHive}
title={ title={
hasHive 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" : "no hive on record for this agent — nothing to link against"
} }
/> />

View file

@ -18,7 +18,7 @@ import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { accountKindLabel } from "./account-kind.js"; import { accountKindLabel } from "./account-kind.js";
import "./LinkedAccounts.css"; import "./LinkedAccounts.css";
type AccountKind = "matrix" | "forgejo" | "github"; type AccountKind = "matrix" | "forgejo" | "github" | "backend";
// Mirrors `linked_accounts::LinkedAccount`. // Mirrors `linked_accounts::LinkedAccount`.
interface LinkedAccount { interface LinkedAccount {
@ -38,6 +38,8 @@ function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
return `${base}/forge-accounts/${encodeURIComponent(a.name)}`; return `${base}/forge-accounts/${encodeURIComponent(a.name)}`;
case "github": case "github":
return `${base}/github-account`; return `${base}/github-account`;
case "backend":
return `${base}/backend-env`;
} }
} }

View file

@ -17,6 +17,7 @@ import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../../ui/dialog/Dialog.js"; import { Dialog } from "../../ui/dialog/Dialog.js";
import { LinkMatrixAccountForm } from "../../pages/LinkMatrixAccountForm.js"; import { LinkMatrixAccountForm } from "../../pages/LinkMatrixAccountForm.js";
import { LinkForgeAccountForm } from "../../pages/LinkForgeAccountForm.js"; import { LinkForgeAccountForm } from "../../pages/LinkForgeAccountForm.js";
import { LinkBackendEnvForm } from "../../pages/LinkBackendEnvForm.js";
import { LinkGithubAccountForm } from "../../pages/LinkGithubAccountForm.js"; import { LinkGithubAccountForm } from "../../pages/LinkGithubAccountForm.js";
import type { AgentRow } from "../AgentTypes.js"; import type { AgentRow } from "../AgentTypes.js";
import type { LinkKind } from "../detail/LinkAccountMenu.js"; import type { LinkKind } from "../detail/LinkAccountMenu.js";
@ -96,6 +97,7 @@ export function useAgentActions(
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null); const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null); const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null); const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
const [backendTarget, setBackendTarget] = useState<AgentRow | null>(null);
const [linkVersion, setLinkVersion] = useState(0); const [linkVersion, setLinkVersion] = useState(0);
const linkClosed = () => setLinkVersion((v) => v + 1); const linkClosed = () => setLinkVersion((v) => v + 1);
@ -111,6 +113,10 @@ export function useAgentActions(
setGithubTarget(null); setGithubTarget(null);
linkClosed(); linkClosed();
}; };
const closeBackend = () => {
setBackendTarget(null);
linkClosed();
};
// The one PUT every wanted-state selection ends at, whether or not it went // The one PUT every wanted-state selection ends at, whether or not it went
// through a `ConfirmDialog` first: PUT a new `wanted` declaration, track // through a `ConfirmDialog` first: PUT a new `wanted` declaration, track
@ -213,6 +219,20 @@ export function useAgentActions(
/> />
) : null} ) : null}
</Dialog> </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 <ConfirmDialog
open={confirmTarget !== null} open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""} label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
@ -259,7 +279,8 @@ export function useAgentActions(
openLink: (row, kind) => { openLink: (row, kind) => {
if (kind === "matrix") setMatrixTarget(row); if (kind === "matrix") setMatrixTarget(row);
else if (kind === "forge") setForgeTarget(row); else if (kind === "forge") setForgeTarget(row);
else setGithubTarget(row); else if (kind === "github") setGithubTarget(row);
else setBackendTarget(row);
}, },
dialogs, dialogs,
}; };

View 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>
);
}

View file

@ -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", () => { test("the other kinds keep their names", () => {
assert.equal(accountKindLabel("matrix"), "matrix"); assert.equal(accountKindLabel("matrix"), "matrix");
assert.equal(accountKindLabel("github"), "github"); assert.equal(accountKindLabel("github"), "github");
assert.equal(accountKindLabel("backend"), "backend");
}); });

View file

@ -474,6 +474,10 @@ let
capabilities = ["delete"] capabilities = ["delete"]
} }
path "${credentialMountPath}/metadata/swarm/agents/+/backend-env" {
capabilities = ["delete"]
}
path "${credentialMountPath}/data/swarm/hives/+/matrix/sender-token" { path "${credentialMountPath}/data/swarm/hives/+/matrix/sender-token" {
capabilities = ["create", "read", "update"] capabilities = ["create", "read", "update"]
} }

View file

@ -2265,11 +2265,12 @@ let
in in
lib.hasInfix "path \"secret/metadata/swarm/agents/+/matrix/\" {\n capabilities = [\"list\"]\n}" s 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 && 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 # `linked_accounts` deletes one matrix account, forge account, GitHub
# token through `delete_all_versions`, which addresses its metadata path. # token or backend environment through `delete_all_versions`, which
# addresses its metadata path.
# A trailing `+` is one segment, so each stanza reaches the accounts # A trailing `+` is one segment, so each stanza reaches the accounts
# directly in that directory and nothing deeper. Pinned as whole stanzas, # directly in that directory and nothing deeper. Pinned as whole stanzas,
# so `list` or `read` here, which would expose version history, fails. # so `list` or `read` here, which would expose version history, fails.
@ -2280,7 +2281,8 @@ let
in in
lib.hasInfix "path \"secret/metadata/swarm/agents/+/matrix/+\" {\n capabilities = [\"delete\"]\n}" s 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/+/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 # The swarm appservice token is a homeserver-admin credential. The

View file

@ -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 agent's forge token and matrix account, and renews store certificates and
queue secrets as they age. queue secrets as they age.
- **Linked external accounts** — an operator-supplied matrix, forge or - **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}`, (`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 accounts above. `GET .../linked-accounts` lists them, plus the agent's own
`main` matrix account, by kind, name and host, never with a credential. `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 `?revoke=true`, which logs the stored token out at its homeserver first and
leaves the account in place if that fails. leaves the account in place if that fails.
- **Config PR status** — each agent's open config-repo PR, cached from forge - **Config PR status** — each agent's open config-repo PR, cached from forge

View 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:?}");
}
}

View file

@ -2,13 +2,15 @@
//! UI's agent panel lists, and deletes. //! UI's agent panel lists, and deletes.
//! //!
//! [`link`] is the write the three link routes share: it refuses a path that //! [`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 //! Read from the paths [`crate::matrix_account`], [`crate::forge_account`],
//! [`crate::github_account`] write, plus the agent's own `main` matrix account, //! [`crate::github_account`] and [`crate::backend_env`] write, plus the
//! which `matrix_account::agent_token` mints into the same directory. Each //! agent's own `main` matrix account, which `matrix_account::agent_token`
//! entry is read for its host and nothing else leaves this module: //! mints into the same directory. Each entry is read for its host and nothing
//! [`LinkedAccount`] has no field a credential could land in. //! 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 //! Naming the matrix accounts and forge labels takes `list` on each agent's
//! `matrix` and `forge` metadata directories, and deleting an account takes //! `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 //! 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 //! 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. //! 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; use std::future::Future;
@ -26,7 +31,7 @@ use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use serde::de::{DeserializeOwned, IgnoredAny}; use serde::de::{DeserializeOwned, IgnoredAny};
use serde::{Deserialize, Serialize}; 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 utoipa::{IntoParams, ToSchema};
use crate::routes::agents::swarm_hive; use crate::routes::agents::swarm_hive;
@ -43,13 +48,14 @@ pub enum AccountKind {
Matrix, Matrix,
Forgejo, Forgejo,
Github, Github,
Backend,
} }
/// One account linked to an agent. Never carries the credential. /// One account linked to an agent. Never carries the credential.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
pub struct LinkedAccount { pub struct LinkedAccount {
kind: AccountKind, 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")] #[schema(example = "main")]
name: String, name: String,
/// The matrix homeserver, the forge base URL, or `github.com`. `None` for a /// 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 /// 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 /// A missing directory lists nothing, and a listed name whose object is gone
/// gets no entry. /// 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) Ok(out)
} }
@ -567,13 +583,55 @@ pub async fn delete_github_account(
Ok(StatusCode::NO_CONTENT) 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)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use serde::Serialize; use serde::Serialize;
use serde::de::DeserializeOwned; use serde::de::{DeserializeOwned, IgnoredAny};
use serde_json::{Value, json}; use serde_json::{Value, json};
use swarm_secret_client::Error; use swarm_secret_client::Error;
@ -596,7 +654,15 @@ pub(crate) mod tests {
} }
impl FakeStore { 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 self.objects
.lock() .lock()
.expect("not poisoned") .expect("not poisoned")
@ -705,6 +771,10 @@ pub(crate) mod tests {
"swarm/agents/atlas/github-token", "swarm/agents/atlas/github-token",
json!({"value": "t0k3n-gh"}), 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 // Another agent's accounts, and the agent's own forge token beside
// its `forge/` directory: neither is a linked account of atlas. // its `forge/` directory: neither is a linked account of atlas.
.with( .with(
@ -745,6 +815,7 @@ pub(crate) mod tests {
false false
), ),
row(AccountKind::Github, "github", Some("github.com"), 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") remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
.await .await
.expect("stored"); .expect("stored");
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env")
.await
.expect("stored");
assert_eq!( assert_eq!(
deleted(&store), deleted(&store),
[ [
"swarm/agents/atlas/matrix/catgirl", "swarm/agents/atlas/matrix/catgirl",
"swarm/agents/atlas/forge/myforge", "swarm/agents/atlas/forge/myforge",
"swarm/agents/atlas/github-token", "swarm/agents/atlas/github-token",
"swarm/agents/atlas/backend-env",
] ]
); );
} }
@ -835,6 +910,7 @@ pub(crate) mod tests {
.await, .await,
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge").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::<github::Credential>(&store, "swarm/agents/atlas/github-token").await,
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env").await,
]; ];
for result in results { for result in results {
let removal = result.expect_err("nothing is stored"); 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!(status, axum::http::StatusCode::NO_CONTENT);
assert_eq!(deleted(&store), ["swarm/agents/atlas/matrix/catgirl"]); 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);
}
} }

View file

@ -39,6 +39,7 @@ mod alerts;
mod archetypes; mod archetypes;
mod attic_copy; mod attic_copy;
mod auth; mod auth;
mod backend_env;
mod builder; mod builder;
mod claude_login; mod claude_login;
mod config_pr; mod config_pr;

View file

@ -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::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph};
use crate::{ use crate::{
agent_control, agent_icon, agent_input, agent_limits, agent_login, agent_permissions, 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, agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth,
claude_login, config_pr, forge, forge_account, github_account, inference, issue_report, backend_env, builder, claude_login, config_pr, forge, forge_account, github_account, inference,
linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api, pulls, issue_report, linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api,
queue_identity, review_classifier, schedules, status, subagent_term, term_stream, wanted, pulls, queue_identity, review_classifier, schedules, status, subagent_term, term_stream,
webhook, wanted, webhook,
}; };
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`. /// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
@ -552,6 +552,29 @@ pub(crate) async fn start_forge(
.await; .await;
} }
/// 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
))
.routes(routes!(
forge_account::put_forge_account,
linked_accounts::delete_forge_account
))
.routes(routes!(
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))
}
/// Every route this daemon serves, wired to its state. /// Every route this daemon serves, wired to its state.
/// ///
/// Split out of `main` because the route list is the part that grows, and /// Split out of `main` because the route list is the part that grows, and
@ -559,7 +582,8 @@ pub(crate) async fn start_forge(
/// tripped a lint about the startup sequence, which is not where the change /// 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. /// was. A new route now costs one line here and none there.
pub(crate) fn build_app(state: AppState) -> axum::Router { pub(crate) fn build_app(state: AppState) -> axum::Router {
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi()) let (router, api) =
linked_account_routes(OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi()))
.routes(routes!(routes::health)) .routes(routes!(routes::health))
.routes(routes!(alerts::get_alerts)) .routes(routes!(alerts::get_alerts))
.routes(routes!(hives::get_hives)) .routes(routes!(hives::get_hives))
@ -595,19 +619,6 @@ pub(crate) fn build_app(state: AppState) -> axum::Router {
.routes(routes!(agents::get_agents)) .routes(routes!(agents::get_agents))
.routes(routes!(agents::get_agents_status)) .routes(routes!(agents::get_agents_status))
.routes(routes!(agents::set_agent_state)) .routes(routes!(agents::set_agent_state))
.routes(routes!(
matrix_account::put_matrix_account,
linked_accounts::delete_matrix_account
))
.routes(routes!(
forge_account::put_forge_account,
linked_accounts::delete_forge_account
))
.routes(routes!(
github_account::put_github_account,
linked_accounts::delete_github_account
))
.routes(routes!(linked_accounts::get_linked_accounts))
.routes(routes!(agents::get_hive_wanted)) .routes(routes!(agents::get_hive_wanted))
.routes(routes!(term_stream::stream_agent_term)) .routes(routes!(term_stream::stream_agent_term))
.routes(routes!(message_stream::stream_messages)) .routes(routes!(message_stream::stream_messages))