From d5de1e4943ab5aaa5b8a98db640447333efa904e Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 14 Oct 2026 22:26:53 +0200 Subject: [PATCH] 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//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 `/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 --- CLAUDE.md | 3 + Cargo.lock | 15 + Cargo.toml | 1 + docs/README.md | 3 + docs/swarm/claude-login.md | 66 ++ docs/swarm/credentials.md | 1 + docs/swarm/secrets.md | 26 +- docs/swarm/services.md | 20 + docs/swarm/ui.md | 7 +- flake.nix | 3 + .../agent/claude-login/ClaudeLoginDialog.css | 13 + .../agent/claude-login/ClaudeLoginDialog.tsx | 130 ++++ .../agent/claude-login/ClaudeLoginSteps.ts | 42 ++ .../swarm-ui/src/agent/roster/AgentCard.css | 7 + .../swarm-ui/src/agent/roster/AgentCard.tsx | 30 +- .../swarm-ui/test/claude-login.test.mjs | 54 ++ hive-agent/src/claude_credentials.rs | 305 +++++++++ hive-agent/src/login.rs | 40 ++ hive-agent/src/main.rs | 1 + hive-agent/src/swarm_queue.rs | 25 +- nix/host-modules/default.nix | 2 + nix/host-modules/deploy.nix | 20 + nix/host-modules/glue-bao-tls.nix | 9 + .../glue-claude-login-oidc-client.nix | 35 ++ nix/host-modules/swarm-bao.nix | 56 ++ nix/host-modules/swarm-claude-login.nix | 203 ++++++ nix/host-modules/swarm-nats.nix | 2 + nix/host-modules/swarm-required-services.nix | 8 + nix/host-modules/swarm-secret-publisher.nix | 2 + nix/host-modules/swarm.nix | 1 + nix/module-eval/bao-grants.nix | 55 +- nix/module-eval/core-toggle.nix | 21 + nix/module-eval/swarm-services-switch.nix | 29 +- nix/packages/default.nix | 1 + swarm-claude-login/Cargo.toml | 26 + swarm-claude-login/README.md | 20 + swarm-claude-login/src/login.rs | 589 ++++++++++++++++++ swarm-claude-login/src/main.rs | 192 ++++++ swarm-controller/src/claude_login.rs | 313 ++++++++++ swarm-controller/src/main.rs | 6 + swarm-nats-auth/src/main.rs | 8 +- swarm-nats-auth/src/policy.rs | 85 +++ swarm-queue-client/src/claude_login.rs | 93 +++ swarm-queue-client/src/lib.rs | 4 + swarm-secret-client/Cargo.toml | 6 +- swarm-secret-client/src/claude_login.rs | 108 ++++ swarm-secret-client/src/client.rs | 28 + swarm-secret-client/src/lib.rs | 6 +- 48 files changed, 2679 insertions(+), 41 deletions(-) create mode 100644 docs/swarm/claude-login.md create mode 100644 frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css create mode 100644 frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx create mode 100644 frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts create mode 100644 frontend/packages/swarm-ui/test/claude-login.test.mjs create mode 100644 hive-agent/src/claude_credentials.rs create mode 100644 nix/host-modules/glue-claude-login-oidc-client.nix create mode 100644 nix/host-modules/swarm-claude-login.nix create mode 100644 swarm-claude-login/Cargo.toml create mode 100644 swarm-claude-login/README.md create mode 100644 swarm-claude-login/src/login.rs create mode 100644 swarm-claude-login/src/main.rs create mode 100644 swarm-controller/src/claude_login.rs create mode 100644 swarm-queue-client/src/claude_login.rs create mode 100644 swarm-secret-client/src/claude_login.rs diff --git a/CLAUDE.md b/CLAUDE.md index 5ff74582..2f20d219 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,9 @@ Swarm-level: crates. - **`swarm-builder/`** — builds agent config PR heads for commit status. 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//claude-credentials`. + Design: [`docs/swarm/claude-login.md`](docs/swarm/claude-login.md). - **`swarm-review-classifier/`** — one `claude --print` per PR classification, no tools, no forge credential. Design: [`docs/swarm/review-classifier.md`](docs/swarm/review-classifier.md). diff --git a/Cargo.lock b/Cargo.lock index c735e9e0..2308ca36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4851,6 +4851,21 @@ dependencies = [ "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]] name = "swarm-controller" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 096d4bd8..8f9f1a9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "swarm-agent-verify", "swarm-authelia-bridge", "swarm-builder", + "swarm-claude-login", "swarm-authelia-bridge-sock", "swarm-controller", "swarm-matrix-client", diff --git a/docs/README.md b/docs/README.md index 0121a33e..95c804ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -91,6 +91,9 @@ declarations. (planned, not implemented yet). - **Does an agent config PR build before it's merged?** → [`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 diff --git a/docs/swarm/claude-login.md b/docs/swarm/claude-login.md new file mode 100644 index 00000000..10fa3d42 --- /dev/null +++ b/docs/swarm/claude-login.md @@ -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//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//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//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//oidc/client` | +| the agent, under its own certificate | `read` on its own `swarm/agents//*`, 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. diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 7fd613a3..2c5cd4e4 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -68,6 +68,7 @@ of the cell says how. | `swarm/agents//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//forge/