diff --git a/AGENTS.md b/AGENTS.md index 4b29df7c0..3508021fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,7 +171,7 @@ Swarm-level: [`docs/4-implementation/swarm/ci-metrics.md`](docs/4-implementation/swarm/ci-metrics.md). - **`swarm-claude-login/`** — runs `claude auth login` for one agent and stores the credentials at `swarm/agents//claude-credentials`. - Design: [`docs/2-handbook/swarm/claude-login.md`](docs/2-handbook/swarm/claude-login.md). + Design: [`docs/4-implementation/swarm/claude-login.md`](docs/4-implementation/swarm/claude-login.md). - **`swarm-notify-router/`** — forge events from swarm-controller's webhooks to todos on the agents they concern, through each agent's own consumer on the `ROUTER_TODOS` stream. Design: diff --git a/docs/2-handbook/swarm/claude-login.md b/docs/2-handbook/swarm/claude-login.md index 90176f444..42243b7b0 100644 --- a/docs/2-handbook/swarm/claude-login.md +++ b/docs/2-handbook/swarm/claude-login.md @@ -7,123 +7,42 @@ agent's harness installs them from there. Where it runs and how it's enabled: [`services.md`](services.md#claude-login-minter). -The service runs the `claude-code` that its host's -`services.hyperhive.deploy.hive-controller.claudeCodePackage` -pins for agents, and its container's own nixpkgs' where the host pins none. +## Logging an agent in -## What happens on a login +1. The agent's card shows **login needed** with a **log in** button when its + claude has no session. +2. **log in** opens a dialog with a link. Open it, sign in to the agent's own + claude account, and paste the code the page shows into the dialog. +3. When the secret store has the login, the dialog closes and the agent picks the + login up within a few minutes, then resumes its turns. -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 reads that leaf under the agent's own certificate: every - 30 seconds while it waits for a login, every 5 minutes while it has a - session, and once as a paused agent resumes. When the stored version is - newer than the one it installed last, it writes the file to - `~/.claude/.credentials.json`, records the version, and, for an agent that - waited, 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 stops. Starting another login +for the same agent replaces the running one. A refused code fails the login, and +the dialog shows what claude printed. -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. +The agent's own web UI login (`/login`) still works, unchanged. It goes away once +you have confirmed the swarm login works. ## The stored login -The agent's card shows when the swarm stored its login for it, or -that none is. The swarm UI reads it with -`GET /api/agents//claude-login`, which the minter answers from the -leaf's metadata: it can say the login exists and when it changed, and never -reads the credential. +The agent's card shows when the swarm stored its login for it, or that none is. +**delete login** on the card removes the stored login, every version of it, after +asking first. An agent that already holds a session logs out too, the next time +it reads the store: within 5 minutes, or when a paused agent resumes. -**delete login** on the card calls -`DELETE /api/agents//claude-login/stored`. The minter removes the leaf -and every version of it, not just the newest: an older version is still a -session. An agent that already holds a session logs out too, the next time it -reads the store: within 5 minutes, or when a paused agent resumes (see -[the harness's rule](#why-the-harness-takes-only-a-newer-login)). +An agent installs a stored login only when it's newer than the one it installed +last, so a login that has since gone bad is never put back. The rule and its +reasons are in +[the reference](../../4-implementation/swarm/claude-login.md#why-the-harness-takes-only-a-newer-login). -## Why the harness takes only a newer login +## Who may log an agent in -`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 version with the last one it -installed (`claude-credentials-version` under the harness dir), by the time -the store wrote each. A delete restarts the KV number but not the time, so a -login right after a delete, version 1 again, still counts as newer. A record -from before the harness kept the time falls back to the number, and gets its -time the first time it reads the same number. +The `operatorsOnly` authelia rule on the swarm domain keeps the login routes to +operators; the controller checks only that the gateway named a user. Without that +rule any account authelia admits could store its own claude session as any +agent's, so no agent account may exist before the rule reaches the swarm. Every +hive's policy reads `swarm/agents/*`, so every hive can read every agent's claude +session, as it can read the GitHub token. -- it installs a newer write, that is, a new login; -- it leaves an equal or older one alone, even when the agent needs a login - again, so it never puts back a session that has gone bad; -- it logs the agent out when the store holds nothing although a version was - installed: the operator deleted the stored login. A read that failed never - does it, since an unreachable store says nothing about the login. The - logout is the one `/api/logout` does, and the recorded version is - forgotten so the next login installs. - -## Credentials - -| Principal | Grant | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `swarm-claude-login`, in its container | `create`, `update` on `swarm/agents/+/claude-credentials`; `read`, `delete` on that leaf's metadata; `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's value and nothing else under an agent. Every -hive's policy reads `swarm/agents/*`, so every hive can read every agent's -claude session, as it can read the GitHub token. - -The `operatorsOnly` authelia rule on the swarm domain, not the controller, -keeps these routes to operators: the controller checks only that the gateway -named a user. Without that rule any account authelia admits could store its -own claude session as any agent's, so no agent account may exist before the -rule reaches the swarm. - -The minter refuses a step whose agent name isn't an identifier before it -makes a directory or runs `claude`. - -## Caveat - -The installed-version record lives under the harness dir and the credentials -file under `~/.claude`. If only the record survives a rebuild, the agent -ignores the stored version until the operator logs it in again, which writes a -newer one. - -## What stays - -The agent's own web UI login (`/login`) still works, unchanged. It goes away -once the operator has confirmed the swarm login works. - -## Observability - -The minter pushes OTLP metrics through its container's forwarder to the swarm -collector, labelled `service.name=hyperhive-claude-login`. With no -`OTEL_EXPORTER_OTLP_ENDPOINT` it exports nothing. The client-certificate -gauges (`bao.client_cert.*`) come with them. - -| Metric | Unit | Kind | Attributes | -| ----------------------------------------- | ---- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `hyperhive.claude_login.steps` | — | counter | `step`: `start`, `code`, `cancel`, `status`, `delete`, `invalid`; `outcome`: `started`, `stored`, `cancelled`, `login`, `deleted`, `failed` | -| `hyperhive.claude_login.step.duration` | `s` | histogram | `step` | -| `hyperhive.claude_login.sessions.expired` | — | counter | — | -| `hyperhive.claude_login.sessions.active` | — | gauge | logins waiting for a code, as of the last step | - -`invalid` is a request that didn't decode or named no agent. A `start` spawns -`claude auth login` and a `code` stores the credentials, so those two are the -slow steps. `expired` counts logins stopped because their code never came. +The minter's grants, the steps of a login and its metrics are in +[the reference](../../4-implementation/swarm/claude-login.md). diff --git a/docs/4-implementation/swarm/claude-login.md b/docs/4-implementation/swarm/claude-login.md new file mode 100644 index 000000000..af9d8d638 --- /dev/null +++ b/docs/4-implementation/swarm/claude-login.md @@ -0,0 +1,106 @@ +# Claude login: the minter, the harness's version rule and metrics + +How the `swarm-claude-login` service runs a login, why the agent's harness takes +only a newer stored login, the minter's grants and its metrics. The operator's +view is [the claude login page](../../2-handbook/swarm/claude-login.md). + +The service runs the `claude-code` that its host's +`services.hyperhive.deploy.hive-controller.claudeCodePackage` +pins for agents, and its container's own nixpkgs' where the host pins none. + +## 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 reads that leaf under the agent's own certificate: every + 30 seconds while it waits for a login, every 5 minutes while it has a + session, and once as a paused agent resumes. When the stored version is + newer than the one it installed last, it writes the file to + `~/.claude/.credentials.json`, records the version, and, for an agent that + waited, 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 login + +`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 version with the last one it +installed (`claude-credentials-version` under the harness dir), by the time +the store wrote each. A delete restarts the KV number but not the time, so a +login right after a delete, version 1 again, still counts as newer. A record +from before the harness kept the time falls back to the number, and gets its +time the first time it reads the same number. + +- it installs a newer write, that is, a new login; +- it leaves an equal or older one alone, even when the agent needs a login + again, so it never puts back a session that has gone bad; +- it logs the agent out when the store holds nothing although a version was + installed: the operator deleted the stored login. A read that failed never + does it, since an unreachable store says nothing about the login. The + logout is the one `/api/logout` does, and the recorded version is + forgotten so the next login installs. + +## Credentials + +| Principal | Grant | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `swarm-claude-login`, in its container | `create`, `update` on `swarm/agents/+/claude-credentials`; `read`, `delete` on that leaf's metadata; `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's value and nothing else under an agent. Every +hive's policy reads `swarm/agents/*`, so every hive can read every agent's +claude session, as it can read the GitHub token. + +The `operatorsOnly` authelia rule on the swarm domain, not the controller, +keeps these routes to operators: the controller checks only that the gateway +named a user. Without that rule any account authelia admits could store its +own claude session as any agent's, so no agent account may exist before the +rule reaches the swarm. + +The minter refuses a step whose agent name isn't an identifier before it +makes a directory or runs `claude`. + +## Caveat + +The installed-version record lives under the harness dir and the credentials +file under `~/.claude`. If only the record survives a rebuild, the agent +ignores the stored version until the operator logs it in again, which writes a +newer one. + +## Observability + +The minter pushes OTLP metrics through its container's forwarder to the swarm +collector, labelled `service.name=hyperhive-claude-login`. With no +`OTEL_EXPORTER_OTLP_ENDPOINT` it exports nothing. The client-certificate +gauges (`bao.client_cert.*`) come with them. + +| Metric | Unit | Kind | Attributes | +| ----------------------------------------- | ---- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `hyperhive.claude_login.steps` | — | counter | `step`: `start`, `code`, `cancel`, `status`, `delete`, `invalid`; `outcome`: `started`, `stored`, `cancelled`, `login`, `deleted`, `failed` | +| `hyperhive.claude_login.step.duration` | `s` | histogram | `step` | +| `hyperhive.claude_login.sessions.expired` | — | counter | — | +| `hyperhive.claude_login.sessions.active` | — | gauge | logins waiting for a code, as of the last step | + +`invalid` is a request that didn't decode or named no agent. A `start` spawns +`claude auth login` and a `code` stores the credentials, so those two are the +slow steps. `expired` counts logins stopped because their code never came. diff --git a/docs/README.md b/docs/README.md index 5bea1102c..54fd7be53 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,9 +61,10 @@ The services that exist once per swarm. Start at twice after downtime?** → [`2-handbook/swarm/schedules.md`](2-handbook/swarm/schedules.md). - **How to create an agent from a ready-made set of permissions and settings?** → [`2-handbook/swarm/archetypes.md`](2-handbook/swarm/archetypes.md). -- **How to log an agent in to claude from the swarm UI, and why doesn't a - stale stored copy log it out again?** → - [`2-handbook/swarm/claude-login.md`](2-handbook/swarm/claude-login.md). +- **How to log an agent in to claude from the swarm UI?** → + [`2-handbook/swarm/claude-login.md`](2-handbook/swarm/claude-login.md). Why a + stale stored copy does not log the agent out again, and the minter's grants: + [`4-implementation/swarm/claude-login.md`](4-implementation/swarm/claude-login.md). - **Where do the hives back up each agent's state, how long does it stay, and how to check it?** → [`2-handbook/swarm/archive.md`](2-handbook/swarm/archive.md). diff --git a/nix/host-modules/swarm-claude-login.nix b/nix/host-modules/swarm-claude-login.nix index 63ce7e01c..74e9d1891 100644 --- a/nix/host-modules/swarm-claude-login.nix +++ b/nix/host-modules/swarm-claude-login.nix @@ -4,7 +4,7 @@ # 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/2-handbook/swarm/claude-login.md has the design. +# docs/4-implementation/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 diff --git a/swarm-claude-login/README.md b/swarm-claude-login/README.md index bb9b36291..dd1377897 100644 --- a/swarm-claude-login/README.md +++ b/swarm-claude-login/README.md @@ -2,7 +2,7 @@ The claude login minter, in the `swarm-claude-login` container on the host that sets `deploy.claude-login.enable`. The design is -[`docs/2-handbook/swarm/claude-login.md`](../docs/2-handbook/swarm/claude-login.md). +[`docs/4-implementation/swarm/claude-login.md`](../docs/4-implementation/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