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/