Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
04676bfb2d module-eval: count both the builder and the minter containers after the rebase 2026-10-15 13:26:29 +02:00
atlas
0bb8e80a58 docs/swarm/secrets.md: a row for the claude login the minter stores and the agent reads 2026-10-15 13:26:29 +02:00
atlas
693e6e738b swarm-claude-login: refuse a step whose agent name is not an identifier
The minter parses `Request.agent` with `Ident::parse`, as the controller
does, before it makes a scratch directory, runs `claude auth login` or
touches the store, and answers `Failed` otherwise. The name becomes a
directory name and a store path in the one service that writes every
agent's credentials, so it is checked at this service's own boundary.

The doc states that the `operatorsOnly` authelia rule, not the
controller, keeps the routes to operators, and that a stale version record
waits for a relogin. The env-strip comment says it is defence in depth.

Refs #5268
2026-10-15 13:26:29 +02:00
atlas
d5de1e4943 swarm UI: log an agent into claude from its card; the credentials go to bao and the agent picks them up
A new swarm service, `swarm-claude-login`, in its own container on the host
that sets `deploy.claude-login.enable` (defaulted on by `allSwarmServices`
where the host has the minter's store leaf, like the review classifier).

- swarm-ui: an agent card showing "login needed" gets a "log in" button.
  Its dialog starts the login, shows the URL claude printed, and takes the
  pasted code.
- swarm-controller: `POST/DELETE /api/agents/{name}/claude-login` and
  `POST /api/agents/{name}/claude-login/code`, behind the swarm UI's
  authelia-gated `/api/`, relay the steps to the minter as requests on
  `$SWARM.claude-login.request`.
- swarm-claude-login: runs `claude auth login` over piped stdio with HOME
  and CLAUDE_CONFIG_DIR in a per-login scratch dir, scrapes the first
  https URL, writes the code to stdin, and stores the `.credentials.json`
  at `swarm/agents/<agent>/claude-credentials` with a KV v2 write, a new
  version over any earlier one. The scratch dir goes on every exit path; a
  login waits 15 minutes for its code.
- hive-agent: while parked in `wait_for_login`, reads that leaf every 30 s
  under the agent's own cert and installs it only when its KV version is
  newer than the one recorded in `<harness>/claude-credentials-version`.
  An equal or older version is never put back, because claude rotates the
  refresh token and the stored copy is stale after the first refresh.
- swarm-secret-client: `claude_login` paths and `SecretStore::read_versioned`.
- Credentials: a `claude-login` store leaf minted by glue-bao-tls, a
  `swarm-claude-login` policy (create/update on
  `swarm/agents/+/claude-credentials`, read on its own queue client
  secret), an authelia machine client, and a `--claude-login-client`
  grant in the queue's auth-callout responder. The unit runs as a static
  system user.

The hive's own web-UI login is unchanged.

Refs #5268
2026-10-15 13:26:29 +02:00
48 changed files with 2808 additions and 68 deletions

View file

@ -145,6 +145,9 @@ Swarm-level:
crates. crates.
- **`swarm-builder/`** — builds agent config PR heads for commit status. - **`swarm-builder/`** — builds agent config PR heads for commit status.
Design: [`docs/swarm/builder.md`](docs/swarm/builder.md). Design: [`docs/swarm/builder.md`](docs/swarm/builder.md).
- **`swarm-claude-login/`** — runs `claude auth login` for one agent and
stores the credentials at `swarm/agents/<agent>/claude-credentials`.
Design: [`docs/swarm/claude-login.md`](docs/swarm/claude-login.md).
- **`swarm-review-classifier/`** — one `claude --print` per PR - **`swarm-review-classifier/`** — one `claude --print` per PR
classification, no tools, no forge credential. Design: classification, no tools, no forge credential. Design:
[`docs/swarm/review-classifier.md`](docs/swarm/review-classifier.md). [`docs/swarm/review-classifier.md`](docs/swarm/review-classifier.md).

16
Cargo.lock generated
View file

@ -4851,6 +4851,22 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "swarm-claude-login"
version = "0.1.0"
dependencies = [
"anyhow",
"async-nats",
"futures-util",
"hive-log",
"hive-types",
"serde_json",
"swarm-queue-client",
"swarm-secret-client",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "swarm-controller" name = "swarm-controller"
version = "0.1.0" version = "0.1.0"

View file

@ -28,6 +28,7 @@ members = [
"swarm-agent-verify", "swarm-agent-verify",
"swarm-authelia-bridge", "swarm-authelia-bridge",
"swarm-builder", "swarm-builder",
"swarm-claude-login",
"swarm-authelia-bridge-sock", "swarm-authelia-bridge-sock",
"swarm-controller", "swarm-controller",
"swarm-matrix-client", "swarm-matrix-client",

View file

@ -91,6 +91,9 @@ declarations.
(planned, not implemented yet). (planned, not implemented yet).
- **Does an agent config PR build before it's merged?** → - **Does an agent config PR build before it's merged?** →
[`swarm/builder.md`](swarm/builder.md). [`swarm/builder.md`](swarm/builder.md).
- **How do I log an agent in to claude from the swarm UI, and why doesn't a
stale stored copy log it out again?** →
[`swarm/claude-login.md`](swarm/claude-login.md).
## Scheduler, CI, observability ## Scheduler, CI, observability

View file

@ -0,0 +1,82 @@
# Claude login from the swarm UI
An operator logs an agent in to claude from the agent's card in the swarm UI.
No hive is in the path: the login runs in the swarm's `swarm-claude-login`
service, the credentials go to the agent's own leaf in the secret store, and the
agent's harness installs them from there.
Where it runs and how it's enabled: [`services.md`](services.md#claude-login-minter).
## What happens on a login
1. The agent publishes `needs_login` in its turn-state header, and its card
shows **login needed** with a **log in** button.
2. **log in** opens a dialog, which calls
`POST /api/agents/<agent>/claude-login`. swarm-controller relays the start as
a request on the queue subject `$SWARM.claude-login.request`.
3. The minter runs `claude auth login` with `HOME` and `CLAUDE_CONFIG_DIR` in a
fresh scratch directory, over plain stdio pipes, and answers with the first
`https://` URL it prints. The dialog shows that URL.
4. The operator opens the URL, signs in to the agent's own claude account, and
pastes the code the page shows into the dialog, which posts it to
`POST /api/agents/<agent>/claude-login/code`.
5. The minter writes the code to claude's stdin and waits for it to exit. It
stores the `.credentials.json` claude wrote at
`swarm/agents/<agent>/claude-credentials` (field `value`), as a new version
over any stored before, and removes the scratch directory.
6. The agent's harness, while it waits for a login, reads that leaf every 30
seconds under the agent's own certificate. When the stored version is newer
than the one it installed last, it writes the file to
`~/.claude/.credentials.json`, records the version, and resumes its turn
loop as it does after a login from the agent's web UI.
A started login waits 15 minutes for its code, then the minter stops it.
Starting another login for the same agent replaces the running one. Each step
has a minute to finish; a refused code fails the login, and the dialog shows
what claude printed.
## Why the harness takes only a newer version
`claude` rotates the refresh token in `.credentials.json` as it uses the
session, and the agent can't write the store. After the first refresh the
stored copy is stale. A harness that installed it whenever the bytes differed
would replace the agent's live session with a stale copy and log the agent
out. The harness therefore compares the store's KV version with the last
version it installed (`claude-credentials-version` under the harness dir):
- it installs a newer version, that is, a new login;
- it leaves an equal or older version alone, even when the agent needs a
login again, so it never puts back a session that has gone bad;
- it reads nothing while the agent has a session.
## Credentials
| Principal | Grant |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `swarm-claude-login`, in its container | `create`, `update` on `swarm/agents/+/claude-credentials`; `read` on `swarm/services/<minter client id>/oidc/client` |
| the agent, under its own certificate | `read` on its own `swarm/agents/<agent>/*`, which covers the leaf |
The minter has no read on the leaf and nothing else under an agent. Every
hive's policy reads `swarm/agents/*`, so every hive can read every agent's
claude session, as it can read the GitHub token.
The `operatorsOnly` authelia rule on the swarm domain, not the controller,
keeps these routes to operators: the controller checks only that the gateway
named a user. Without that rule any account authelia admits could store its
own claude session as any agent's, so no agent account may exist before the
rule reaches the swarm.
The minter refuses a step whose agent name isn't an identifier before it
makes a directory or runs `claude`.
## Caveat
The installed-version record lives under the harness dir and the credentials
file under `~/.claude`. If only the record survives a rebuild, the agent
ignores the stored version until the operator logs it in again, which writes a
newer one.
## What stays
The agent's own web UI login (`/login`) still works, unchanged. It goes away
once the operator has confirmed the swarm login works.

View file

@ -68,6 +68,7 @@ of the cell says how.
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer | | `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer | | `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer |
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by deleting it and linking it again | ✅ the agent re-fetches on a 2-minute timer, and `hive-github-notify` re-reads the file on every poll | | `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by deleting it and linking it again | ✅ the agent re-fetches on a 2-minute timer, and `hive-github-notify` re-reads the file on every poll |
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, under its own certificate, when an operator logs the agent in from its card in the swarm UI; a new version per login | `hive-agent` in the agent container, under the agent's own certificate, into `~/.claude/.credentials.json`, only while the agent waits for a login and only a version newer than the one it installed last | ❌ an operator's login; a relogin writes a new version | ✅ the waiting agent reads the store every 30 seconds |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs | | `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, with the forge's admin API, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `forge::reader_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ hive-c0re reads the store on every forge read | | `swarm/hives/<hive>/forge/token` | `swarm-controller`, with the forge's admin API, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `forge::reader_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ hive-c0re reads the store on every forge read |
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |

View file

@ -43,31 +43,32 @@ neither is a renaming of the other.
## 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 and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them | | authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them |
| authelia OIDC HMAC key | same unit | `/var/lib/authelia-swarm/oidc-hmac.key` | same | | authelia OIDC HMAC key | same unit | `/var/lib/authelia-swarm/oidc-hmac.key` | same |
| authelia OIDC issuer key (RSA) | same unit | `/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 | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
| 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 (below) | | 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 (below) |
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` | | 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** | `/var/lib/swarm-otel-oidc/<id>.secret` inside the `swarm-otel` container | 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 below | | 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** | `/var/lib/swarm-otel-oidc/<id>.secret` inside the `swarm-otel` container | 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 below |
| 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** | `/var/lib/grafana-oidc/<id>.secret` inside the `swarm-grafana` container | same unit, same path — one route, co-located or not. Nothing for an operator to place beyond this host's store leaf, see below | | 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** | `/var/lib/grafana-oidc/<id>.secret` inside the `swarm-grafana` container | same unit, same path — one route, co-located or not. Nothing for an operator to place beyond this host's store leaf, see below |
| 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](#the-queues-callout-xkey) | `/var/lib/grafana-admin/password` inside the `swarm-grafana` container: `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. Grafana doesn't start until the file exists, so it stays down rather than falling back to the upstream default `admin` | | 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](#the-queues-callout-xkey) | `/var/lib/grafana-admin/password` inside the `swarm-grafana` container: `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. Grafana doesn't start until the file 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` | `/var/lib/grafana-metrics/bearer` inside the `swarm-grafana` container, holding `Bearer <value>`: the metrics datasource's `Authorization` header. `swarm-metrics-auth` on the metrics store's host reads the stored value itself, under its own leaf | same unit, same path. The unit restarts Grafana when the file changes | | 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` | `/var/lib/grafana-metrics/bearer` inside the `swarm-grafana` container, holding `Bearer <value>`: the metrics datasource's `Authorization` header. `swarm-metrics-auth` on the metrics store's host reads the stored value itself, under its own leaf | same unit, same path. The unit restarts Grafana when the file changes |
| 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 grant on the bearer leaf, 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) | **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 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 grant on the bearer leaf, 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) | **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 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`) | | 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`) | | 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** | `/var/lib/hive-otel-oidc/hive-<name>.secret` on the hive's host, 0400 root, loaded by `LoadCredential` | same unit, same path, 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** | `/var/lib/hive-otel-oidc/hive-<name>.secret` on the hive's host, 0400 root, loaded by `LoadCredential` | same unit, same path, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
| the swarm controller's forge token | the forge's `forgejo-swarm-controller-account`, in-container, once | `/var/lib/forgejo/swarm-controller-token` inside the forge container, the minter's record that it has minted; `swarm-bao-forge-publisher` copies it into the swarm secret store at `swarm/controller/swarm-controller/forge/token` each time the forge container starts and each time the mint writes the file; the controller holds it in memory only | 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 | `/var/lib/forgejo/swarm-controller-token` inside the forge container, the minter's record that it has minted; `swarm-bao-forge-publisher` copies it into the swarm secret store at `swarm/controller/swarm-controller/forge/token` each time the forge container starts and each time the mint writes the file; the controller holds it in memory only | 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 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`) | | 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) |
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia | | 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`) |
| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you | | authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
| 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`) | | wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
| 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`) | | 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`) |
| 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 reloads nginx if it's active); 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 | | 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 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 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 reloads nginx if it's active); 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 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 | | 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 |
Authelia mints the three keys for itself, in-container, precisely Authelia mints the three keys for itself, in-container, precisely
because nothing outside that container ever reads them. **That's the test worth because nothing outside that container ever reads them. **That's the test worth
@ -405,10 +406,10 @@ next controller restart.
### Per-principal identities ### Per-principal identities
Bao matches a cert-auth role on the certificate's subject, so a certificate is an Bao matches a cert-auth role on the certificate's subject, so a certificate is an
identity and sharing one merges the identities. Each of these eight leaves has identity and sharing one merges the identities. Each of these nine leaves has
a role and a policy of its own, and reaches one path, except the review a role and a policy of its own, and reaches one path, except the review
classifier's, the builder's, the forge host's and Grafana's, which reach two classifier's, the builder's, the claude login minter's, the forge host's and
each: Grafana's, which reach two each:
| unit | option pair under `deploy.bao.` | reads, or writes | | unit | option pair under `deploy.bao.` | reads, or writes |
| --------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@ -418,28 +419,30 @@ each:
| `swarm-bao-forwarder-oidc` | `forwarderOidcClientCertFile` / `forwarderOidcClientKeyFile` | `swarm/services/<store forwarder client id>/oidc/client` | | `swarm-bao-forwarder-oidc` | `forwarderOidcClientCertFile` / `forwarderOidcClientKeyFile` | `swarm/services/<store forwarder client id>/oidc/client` |
| `swarm-bao-ci-runner` | `ciRunnerClientCertFile` / `ciRunnerClientKeyFile` | `swarm/services/ci-runner/registration-token` | | `swarm-bao-ci-runner` | `ciRunnerClientCertFile` / `ciRunnerClientKeyFile` | `swarm/services/ci-runner/registration-token` |
| `swarm-review-classifier`, in its container | `reviewClassifierClientCertFile` / `reviewClassifierClientKeyFile` | `swarm/services/review-classifier/anthropic-api-key`, `swarm/services/review-classifier/acp-provider` and `swarm/services/<classifier client id>/oidc/client` | | `swarm-review-classifier`, in its container | `reviewClassifierClientCertFile` / `reviewClassifierClientKeyFile` | `swarm/services/review-classifier/anthropic-api-key`, `swarm/services/review-classifier/acp-provider` and `swarm/services/<classifier client id>/oidc/client` |
| `swarm-claude-login`, in its container | `claudeLoginClientCertFile` / `claudeLoginClientKeyFile` | creates and updates `swarm/agents/+/claude-credentials`; reads `swarm/services/<minter client id>/oidc/client` |
| `swarm-bao-forge-publisher`, `swarm-bao-forge-oidc` | `forgePublisherClientCertFile` / `forgePublisherClientKeyFile` | writes `swarm/controller/swarm-controller/forge/token`; reads `swarm/services/<forge client id>/oidc/client` | | `swarm-bao-forge-publisher`, `swarm-bao-forge-oidc` | `forgePublisherClientCertFile` / `forgePublisherClientKeyFile` | writes `swarm/controller/swarm-controller/forge/token`; reads `swarm/services/<forge client id>/oidc/client` |
The first exists **per hive**, because the path it reads carries a hive name The first exists **per hive**, because the path it reads carries a hive name
and every hive runs its own reader. Its subject is and every hive runs its own reader. Its subject is
`<deploy.bao.queueAgentCommonNamePrefix>-<hive>`; `swarm.nix` reserves that `<deploy.bao.queueAgentCommonNamePrefix>-<hive>`; `swarm.nix` reserves that
composed spelling as a hive name, so nobody can name a hive into another hive's composed spelling as a hive name, so nobody can name a hive into another hive's
role. The other seven reach a path that names a swarm service rather than a hive, role. The other eight reach a path that names a swarm service rather than a hive,
so one role each is enough and their subjects are the flat so one role each is enough and their subjects are the flat
`deploy.bao.grafanaOidcCommonName`, `deploy.bao.otelOidcCommonName`, `deploy.bao.grafanaOidcCommonName`, `deploy.bao.otelOidcCommonName`,
`deploy.bao.forwarderOidcCommonName`, `deploy.bao.ciRunnerCommonName`, `deploy.bao.forwarderOidcCommonName`, `deploy.bao.ciRunnerCommonName`,
`deploy.bao.reviewClassifierCommonName`, `deploy.bao.builderCommonName` and `deploy.bao.reviewClassifierCommonName`, `deploy.bao.builderCommonName`,
`deploy.bao.forgePublisherCommonName`. The last is the only writer, and the one `deploy.bao.claudeLoginCommonName` and `deploy.bao.forgePublisherCommonName`.
leaf two units share: the forge's host holds one store identity for the token it The last two are the writers, and the last is the one leaf two units share:
publishes and the OIDC client secret it reads. the forge's host holds one store identity for the token it publishes and the
OIDC client secret it reads.
On a host that mints its own PKI, `glue-bao-tls.nix` signs all seven and defaults On a host that mints its own PKI, `glue-bao-tls.nix` signs all eight and defaults
all fourteen options, and there is nothing to do. Elsewhere you copy each leaf from all sixteen options, and there is nothing to do. Elsewhere you copy each leaf from
the store's host and name it here: a service principal's from the top of the store's host and name it here: a service principal's from the top of
`/var/lib/swarm-bao-pki/`, another hive's queue-agent pair from `/var/lib/swarm-bao-pki/`, another hive's queue-agent pair from
`/var/lib/swarm-bao-pki/hives/<hive>/` ([bao.md § TLS](bao.md#tls) lists that `/var/lib/swarm-bao-pki/hives/<hive>/` ([bao.md § TLS](bao.md#tls) lists that
directory). That's one file per principal rather than one file directory). That's one file per principal rather than one file
shared by seven, which is the whole of what this buys. `swarm-ci-runner.nix` shared by eight, which is the whole of what this buys. `swarm-ci-runner.nix`
refuses the build wherever the runner runs without its pair, as refuses the build wherever the runner runs without its pair, as
`swarm-review-classifier.nix` does for the classifier and `swarm-grafana.nix` `swarm-review-classifier.nix` does for the classifier and `swarm-grafana.nix`
for Grafana. The forge's pair refuses nothing: without for Grafana. The forge's pair refuses nothing: without
@ -448,7 +451,7 @@ naming both options, and the controller runs without forge access and the forge
without a login source until an operator places the leaf. without a login source until an operator places the leaf.
On a host without the store, where the forwarder never runs, forgetting one of On a host without the store, where the forwarder never runs, forgetting one of
the other seven isn't a quiet degrade. A hand-configured remote-store hive that the other eight isn't a quiet degrade. A hand-configured remote-store hive that
names the hive's own `clientCertFile` and misses a principal's pair has the names the hive's own `clientCertFile` and misses a principal's pair has the
build refused, by the module of the reader it missed, naming that pair — see build refused, by the module of the reader it missed, naming that pair — see
[what a missing leaf costs](#what-a-missing-leaf-costs) below. [what a missing leaf costs](#what-a-missing-leaf-costs) below.

View file

@ -330,3 +330,23 @@ secret store by name, under its own `review-classifier` leaf. On a host
without the store, that leaf is the one file to copy, as for the CI runner. without the store, that leaf is the one file to copy, as for the CI runner.
The swarm UI's try-out drives it through swarm-controller; what a run is The swarm UI's try-out drives it through swarm-controller; what a run is
and what it costs are in [`review-classifier.md`](review-classifier.md). and what it costs are in [`review-classifier.md`](review-classifier.md).
### Claude login minter
The claude login minter, in a `swarm-claude-login` container on the host that
sets `deploy.claude-login.enable`. `allSwarmServices` defaults it on wherever
the host has the minter's store identity, on the review classifier's terms.
Run one: a login in progress lives in the service's memory between its start
and its code.
An operator logs an agent in from the **log in** button on its card in the
swarm UI. swarm-controller relays the start and the pasted code over the queue,
the minter runs `claude auth login` and writes the credentials to the agent's
`swarm/agents/<agent>/claude-credentials`, and the agent's harness installs a
version newer than the one it installed last.
It needs neither the forge nor a hive on its host. It reaches the queue and the
secret store by name, under its own `claude-login` leaf, and reaches claude's
login endpoints over the network. On a host without the store, that leaf is
the one file to copy, as for the CI runner. The flow and the version rule are
in [`claude-login.md`](claude-login.md).

View file

@ -22,8 +22,11 @@ domain, covers host-level detail for one hive.
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
An agent created here or with `swarmctl agent create` starts `paused`; set it An agent created here or with `swarmctl agent create` starts `paused`; set it
`up` from its card. A card shows **login needed** when the agent's own `up` from its card. A card shows **login needed** when the agent's own
turn-state header says its claude has no session. Log it in from the agent's turn-state header says its claude has no session, and a **log in** button
own web UI. that runs the claude login from a dialog: open the URL it shows, then paste
the code back. The credentials go to the agent through the secret store
([`claude-login.md`](claude-login.md)). The agent's own web UI login still
works too.
### Agent and subagent terminals ### Agent and subagent terminals

View file

@ -172,6 +172,9 @@
services.hyperhive.deploy.builder.package = services.hyperhive.deploy.builder.package =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-builder; self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-builder;
services.hyperhive.deploy.claude-login.package =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-claude-login;
services.hyperhive.deploy.authelia.bridgePackage = services.hyperhive.deploy.authelia.bridgePackage =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;

View file

@ -0,0 +1,13 @@
/* <ClaudeLoginDialog> — the code form's column, as
`LinkMatrixAccountForm.css` lays its form out. */
.claude-login-form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
margin-top: 1em;
}
.claude-login-ok {
margin-top: 1em;
color: var(--green);
}

View file

@ -0,0 +1,130 @@
// <ClaudeLoginDialog> — logs one agent in to claude through the swarm's
// login minter. Opening it starts the login (`POST …/claude-login`), which
// answers with the URL claude printed; the operator opens it and pastes the
// code back (`POST …/claude-login/code`). Closing it while the code is
// still awaited stops the login (`DELETE …/claude-login`).
//
// Mounted only while open, so each open is a fresh login.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import {
afterCode,
afterStart,
claudeLoginPath,
leavesLoginRunning,
pastedCode,
type LoginStep,
} from "./ClaudeLoginSteps.js";
import "./ClaudeLoginDialog.css";
async function problemOf(r: Response): Promise<ProblemDetails | null> {
return r.ok ? null : await readApiError(r);
}
export function ClaudeLoginDialog({
agent,
onClose,
}: {
agent: string;
onClose: () => void;
}) {
const [step, setStep] = useState<LoginStep>({ step: "starting" });
const [code, setCode] = useState("");
useEffect(() => {
let live = true;
void (async () => {
try {
const r = await fetch(claudeLoginPath(agent), { method: "POST" });
const problem = await problemOf(r);
const next = afterStart(
problem ? { problem } : ((await r.json()) as { url: string }),
);
if (live) setStep(next);
} catch (err) {
if (live) setStep(afterStart({ problem: { detail: String(err) } }));
}
})();
return () => {
live = false;
};
}, [agent]);
function close() {
if (leavesLoginRunning(step)) {
void fetch(claudeLoginPath(agent), { method: "DELETE" });
}
onClose();
}
async function submit(e: Event) {
e.preventDefault();
const pasted = pastedCode(code);
if (step.step !== "code" || pasted === null) return;
const { url } = step;
setStep({ step: "submitting", url });
try {
const r = await fetch(claudeLoginPath(agent, true), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code: pasted }),
});
setStep(afterCode(await problemOf(r)));
} catch (err) {
setStep(afterCode({ detail: String(err) }));
}
setCode("");
}
return (
<Dialog open onClose={close} label={`log ${agent} in to claude`} plain>
<Panel title={`log in to claude — ${agent}`} icon="🔑" onClose={close}>
{step.step === "starting" && <p>starting the login…</p>}
{(step.step === "code" || step.step === "submitting") && (
<>
<p>
Open{" "}
<a href={step.url} target="_blank" rel="noopener noreferrer">
the claude login page
</a>
, sign in with this agent's account, and paste the code it shows.
</p>
<form class="claude-login-form" onSubmit={submit}>
<TextField
id="claude-login-code"
label="code"
value={code}
required
onInput={setCode}
/>
<Button
variant="primary"
type="submit"
disabled={step.step === "submitting"}
>
{step.step === "submitting" ? "logging in…" : "log in"}
</Button>
</form>
</>
)}
{step.step === "stored" && (
<p class="claude-login-ok">
Stored. <strong>{agent}</strong> picks the login up within a minute
and resumes.
</p>
)}
{step.step === "failed" && (
<ApiErrorPanel
context="the claude login failed; close and start again"
problem={step.problem}
/>
)}
</Panel>
</Dialog>
);
}

View file

@ -0,0 +1,42 @@
// The claude login dialog's steps, kept out of the component so `npm test`
// runs them. The routes are swarm-controller's `claude_login` module.
import type { ProblemDetails } from "@hive/shared/api-error.js";
export type LoginStep =
| { step: "starting" }
| { step: "code"; url: string }
| { step: "submitting"; url: string }
| { step: "stored" }
| { step: "failed"; problem: ProblemDetails };
export function claudeLoginPath(agent: string, code = false): string {
return `/api/agents/${encodeURIComponent(agent)}/claude-login${code ? "/code" : ""}`;
}
// The step after the start answered: its URL, or why there is none.
export function afterStart(
answer: { url: string } | { problem: ProblemDetails },
): LoginStep {
return "url" in answer
? { step: "code", url: answer.url }
: { step: "failed", problem: answer.problem };
}
// The step after the code was posted. The minter ends the login on a
// refused code, so the next attempt is a fresh start.
export function afterCode(problem: ProblemDetails | null): LoginStep {
return problem === null ? { step: "stored" } : { step: "failed", problem };
}
// Whether closing the dialog leaves a login running that should be
// stopped.
export function leavesLoginRunning(s: LoginStep): boolean {
return s.step === "code";
}
// What the code form may submit: the pasted code without surrounding
// whitespace, or nothing.
export function pastedCode(raw: string): string | null {
const code = raw.trim();
return code === "" ? null : code;
}

View file

@ -17,6 +17,13 @@
gap: 0.4em; gap: 0.4em;
margin-left: auto; margin-left: auto;
} }
/* The "login needed" badge and its "log in" button, kept together; the
wrapper also keeps their clicks, and the dialog's, off the card. */
.ui-agent-card-login {
display: inline-flex;
align-items: center;
gap: 0.4em;
}
.ui-agent-card-message { .ui-agent-card-message {
color: var(--muted); color: var(--muted);
white-space: normal; white-space: normal;

View file

@ -12,11 +12,13 @@
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized, // `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s // `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel. // `onClick` and opening the detail panel.
import { useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import type { ProblemDetails } from "@hive/shared/api-error.js"; import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../../ui/card/Card.js"; import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { AgentIcon } from "../AgentIcon.js"; import { AgentIcon } from "../AgentIcon.js";
import { ClaudeLoginDialog } from "../claude-login/ClaudeLoginDialog.js";
import { FRESHNESS, showsLoginNeeded, type AgentRow } from "../AgentTypes.js"; import { FRESHNESS, showsLoginNeeded, type AgentRow } from "../AgentTypes.js";
import { WantedMenu } from "./WantedMenu.js"; import { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css"; import "./AgentCard.css";
@ -45,6 +47,7 @@ export function AgentCard({
onOpenDetail: (row: AgentRow) => void; onOpenDetail: (row: AgentRow) => void;
}) { }) {
const { tone, label } = FRESHNESS[row.freshness]; const { tone, label } = FRESHNESS[row.freshness];
const [loggingIn, setLoggingIn] = useState(false);
return ( return (
<Card selected={selected} onClick={() => onOpenDetail(row)}> <Card selected={selected} onClick={() => onOpenDetail(row)}>
<div class="ui-agent-card-row"> <div class="ui-agent-card-row">
@ -72,11 +75,28 @@ export function AgentCard({
} }
/> />
{showsLoginNeeded(row) ? ( {showsLoginNeeded(row) ? (
<Badge <span
tone="warning" class="ui-agent-card-login"
value="login needed" onClick={(e) => e.stopPropagation()}
title="the agent's claude has no session; its turns wait until it is logged in" >
/> <Badge
tone="warning"
value="login needed"
title="the agent's claude has no session; its turns wait until it is logged in"
/>
<Badge
variant="quiet"
value="log in"
title={`log ${row.name} in to claude from here`}
onClick={() => setLoggingIn(true)}
/>
{loggingIn ? (
<ClaudeLoginDialog
agent={row.name}
onClose={() => setLoggingIn(false)}
/>
) : null}
</span>
) : null} ) : null}
<span <span
class="ui-agent-card-wanted" class="ui-agent-card-wanted"

View file

@ -0,0 +1,54 @@
// The claude login dialog's step logic. Run by `npm test`, which Node runs
// with its built-in test runner and type stripping, so the `.ts` module is
// imported as it is.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
afterCode,
afterStart,
claudeLoginPath,
leavesLoginRunning,
pastedCode,
} from "../src/agent/claude-login/ClaudeLoginSteps.ts";
test("the routes name the agent, escaped", () => {
assert.equal(claudeLoginPath("atlas"), "/api/agents/atlas/claude-login");
assert.equal(
claudeLoginPath("atlas", true),
"/api/agents/atlas/claude-login/code",
);
assert.equal(claudeLoginPath("a/b"), "/api/agents/a%2Fb/claude-login");
});
test("a started login asks for the code at its URL", () => {
assert.deepEqual(afterStart({ url: "https://claude.ai/oauth/x" }), {
step: "code",
url: "https://claude.ai/oauth/x",
});
});
test("a start that failed shows why and has no URL", () => {
const problem = { detail: "no claude login minter is listening" };
assert.deepEqual(afterStart({ problem }), { step: "failed", problem });
});
test("an accepted code is stored; a refused one is a failure", () => {
assert.deepEqual(afterCode(null), { step: "stored" });
const problem = { detail: "OAuth error: invalid code" };
assert.deepEqual(afterCode(problem), { step: "failed", problem });
});
test("only a login waiting for its code is stopped on close", () => {
assert.equal(leavesLoginRunning({ step: "code", url: "https://u" }), true);
assert.equal(leavesLoginRunning({ step: "starting" }), false);
assert.equal(
leavesLoginRunning({ step: "submitting", url: "https://u" }),
false,
);
assert.equal(leavesLoginRunning({ step: "stored" }), false);
});
test("a pasted code is trimmed, and a blank one is nothing", () => {
assert.equal(pastedCode(" abc-def \n"), "abc-def");
assert.equal(pastedCode(" \n "), null);
});

View file

@ -0,0 +1,305 @@
//! The claude credentials the swarm's login minter stored for this agent, at
//! `swarm/agents/<agent>/claude-credentials`, read under the agent's own
//! certificate while it waits for a login ([`crate::login::wait_for_login`]).
//!
//! ⚠️ **Installed only when the store's version is newer than the last one
//! installed here**, never because the bytes differ. `claude` rotates the
//! refresh token in `~/.claude/.credentials.json`, so after its first refresh
//! the stored copy is stale; putting it back would end the rotated session.
//! The last installed version is kept in [`VERSION_FILE`] under the harness
//! dir. A credentials file `wait_for_login` sees land resumes the agent as a
//! web-UI login does.
use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Context as _;
use swarm_secret_client::{
SecretStore,
claude_login::{Credentials, credentials_path},
client::DEFAULT_CERT_MOUNT,
};
/// The file under the harness dir holding the last installed version.
pub const VERSION_FILE: &str = "claude-credentials-version";
/// How often a waiting agent asks the store.
pub const POLL_INTERVAL: Duration = Duration::from_secs(30);
/// How long one read of the store may take.
const STORE_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// The file `claude` reads its session from.
const CREDENTIALS_FILE: &str = ".credentials.json";
/// Whether a stored `version` replaces what is installed: only a version
/// newer than the last one installed, or any when none has been.
#[must_use]
pub fn is_newer(stored: u64, installed: Option<u64>) -> bool {
installed.is_none_or(|last| stored > last)
}
/// What one [`Pickup::check`] did.
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
/// Nothing is stored for this agent.
NothingStored,
/// The stored version is not newer than the installed one.
NotNewer { stored: u64, installed: u64 },
/// The stored version is installed now.
Installed { version: u64 },
}
/// Where [`Pickup`] reads from. A trait so the guard can be exercised without
/// a store.
pub trait Source: Send + Sync {
/// The stored credentials and their version, or `None` when nothing is
/// stored.
fn fetch(&self) -> impl Future<Output = anyhow::Result<Option<(Credentials, u64)>>> + Send;
}
/// The agent's own leaf, read under its own certificate.
pub struct Store {
settings: swarm_secret_client::client::Settings,
role: String,
path: String,
}
impl Source for Store {
async fn fetch(&self) -> anyhow::Result<Option<(Credentials, u64)>> {
let read = async {
let store = SecretStore::connect(&self.settings, &self.role, DEFAULT_CERT_MOUNT)
.await
.context("logging in to the swarm secret store as this agent")?;
store
.read_versioned(&self.path)
.await
.with_context(|| format!("reading {}", self.path))
};
tokio::time::timeout(STORE_READ_TIMEOUT, read)
.await
.with_context(|| format!("no answer from the store within {STORE_READ_TIMEOUT:?}"))?
}
}
impl Store {
/// This agent's leaf, from the environment `crate::swarm_queue` reads.
///
/// # Errors
/// A store coordinate is missing.
pub fn from_env() -> anyhow::Result<Self> {
let (settings, role, agent) = crate::swarm_queue::agent_store(|k| std::env::var(k).ok())?;
Ok(Self {
settings,
role,
path: credentials_path(&agent)?,
})
}
}
/// Installs the stored credentials into `claude_dir` when they are newer.
pub struct Pickup<S> {
source: S,
claude_dir: PathBuf,
version_file: PathBuf,
}
impl<S: Source> Pickup<S> {
pub fn new(source: S, claude_dir: PathBuf, version_file: PathBuf) -> Self {
Self {
source,
claude_dir,
version_file,
}
}
/// Read the store once and install what it holds if it is newer.
///
/// # Errors
/// The store read failed, or writing the credentials or the version did.
pub async fn check(&self) -> anyhow::Result<Outcome> {
let Some((credentials, stored)) = self.source.fetch().await? else {
return Ok(Outcome::NothingStored);
};
let installed = read_version(&self.version_file)?;
if !is_newer(stored, installed) {
return Ok(Outcome::NotNewer {
stored,
installed: installed.unwrap_or_default(),
});
}
write_private(
&self.claude_dir.join(CREDENTIALS_FILE),
credentials.value.as_bytes(),
)
.context("installing the stored claude credentials")?;
write_private(&self.version_file, stored.to_string().as_bytes())
.context("recording the installed credentials version")?;
Ok(Outcome::Installed { version: stored })
}
}
/// The version recorded in `file`, or `None` when there is no file.
fn read_version(file: &Path) -> anyhow::Result<Option<u64>> {
match std::fs::read_to_string(file) {
Ok(s) => s
.trim()
.parse()
.map(Some)
.with_context(|| format!("{} does not hold a version", file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", file.display())),
}
}
/// Replace `path` with `bytes`, mode 0600, through a sibling temp file so a
/// reader never sees half of it.
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
struct Fake(Mutex<Option<(String, u64)>>);
impl Source for Fake {
async fn fetch(&self) -> anyhow::Result<Option<(Credentials, u64)>> {
Ok(self
.0
.lock()
.expect("lock")
.clone()
.map(|(value, v)| (Credentials { value }, v)))
}
}
struct Dir(PathBuf);
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn dir(name: &str) -> Dir {
let d = std::env::temp_dir().join(format!(
"hive-agent-claude-credentials-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("claude")).expect("temp dir");
Dir(d)
}
fn pickup(d: &Dir, stored: Option<(&str, u64)>) -> Pickup<Fake> {
Pickup::new(
Fake(Mutex::new(stored.map(|(v, n)| (v.to_owned(), n)))),
d.0.join("claude"),
d.0.join(VERSION_FILE),
)
}
fn installed(d: &Dir) -> Option<String> {
std::fs::read_to_string(d.0.join("claude").join(CREDENTIALS_FILE)).ok()
}
#[test]
fn only_a_newer_version_replaces_the_installed_one() {
assert!(is_newer(1, None));
assert!(is_newer(3, Some(2)));
assert!(
!is_newer(2, Some(2)),
"an equal version was installed again"
);
assert!(!is_newer(1, Some(2)), "an older version was installed");
}
#[tokio::test]
async fn the_first_stored_version_is_installed_private_and_recorded() {
use std::os::unix::fs::PermissionsExt as _;
let d = dir("first");
let p = pickup(&d, Some(("{\"v\":1}", 1)));
assert_eq!(
p.check().await.expect("check"),
Outcome::Installed { version: 1 }
);
assert_eq!(installed(&d).as_deref(), Some("{\"v\":1}"));
let mode = std::fs::metadata(d.0.join("claude").join(CREDENTIALS_FILE))
.expect("installed")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
assert_eq!(
read_version(&d.0.join(VERSION_FILE)).expect("read"),
Some(1)
);
}
#[tokio::test]
async fn an_equal_or_older_version_leaves_the_rotated_session_alone() {
let d = dir("stale");
let p = pickup(&d, Some(("{\"v\":2}", 2)));
p.check().await.expect("first install");
// claude refreshes the session in place.
std::fs::write(d.0.join("claude").join(CREDENTIALS_FILE), "rotated").expect("refresh");
assert_eq!(
p.check().await.expect("check"),
Outcome::NotNewer {
stored: 2,
installed: 2
}
);
*p.source.0.lock().expect("lock") = Some(("{\"v\":1}".to_owned(), 1));
assert_eq!(
p.check().await.expect("check"),
Outcome::NotNewer {
stored: 1,
installed: 2
}
);
assert_eq!(installed(&d).as_deref(), Some("rotated"));
}
#[tokio::test]
async fn a_relogin_is_installed_over_the_rotated_session() {
let d = dir("relogin");
let p = pickup(&d, Some(("{\"v\":1}", 1)));
p.check().await.expect("first install");
std::fs::write(d.0.join("claude").join(CREDENTIALS_FILE), "rotated").expect("refresh");
*p.source.0.lock().expect("lock") = Some(("{\"v\":2}".to_owned(), 2));
assert_eq!(
p.check().await.expect("check"),
Outcome::Installed { version: 2 }
);
assert_eq!(installed(&d).as_deref(), Some("{\"v\":2}"));
}
#[tokio::test]
async fn nothing_stored_installs_nothing() {
let d = dir("empty");
let p = pickup(&d, None);
assert_eq!(p.check().await.expect("check"), Outcome::NothingStored);
assert_eq!(installed(&d), None);
assert_eq!(read_version(&d.0.join(VERSION_FILE)).expect("read"), None);
}
}

View file

@ -15,6 +15,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
use crate::events::Bus; use crate::events::Bus;
use crate::{claude_credentials, paths};
/// Returns the Claude credentials directory for this agent. Delegates /// Returns the Claude credentials directory for this agent. Delegates
/// to `paths::claude_dir`, which reads `$HOME/.claude`. The service /// to `paths::claude_dir`, which reads `$HOME/.claude`. The service
@ -178,7 +179,25 @@ pub async fn wait_for_login(
// re-trigger the fallback on every iteration. // re-trigger the fallback on every iteration.
let baseline_unreadable = unreadable_mtime_names(claude_dir); let baseline_unreadable = unreadable_mtime_names(claude_dir);
let probe = Duration::from_millis(poll_ms.max(2000)); let probe = Duration::from_millis(poll_ms.max(2000));
let pickup = swarm_pickup(claude_dir);
let mut next_pickup = tokio::time::Instant::now();
loop { loop {
if let Some(pickup) = &pickup
&& tokio::time::Instant::now() >= next_pickup
{
next_pickup = tokio::time::Instant::now() + claude_credentials::POLL_INTERVAL;
match pickup.check().await {
Ok(claude_credentials::Outcome::Installed { version }) => tracing::info!(
version,
"installed the claude credentials the swarm login stored"
),
Ok(outcome) => tracing::debug!(?outcome, "no newer swarm claude credentials"),
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
"checking the swarm store for claude credentials failed"
),
}
}
tokio::time::sleep(probe).await; tokio::time::sleep(probe).await;
// A login that already landed by the time this first poll runs // A login that already landed by the time this first poll runs
// (the race this baseline design closes) is caught here on the // (the race this baseline design closes) is caught here on the
@ -193,6 +212,27 @@ pub async fn wait_for_login(
} }
} }
/// The swarm-login pickup for `claude_dir`, or `None` when this process has
/// no store to read from.
fn swarm_pickup(
claude_dir: &Path,
) -> Option<claude_credentials::Pickup<claude_credentials::Store>> {
match claude_credentials::Store::from_env() {
Ok(store) => Some(claude_credentials::Pickup::new(
store,
claude_dir.to_owned(),
paths::harness_dir().join(claude_credentials::VERSION_FILE),
)),
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
"no swarm store coordinates, so a swarm login cannot reach this agent"
);
None
}
}
}
/// Names of credential files (see [`CRED_FILE_NAMES`]) currently present in /// Names of credential files (see [`CRED_FILE_NAMES`]) currently present in
/// `dir` whose mtime is *not* readable (`metadata()`/`modified()` errors — /// `dir` whose mtime is *not* readable (`metadata()`/`modified()` errors —
/// exotic fs, NFS quirks). Used once, at [`wait_for_login`]'s entry, as the /// exotic fs, NFS quirks). Used once, at [`wait_for_login`]'s entry, as the

View file

@ -10,6 +10,7 @@
//! before lib + bin were collapsed into one) plus the serve loop. //! before lib + bin were collapsed into one) plus the serve loop.
mod boot_announce; mod boot_announce;
mod claude_credentials;
mod claude_md_watch; mod claude_md_watch;
mod claude_usage_watch; mod claude_usage_watch;
mod db_migrate; mod db_migrate;

View file

@ -210,10 +210,26 @@ impl<S: SecretSource> AgentSecret<S> {
/// # Errors /// # Errors
/// No store address, or one without the agent name or the identity beside it. /// No store address, or one without the agent name or the identity beside it.
fn store_source(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<StoreSource> { fn store_source(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<StoreSource> {
let (settings, role, agent) = agent_store(get)?;
Ok(StoreSource {
settings,
role,
path: queue::agent_queue_path(&agent)?,
})
}
/// Where the store is, the cert-auth role this agent logs in as, and the
/// agent's name, from `get`.
///
/// # Errors
/// No store address, or one without the agent name or the identity beside it.
pub(crate) fn agent_store(
get: impl Fn(&str) -> Option<String>,
) -> anyhow::Result<(Settings, String, String)> {
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) { if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
anyhow::bail!( anyhow::bail!(
"{ENV_ADDR} is unset or empty, so there is no secret store to read this agent's \ "{ENV_ADDR} is unset or empty, so there is no secret store to read this agent's \
queue credential from" secrets from"
); );
} }
let agent = get(ENV_AGENT_NAME) let agent = get(ENV_AGENT_NAME)
@ -221,11 +237,8 @@ fn store_source(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<StoreSou
.with_context(|| format!("{ENV_AGENT_NAME} is unset or empty"))?; .with_context(|| format!("{ENV_AGENT_NAME} is unset or empty"))?;
let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v))) let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
.context("reading the swarm secret store's coordinates from the environment")?; .context("reading the swarm secret store's coordinates from the environment")?;
Ok(StoreSource { let role = policy::agent_object_name(&agent)?;
settings, Ok((settings, role, agent))
role: policy::agent_object_name(&agent)?,
path: queue::agent_queue_path(&agent)?,
})
} }
/// Whether the CA bundle at `path` is a file with bytes in it. `BAO_CACERT` /// Whether the CA bundle at `path` is a file with bytes in it. `BAO_CACERT`

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