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
This commit is contained in:
parent
248df20164
commit
d5de1e4943
48 changed files with 2680 additions and 42 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
15
Cargo.lock
generated
15
Cargo.lock
generated
|
|
@ -4851,6 +4851,21 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swarm-claude-login"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-nats",
|
||||||
|
"futures-util",
|
||||||
|
"hive-log",
|
||||||
|
"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,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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
66
docs/swarm/claude-login.md
Normal file
66
docs/swarm/claude-login.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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,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 |
|
||||||
|
|
|
||||||
|
|
@ -405,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 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 +418,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 +450,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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
54
frontend/packages/swarm-ui/test/claude-login.test.mjs
Normal file
54
frontend/packages/swarm-ui/test/claude-login.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
305
hive-agent/src/claude_credentials.rs
Normal file
305
hive-agent/src/claude_credentials.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
./glue-nats-wireguard.nix
|
./glue-nats-wireguard.nix
|
||||||
./glue-queue-agent-credential.nix
|
./glue-queue-agent-credential.nix
|
||||||
./glue-builder-oidc-client.nix
|
./glue-builder-oidc-client.nix
|
||||||
|
./glue-claude-login-oidc-client.nix
|
||||||
./glue-review-classifier-oidc-client.nix
|
./glue-review-classifier-oidc-client.nix
|
||||||
./glue-secret-publisher-authelia.nix
|
./glue-secret-publisher-authelia.nix
|
||||||
./glue-secret-publisher-bao-identity.nix
|
./glue-secret-publisher-bao-identity.nix
|
||||||
|
|
@ -57,6 +58,7 @@
|
||||||
./swarm-bao-service.nix
|
./swarm-bao-service.nix
|
||||||
./swarm-bao.nix
|
./swarm-bao.nix
|
||||||
./swarm-builder.nix
|
./swarm-builder.nix
|
||||||
|
./swarm-claude-login.nix
|
||||||
./swarm-ci-runner.nix
|
./swarm-ci-runner.nix
|
||||||
./swarm-review-classifier.nix
|
./swarm-review-classifier.nix
|
||||||
./swarm-secret-publisher.nix
|
./swarm-secret-publisher.nix
|
||||||
|
|
|
||||||
|
|
@ -700,6 +700,26 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
claude-login.enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
example = true;
|
||||||
|
description = ''
|
||||||
|
Run the claude login minter in a `swarm-claude-login` container on
|
||||||
|
this host. It runs `claude auth login` for the agent an operator logs
|
||||||
|
in from its card in the swarm UI, and writes the credentials to that
|
||||||
|
agent's `secret/swarm/agents/<agent>/claude-credentials`, which the
|
||||||
|
agent's harness installs. Run it on one host: a login in progress
|
||||||
|
lives in the service's memory.
|
||||||
|
|
||||||
|
On by default where
|
||||||
|
{option}`services.hyperhive.deploy.allSwarmServices` is set and this
|
||||||
|
host has the minter's store identity (the pair
|
||||||
|
{option}`services.hyperhive.deploy.bao.claudeLoginClientCertFile`
|
||||||
|
and `claudeLoginClientKeyFile`, minted on the store's host).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
builder.enable = lib.mkOption {
|
builder.enable = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,8 @@ in
|
||||||
reviewClassifierClientKeyFile = lib.mkDefault "${pkiDir}/review-classifier-key.pem";
|
reviewClassifierClientKeyFile = lib.mkDefault "${pkiDir}/review-classifier-key.pem";
|
||||||
builderClientCertFile = lib.mkDefault "${pkiDir}/builder.pem";
|
builderClientCertFile = lib.mkDefault "${pkiDir}/builder.pem";
|
||||||
builderClientKeyFile = lib.mkDefault "${pkiDir}/builder-key.pem";
|
builderClientKeyFile = lib.mkDefault "${pkiDir}/builder-key.pem";
|
||||||
|
claudeLoginClientCertFile = lib.mkDefault "${pkiDir}/claude-login.pem";
|
||||||
|
claudeLoginClientKeyFile = lib.mkDefault "${pkiDir}/claude-login-key.pem";
|
||||||
forgePublisherClientCertFile = lib.mkDefault "${pkiDir}/forge-publisher.pem";
|
forgePublisherClientCertFile = lib.mkDefault "${pkiDir}/forge-publisher.pem";
|
||||||
forgePublisherClientKeyFile = lib.mkDefault "${pkiDir}/forge-publisher-key.pem";
|
forgePublisherClientKeyFile = lib.mkDefault "${pkiDir}/forge-publisher-key.pem";
|
||||||
servicesIssuerClientCertFile = lib.mkDefault "${pkiDir}/services-issuer.pem";
|
servicesIssuerClientCertFile = lib.mkDefault "${pkiDir}/services-issuer.pem";
|
||||||
|
|
@ -303,6 +305,13 @@ in
|
||||||
[ -s ${pkiDir}/builder.pem ] || ${signLeaf} ${pkiDir} builder \
|
[ -s ${pkiDir}/builder.pem ] || ${signLeaf} ${pkiDir} builder \
|
||||||
${lib.escapeShellArg deployCfg.bao.builderCommonName} "" clientAuth
|
${lib.escapeShellArg deployCfg.bao.builderCommonName} "" clientAuth
|
||||||
|
|
||||||
|
# The claude login minter's, which writes agents' claude credentials
|
||||||
|
# and reads its queue client secret. Minted here for the matrix-ctl
|
||||||
|
# leaf's reason: the minter is a swarm service, so on a minter host
|
||||||
|
# without the store this is the file an operator copies.
|
||||||
|
[ -s ${pkiDir}/claude-login.pem ] || ${signLeaf} ${pkiDir} claude-login \
|
||||||
|
${lib.escapeShellArg deployCfg.bao.claudeLoginCommonName} "" clientAuth
|
||||||
|
|
||||||
# The forge's token publisher. Minted here for the matrix-ctl leaf's
|
# The forge's token publisher. Minted here for the matrix-ctl leaf's
|
||||||
# reason: the forge is a swarm singleton, so on a forge host without the
|
# reason: the forge is a swarm singleton, so on a forge host without the
|
||||||
# store this is the file an operator copies.
|
# store this is the file an operator copies.
|
||||||
|
|
|
||||||
35
nix/host-modules/glue-claude-login-oidc-client.nix
Normal file
35
nix/host-modules/glue-claude-login-oidc-client.nix
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Glue: register the claude login minter as an OIDC client wherever authelia
|
||||||
|
# runs.
|
||||||
|
#
|
||||||
|
# ONE PAIRING PER FILE — swarm-claude-login ← authelia, and nothing else.
|
||||||
|
# Deleting this leaves a minter whose token requests authelia answers with a
|
||||||
|
# 401, and no minted secret for swarm-secret-publish to put in the store.
|
||||||
|
#
|
||||||
|
# ⚠️ Gated on authelia being HERE, and NOT on this host running the minter,
|
||||||
|
# for ./glue-controller-oidc-client.nix's reason: a client is a row in THIS
|
||||||
|
# host's provider config. Registered whether or not any host enables the
|
||||||
|
# minter: nothing in `swarm.*` records that, so a swarm without one carries
|
||||||
|
# one unused client and one unused minted secret.
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
hyperhiveCfg = config.services.hyperhive;
|
||||||
|
deployCfg = hyperhiveCfg.deploy;
|
||||||
|
clientId = hyperhiveCfg.swarm.claude-login.clientId;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkIf deployCfg.authelia.enable {
|
||||||
|
services.hyperhive.swarm.authelia.oidc.clients = [
|
||||||
|
{
|
||||||
|
id = clientId;
|
||||||
|
description = "HyperHive claude login minter";
|
||||||
|
# `client_credentials`: a daemon authenticating as itself.
|
||||||
|
kind = "machine";
|
||||||
|
audience = [ clientId ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1054,6 +1054,22 @@ let
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# The claude login minter: every agent's `claude-credentials` leaf,
|
||||||
|
# create and update so a relogin replaces the last, and nothing else under
|
||||||
|
# an agent; and its own queue client secret, read at start.
|
||||||
|
claudeLoginWriters = [
|
||||||
|
{
|
||||||
|
name = "swarm-claude-login";
|
||||||
|
cn = baoDeploy.claudeLoginCommonName;
|
||||||
|
policyText = ''
|
||||||
|
path "${credentialMountPath}/data/swarm/agents/+/claude-credentials" {
|
||||||
|
capabilities = ["create", "update"]
|
||||||
|
}
|
||||||
|
''
|
||||||
|
+ readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.claude-login.clientId}/oidc/client";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
# The forge host's one store identity. `swarm-bao-forge-publisher` writes
|
# The forge host's one store identity. `swarm-bao-forge-publisher` writes
|
||||||
# the controller's forge token under it, the one writer of that path and
|
# the controller's forge token under it, the one writer of that path and
|
||||||
# write-only: it copies a file in and never reads one back.
|
# write-only: it copies a file in and never reads one back.
|
||||||
|
|
@ -1932,6 +1948,19 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
claudeLoginCommonName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "swarm-claude-login";
|
||||||
|
description = ''
|
||||||
|
Subject the store's `swarm-claude-login` cert-auth role accepts: the
|
||||||
|
identity the claude login minter presents from inside its container.
|
||||||
|
Its grant is create and update on `swarm/agents/+/claude-credentials`
|
||||||
|
and read on its own queue client secret.
|
||||||
|
|
||||||
|
⚠️ Reserved as a hive name by ./swarm.nix, like its siblings.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
forgePublisherCommonName = lib.mkOption {
|
forgePublisherCommonName = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "swarm-forge-publisher";
|
default = "swarm-forge-publisher";
|
||||||
|
|
@ -2232,6 +2261,31 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
claudeLoginClientCertFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "/var/lib/swarm-bao-pki/claude-login.pem";
|
||||||
|
description = ''
|
||||||
|
Certificate the claude login minter presents to the store. Its subject
|
||||||
|
must be {option}`services.hyperhive.deploy.bao.claudeLoginCommonName`.
|
||||||
|
|
||||||
|
./glue-bao-tls.nix points it at the leaf it mints on the store's host.
|
||||||
|
On a minter host without the store, it is a file the operator copies
|
||||||
|
from the store's host. A path, never a value.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
claudeLoginClientKeyFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "/var/lib/swarm-bao-pki/claude-login-key.pem";
|
||||||
|
description = ''
|
||||||
|
Private key for
|
||||||
|
{option}`services.hyperhive.deploy.bao.claudeLoginClientCertFile`.
|
||||||
|
A path, never a value.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
forgePublisherClientCertFile = lib.mkOption {
|
forgePublisherClientCertFile = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
default = null;
|
default = null;
|
||||||
|
|
@ -3318,6 +3372,8 @@ in
|
||||||
|
|
||||||
systemd.services.swarm-bao-builder-policy = readerPolicyUnit "write the swarm builder's forge-token-reader bao policy and cert-auth role" builderReaders;
|
systemd.services.swarm-bao-builder-policy = readerPolicyUnit "write the swarm builder's forge-token-reader bao policy and cert-auth role" builderReaders;
|
||||||
|
|
||||||
|
systemd.services.swarm-bao-claude-login-policy = readerPolicyUnit "write the claude login minter's credentials-writer bao policy and cert-auth role" claudeLoginWriters;
|
||||||
|
|
||||||
systemd.services.swarm-bao-forge-publisher-policy = readerPolicyUnit "write the forge's controller-token-publisher bao policy and cert-auth role" forgePublisherWriters;
|
systemd.services.swarm-bao-forge-publisher-policy = readerPolicyUnit "write the forge's controller-token-publisher bao policy and cert-auth role" forgePublisherWriters;
|
||||||
|
|
||||||
# A FOURTH sibling, same shape and same reasons as the two above. This
|
# A FOURTH sibling, same shape and same reasons as the two above. This
|
||||||
|
|
|
||||||
203
nix/host-modules/swarm-claude-login.nix
Normal file
203
nix/host-modules/swarm-claude-login.nix
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
# The claude login minter, in a `swarm-claude-login` container on the host
|
||||||
|
# that sets `deploy.claude-login.enable`, which `deploy.allSwarmServices`
|
||||||
|
# defaults on where the host has the minter's store identity. An operator logs
|
||||||
|
# an agent in from its card in the swarm UI: swarm-controller relays the start
|
||||||
|
# and the pasted code over the queue, and the daemon runs `claude auth login`
|
||||||
|
# and writes the credentials to that agent's store leaf.
|
||||||
|
# docs/swarm/claude-login.md has the design.
|
||||||
|
#
|
||||||
|
# Its own network namespace on the bridge, as ./swarm-review-classifier.nix
|
||||||
|
# has: it reaches the swarm services by name and nothing on the host's
|
||||||
|
# loopback.
|
||||||
|
#
|
||||||
|
# The container holds one credential, its own store leaf, which nspawn hands in
|
||||||
|
# as a system credential with no copy on disk. The daemon reads its queue
|
||||||
|
# client secret with it and writes agents' credentials under it.
|
||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
hyperhiveCfg = config.services.hyperhive;
|
||||||
|
cfg = hyperhiveCfg.deploy.claude-login;
|
||||||
|
baoCfg = hyperhiveCfg.swarm.bao;
|
||||||
|
baoDeploy = hyperhiveCfg.deploy.bao;
|
||||||
|
natsCfg = hyperhiveCfg.swarm.nats;
|
||||||
|
autheliaCfg = hyperhiveCfg.swarm.authelia;
|
||||||
|
gatewayCfg = hyperhiveCfg.gateway;
|
||||||
|
networkCfg = hyperhiveCfg.network;
|
||||||
|
tlsCfg = hyperhiveCfg.deploy.hive-controller.tls;
|
||||||
|
|
||||||
|
machine = "swarm-claude-login";
|
||||||
|
unit = "swarm-claude-login";
|
||||||
|
clientId = hyperhiveCfg.swarm.claude-login.clientId;
|
||||||
|
|
||||||
|
# The daemon reaches authelia, the store and the queue by name over TLS, and
|
||||||
|
# in self-signed mode their leaves chain to the hive CA.
|
||||||
|
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
||||||
|
caBundleModule = caTrust.trustBundle {
|
||||||
|
inherit pkgs;
|
||||||
|
name = machine;
|
||||||
|
consumers = [ unit ];
|
||||||
|
};
|
||||||
|
|
||||||
|
haveClientIdentity =
|
||||||
|
baoDeploy.claudeLoginClientCertFile != null && baoDeploy.claudeLoginClientKeyFile != null;
|
||||||
|
|
||||||
|
# Credential id → host source, as ./swarm-review-classifier.nix hands its
|
||||||
|
# leaf in.
|
||||||
|
storeFiles = lib.filterAttrs (_: source: source != null) {
|
||||||
|
claude-login-bao-cert = baoDeploy.claudeLoginClientCertFile;
|
||||||
|
claude-login-bao-key = baoDeploy.claudeLoginClientKeyFile;
|
||||||
|
claude-login-bao-server-ca = baoDeploy.serverCaFile;
|
||||||
|
};
|
||||||
|
# nixos-container expands `extraFlags` unquoted, so a source path must be
|
||||||
|
# one word.
|
||||||
|
oneWord = path: builtins.match ".*[[:space:]].*" path == null;
|
||||||
|
credential = id: "/run/credentials/${unit}.service/${id}";
|
||||||
|
|
||||||
|
privateNetwork = true;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.hyperhive.swarm.claude-login.clientId = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = "swarm-claude-login";
|
||||||
|
description = ''
|
||||||
|
The OAuth2 client id the claude login minter presents to the swarm
|
||||||
|
queue. Read-only: the host running authelia registers it, the queue's
|
||||||
|
auth-callout responder grants it the login subject, and the secret
|
||||||
|
publisher carries its secret to the store, all from this one value.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
options.services.hyperhive.deploy.claude-login.package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-claude-login";
|
||||||
|
description = "The claude login minter daemon package.";
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = haveClientIdentity;
|
||||||
|
message = ''
|
||||||
|
services.hyperhive.deploy.claude-login.enable requires the minter's
|
||||||
|
swarm-secret-store client identity: set both
|
||||||
|
|
||||||
|
services.hyperhive.deploy.bao.claudeLoginClientCertFile
|
||||||
|
services.hyperhive.deploy.bao.claudeLoginClientKeyFile
|
||||||
|
|
||||||
|
The minter reads its queue client secret and writes agents' claude
|
||||||
|
credentials with it.
|
||||||
|
|
||||||
|
Both default to the pair glue-bao-tls.nix mints when
|
||||||
|
services.hyperhive.deploy.bao.enable is true (the host that runs the
|
||||||
|
store). Otherwise copy claude-login.pem and claude-login-key.pem from
|
||||||
|
the store host's /var/lib/swarm-bao-pki/ and name them in those two
|
||||||
|
options.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.all oneWord (lib.attrValues storeFiles);
|
||||||
|
message = ''
|
||||||
|
The claude login minter's store identity paths must not contain
|
||||||
|
whitespace: nspawn's --load-credential receives them through
|
||||||
|
nixos-container's unquoted extra flags.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# The container hangs off the bridge, takes its address from the gateway's
|
||||||
|
# DHCP and resolves through its dnsmasq.
|
||||||
|
services.hyperhive.network.enable = lib.mkDefault true;
|
||||||
|
services.hyperhive.gateway.enable = lib.mkDefault true;
|
||||||
|
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
|
||||||
|
|
||||||
|
# nspawn fails to start the container when a credential source is
|
||||||
|
# missing, so where this host mints the leaf the container waits for it.
|
||||||
|
systemd.services."container@${machine}" = lib.mkMerge [
|
||||||
|
caTrust.containerOrdering
|
||||||
|
{ after = [ "swarm-bao-pki.service" ]; }
|
||||||
|
];
|
||||||
|
|
||||||
|
containers.${machine} = {
|
||||||
|
autoStart = true;
|
||||||
|
# The daemon keeps no state; each login's scratch directory is under
|
||||||
|
# PrivateTmp.
|
||||||
|
ephemeral = false;
|
||||||
|
# Journal files on the host, not inside the container: nixpkgs hardcodes
|
||||||
|
# --link-journal=try-guest, and EXTRA_NSPAWN_FLAGS expands after it.
|
||||||
|
extraFlags = [
|
||||||
|
"--link-journal=host"
|
||||||
|
]
|
||||||
|
++ lib.mapAttrsToList (id: source: "--load-credential=${id}:${source}") storeFiles;
|
||||||
|
inherit privateNetwork;
|
||||||
|
hostBridge = networkCfg.bridgeName;
|
||||||
|
bindMounts = caTrust.bindMount;
|
||||||
|
|
||||||
|
config =
|
||||||
|
{ pkgs, lib, ... }:
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
../container-modules/swarm-container.nix
|
||||||
|
caBundleModule
|
||||||
|
];
|
||||||
|
|
||||||
|
services.hyperhive.swarmContainer = {
|
||||||
|
inherit privateNetwork;
|
||||||
|
writesOwnResolvConf = false;
|
||||||
|
};
|
||||||
|
networking.nameservers = [ networkCfg.bridgeIp ];
|
||||||
|
networking.interfaces.eth0.useDHCP = true;
|
||||||
|
|
||||||
|
# `claude-code` is unfree, and this container evaluates its own
|
||||||
|
# nixpkgs, so the host's allowance does not reach here.
|
||||||
|
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "claude-code" ];
|
||||||
|
|
||||||
|
users.users.${unit} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = unit;
|
||||||
|
};
|
||||||
|
users.groups.${unit} = { };
|
||||||
|
|
||||||
|
systemd.services.${unit} = {
|
||||||
|
description = "log agents in to claude for the swarm UI";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
environment = {
|
||||||
|
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||||
|
BAO_CLIENT_CERT = credential "claude-login-bao-cert";
|
||||||
|
BAO_CLIENT_KEY = credential "claude-login-bao-key";
|
||||||
|
SWARM_CLAUDE_LOGIN_NATS_URL = "tls://${natsCfg.domain}:${toString natsCfg.port}";
|
||||||
|
SWARM_CLAUDE_LOGIN_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token";
|
||||||
|
SWARM_CLAUDE_LOGIN_OIDC_CLIENT_ID = clientId;
|
||||||
|
SWARM_CLAUDE_LOGIN_CLAUDE = "${pkgs.claude-code}/bin/claude";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (storeFiles ? claude-login-bao-server-ca) {
|
||||||
|
BAO_CACERT = credential "claude-login-bao-server-ca";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs caTrust.useSelfSigned {
|
||||||
|
SWARM_CLAUDE_LOGIN_OIDC_CA_FILE = caTrust.bundlePathFor machine;
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${cfg.package}/bin/swarm-claude-login";
|
||||||
|
User = unit;
|
||||||
|
Group = unit;
|
||||||
|
# Each login's scratch directory, gone with the unit.
|
||||||
|
PrivateTmp = true;
|
||||||
|
UMask = "0077";
|
||||||
|
# Bare ids: the system credentials nspawn handed in.
|
||||||
|
LoadCredential = lib.attrNames storeFiles;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "10s";
|
||||||
|
SyslogIdentifier = unit;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -795,6 +795,8 @@ in
|
||||||
"--review-classifier-client ${lib.escapeShellArg config.services.hyperhive.swarm.review-classifier.clientId}"
|
"--review-classifier-client ${lib.escapeShellArg config.services.hyperhive.swarm.review-classifier.clientId}"
|
||||||
# Hears builds and answers each by reply, and nothing else.
|
# Hears builds and answers each by reply, and nothing else.
|
||||||
"--builder-client ${lib.escapeShellArg config.services.hyperhive.swarm.builder.clientId}"
|
"--builder-client ${lib.escapeShellArg config.services.hyperhive.swarm.builder.clientId}"
|
||||||
|
# Hears login steps and answers each by reply, and nothing else.
|
||||||
|
"--claude-login-client ${lib.escapeShellArg config.services.hyperhive.swarm.claude-login.clientId}"
|
||||||
# What an agent may publish to, keyed on its own name: its
|
# What an agent may publish to, keyed on its own name: its
|
||||||
# terminal rows and its turn-state header
|
# terminal rows and its turn-state header
|
||||||
# (`hive-agent::swarm_agent_state`), two subject families so a
|
# (`hive-agent::swarm_agent_state`), two subject families so a
|
||||||
|
|
|
||||||
|
|
@ -126,4 +126,12 @@ in
|
||||||
&& deployCfg.bao.builderClientCertFile != null
|
&& deployCfg.bao.builderClientCertFile != null
|
||||||
&& deployCfg.bao.builderClientKeyFile != null
|
&& deployCfg.bao.builderClientKeyFile != null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
# The claude login minter, defaulted on the same terms as the review
|
||||||
|
# classifier.
|
||||||
|
config.services.hyperhive.deploy.claude-login.enable = lib.mkDefault (
|
||||||
|
deployCfg.allSwarmServices
|
||||||
|
&& deployCfg.bao.claudeLoginClientCertFile != null
|
||||||
|
&& deployCfg.bao.claudeLoginClientKeyFile != null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,8 @@ let
|
||||||
hyperhiveCfg.swarm.review-classifier.clientId
|
hyperhiveCfg.swarm.review-classifier.clientId
|
||||||
# The swarm builder, which reads it itself inside its container.
|
# The swarm builder, which reads it itself inside its container.
|
||||||
hyperhiveCfg.swarm.builder.clientId
|
hyperhiveCfg.swarm.builder.clientId
|
||||||
|
# The claude login minter, which reads it itself inside its container.
|
||||||
|
hyperhiveCfg.swarm.claude-login.clientId
|
||||||
];
|
];
|
||||||
|
|
||||||
# The swarm controller's OIDC client, which it reads back from the store
|
# The swarm controller's OIDC client, which it reads back from the store
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ let
|
||||||
deployCfg.bao.metricsAuthCommonName
|
deployCfg.bao.metricsAuthCommonName
|
||||||
deployCfg.bao.reviewClassifierCommonName
|
deployCfg.bao.reviewClassifierCommonName
|
||||||
deployCfg.bao.builderCommonName
|
deployCfg.bao.builderCommonName
|
||||||
|
deployCfg.bao.claudeLoginCommonName
|
||||||
deployCfg.bao.forgePublisherCommonName
|
deployCfg.bao.forgePublisherCommonName
|
||||||
]
|
]
|
||||||
# The per-hive subjects, spelled out per hive rather than as the prefix.
|
# The per-hive subjects, spelled out per hive rather than as the prefix.
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ let
|
||||||
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
|
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
|
||||||
) baoGrantWithConsumers.systemd.services;
|
) baoGrantWithConsumers.systemd.services;
|
||||||
|
|
||||||
# The nineteen units that write a `swarm-*` grant, by name, for the discovery
|
# The twenty units that write a `swarm-*` grant, by name, for the discovery
|
||||||
# control below.
|
# control below.
|
||||||
grantingUnitNames = [
|
grantingUnitNames = [
|
||||||
"swarm-bao-controller-policy"
|
"swarm-bao-controller-policy"
|
||||||
|
|
@ -191,6 +191,7 @@ let
|
||||||
"swarm-bao-forge-publisher-policy"
|
"swarm-bao-forge-publisher-policy"
|
||||||
"swarm-bao-review-classifier-policy"
|
"swarm-bao-review-classifier-policy"
|
||||||
"swarm-bao-builder-policy"
|
"swarm-bao-builder-policy"
|
||||||
|
"swarm-bao-claude-login-policy"
|
||||||
];
|
];
|
||||||
|
|
||||||
# Comment lines dropped first: both the HCL and the scripts explain
|
# Comment lines dropped first: both the HCL and the scripts explain
|
||||||
|
|
@ -976,24 +977,24 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# A store host without the granter's pair writes its grants some other
|
# A store host without the granter's pair writes its grants some other
|
||||||
# way, so none of the nineteen units may exist. Without this arm
|
# way, so none of the twenty units may exist. Without this arm
|
||||||
# `lib.mkIf haveGranter` could be dropped from any of them and every other
|
# `lib.mkIf haveGranter` could be dropped from any of them and every other
|
||||||
# case here would still pass.
|
# case here would still pass.
|
||||||
name = "without the granter's pair none of the nineteen granting units render";
|
name = "without the granter's pair none of the twenty granting units render";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGranterOptOut.systemd.services;
|
s = baoGranterOptOut.systemd.services;
|
||||||
in
|
in
|
||||||
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
|
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
|
||||||
# The control: the same store with the pair renders all nineteen.
|
# The control: the same store with the pair renders all twenty.
|
||||||
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
|
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# 🩸 What replaced the silent skip. With no bootstrap token the nineteen still
|
# 🩸 What replaced the silent skip. With no bootstrap token the twenty still
|
||||||
# render, and a refused granter fails them with the step that fixes it.
|
# render, and a refused granter fails them with the step that fixes it.
|
||||||
# A store host that never named a token is told to name one, since the
|
# A store host that never named a token is told to name one, since the
|
||||||
# unit that sets the granter up renders only where it has.
|
# unit that sets the granter up renders only where it has.
|
||||||
name = "a store host without a bootstrap token renders the nineteen, each failing loudly with the one-time step";
|
name = "a store host without a bootstrap token renders the twenty, each failing loudly with the one-time step";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGranterNoToken.systemd.services;
|
s = baoGranterNoToken.systemd.services;
|
||||||
|
|
@ -1595,6 +1596,44 @@ let
|
||||||
in
|
in
|
||||||
lib.hasInfix "/builder.pem ]" s && lib.hasInfix "swarm-builder \"\" clientAuth" s;
|
lib.hasInfix "/builder.pem ]" s && lib.hasInfix "swarm-builder \"\" clientAuth" s;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The minter writes one leaf under every agent, create and update so a
|
||||||
|
# relogin replaces the last, and reads its own queue client secret. No
|
||||||
|
# read on the leaf, and nothing else under an agent.
|
||||||
|
name = "the claude login minter's grant is create and update on agents' claude-credentials, and its queue client secret";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = baoGrantHere.systemd.services.swarm-bao-claude-login-policy.script;
|
||||||
|
grants = grantsIn s;
|
||||||
|
in
|
||||||
|
(grantFor grants "secret/data/swarm/agents/+/claude-credentials").caps == [
|
||||||
|
"create"
|
||||||
|
"update"
|
||||||
|
]
|
||||||
|
&& (grantFor grants "secret/data/swarm/services/swarm-claude-login/oidc/client").caps == [ "read" ]
|
||||||
|
&& lib.length grants == 2
|
||||||
|
&& !(lib.hasInfix "secret/data/swarm/agents/*" s)
|
||||||
|
&& lib.hasInfix "auth/cert/certs/swarm-claude-login" s
|
||||||
|
&& lib.hasInfix "allowed_common_names=swarm-claude-login" s
|
||||||
|
&& lib.hasInfix "token_policies=swarm-claude-login" s;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The leaf by name: the minter writes it. The agent's own read and the
|
||||||
|
# controller's `swarm/agents/*` reach it without naming it.
|
||||||
|
name = "no granting unit but the claude login minter's names the claude-credentials leaf";
|
||||||
|
ok =
|
||||||
|
lib.filter (
|
||||||
|
u: lib.hasInfix "claude-credentials" baoGrantHere.systemd.services.${u}.script
|
||||||
|
) grantingUnitNames == [ "swarm-bao-claude-login-policy" ];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name = "the PKI unit signs the claude login minter's leaf under its own subject";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = baoGrantHere.systemd.services.swarm-bao-pki.script;
|
||||||
|
in
|
||||||
|
lib.hasInfix "/claude-login.pem ]" s && lib.hasInfix "swarm-claude-login \"\" clientAuth" s;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
name = "the PKI unit signs the CI runner's leaf under its own subject";
|
name = "the PKI unit signs the CI runner's leaf under its own subject";
|
||||||
ok =
|
ok =
|
||||||
|
|
@ -1826,8 +1865,8 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# What makes the case above mean something: discovery by the granter's
|
# What makes the case above mean something: discovery by the granter's
|
||||||
# certificate reaches all nineteen units, and each yields calls.
|
# certificate reaches all twenty units, and each yields calls.
|
||||||
name = "the granter-policy check sees all nineteen granting units, and parses calls from each";
|
name = "the granter-policy check sees all twenty granting units, and parses calls from each";
|
||||||
ok =
|
ok =
|
||||||
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
|
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
|
||||||
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
|
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,27 @@ let
|
||||||
&& lib.hasInfix socket l."/webhook/forge/".proxyPass
|
&& lib.hasInfix socket l."/webhook/forge/".proxyPass
|
||||||
&& !(lib.hasInfix "auth_request" l."/webhook/forge/".extraConfig);
|
&& !(lib.hasInfix "auth_request" l."/webhook/forge/".extraConfig);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# nginx serves a path from its longest matching prefix location, so that
|
||||||
|
# one must be the gated `/api/`, and no exact location may take a route
|
||||||
|
# out from under it.
|
||||||
|
name = "the claude login routes reach the controller only through a location that asks authelia";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
l = allLocal.services.nginx.virtualHosts."t.local".locations;
|
||||||
|
route = "/api/agents/a/claude-login/code";
|
||||||
|
covering = lib.filter (
|
||||||
|
name: !(lib.hasPrefix "~" name) && !(lib.hasPrefix "= " name) && lib.hasPrefix (prefix name) route
|
||||||
|
) (lib.attrNames l);
|
||||||
|
prefix = lib.removePrefix "^~ ";
|
||||||
|
longest = lib.foldl' (
|
||||||
|
a: b: if lib.stringLength (prefix b) > lib.stringLength (prefix a) then b else a
|
||||||
|
) "" covering;
|
||||||
|
in
|
||||||
|
longest == "/api/"
|
||||||
|
&& lib.hasInfix "auth_request /__hive_authelia;" l.${longest}.extraConfig
|
||||||
|
&& !(lib.any (name: lib.hasPrefix "= /api/agents" name) (lib.attrNames l));
|
||||||
|
}
|
||||||
{
|
{
|
||||||
name = "a hive with matrix off serves no matrix discovery endpoint";
|
name = "a hive with matrix off serves no matrix discovery endpoint";
|
||||||
ok =
|
ok =
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,9 @@ let
|
||||||
lib.filter (l: swarmServiceLinkDomains ? ${l.label}) cfg.services.hyperhive.swarm.controller.links;
|
lib.filter (l: swarmServiceLinkDomains ? ${l.label}) cfg.services.hyperhive.swarm.controller.links;
|
||||||
|
|
||||||
# Every service container at once: the services-only host plus the CI
|
# Every service container at once: the services-only host plus the CI
|
||||||
# runner. The CI runner, the review classifier, the builder, the metrics
|
# runner. The CI runner, the review classifier, the builder, the claude login
|
||||||
# store and the homeserver are the five containers on a netns of their own.
|
# minter, the metrics store and the homeserver are the six containers on a
|
||||||
|
# netns of their own.
|
||||||
serviceContainersWithCi = hive {
|
serviceContainersWithCi = hive {
|
||||||
deploy.hive-controller.enable = false;
|
deploy.hive-controller.enable = false;
|
||||||
deploy.allSwarmServices = true;
|
deploy.allSwarmServices = true;
|
||||||
|
|
@ -247,6 +248,30 @@ let
|
||||||
&& !(swarmServicesBaoElsewhere.containers ? swarm-builder)
|
&& !(swarmServicesBaoElsewhere.containers ? swarm-builder)
|
||||||
&& swarmServicesBaoElsewhere.services.hyperhive.deploy.nats.enable;
|
&& swarmServicesBaoElsewhere.services.hyperhive.deploy.nats.enable;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# Static, not `DynamicUser`: the user is declared in the container and
|
||||||
|
# the unit names it. `nats` is the control that the switch is on at all.
|
||||||
|
name = "hosting the swarm's shared services runs the claude login minter in its container as a static user";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
c = swarmServicesHere.containers.swarm-claude-login.config;
|
||||||
|
sc = c.systemd.services.swarm-claude-login.serviceConfig;
|
||||||
|
in
|
||||||
|
swarmServicesHere.services.hyperhive.deploy.claude-login.enable
|
||||||
|
&& c.users.users.swarm-claude-login.isSystemUser
|
||||||
|
&& sc.User == "swarm-claude-login"
|
||||||
|
&& !(sc.DynamicUser or false)
|
||||||
|
&& swarmServicesHere.services.hyperhive.deploy.nats.enable;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The minter's module asserts the store identity the store's host mints,
|
||||||
|
# as the classifier's does.
|
||||||
|
name = "the claude login minter stays off where the shared services are hosted without the store";
|
||||||
|
ok =
|
||||||
|
!swarmServicesBaoElsewhere.services.hyperhive.deploy.claude-login.enable
|
||||||
|
&& !(swarmServicesBaoElsewhere.containers ? swarm-claude-login)
|
||||||
|
&& swarmServicesBaoElsewhere.services.hyperhive.deploy.nats.enable;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# The switch fills in for an operator who has not spoken and yields to
|
# The switch fills in for an operator who has not spoken and yields to
|
||||||
# one who has — that is what keeps a shared service placeable on a host
|
# one who has — that is what keeps a shared service placeable on a host
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,7 @@ in
|
||||||
# reason: it runs inside its own container, not on the host.
|
# reason: it runs inside its own container, not on the host.
|
||||||
swarm-review-classifier = mkBinPackage "swarm-review-classifier" "hyperhive review classifier try-out daemon";
|
swarm-review-classifier = mkBinPackage "swarm-review-classifier" "hyperhive review classifier try-out daemon";
|
||||||
swarm-builder = mkBinPackage "swarm-builder" "hyperhive swarm builder: agent config build checks";
|
swarm-builder = mkBinPackage "swarm-builder" "hyperhive swarm builder: agent config build checks";
|
||||||
|
swarm-claude-login = mkBinPackage "swarm-claude-login" "hyperhive claude login minter";
|
||||||
|
|
||||||
# The only process allowed to write swarm-authelia's users database —
|
# The only process allowed to write swarm-authelia's users database —
|
||||||
# same "runs *inside* a container, not on the host" placement as
|
# same "runs *inside* a container, not on the host" placement as
|
||||||
|
|
|
||||||
26
swarm-claude-login/Cargo.toml
Normal file
26
swarm-claude-login/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "swarm-claude-login"
|
||||||
|
version.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "swarm-claude-login"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
async-nats.workspace = true
|
||||||
|
# StreamExt::next on the subscription.
|
||||||
|
futures-util.workspace = true
|
||||||
|
hive-log.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
# The login subject and the shapes the controller sends and reads back.
|
||||||
|
swarm-queue-client.workspace = true
|
||||||
|
# The credentials and queue client secret paths, and the store login.
|
||||||
|
swarm-secret-client.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
20
swarm-claude-login/README.md
Normal file
20
swarm-claude-login/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# swarm-claude-login
|
||||||
|
|
||||||
|
The claude login minter, in the `swarm-claude-login` container on the host
|
||||||
|
that sets `deploy.claude-login.enable`. The design is
|
||||||
|
[`docs/swarm/claude-login.md`](../docs/swarm/claude-login.md).
|
||||||
|
|
||||||
|
swarm-controller relays an operator's login of one agent as two requests on
|
||||||
|
`$SWARM.claude-login.request`: a start, answered with the URL
|
||||||
|
`claude auth login` printed, and the code the operator pasted, answered once
|
||||||
|
the agent's `.credentials.json` is stored at
|
||||||
|
`swarm/agents/<agent>/claude-credentials`. Each login runs with `HOME` and
|
||||||
|
`CLAUDE_CONFIG_DIR` in its own scratch directory, removed when the login
|
||||||
|
ends however it ends.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
The container holds one credential: its own store certificate, which nspawn
|
||||||
|
hands in at start without a copy on disk. With it the service reads its queue
|
||||||
|
client secret, and writes the `claude-credentials` leaf of any agent, a new
|
||||||
|
version over the last. Its store role can do exactly those two things.
|
||||||
589
swarm-claude-login/src/login.rs
Normal file
589
swarm-claude-login/src/login.rs
Normal file
|
|
@ -0,0 +1,589 @@
|
||||||
|
//! One `claude auth login` per agent, driven over plain stdio pipes.
|
||||||
|
//!
|
||||||
|
//! [`Logins::start`] spawns the login with `HOME` and `CLAUDE_CONFIG_DIR` in
|
||||||
|
//! a fresh scratch directory and answers with the first `https://` URL it
|
||||||
|
//! prints. [`Logins::code`] writes the operator's code to its stdin, waits
|
||||||
|
//! for it to exit, and hands the `.credentials.json` it wrote to a
|
||||||
|
//! [`CredentialSink`]. The scratch directory goes with the login, on every
|
||||||
|
//! path out: stored, failed, cancelled, replaced or expired.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use swarm_queue_client::claude_login::Reply;
|
||||||
|
use swarm_secret_client::claude_login::Credentials;
|
||||||
|
use tokio::io::{AsyncBufReadExt as _, AsyncRead, AsyncWriteExt as _, BufReader};
|
||||||
|
use tokio::process::{Child, ChildStdin, Command};
|
||||||
|
use tokio::sync::{Mutex, watch};
|
||||||
|
|
||||||
|
/// The file `claude auth login` writes the session to, under its config dir.
|
||||||
|
const CREDENTIALS_FILE: &str = ".credentials.json";
|
||||||
|
|
||||||
|
/// The most output lines a failure carries back.
|
||||||
|
const TAIL_LINES: usize = 20;
|
||||||
|
|
||||||
|
/// The variables naming this service's own store identity, kept out of the
|
||||||
|
/// login's environment.
|
||||||
|
const STRIPPED_ENV: [&str; 4] = [
|
||||||
|
"BAO_ADDR",
|
||||||
|
"BAO_CLIENT_CERT",
|
||||||
|
"BAO_CLIENT_KEY",
|
||||||
|
"BAO_CACERT",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Where stored credentials go: the agent's store leaf in production, a map
|
||||||
|
/// in the tests.
|
||||||
|
pub trait CredentialSink: Send + Sync {
|
||||||
|
/// Store `credentials` as `agent`'s, replacing any stored before.
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
agent: &str,
|
||||||
|
credentials: &Credentials,
|
||||||
|
) -> impl Future<Output = anyhow::Result<()>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The logins running now, one per agent.
|
||||||
|
pub struct Logins {
|
||||||
|
claude: PathBuf,
|
||||||
|
scratch_root: PathBuf,
|
||||||
|
step_deadline: Duration,
|
||||||
|
running: Mutex<HashMap<String, Running>>,
|
||||||
|
next_id: std::sync::atomic::AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Running {
|
||||||
|
id: u64,
|
||||||
|
session: Session,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A spawned `claude auth login` and its scratch directory.
|
||||||
|
struct Session {
|
||||||
|
child: Child,
|
||||||
|
stdin: Option<ChildStdin>,
|
||||||
|
output: Arc<std::sync::Mutex<String>>,
|
||||||
|
url: watch::Receiver<Option<String>>,
|
||||||
|
/// The tasks copying stdout and stderr, done at the child's EOF.
|
||||||
|
pumps: Vec<tokio::task::JoinHandle<()>>,
|
||||||
|
scratch: Scratch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Wait for the output of an exited child to be copied in full, so a
|
||||||
|
/// failure carries its last lines.
|
||||||
|
async fn settle(&mut self) {
|
||||||
|
for pump in self.pumps.drain(..) {
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(2), pump).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory removed when dropped.
|
||||||
|
struct Scratch(PathBuf);
|
||||||
|
|
||||||
|
impl Drop for Scratch {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Err(e) = std::fs::remove_dir_all(&self.0)
|
||||||
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
tracing::warn!(dir = %self.0.display(), error = %e, "removing a login's scratch directory failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Logins {
|
||||||
|
/// `claude` is the binary to run; each login's scratch directory is made
|
||||||
|
/// under `scratch_root`.
|
||||||
|
pub fn new(claude: PathBuf, scratch_root: PathBuf, step_deadline: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
claude,
|
||||||
|
scratch_root,
|
||||||
|
step_deadline,
|
||||||
|
running: Mutex::new(HashMap::new()),
|
||||||
|
next_id: std::sync::atomic::AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a login for `agent`, stopping one already running for it, and
|
||||||
|
/// answer with the URL it printed. The login stays running until
|
||||||
|
/// [`Self::code`], [`Self::cancel`] or [`Self::expire`].
|
||||||
|
pub async fn start(&self, agent: &str) -> (Reply, Option<u64>) {
|
||||||
|
let id = self
|
||||||
|
.next_id
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut session = match self.spawn(agent, id) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => return (failed(&format!("{e:#}"), None), None),
|
||||||
|
};
|
||||||
|
let mut url = session.url.clone();
|
||||||
|
let scraped = tokio::time::timeout(self.step_deadline, async {
|
||||||
|
tokio::select! {
|
||||||
|
found = url.wait_for(Option::is_some) => found.ok().and_then(|u| u.clone()),
|
||||||
|
_ = session.child.wait() => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match scraped {
|
||||||
|
Ok(Some(url)) => {
|
||||||
|
self.running
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(agent.to_owned(), Running { id, session });
|
||||||
|
(Reply::Started { url }, Some(id))
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
session.settle().await;
|
||||||
|
(
|
||||||
|
failed(
|
||||||
|
"claude exited before its login could take a code",
|
||||||
|
Some(&session),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(_) => (
|
||||||
|
failed(
|
||||||
|
&format!(
|
||||||
|
"claude printed no login URL within {}s",
|
||||||
|
self.step_deadline.as_secs()
|
||||||
|
),
|
||||||
|
Some(&session),
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand `agent`'s running login `code`, and store what it writes.
|
||||||
|
pub async fn code(&self, agent: &str, code: &str, sink: &impl CredentialSink) -> Reply {
|
||||||
|
let Some(Running { mut session, .. }) = self.running.lock().await.remove(agent) else {
|
||||||
|
return failed(
|
||||||
|
&format!("no login is running for {agent}: start one, then paste its code"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if let Err(e) = submit(&mut session, code).await {
|
||||||
|
return failed(
|
||||||
|
&format!("handing claude the code failed: {e}"),
|
||||||
|
Some(&session),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let status = match tokio::time::timeout(self.step_deadline, session.child.wait()).await {
|
||||||
|
Ok(Ok(status)) => {
|
||||||
|
session.settle().await;
|
||||||
|
status
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
return failed(&format!("waiting for claude failed: {e}"), Some(&session));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return failed(
|
||||||
|
&format!(
|
||||||
|
"claude did not finish within {}s of the code",
|
||||||
|
self.step_deadline.as_secs()
|
||||||
|
),
|
||||||
|
Some(&session),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !status.success() {
|
||||||
|
return failed(&format!("claude {status}"), Some(&session));
|
||||||
|
}
|
||||||
|
let file = config_dir(&session.scratch.0).join(CREDENTIALS_FILE);
|
||||||
|
let value = match tokio::fs::read_to_string(&file).await {
|
||||||
|
Ok(v) if !v.trim().is_empty() => v,
|
||||||
|
Ok(_) => return failed("claude wrote an empty credentials file", Some(&session)),
|
||||||
|
Err(e) => {
|
||||||
|
return failed(
|
||||||
|
&format!("claude exited without writing its credentials: {e}"),
|
||||||
|
Some(&session),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match sink.put(agent, &Credentials { value }).await {
|
||||||
|
Ok(()) => Reply::Stored,
|
||||||
|
Err(e) => failed(&format!("storing the credentials failed: {e:#}"), None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop `agent`'s running login, if any.
|
||||||
|
pub async fn cancel(&self, agent: &str) -> Reply {
|
||||||
|
self.running.lock().await.remove(agent);
|
||||||
|
Reply::Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop `agent`'s login `id` if it is still the one running: called once
|
||||||
|
/// its time to wait for a code is up.
|
||||||
|
pub async fn expire(&self, agent: &str, id: u64) {
|
||||||
|
let mut running = self.running.lock().await;
|
||||||
|
if running.get(agent).is_some_and(|r| r.id == id) {
|
||||||
|
running.remove(agent);
|
||||||
|
tracing::info!(%agent, "a login waited too long for its code; stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn(&self, agent: &str, id: u64) -> anyhow::Result<Session> {
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use std::os::unix::fs::DirBuilderExt as _;
|
||||||
|
|
||||||
|
let dir = self.scratch_root.join(format!("{agent}-{id}"));
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.mode(0o700)
|
||||||
|
.create(&dir)
|
||||||
|
.with_context(|| format!("making the scratch directory {}", dir.display()))?;
|
||||||
|
let scratch = Scratch(dir);
|
||||||
|
let mut command = Command::new(&self.claude);
|
||||||
|
command
|
||||||
|
.args(["auth", "login"])
|
||||||
|
.env("HOME", &scratch.0)
|
||||||
|
.env("CLAUDE_CONFIG_DIR", config_dir(&scratch.0))
|
||||||
|
.current_dir(&scratch.0)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
for var in STRIPPED_ENV {
|
||||||
|
command.env_remove(var);
|
||||||
|
}
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawning {}", self.claude.display()))?;
|
||||||
|
let stdin = child.stdin.take();
|
||||||
|
let output = Arc::new(std::sync::Mutex::new(String::new()));
|
||||||
|
let (url_tx, url) = watch::channel(None);
|
||||||
|
let url_tx = Arc::new(url_tx);
|
||||||
|
let mut pumps = Vec::new();
|
||||||
|
if let Some(out) = child.stdout.take() {
|
||||||
|
pumps.push(tokio::spawn(pump(
|
||||||
|
out,
|
||||||
|
Arc::clone(&output),
|
||||||
|
Arc::clone(&url_tx),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(err) = child.stderr.take() {
|
||||||
|
pumps.push(tokio::spawn(pump(err, Arc::clone(&output), url_tx)));
|
||||||
|
}
|
||||||
|
Ok(Session {
|
||||||
|
child,
|
||||||
|
stdin,
|
||||||
|
output,
|
||||||
|
url,
|
||||||
|
pumps,
|
||||||
|
scratch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `claude` keeps its config, the credentials among it, under a
|
||||||
|
/// login's scratch directory: `$HOME/.claude`, which `CLAUDE_CONFIG_DIR`
|
||||||
|
/// names as well.
|
||||||
|
fn config_dir(scratch: &Path) -> PathBuf {
|
||||||
|
scratch.join(".claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `code` and a newline to the login's stdin, then close it.
|
||||||
|
async fn submit(session: &mut Session, code: &str) -> std::io::Result<()> {
|
||||||
|
let mut stdin = session
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| std::io::Error::other("stdin already closed"))?;
|
||||||
|
stdin
|
||||||
|
.write_all(format!("{}\n", code.trim()).as_bytes())
|
||||||
|
.await?;
|
||||||
|
stdin.flush().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy one of the child's streams into `output`, and the first URL on it
|
||||||
|
/// into `url`.
|
||||||
|
async fn pump(
|
||||||
|
stream: impl AsyncRead + Unpin,
|
||||||
|
output: Arc<std::sync::Mutex<String>>,
|
||||||
|
url: Arc<watch::Sender<Option<String>>>,
|
||||||
|
) {
|
||||||
|
let mut lines = BufReader::new(stream).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if let Some(found) = extract_url(&line) {
|
||||||
|
url.send_if_modified(|u| {
|
||||||
|
let first = u.is_none();
|
||||||
|
if first {
|
||||||
|
*u = Some(found);
|
||||||
|
}
|
||||||
|
first
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = output
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
out.push_str(&line);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first `https://…` on `line`, up to whitespace, without trailing
|
||||||
|
/// sentence punctuation.
|
||||||
|
fn extract_url(line: &str) -> Option<String> {
|
||||||
|
let start = line.find("https://")?;
|
||||||
|
let tail = &line[start..];
|
||||||
|
let end = tail
|
||||||
|
.find(|c: char| c.is_ascii_whitespace())
|
||||||
|
.unwrap_or(tail.len());
|
||||||
|
let url = tail[..end].trim_end_matches(['.', ',', ')', ']']);
|
||||||
|
(url.len() > "https://".len()).then(|| url.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(why: &str, session: Option<&Session>) -> Reply {
|
||||||
|
let tail = session.map(|s| {
|
||||||
|
let out = s
|
||||||
|
.output
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let lines: Vec<&str> = out.lines().collect();
|
||||||
|
lines[lines.len().saturating_sub(TAIL_LINES)..].join("\n")
|
||||||
|
});
|
||||||
|
Reply::Failed {
|
||||||
|
error: match tail {
|
||||||
|
Some(t) if !t.is_empty() => format!("{why}\n{t}"),
|
||||||
|
_ => why.to_owned(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A stand-in for `claude auth login`: prints a URL, reads a code, and
|
||||||
|
/// writes a credentials file holding it where `claude` would. A code of
|
||||||
|
/// `bad` exits non-zero without writing; `partial` writes, then exits
|
||||||
|
/// non-zero.
|
||||||
|
const STUB: &str = r#"#!/bin/sh
|
||||||
|
[ "$1 $2" = "auth login" ] || { echo "unexpected args: $*" >&2; exit 2; }
|
||||||
|
[ "$CLAUDE_CONFIG_DIR" = "$HOME/.claude" ] || { echo "config dir $CLAUDE_CONFIG_DIR" >&2; exit 2; }
|
||||||
|
[ -n "$BAO_CLIENT_KEY" ] && { echo "store identity leaked" >&2; exit 2; }
|
||||||
|
echo "Opening browser to sign in…"
|
||||||
|
echo "If the browser didn't open, visit: https://claude.ai/oauth/authorize?code=true&state=s1."
|
||||||
|
printf 'Paste code here if prompted > '
|
||||||
|
read code
|
||||||
|
if [ "$code" = bad ]; then echo "OAuth error: invalid code" >&2; exit 1; fi
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR"
|
||||||
|
printf '{"claudeAiOauth":{"accessToken":"%s"}}' "$code" > "$CLAUDE_CONFIG_DIR/.credentials.json"
|
||||||
|
if [ "$code" = partial ]; then echo "profile fetch failed" >&2; exit 1; fi
|
||||||
|
echo "Login successful."
|
||||||
|
"#;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
root: PathBuf,
|
||||||
|
logins: Logins,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every stub, written once before any test spawns one: a file still
|
||||||
|
/// open for writing in a child another test forked meanwhile cannot be
|
||||||
|
/// executed (`ETXTBSY`).
|
||||||
|
fn stubs() -> &'static Path {
|
||||||
|
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||||
|
DIR.get_or_init(|| {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"swarm-claude-login-test-stubs-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("stub dir");
|
||||||
|
for (name, script) in [
|
||||||
|
("claude", STUB),
|
||||||
|
("silent", "#!/bin/sh\nexec sleep 600\n"),
|
||||||
|
("exits", "#!/bin/sh\necho \"not logged in\" >&2\nexit 1\n"),
|
||||||
|
] {
|
||||||
|
let stub = dir.join(name);
|
||||||
|
std::fs::write(&stub, script).expect("stub");
|
||||||
|
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.expect("chmod");
|
||||||
|
}
|
||||||
|
dir
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> Fixture {
|
||||||
|
fixture_with(name, "claude", Duration::from_secs(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_with(name: &str, stub: &str, deadline: Duration) -> Fixture {
|
||||||
|
let stub = stubs().join(stub);
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"swarm-claude-login-test-{name}-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
std::fs::create_dir_all(root.join("scratch")).expect("temp dir");
|
||||||
|
let logins = Logins::new(stub, root.join("scratch"), deadline);
|
||||||
|
Fixture { root, logins }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scratch_dirs(f: &Fixture) -> usize {
|
||||||
|
std::fs::read_dir(f.root.join("scratch"))
|
||||||
|
.expect("scratch root")
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sink as KV v2 behaves under `write`: every put is a new version
|
||||||
|
/// replacing the last.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Store(std::sync::Mutex<HashMap<String, (String, u64)>>);
|
||||||
|
|
||||||
|
impl CredentialSink for Store {
|
||||||
|
async fn put(&self, agent: &str, c: &Credentials) -> anyhow::Result<()> {
|
||||||
|
let mut m = self.0.lock().expect("lock");
|
||||||
|
let version = m.get(agent).map_or(1, |(_, v)| v + 1);
|
||||||
|
m.insert(agent.to_owned(), (c.value.clone(), version));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
fn get(&self, agent: &str) -> Option<(String, u64)> {
|
||||||
|
self.0.lock().expect("lock").get(agent).cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_first_https_url_is_scraped_without_trailing_punctuation() {
|
||||||
|
assert_eq!(
|
||||||
|
extract_url("visit: https://claude.ai/oauth/authorize?code=true&state=s1.").as_deref(),
|
||||||
|
Some("https://claude.ai/oauth/authorize?code=true&state=s1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_url("(https://a.example/x) or https://b.example").as_deref(),
|
||||||
|
Some("https://a.example/x")
|
||||||
|
);
|
||||||
|
assert_eq!(extract_url("no link here"), None);
|
||||||
|
assert_eq!(extract_url("https:// alone"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_login_answers_with_its_url_relays_the_code_and_stores_the_file() {
|
||||||
|
let f = fixture("relay");
|
||||||
|
let store = Store::default();
|
||||||
|
let (reply, _) = f.logins.start("atlas").await;
|
||||||
|
assert_eq!(
|
||||||
|
reply,
|
||||||
|
Reply::Started {
|
||||||
|
url: "https://claude.ai/oauth/authorize?code=true&state=s1".to_owned()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(scratch_dirs(&f), 1);
|
||||||
|
assert_eq!(
|
||||||
|
f.logins.code("atlas", " c0de \n", &store).await,
|
||||||
|
Reply::Stored
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("atlas"),
|
||||||
|
Some((r#"{"claudeAiOauth":{"accessToken":"c0de"}}"#.to_owned(), 1))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scratch_dirs(&f),
|
||||||
|
0,
|
||||||
|
"the scratch directory outlived the store write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_relogin_overwrites_the_stored_credentials() {
|
||||||
|
let f = fixture("overwrite");
|
||||||
|
let store = Store::default();
|
||||||
|
for code in ["first", "second"] {
|
||||||
|
f.logins.start("atlas").await;
|
||||||
|
assert_eq!(f.logins.code("atlas", code, &store).await, Reply::Stored);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
store.get("atlas"),
|
||||||
|
Some((
|
||||||
|
r#"{"claudeAiOauth":{"accessToken":"second"}}"#.to_owned(),
|
||||||
|
2
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_code_stores_nothing_and_says_what_claude_printed() {
|
||||||
|
let f = fixture("refused");
|
||||||
|
let store = Store::default();
|
||||||
|
f.logins.start("atlas").await;
|
||||||
|
let Reply::Failed { error } = f.logins.code("atlas", "bad", &store).await else {
|
||||||
|
panic!("a refused code was not a failure");
|
||||||
|
};
|
||||||
|
assert!(error.contains("invalid code"), "{error}");
|
||||||
|
assert_eq!(store.get("atlas"), None);
|
||||||
|
assert_eq!(scratch_dirs(&f), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_login_that_exits_non_zero_stores_nothing_even_with_a_file() {
|
||||||
|
let f = fixture("partial");
|
||||||
|
let store = Store::default();
|
||||||
|
f.logins.start("atlas").await;
|
||||||
|
assert!(matches!(
|
||||||
|
f.logins.code("atlas", "partial", &store).await,
|
||||||
|
Reply::Failed { .. }
|
||||||
|
));
|
||||||
|
assert_eq!(store.get("atlas"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_code_with_no_login_running_is_refused() {
|
||||||
|
let f = fixture("none");
|
||||||
|
let store = Store::default();
|
||||||
|
assert!(matches!(
|
||||||
|
f.logins.code("atlas", "c", &store).await,
|
||||||
|
Reply::Failed { .. }
|
||||||
|
));
|
||||||
|
// The control: the same code after a start is taken.
|
||||||
|
f.logins.start("atlas").await;
|
||||||
|
assert_eq!(f.logins.code("atlas", "c", &store).await, Reply::Stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_restart_replaces_the_running_login_and_its_scratch() {
|
||||||
|
let f = fixture("restart");
|
||||||
|
f.logins.start("atlas").await;
|
||||||
|
f.logins.start("atlas").await;
|
||||||
|
assert_eq!(scratch_dirs(&f), 1);
|
||||||
|
assert_eq!(f.logins.cancel("atlas").await, Reply::Cancelled);
|
||||||
|
assert_eq!(scratch_dirs(&f), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_expiry_stops_only_the_login_it_was_set_for() {
|
||||||
|
let f = fixture("expire");
|
||||||
|
let (_, first) = f.logins.start("atlas").await;
|
||||||
|
let (_, second) = f.logins.start("atlas").await;
|
||||||
|
f.logins.expire("atlas", first.expect("started")).await;
|
||||||
|
assert_eq!(
|
||||||
|
scratch_dirs(&f),
|
||||||
|
1,
|
||||||
|
"a stale expiry stopped the newer login"
|
||||||
|
);
|
||||||
|
f.logins.expire("atlas", second.expect("started")).await;
|
||||||
|
assert_eq!(scratch_dirs(&f), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_login_that_prints_no_url_fails_its_start_and_leaves_no_scratch() {
|
||||||
|
for name in ["silent", "exits"] {
|
||||||
|
let f = fixture_with(name, name, Duration::from_millis(500));
|
||||||
|
let (reply, id) = f.logins.start("atlas").await;
|
||||||
|
assert!(matches!(reply, Reply::Failed { .. }), "{name}: {reply:?}");
|
||||||
|
assert_eq!(id, None, "{name}");
|
||||||
|
assert_eq!(scratch_dirs(&f), 0, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
swarm-claude-login/src/main.rs
Normal file
192
swarm-claude-login/src/main.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
//! The claude login minter.
|
||||||
|
//!
|
||||||
|
//! Connects to the swarm queue as its own OIDC client, takes an operator's
|
||||||
|
//! login steps from swarm-controller on
|
||||||
|
//! [`swarm_queue_client::claude_login::SUBJECT`], runs `claude auth login`
|
||||||
|
//! for the agent named ([`login`]), and writes the credentials it produces to
|
||||||
|
//! that agent's store leaf, a new version over any stored before. The agent's
|
||||||
|
//! harness takes a version newer than the one it last installed.
|
||||||
|
//!
|
||||||
|
//! The store identity comes from the `BAO_*` environment. The queue client
|
||||||
|
//! secret is read at start and again when authelia refuses it; the store is
|
||||||
|
//! logged in to afresh for each write.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use futures_util::StreamExt as _;
|
||||||
|
use swarm_queue_client::claude_login::{Reply, Request, SESSION_DEADLINE, STEP_DEADLINE, SUBJECT};
|
||||||
|
use swarm_queue_client::stored_secret::StoredSecret;
|
||||||
|
use swarm_queue_client::{ClientSecret, QueueConfig};
|
||||||
|
use swarm_secret_client::claude_login::{Credentials, client_secret_path, credentials_path};
|
||||||
|
|
||||||
|
mod login;
|
||||||
|
|
||||||
|
/// The cert-auth role this service logs in to the store as. `swarm-bao.nix`
|
||||||
|
/// writes it, matching the leaf's subject.
|
||||||
|
const CERT_ROLE: &str = "swarm-claude-login";
|
||||||
|
|
||||||
|
/// The environment prefix `swarm-claude-login.nix` sets.
|
||||||
|
const PREFIX: &str = "SWARM_CLAUDE_LOGIN";
|
||||||
|
|
||||||
|
/// Seconds to wait after each failed read of the queue client secret. A
|
||||||
|
/// longer outage fails the start, and the unit's `Restart=` tries again.
|
||||||
|
const RETRY_DELAYS_S: [u64; 6] = [1, 2, 4, 8, 16, 30];
|
||||||
|
|
||||||
|
/// The agent's store leaf, written under this service's certificate.
|
||||||
|
struct AgentLeaf;
|
||||||
|
|
||||||
|
impl login::CredentialSink for AgentLeaf {
|
||||||
|
async fn put(&self, agent: &str, credentials: &Credentials) -> Result<()> {
|
||||||
|
let path = credentials_path(agent)?;
|
||||||
|
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
|
||||||
|
.await
|
||||||
|
.context("logging in to the secret store")?;
|
||||||
|
// `write`, not `create`: a relogin replaces what an earlier one
|
||||||
|
// stored.
|
||||||
|
store
|
||||||
|
.write(&path, credentials)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("writing {path}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
hive_log::init();
|
||||||
|
swarm_queue_client::install_crypto_provider();
|
||||||
|
let var = |name: &str| {
|
||||||
|
let full = format!("{PREFIX}_{name}");
|
||||||
|
std::env::var(&full).with_context(|| format!("{full} is unset"))
|
||||||
|
};
|
||||||
|
let client_id = var("OIDC_CLIENT_ID")?;
|
||||||
|
let logins = Arc::new(login::Logins::new(
|
||||||
|
PathBuf::from(var("CLAUDE")?),
|
||||||
|
std::env::temp_dir(),
|
||||||
|
STEP_DEADLINE,
|
||||||
|
));
|
||||||
|
let secret = client_secret_with_retry(&client_id).await?;
|
||||||
|
let reread_id = client_id.clone();
|
||||||
|
let secret = StoredSecret::new(client_id.clone(), secret, move || {
|
||||||
|
let client_id = reread_id.clone();
|
||||||
|
async move { client_secret(&client_id).await.map_err(Into::into) }
|
||||||
|
});
|
||||||
|
let queue = swarm_queue_client::connect(QueueConfig {
|
||||||
|
url: var("NATS_URL")?,
|
||||||
|
token_endpoint: var("OIDC_TOKEN_ENDPOINT")?,
|
||||||
|
client_id,
|
||||||
|
client_secret: ClientSecret::Stored(secret),
|
||||||
|
ca_file: std::env::var(format!("{PREFIX}_OIDC_CA_FILE"))
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
// No queue group: a running login lives in this process's memory, so
|
||||||
|
// every step for it has to reach this process.
|
||||||
|
let mut requests = queue
|
||||||
|
.subscribe(SUBJECT)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("subscribing to {SUBJECT}"))?;
|
||||||
|
tracing::info!(subject = SUBJECT, "claude login minter: awaiting logins");
|
||||||
|
|
||||||
|
while let Some(msg) = requests.next().await {
|
||||||
|
let Some(reply_to) = msg.reply.clone() else {
|
||||||
|
tracing::warn!("a login step arrived with no reply subject; dropping it");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let queue = queue.clone();
|
||||||
|
let logins = Arc::clone(&logins);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let reply = handle(&msg.payload, &logins).await;
|
||||||
|
match serde_json::to_vec(&reply) {
|
||||||
|
Ok(body) => {
|
||||||
|
if let Err(e) = queue.publish(reply_to, body.into()).await {
|
||||||
|
tracing::warn!(error = %e, "publishing a login step's reply failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "encoding a login step's reply failed"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bail!("the subscription to {SUBJECT} ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one step, run it and log it. Never logs the code or the URL.
|
||||||
|
async fn handle(payload: &[u8], logins: &Arc<login::Logins>) -> Reply {
|
||||||
|
let request: Request = match serde_json::from_slice(payload) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "an undecodable login step; answering with the error");
|
||||||
|
return Reply::Failed {
|
||||||
|
error: format!("the minter could not decode the request: {e}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (agent, reply) = match request {
|
||||||
|
Request::Start { agent, operator } => {
|
||||||
|
tracing::info!(%agent, %operator, "starting a claude login");
|
||||||
|
let (reply, id) = logins.start(&agent).await;
|
||||||
|
if let Some(id) = id {
|
||||||
|
let logins = Arc::clone(logins);
|
||||||
|
let expiring = agent.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(SESSION_DEADLINE).await;
|
||||||
|
logins.expire(&expiring, id).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(agent, reply)
|
||||||
|
}
|
||||||
|
Request::Code { agent, code } => {
|
||||||
|
let reply = logins.code(&agent, &code, &AgentLeaf).await;
|
||||||
|
(agent, reply)
|
||||||
|
}
|
||||||
|
Request::Cancel { agent } => {
|
||||||
|
let reply = logins.cancel(&agent).await;
|
||||||
|
(agent, reply)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match &reply {
|
||||||
|
Reply::Started { .. } => tracing::info!(%agent, "claude login waiting for its code"),
|
||||||
|
Reply::Stored => tracing::info!(%agent, "claude credentials stored"),
|
||||||
|
Reply::Cancelled => tracing::info!(%agent, "claude login cancelled"),
|
||||||
|
Reply::Failed { error } => tracing::warn!(%agent, %error, "claude login step failed"),
|
||||||
|
}
|
||||||
|
reply
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The queue client secret, read from the store, retried through a store that
|
||||||
|
/// is restarting alongside this service.
|
||||||
|
async fn client_secret_with_retry(client_id: &str) -> Result<String> {
|
||||||
|
for delay in RETRY_DELAYS_S.map(Duration::from_secs) {
|
||||||
|
match client_secret(client_id).await {
|
||||||
|
Ok(secret) => return Ok(secret),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), retry_in = ?delay, "reading the queue client secret failed");
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client_secret(client_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn client_secret(client_id: &str) -> Result<String> {
|
||||||
|
use swarm_secret_client::builder::ClientSecret;
|
||||||
|
|
||||||
|
let path = client_secret_path(client_id)?;
|
||||||
|
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
|
||||||
|
.await
|
||||||
|
.context("logging in to the secret store")?;
|
||||||
|
let stored: Option<ClientSecret> = store
|
||||||
|
.read_optional(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading {path}"))?;
|
||||||
|
match stored {
|
||||||
|
Some(s) if !s.value.trim().is_empty() => Ok(s.value.trim().to_owned()),
|
||||||
|
_ => bail!(
|
||||||
|
"nothing at {path}: swarm-secret-publish, on the host that runs authelia, has not \
|
||||||
|
published this service's client secret"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
313
swarm-controller/src/claude_login.rs
Normal file
313
swarm-controller/src/claude_login.rs
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
//! An agent's claude login, run from the swarm UI: the operator's two steps,
|
||||||
|
//! relayed to the `swarm-claude-login` minter over the queue.
|
||||||
|
//!
|
||||||
|
//! - `POST /api/agents/{name}/claude-login` starts `claude auth login` for
|
||||||
|
//! the agent and answers with the URL to open.
|
||||||
|
//! - `POST /api/agents/{name}/claude-login/code` hands it the code that page
|
||||||
|
//! shows; the minter stores the credentials at
|
||||||
|
//! `swarm/agents/<agent>/claude-credentials` and the agent's harness
|
||||||
|
//! installs them.
|
||||||
|
//! - `DELETE /api/agents/{name}/claude-login` stops a login started and not
|
||||||
|
//! finished.
|
||||||
|
//!
|
||||||
|
//! The operator is the `Remote-User` header the gateway sets, as for
|
||||||
|
//! [`crate::agent_input`]. Nothing here touches the store: the minter holds
|
||||||
|
//! the one grant that writes the credentials.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use problem_details::ProblemDetails;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use swarm_queue_client::claude_login::{REPLY_DEADLINE, Reply, Request, SUBJECT};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::{AppState, error_problem};
|
||||||
|
|
||||||
|
/// The header the gateway names the session's user in.
|
||||||
|
const OPERATOR_HEADER: &str = "remote-user";
|
||||||
|
|
||||||
|
/// Where a step is sent: the queue in production, a fake in the tests.
|
||||||
|
/// `Err` is a step that never reached the minter or never came back.
|
||||||
|
pub(crate) trait LoginSink {
|
||||||
|
fn step(&self, request: Vec<u8>) -> impl Future<Output = Result<Reply, String>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginSink for async_nats::Client {
|
||||||
|
async fn step(&self, request: Vec<u8>) -> Result<Reply, String> {
|
||||||
|
use async_nats::RequestErrorKind;
|
||||||
|
|
||||||
|
let sent = async_nats::Request::new()
|
||||||
|
.payload(request.into())
|
||||||
|
.timeout(Some(REPLY_DEADLINE));
|
||||||
|
match self.send_request(SUBJECT, sent).await {
|
||||||
|
Ok(msg) => serde_json::from_slice(&msg.payload)
|
||||||
|
.map_err(|e| format!("the claude login minter's reply did not decode: {e}")),
|
||||||
|
Err(e) => Err(match e.kind() {
|
||||||
|
RequestErrorKind::NoResponders => "no claude login minter is listening: \
|
||||||
|
deploy.claude-login.enable is set on no host, or its service is not \
|
||||||
|
connected to the swarm queue"
|
||||||
|
.to_owned(),
|
||||||
|
RequestErrorKind::TimedOut => format!(
|
||||||
|
"the claude login minter sent no answer within {}s",
|
||||||
|
REPLY_DEADLINE.as_secs()
|
||||||
|
),
|
||||||
|
_ => format!("sending the step to the claude login minter failed: {e}"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page the operator opens to log the agent in.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct ClaudeLoginStarted {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The code the login page showed. No `Debug`: it is a one-time credential.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct PostClaudeLoginCodeRequest {
|
||||||
|
/// Not logged.
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a claude login for an agent.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/agents/{name}/claude-login",
|
||||||
|
params(("name" = String, Path, description = "agent to log in")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "the login is running; open `url` and post the code it shows", body = ClaudeLoginStarted),
|
||||||
|
(status = 400, description = "the agent name is not an identifier (problem+json)", body = String),
|
||||||
|
(status = 401, description = "the request carries no `Remote-User` (problem+json)", body = String),
|
||||||
|
(status = 502, description = "the minter could not start the login (problem+json)", body = String),
|
||||||
|
(status = 503, description = "no swarm queue here, or no minter listening on it (problem+json)", body = String),
|
||||||
|
),
|
||||||
|
tag = "agents"
|
||||||
|
)]
|
||||||
|
pub(crate) async fn post_claude_login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(agent): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<ClaudeLoginStarted>, ProblemDetails> {
|
||||||
|
let operator = operator(&headers).map_err(problem)?;
|
||||||
|
let agent = agent_name(&agent).map_err(problem)?;
|
||||||
|
let sink = queue(&state).map_err(problem)?;
|
||||||
|
match relay(&sink, &Request::Start { agent, operator }).await? {
|
||||||
|
Reply::Started { url } => Ok(Json(ClaudeLoginStarted { url })),
|
||||||
|
other => Err(unexpected(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand an agent's running claude login the code its page showed.
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/agents/{name}/claude-login/code",
|
||||||
|
params(("name" = String, Path, description = "agent being logged in")),
|
||||||
|
request_body = PostClaudeLoginCodeRequest,
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "the credentials are stored; the agent installs them within a minute"),
|
||||||
|
(status = 400, description = "the agent name is not an identifier, or the code is empty (problem+json)", body = String),
|
||||||
|
(status = 401, description = "the request carries no `Remote-User` (problem+json)", body = String),
|
||||||
|
(status = 502, description = "the login failed or its credentials could not be stored (problem+json)", body = String),
|
||||||
|
(status = 503, description = "no swarm queue here, or no minter listening on it (problem+json)", body = String),
|
||||||
|
),
|
||||||
|
tag = "agents"
|
||||||
|
)]
|
||||||
|
pub(crate) async fn post_claude_login_code(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(agent): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<PostClaudeLoginCodeRequest>,
|
||||||
|
) -> Result<StatusCode, ProblemDetails> {
|
||||||
|
let operator = operator(&headers).map_err(problem)?;
|
||||||
|
let agent = agent_name(&agent).map_err(problem)?;
|
||||||
|
let code = req.code.trim();
|
||||||
|
if code.is_empty() {
|
||||||
|
return Err(error_problem(StatusCode::BAD_REQUEST, "code is empty"));
|
||||||
|
}
|
||||||
|
let sink = queue(&state).map_err(problem)?;
|
||||||
|
let request = Request::Code {
|
||||||
|
agent: agent.clone(),
|
||||||
|
code: code.to_owned(),
|
||||||
|
};
|
||||||
|
match relay(&sink, &request).await? {
|
||||||
|
Reply::Stored => {
|
||||||
|
tracing::info!(%agent, %operator, "claude credentials stored for the agent");
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
other => Err(unexpected(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop an agent's running claude login.
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/agents/{name}/claude-login",
|
||||||
|
params(("name" = String, Path, description = "agent being logged in")),
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "no login is running for the agent now"),
|
||||||
|
(status = 400, description = "the agent name is not an identifier (problem+json)", body = String),
|
||||||
|
(status = 401, description = "the request carries no `Remote-User` (problem+json)", body = String),
|
||||||
|
(status = 503, description = "no swarm queue here, or no minter listening on it (problem+json)", body = String),
|
||||||
|
),
|
||||||
|
tag = "agents"
|
||||||
|
)]
|
||||||
|
pub(crate) async fn delete_claude_login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(agent): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<StatusCode, ProblemDetails> {
|
||||||
|
operator(&headers).map_err(problem)?;
|
||||||
|
let agent = agent_name(&agent).map_err(problem)?;
|
||||||
|
let sink = queue(&state).map_err(problem)?;
|
||||||
|
match relay(&sink, &Request::Cancel { agent }).await? {
|
||||||
|
Reply::Cancelled => Ok(StatusCode::NO_CONTENT),
|
||||||
|
other => Err(unexpected(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refusal decided before the queue is touched.
|
||||||
|
type Refusal = (StatusCode, String);
|
||||||
|
|
||||||
|
fn problem((status, detail): Refusal) -> ProblemDetails {
|
||||||
|
error_problem(status, &detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operator(headers: &HeaderMap) -> Result<String, Refusal> {
|
||||||
|
headers
|
||||||
|
.get(OPERATOR_HEADER)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"no Remote-User header: the gateway names the operator, and this request did \
|
||||||
|
not come through it"
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_name(agent: &str) -> Result<String, Refusal> {
|
||||||
|
Ok(hive_types::Ident::parse(agent)
|
||||||
|
.map_err(|reason| (StatusCode::BAD_REQUEST, reason.to_owned()))?
|
||||||
|
.into_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue(state: &AppState) -> Result<async_nats::Client, Refusal> {
|
||||||
|
let Some(status) = state.status.as_ref() else {
|
||||||
|
return Err((
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"no swarm queue is configured on this host".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let client = status.queue_client();
|
||||||
|
swarm_queue_client::ensure_connected(&client).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
swarm_queue_client::chain(&e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send `request` and return the minter's reply, a `Failed` one as a 502.
|
||||||
|
async fn relay(sink: &impl LoginSink, request: &Request) -> Result<Reply, ProblemDetails> {
|
||||||
|
let payload = serde_json::to_vec(request).map_err(|e| {
|
||||||
|
error_problem(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("encoding the step failed: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match sink.step(payload).await {
|
||||||
|
Ok(Reply::Failed { error }) => Err(error_problem(StatusCode::BAD_GATEWAY, &error)),
|
||||||
|
Ok(reply) => Ok(reply),
|
||||||
|
Err(e) => Err(error_problem(StatusCode::SERVICE_UNAVAILABLE, &e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unexpected(reply: &Reply) -> ProblemDetails {
|
||||||
|
error_problem(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
&format!("the claude login minter answered out of step: {reply:?}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use swarm_queue_client::claude_login::{Reply, Request};
|
||||||
|
|
||||||
|
use super::{LoginSink, relay};
|
||||||
|
|
||||||
|
/// Answers every step with one fixed outcome and records what it got.
|
||||||
|
struct FakeSink {
|
||||||
|
answer: Result<Reply, String>,
|
||||||
|
got: Mutex<Vec<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginSink for FakeSink {
|
||||||
|
async fn step(&self, request: Vec<u8>) -> Result<Reply, String> {
|
||||||
|
self.got.lock().expect("lock").push(request);
|
||||||
|
self.answer.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sink(answer: Result<Reply, String>) -> FakeSink {
|
||||||
|
FakeSink {
|
||||||
|
answer,
|
||||||
|
got: Mutex::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_code_reaches_the_minter_as_sent() {
|
||||||
|
let s = sink(Ok(Reply::Stored));
|
||||||
|
let request = Request::Code {
|
||||||
|
agent: "atlas".to_owned(),
|
||||||
|
code: "Abc_9.x-Q".to_owned(),
|
||||||
|
};
|
||||||
|
assert!(matches!(relay(&s, &request).await, Ok(Reply::Stored)));
|
||||||
|
let got: serde_json::Value =
|
||||||
|
serde_json::from_slice(&s.got.lock().expect("lock")[0]).expect("json");
|
||||||
|
assert_eq!(got["step"], "code");
|
||||||
|
assert_eq!(got["agent"], "atlas");
|
||||||
|
assert_eq!(got["code"], "Abc_9.x-Q");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failed_step_is_a_bad_gateway_naming_why() {
|
||||||
|
let s = sink(Ok(Reply::Failed {
|
||||||
|
error: "OAuth error: invalid code".to_owned(),
|
||||||
|
}));
|
||||||
|
let request = Request::Cancel {
|
||||||
|
agent: "atlas".to_owned(),
|
||||||
|
};
|
||||||
|
let e = relay(&s, &request).await.expect_err("a failure");
|
||||||
|
assert_eq!(e.status, Some(StatusCode::BAD_GATEWAY));
|
||||||
|
assert!(
|
||||||
|
e.detail
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|d| d.contains("invalid code")),
|
||||||
|
"{:?}",
|
||||||
|
e.detail
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unreached_minter_is_unavailable() {
|
||||||
|
let s = sink(Err("no claude login minter is listening".to_owned()));
|
||||||
|
let request = Request::Cancel {
|
||||||
|
agent: "atlas".to_owned(),
|
||||||
|
};
|
||||||
|
let e = relay(&s, &request).await.expect_err("unreached");
|
||||||
|
assert_eq!(e.status, Some(StatusCode::SERVICE_UNAVAILABLE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,7 @@ mod agent_state_stream;
|
||||||
mod agent_status;
|
mod agent_status;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod builder;
|
mod builder;
|
||||||
|
mod claude_login;
|
||||||
mod config_pr;
|
mod config_pr;
|
||||||
mod forge;
|
mod forge;
|
||||||
mod forge_account;
|
mod forge_account;
|
||||||
|
|
@ -3426,6 +3427,11 @@ fn build_app(state: AppState) -> axum::Router {
|
||||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||||
.routes(routes!(agent_input::post_agent_input))
|
.routes(routes!(agent_input::post_agent_input))
|
||||||
.routes(routes!(agent_control::post_agent_cancel))
|
.routes(routes!(agent_control::post_agent_cancel))
|
||||||
|
.routes(routes!(
|
||||||
|
claude_login::post_claude_login,
|
||||||
|
claude_login::delete_claude_login
|
||||||
|
))
|
||||||
|
.routes(routes!(claude_login::post_claude_login_code))
|
||||||
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
|
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
|
||||||
.routes(routes!(
|
.routes(routes!(
|
||||||
schedules::edit_schedule,
|
schedules::edit_schedule,
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,11 @@ struct Args {
|
||||||
#[arg(long = "builder-client")]
|
#[arg(long = "builder-client")]
|
||||||
builder_clients: Vec<String>,
|
builder_clients: Vec<String>,
|
||||||
|
|
||||||
|
/// Client ids that answer the claude login minter's login requests.
|
||||||
|
/// Repeatable, empty by default, which denies such a client.
|
||||||
|
#[arg(long = "claude-login-client")]
|
||||||
|
claude_login_clients: Vec<String>,
|
||||||
|
|
||||||
/// Role on the secret store's `cert` auth mount this responder logs in
|
/// Role on the secret store's `cert` auth mount this responder logs in
|
||||||
/// as to read the callout keys, its own OIDC client secret and agent
|
/// as to read the callout keys, its own OIDC client secret and agent
|
||||||
/// credentials. The store is found
|
/// credentials. The store is found
|
||||||
|
|
@ -232,7 +237,8 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
|
||||||
)?
|
)?
|
||||||
.with_agent_token_subjects(args.agent_token_publish_subjects.clone())?
|
.with_agent_token_subjects(args.agent_token_publish_subjects.clone())?
|
||||||
.with_review_classifiers(args.review_classifier_clients.clone())
|
.with_review_classifiers(args.review_classifier_clients.clone())
|
||||||
.with_builders(args.builder_clients.clone());
|
.with_builders(args.builder_clients.clone())
|
||||||
|
.with_claude_logins(args.claude_login_clients.clone());
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let settings = swarm_secret_client::client::Settings::from_env()
|
let settings = swarm_secret_client::client::Settings::from_env()
|
||||||
.context("reading the secret store's address and this responder's identity")?;
|
.context("reading the secret store's address and this responder's identity")?;
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ pub struct Policy {
|
||||||
agent_token_subjects: Vec<String>,
|
agent_token_subjects: Vec<String>,
|
||||||
review_classifiers: Vec<String>,
|
review_classifiers: Vec<String>,
|
||||||
builders: Vec<String>,
|
builders: Vec<String>,
|
||||||
|
claude_logins: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
|
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
|
||||||
|
|
@ -122,6 +123,7 @@ impl Policy {
|
||||||
agent_token_subjects: Vec::new(),
|
agent_token_subjects: Vec::new(),
|
||||||
review_classifiers: Vec::new(),
|
review_classifiers: Vec::new(),
|
||||||
builders: Vec::new(),
|
builders: Vec::new(),
|
||||||
|
claude_logins: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +159,13 @@ impl Policy {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the client ids that answer the claude login minter's requests.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_claude_logins(mut self, clients: Vec<String>) -> Self {
|
||||||
|
self.claude_logins = clients;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// The permissions for an agent whose own credential was verified, or
|
/// The permissions for an agent whose own credential was verified, or
|
||||||
/// `None` when none are configured.
|
/// `None` when none are configured.
|
||||||
///
|
///
|
||||||
|
|
@ -229,6 +238,9 @@ impl Policy {
|
||||||
if self.builders.iter().any(|c| c == client_id) {
|
if self.builders.iter().any(|c| c == client_id) {
|
||||||
return Some(Self::builder_permissions(client_id));
|
return Some(Self::builder_permissions(client_id));
|
||||||
}
|
}
|
||||||
|
if self.claude_logins.iter().any(|c| c == client_id) {
|
||||||
|
return Some(Self::claude_login_permissions(client_id));
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,6 +275,20 @@ impl Policy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the claude login minter may do: hear login steps and answer each
|
||||||
|
/// on its reply subject. Its publish list is its own inbox, for
|
||||||
|
/// [`Self::review_classifier_permissions`]' reason.
|
||||||
|
fn claude_login_permissions(client_id: &str) -> Permissions {
|
||||||
|
Permissions {
|
||||||
|
publish: vec![Self::inbox(client_id)],
|
||||||
|
subscribe: Some(vec![
|
||||||
|
swarm_queue_client::claude_login::SUBJECT.to_owned(),
|
||||||
|
Self::inbox(client_id),
|
||||||
|
]),
|
||||||
|
responses: Some(swarm_queue_client::claude_login::REPLY_DEADLINE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a hive may subscribe to: the two events the controller sends it,
|
/// What a hive may subscribe to: the two events the controller sends it,
|
||||||
/// and its own reply inbox.
|
/// and its own reply inbox.
|
||||||
///
|
///
|
||||||
|
|
@ -603,6 +629,8 @@ impl Policy {
|
||||||
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
|
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
|
||||||
// A build of an agent config, sent as a request the same way.
|
// A build of an agent config, sent as a request the same way.
|
||||||
swarm_queue_client::builder::SUBJECT.to_owned(),
|
swarm_queue_client::builder::SUBJECT.to_owned(),
|
||||||
|
// An operator's claude login step, sent as a request the same way.
|
||||||
|
swarm_queue_client::claude_login::SUBJECT.to_owned(),
|
||||||
// The wanted-state buckets — one per hive, all created and written
|
// The wanted-state buckets — one per hive, all created and written
|
||||||
// by this single client.
|
// by this single client.
|
||||||
//
|
//
|
||||||
|
|
@ -1402,6 +1430,63 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLAUDE_LOGIN: &str = "swarm-claude-login";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_claude_login_minter_hears_its_subject_and_inbox_only_and_answers_by_reply() {
|
||||||
|
use swarm_queue_client::claude_login::{REPLY_DEADLINE, SUBJECT};
|
||||||
|
use swarm_queue_client::client_inbox_prefix;
|
||||||
|
|
||||||
|
let p = policy()
|
||||||
|
.with_claude_logins(vec![CLAUDE_LOGIN.to_owned()])
|
||||||
|
.permissions(CLAUDE_LOGIN)
|
||||||
|
.expect("a configured minter is admitted");
|
||||||
|
let inbox = client_inbox_prefix(CLAUDE_LOGIN);
|
||||||
|
assert!(may_subscribe(&p, SUBJECT));
|
||||||
|
assert!(may_subscribe(&p, &reply(&inbox)));
|
||||||
|
for refused in [
|
||||||
|
">".to_owned(),
|
||||||
|
"$SWARM.>".to_owned(),
|
||||||
|
swarm_queue_client::builder::SUBJECT.to_owned(),
|
||||||
|
reply(&client_inbox_prefix("swarm-controller")),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!may_subscribe(&p, &refused),
|
||||||
|
"the minter may subscribe to {refused}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(p.publish, vec![format!("{inbox}.>")]);
|
||||||
|
assert_eq!(p.responses, Some(REPLY_DEADLINE));
|
||||||
|
// The minter's id is nothing until it is configured.
|
||||||
|
assert_eq!(policy().permissions(CLAUDE_LOGIN), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_reader_may_send_a_login_step() {
|
||||||
|
use swarm_queue_client::claude_login::SUBJECT;
|
||||||
|
|
||||||
|
let p = with_classifier()
|
||||||
|
.with_builders(vec![BUILDER.to_owned()])
|
||||||
|
.with_claude_logins(vec![CLAUDE_LOGIN.to_owned()]);
|
||||||
|
let reader = p.permissions("swarm-controller").expect("the reader");
|
||||||
|
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
|
||||||
|
for (who, grant) in [
|
||||||
|
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
||||||
|
("the minter", p.permissions(CLAUDE_LOGIN).expect("admitted")),
|
||||||
|
("the builder", p.permissions(BUILDER).expect("admitted")),
|
||||||
|
(
|
||||||
|
"the classifier",
|
||||||
|
p.permissions(CLASSIFIER).expect("admitted"),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!grant.publish.iter().any(|g| covers(g, SUBJECT)),
|
||||||
|
"{who} may send a login step: {:?}",
|
||||||
|
grant.publish
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_prefix_alone_names_no_hive() {
|
fn the_prefix_alone_names_no_hive() {
|
||||||
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody
|
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody
|
||||||
|
|
|
||||||
93
swarm-queue-client/src/claude_login.rs
Normal file
93
swarm-queue-client/src/claude_login.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
//! The claude login minter's requests: the subject swarm-controller sends an
|
||||||
|
//! operator's login steps on, and what travels each way.
|
||||||
|
//!
|
||||||
|
//! Three crates name these: the controller sends the requests, the minter
|
||||||
|
//! answers them, and the auth-callout responder grants the one subject to
|
||||||
|
//! each.
|
||||||
|
//!
|
||||||
|
//! A login is two requests for one agent:
|
||||||
|
//! [`Request::Start`](crate::claude_login::Request::Start) runs
|
||||||
|
//! `claude auth login` and answers with the URL it printed, and
|
||||||
|
//! [`Request::Code`](crate::claude_login::Request::Code) hands it the code the
|
||||||
|
//! operator pasted and answers once the credentials are in the agent's store
|
||||||
|
//! leaf. The running login lives in the minter's memory between the two, so
|
||||||
|
//! the swarm runs one minter.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The subject every request goes to. The controller is its one publisher
|
||||||
|
/// and the minter its one subscriber; the minter answers on the request's
|
||||||
|
/// reply subject.
|
||||||
|
pub const SUBJECT: &str = "$SWARM.claude-login.request";
|
||||||
|
|
||||||
|
/// The longest the minter waits for `claude` to print the login URL, and
|
||||||
|
/// then for it to finish once the code is in.
|
||||||
|
pub const STEP_DEADLINE: Duration = Duration::from_mins(1);
|
||||||
|
|
||||||
|
/// How long the controller waits for a reply, and the queue lets the minter
|
||||||
|
/// answer: a step at its deadline, plus the store write.
|
||||||
|
pub const REPLY_DEADLINE: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
|
/// How long a started login waits for its code before the minter stops it
|
||||||
|
/// and deletes its scratch directory.
|
||||||
|
pub const SESSION_DEADLINE: Duration = Duration::from_mins(15);
|
||||||
|
|
||||||
|
/// One step of an operator's login of one agent.
|
||||||
|
///
|
||||||
|
/// No `Debug` derive: [`Request::Code`] carries the one-time code.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "step", rename_all = "kebab-case")]
|
||||||
|
pub enum Request {
|
||||||
|
/// Start a login for `agent`, replacing one already running for it.
|
||||||
|
Start {
|
||||||
|
agent: String,
|
||||||
|
/// The swarm UI user, as the gateway named them.
|
||||||
|
operator: String,
|
||||||
|
},
|
||||||
|
/// Hand the running login for `agent` the code the operator pasted.
|
||||||
|
Code { agent: String, code: String },
|
||||||
|
/// Stop the running login for `agent`, if any.
|
||||||
|
Cancel { agent: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The minter's answer to one [`Request`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "state", rename_all = "kebab-case")]
|
||||||
|
pub enum Reply {
|
||||||
|
/// The login is running; the operator opens `url` and pastes the code it
|
||||||
|
/// shows back.
|
||||||
|
Started { url: String },
|
||||||
|
/// The credentials are stored in the agent's leaf, as its newest
|
||||||
|
/// version.
|
||||||
|
Stored,
|
||||||
|
/// The running login, if there was one, is stopped.
|
||||||
|
Cancelled,
|
||||||
|
/// The step failed: why, with the last lines `claude` printed where it
|
||||||
|
/// printed any.
|
||||||
|
Failed { error: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wire_tags_the_request_by_step_and_the_reply_by_state() {
|
||||||
|
let json = serde_json::to_value(Request::Code {
|
||||||
|
agent: "atlas".to_owned(),
|
||||||
|
code: "c".to_owned(),
|
||||||
|
})
|
||||||
|
.expect("encodes");
|
||||||
|
assert_eq!(json["step"], "code");
|
||||||
|
assert_eq!(json["agent"], "atlas");
|
||||||
|
let json = serde_json::to_value(Reply::Stored).expect("encodes");
|
||||||
|
assert_eq!(json["state"], "stored");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_reply_deadline_outlasts_a_step() {
|
||||||
|
assert!(REPLY_DEADLINE > STEP_DEADLINE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -462,6 +462,10 @@ pub mod stored_secret;
|
||||||
/// The builder's build subject and the request and reply it carries.
|
/// The builder's build subject and the request and reply it carries.
|
||||||
pub mod builder;
|
pub mod builder;
|
||||||
|
|
||||||
|
/// The claude login minter's subject and the requests and replies it
|
||||||
|
/// carries.
|
||||||
|
pub mod claude_login;
|
||||||
|
|
||||||
/// Only the fields this needs; authelia returns several.
|
/// Only the fields this needs; authelia returns several.
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct TokenResponse {
|
struct TokenResponse {
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ reqwest.workspace = true
|
||||||
rustify.workspace = true
|
rustify.workspace = true
|
||||||
rustify_derive.workspace = true
|
rustify_derive.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
# `read_versioned` decodes the data half of the response vaultrs hands back as
|
||||||
|
# a `serde_json::Value`.
|
||||||
|
serde_json.workspace = true
|
||||||
strum.workspace = true
|
strum.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
vaultrs.workspace = true
|
vaultrs.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
108
swarm-secret-client/src/claude_login.rs
Normal file
108
swarm-secret-client/src/claude_login.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
//! The claude login agreement: where an agent's claude credentials live in
|
||||||
|
//! the store, what the object there holds, and where the minter's own queue
|
||||||
|
//! client secret lives.
|
||||||
|
//!
|
||||||
|
//! The `swarm-claude-login` minter writes [`credentials_path`] under its own
|
||||||
|
//! certificate, a new version on every login, and `hive-agent` reads it back
|
||||||
|
//! under the agent's. Its write stanza in `swarm-bao.nix` names exactly this
|
||||||
|
//! leaf under every agent.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Error,
|
||||||
|
path::{Kind, principal_prefix},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The path holding `agent`'s claude credentials.
|
||||||
|
///
|
||||||
|
/// A flat leaf under the agent's prefix, like
|
||||||
|
/// [`crate::github::account_path`], so the agent's own read stanza
|
||||||
|
/// ([`crate::policy::render_agent`]) already covers it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||||
|
/// which is what keeps one agent's name from addressing another agent's secret.
|
||||||
|
pub fn credentials_path(agent: &str) -> Result<String, Error> {
|
||||||
|
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||||
|
Ok(format!("{prefix}/claude-credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a [`credentials_path`] holds.
|
||||||
|
///
|
||||||
|
/// No `Debug` derive: `value` is a live claude session.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Credentials {
|
||||||
|
/// The `.credentials.json` `claude auth login` wrote, byte for byte.
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Credentials {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Credentials")
|
||||||
|
.field("value", &"<redacted>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path holding the queue client secret of the minter's OIDC client
|
||||||
|
/// `client_id`, where `swarm-secret-publisher.nix` puts every swarm service's.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::PathSegment`] when `client_id` is not a single path segment.
|
||||||
|
pub fn client_secret_path(client_id: &str) -> Result<String, Error> {
|
||||||
|
crate::builder::client_secret_path(client_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn covered_by(policy: &str, path: &str) -> bool {
|
||||||
|
policy.lines().any(|line| {
|
||||||
|
line.strip_prefix("path \"")
|
||||||
|
.and_then(|rest| rest.split_once("\" {"))
|
||||||
|
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||||
|
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_credentials_land_under_the_agent_prefix() {
|
||||||
|
// Spelled out: `swarm-bao.nix`'s minter stanza spells the same leaf.
|
||||||
|
assert_eq!(
|
||||||
|
credentials_path("atlas").expect("a plain name is legal"),
|
||||||
|
"swarm/agents/atlas/claude-credentials"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_traversal_in_the_agent_name_is_refused() {
|
||||||
|
for bad in ["../argus", "a/b", "a.b", ""] {
|
||||||
|
let e = credentials_path(bad).expect_err("a traversal is not legal");
|
||||||
|
assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}");
|
||||||
|
}
|
||||||
|
assert!(credentials_path("a-b_C9").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_agents_own_read_grant_covers_the_path() {
|
||||||
|
let path = credentials_path("atlas").expect("legal");
|
||||||
|
let own = crate::policy::render_agent("atlas").expect("legal");
|
||||||
|
assert!(
|
||||||
|
covered_by(&own, &path),
|
||||||
|
"no stanza in\n{own}\ncovers {path}"
|
||||||
|
);
|
||||||
|
let other = crate::policy::render_agent("argus").expect("legal");
|
||||||
|
assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_never_prints_the_value() {
|
||||||
|
let c = Credentials {
|
||||||
|
value: "0123456789abcdef".to_owned(),
|
||||||
|
};
|
||||||
|
let shown = format!("{c:?}");
|
||||||
|
assert!(!shown.contains("0123456789abcdef"), "{shown}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -218,6 +218,34 @@ impl SecretStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The newest version of the object at `path` and its KV v2 version
|
||||||
|
/// number, or `None` when nothing is stored there.
|
||||||
|
///
|
||||||
|
/// One read, so the number is the one the value was stored as. Every
|
||||||
|
/// [`write`][Self::write] makes a larger one.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// As [`read_optional`][Self::read_optional].
|
||||||
|
pub async fn read_versioned<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Option<(T, u64)>, Error> {
|
||||||
|
let endpoint = vaultrs::api::kv2::requests::ReadSecretRequest::builder()
|
||||||
|
.mount(MOUNT)
|
||||||
|
.path(path)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| Error::Settings(e.to_string()))?;
|
||||||
|
match vaultrs::api::exec_with_result(&self.inner, endpoint).await {
|
||||||
|
Ok(res) => {
|
||||||
|
let value = serde_json::from_value(res.data)
|
||||||
|
.map_err(|source| vaultrs::error::ClientError::JsonParseError { source })?;
|
||||||
|
Ok(Some((value, res.metadata.version)))
|
||||||
|
}
|
||||||
|
Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The keys stored directly under `path`, or none when nothing is.
|
/// The keys stored directly under `path`, or none when nothing is.
|
||||||
///
|
///
|
||||||
/// A key ending in `/` is a directory below `path` rather than an object;
|
/// A key ending in `/` is a directory below `path` rather than an object;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@
|
||||||
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
||||||
//! of secret, the path it lives at together with the fields it holds
|
//! of secret, the path it lives at together with the fields it holds
|
||||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`acp`], [`ci`],
|
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`acp`], [`ci`],
|
||||||
//! [`review_classifier`], [`builder`]). Each of those is a thing the controller
|
//! [`review_classifier`], [`builder`], [`claude_login`]). Each of those is a
|
||||||
//! and a hive must say identically, so it is said once here.
|
//! thing the controller and a hive must say identically, so it is said once
|
||||||
|
//! here.
|
||||||
//!
|
//!
|
||||||
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
||||||
//! those paths a given principal's own token may read — a hive's, and an
|
//! those paths a given principal's own token may read — a hive's, and an
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
pub mod acp;
|
pub mod acp;
|
||||||
pub mod builder;
|
pub mod builder;
|
||||||
pub mod ci;
|
pub mod ci;
|
||||||
|
pub mod claude_login;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod forge;
|
pub mod forge;
|
||||||
pub mod github;
|
pub mod github;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue