Compare commits
48 changed files with 68 additions and 2808 deletions
|
|
@ -145,9 +145,6 @@ 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
16
Cargo.lock
generated
|
|
@ -4851,22 +4851,6 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,6 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -68,7 +68,6 @@ 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 |
|
||||||
|
|
|
||||||
|
|
@ -43,32 +43,31 @@ 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`) |
|
||||||
| 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`) |
|
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
|
||||||
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
|
| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
|
||||||
| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
|
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-auth ensure-callout-keys`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed; the verb stores a seed it finds at `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed` as is, then removes the file | the swarm secret store: the seeds at `swarm/services/swarm-nats/callout/{user,issuer}-seed`, read by the `swarm-nats-auth` role alone; the public halves at `swarm/public/swarm-nats/callout-{user,issuer}` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||||
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-auth ensure-callout-keys`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed; the verb stores a seed it finds at `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed` as is, then removes the file | the swarm secret store: the seeds at `swarm/services/swarm-nats/callout/{user,issuer}-seed`, read by the `swarm-nats-auth` role alone; the public halves at `swarm/public/swarm-nats/callout-{user,issuer}` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
| queue auth-callout xkey (curve seed) | `swarm-nats-auth ensure-callout-keys`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed | the swarm secret store: the seed at `swarm/services/swarm-nats/callout/xkey-seed`, read by the `swarm-nats-auth` role alone; the public half at `swarm/public/swarm-nats/callout-xkey` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||||
| queue auth-callout xkey (curve seed) | `swarm-nats-auth ensure-callout-keys`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed | the swarm secret store: the seed at `swarm/services/swarm-nats/callout/xkey-seed`, read by the `swarm-nats-auth` role alone; the public half at `swarm/public/swarm-nats/callout-xkey` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
| the queue's TLS key, copied for the gateway's `/metrics` vhost | `swarm-nats-gateway-cert.service` on the queue's host, where the gateway uses self-signed TLS: a copy of the queue's bao-PKI TLS leaf (`/var/lib/swarm-nats-tls/`) once `swarm-bao-nats-tls` has issued it, else a throwaway 30-day self-signed pair for the queue's name until then | `/var/lib/swarm-nats-gateway-tls/{cert,key}.pem`; the key is `0640` group `nginx`, read by nginx for the queue's `/metrics` vhost. The queue's own key stays `0600` root | the copy is redone on each leaf renewal (`swarm-bao-nats-tls` starts the unit, which 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 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 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 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 |
|
||||||
| 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
|
||||||
|
|
@ -406,10 +405,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 nine leaves has
|
identity and sharing one merges the identities. Each of these eight 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 claude login minter's, the forge host's and
|
classifier's, the builder's, the forge host's and Grafana's, which reach two
|
||||||
Grafana's, which reach two each:
|
each:
|
||||||
|
|
||||||
| unit | option pair under `deploy.bao.` | reads, or writes |
|
| unit | option pair under `deploy.bao.` | reads, or writes |
|
||||||
| --------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
|
@ -419,30 +418,28 @@ Grafana's, which reach two 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 eight reach a path that names a swarm service rather than a hive,
|
role. The other seven 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`,
|
`deploy.bao.reviewClassifierCommonName`, `deploy.bao.builderCommonName` and
|
||||||
`deploy.bao.claudeLoginCommonName` and `deploy.bao.forgePublisherCommonName`.
|
`deploy.bao.forgePublisherCommonName`. The last is the only writer, and the one
|
||||||
The last two are the writers, and the last is the one leaf two units share:
|
leaf two units share: the forge's host holds one store identity for the token it
|
||||||
the forge's host holds one store identity for the token it publishes and the
|
publishes and the OIDC client secret it reads.
|
||||||
OIDC client secret it reads.
|
|
||||||
|
|
||||||
On a host that mints its own PKI, `glue-bao-tls.nix` signs all eight and defaults
|
On a host that mints its own PKI, `glue-bao-tls.nix` signs all seven and defaults
|
||||||
all sixteen options, and there is nothing to do. Elsewhere you copy each leaf from
|
all fourteen 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 eight, which is the whole of what this buys. `swarm-ci-runner.nix`
|
shared by seven, which is the whole of what this buys. `swarm-ci-runner.nix`
|
||||||
refuses the build wherever the runner runs without its pair, as
|
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
|
||||||
|
|
@ -451,7 +448,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 eight isn't a quiet degrade. A hand-configured remote-store hive that
|
the other seven isn't a quiet degrade. A hand-configured remote-store hive that
|
||||||
names the hive's own `clientCertFile` and misses a principal's pair has the
|
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.
|
||||||
|
|
|
||||||
|
|
@ -330,23 +330,3 @@ 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).
|
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,8 @@ 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, and a **log in** button
|
turn-state header says its claude has no session. Log it in from the agent's
|
||||||
that runs the claude login from a dialog: open the URL it shows, then paste
|
own web UI.
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,9 +172,6 @@
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/* <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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
// <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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -17,13 +17,6 @@
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,11 @@
|
||||||
// `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";
|
||||||
|
|
@ -47,7 +45,6 @@ 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">
|
||||||
|
|
@ -75,28 +72,11 @@ export function AgentCard({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{showsLoginNeeded(row) ? (
|
{showsLoginNeeded(row) ? (
|
||||||
<span
|
<Badge
|
||||||
class="ui-agent-card-login"
|
tone="warning"
|
||||||
onClick={(e) => e.stopPropagation()}
|
value="login needed"
|
||||||
>
|
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"
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,6 @@ 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
|
||||||
|
|
@ -179,25 +178,7 @@ 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
|
||||||
|
|
@ -212,27 +193,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
//! 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;
|
||||||
|
|
|
||||||
|
|
@ -210,26 +210,10 @@ 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 \
|
||||||
secrets from"
|
queue credential from"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let agent = get(ENV_AGENT_NAME)
|
let agent = get(ENV_AGENT_NAME)
|
||||||
|
|
@ -237,8 +221,11 @@ pub(crate) fn agent_store(
|
||||||
.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")?;
|
||||||
let role = policy::agent_object_name(&agent)?;
|
Ok(StoreSource {
|
||||||
Ok((settings, role, agent))
|
settings,
|
||||||
|
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
Loading…
Reference in a new issue