Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04676bfb2d | ||
|
|
0bb8e80a58 | ||
|
|
693e6e738b | ||
|
|
d5de1e4943 |
48 changed files with 2808 additions and 68 deletions
|
|
@ -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/<agent>/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).
|
||||
|
|
|
|||
16
Cargo.lock
generated
16
Cargo.lock
generated
|
|
@ -4851,6 +4851,22 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-claude-login"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
"futures-util",
|
||||
"hive-log",
|
||||
"hive-types",
|
||||
"serde_json",
|
||||
"swarm-queue-client",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-controller"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
82
docs/swarm/claude-login.md
Normal file
82
docs/swarm/claude-login.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Claude login from the swarm UI
|
||||
|
||||
An operator logs an agent in to claude from the agent's card in the swarm UI.
|
||||
No hive is in the path: the login runs in the swarm's `swarm-claude-login`
|
||||
service, the credentials go to the agent's own leaf in the secret store, and the
|
||||
agent's harness installs them from there.
|
||||
|
||||
Where it runs and how it's enabled: [`services.md`](services.md#claude-login-minter).
|
||||
|
||||
## What happens on a login
|
||||
|
||||
1. The agent publishes `needs_login` in its turn-state header, and its card
|
||||
shows **login needed** with a **log in** button.
|
||||
2. **log in** opens a dialog, which calls
|
||||
`POST /api/agents/<agent>/claude-login`. swarm-controller relays the start as
|
||||
a request on the queue subject `$SWARM.claude-login.request`.
|
||||
3. The minter runs `claude auth login` with `HOME` and `CLAUDE_CONFIG_DIR` in a
|
||||
fresh scratch directory, over plain stdio pipes, and answers with the first
|
||||
`https://` URL it prints. The dialog shows that URL.
|
||||
4. The operator opens the URL, signs in to the agent's own claude account, and
|
||||
pastes the code the page shows into the dialog, which posts it to
|
||||
`POST /api/agents/<agent>/claude-login/code`.
|
||||
5. The minter writes the code to claude's stdin and waits for it to exit. It
|
||||
stores the `.credentials.json` claude wrote at
|
||||
`swarm/agents/<agent>/claude-credentials` (field `value`), as a new version
|
||||
over any stored before, and removes the scratch directory.
|
||||
6. The agent's harness, while it waits for a login, reads that leaf every 30
|
||||
seconds under the agent's own certificate. When the stored version is newer
|
||||
than the one it installed last, it writes the file to
|
||||
`~/.claude/.credentials.json`, records the version, and resumes its turn
|
||||
loop as it does after a login from the agent's web UI.
|
||||
|
||||
A started login waits 15 minutes for its code, then the minter stops it.
|
||||
Starting another login for the same agent replaces the running one. Each step
|
||||
has a minute to finish; a refused code fails the login, and the dialog shows
|
||||
what claude printed.
|
||||
|
||||
## Why the harness takes only a newer version
|
||||
|
||||
`claude` rotates the refresh token in `.credentials.json` as it uses the
|
||||
session, and the agent can't write the store. After the first refresh the
|
||||
stored copy is stale. A harness that installed it whenever the bytes differed
|
||||
would replace the agent's live session with a stale copy and log the agent
|
||||
out. The harness therefore compares the store's KV version with the last
|
||||
version it installed (`claude-credentials-version` under the harness dir):
|
||||
|
||||
- it installs a newer version, that is, a new login;
|
||||
- it leaves an equal or older version alone, even when the agent needs a
|
||||
login again, so it never puts back a session that has gone bad;
|
||||
- it reads nothing while the agent has a session.
|
||||
|
||||
## Credentials
|
||||
|
||||
| Principal | Grant |
|
||||
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm-claude-login`, in its container | `create`, `update` on `swarm/agents/+/claude-credentials`; `read` on `swarm/services/<minter client id>/oidc/client` |
|
||||
| the agent, under its own certificate | `read` on its own `swarm/agents/<agent>/*`, which covers the leaf |
|
||||
|
||||
The minter has no read on the leaf and nothing else under an agent. Every
|
||||
hive's policy reads `swarm/agents/*`, so every hive can read every agent's
|
||||
claude session, as it can read the GitHub token.
|
||||
|
||||
The `operatorsOnly` authelia rule on the swarm domain, not the controller,
|
||||
keeps these routes to operators: the controller checks only that the gateway
|
||||
named a user. Without that rule any account authelia admits could store its
|
||||
own claude session as any agent's, so no agent account may exist before the
|
||||
rule reaches the swarm.
|
||||
|
||||
The minter refuses a step whose agent name isn't an identifier before it
|
||||
makes a directory or runs `claude`.
|
||||
|
||||
## Caveat
|
||||
|
||||
The installed-version record lives under the harness dir and the credentials
|
||||
file under `~/.claude`. If only the record survives a rebuild, the agent
|
||||
ignores the stored version until the operator logs it in again, which writes a
|
||||
newer one.
|
||||
|
||||
## What stays
|
||||
|
||||
The agent's own web UI login (`/login`) still works, unchanged. It goes away
|
||||
once the operator has confirmed the swarm login works.
|
||||
|
|
@ -68,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/<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>/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>/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 |
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ neither is a renaming of the other.
|
|||
## Swarm-level — one of each per swarm
|
||||
|
||||
| secret | generated by | lives at | hive elsewhere |
|
||||
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| swarm-services root CA (cert + key) | `swarm-bao.nix`'s granting unit, inside the store's `pki` mount | the **key** never leaves the mount; the **cert** lands at `/var/lib/swarm-bao-services-pki/services-root.pem` (0644) and at `/var/lib/hive-tls/swarm-services-root.pem` on every hive | nothing to copy — every hive receives the cert with the leaf it asks the store for, or from `pki/ca/pem` where it fronts no swarm name and needs a store identity |
|
||||
| authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them |
|
||||
| authelia OIDC HMAC key | same unit | `/var/lib/authelia-swarm/oidc-hmac.key` | same |
|
||||
|
|
@ -60,6 +60,7 @@ neither is a renaming of the other.
|
|||
| a hive's copy of its own OIDC secret | hive-c0re reads it out of the swarm secret store when it first connects to the queue, under the hive's own leaf | the daemon's memory only; never on disk | same read, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
|
||||
| the hive collector's copy of its hive's OIDC secret | `swarm-bao-hive-otel-oidc.service` reads it out of the swarm secret store under the hive's own leaf, **on every hive that runs the collector and holds that leaf** | `/var/lib/hive-otel-oidc/hive-<name>.secret` on the hive's host, 0400 root, loaded by `LoadCredential` | same unit, same path, co-located or not. Nothing for an operator to place beyond the hive's store leaf (`deploy.bao.clientCertFile`) |
|
||||
| the swarm controller's forge token | the forge's `forgejo-swarm-controller-account`, in-container, once | `/var/lib/forgejo/swarm-controller-token` inside the forge container, the minter's record that it has minted; `swarm-bao-forge-publisher` copies it into the swarm secret store at `swarm/controller/swarm-controller/forge/token` each time the forge container starts and each time the mint writes the file; the controller holds it in memory only | nothing to place beyond the controller's store leaf. A forge host without the store needs the publisher's leaf placed (`deploy.bao.forgePublisherClientCertFile`) |
|
||||
| an agent's claude login (`.credentials.json`) | `swarm-claude-login.service`, in its container, when an operator completes `claude auth login` from the agent's card in the swarm UI; it stores the file as a new version at `swarm/agents/<agent>/claude-credentials` under its own leaf, which may create and update that path and read nothing under an agent | the swarm secret store at that path; the agent's `hive-agent` reads it under the agent's own leaf every 30 seconds while it waits for a login and writes it to `~/.claude/.credentials.json` | the same read, co-located or not; nothing for an operator to place beyond the agent's store leaf. When the store's copy and the agent's differ the KV version decides: the agent installs only a version newer than the last it installed (`claude-credentials-version` under the harness dir), because claude rotates the refresh token in its own copy and the stored one goes stale. The agent ignores a stale or equal version, and a relogin writes a newer one — [claude-login.md](claude-login.md) |
|
||||
| the queue responder's copy of its OIDC secret | the responder reads it out of the swarm secret store itself as it starts, under its own leaf, and again when authelia refuses it | the daemon's memory only; never on disk | same read, wherever authelia runs. Nothing for an operator to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
|
||||
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
|
||||
| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
|
||||
|
|
@ -405,10 +406,10 @@ next controller restart.
|
|||
### Per-principal identities
|
||||
|
||||
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
|
||||
classifier's, the builder's, the forge host's and Grafana's, which reach two
|
||||
each:
|
||||
classifier's, the builder's, the claude login minter's, the forge host's and
|
||||
Grafana's, which reach two each:
|
||||
|
||||
| unit | option pair under `deploy.bao.` | reads, or writes |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -418,28 +419,30 @@ each:
|
|||
| `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-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` |
|
||||
|
||||
The first exists **per hive**, because the path it reads carries a hive name
|
||||
and every hive runs its own reader. Its subject is
|
||||
`<deploy.bao.queueAgentCommonNamePrefix>-<hive>`; `swarm.nix` reserves that
|
||||
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
|
||||
`deploy.bao.grafanaOidcCommonName`, `deploy.bao.otelOidcCommonName`,
|
||||
`deploy.bao.forwarderOidcCommonName`, `deploy.bao.ciRunnerCommonName`,
|
||||
`deploy.bao.reviewClassifierCommonName`, `deploy.bao.builderCommonName` and
|
||||
`deploy.bao.forgePublisherCommonName`. The last is the only writer, and the one
|
||||
leaf two units share: the forge's host holds one store identity for the token it
|
||||
publishes and the OIDC client secret it reads.
|
||||
`deploy.bao.reviewClassifierCommonName`, `deploy.bao.builderCommonName`,
|
||||
`deploy.bao.claudeLoginCommonName` and `deploy.bao.forgePublisherCommonName`.
|
||||
The last two are the writers, and the last is the one leaf two units share:
|
||||
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
|
||||
all fourteen options, and there is nothing to do. Elsewhere you copy each leaf from
|
||||
On a host that mints its own PKI, `glue-bao-tls.nix` signs all eight and defaults
|
||||
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
|
||||
`/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
|
||||
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
|
||||
`swarm-review-classifier.nix` does for the classifier and `swarm-grafana.nix`
|
||||
for Grafana. The forge's pair refuses nothing: without
|
||||
|
|
@ -448,7 +451,7 @@ naming both options, and the controller runs without forge access and the forge
|
|||
without a login source until an operator places the leaf.
|
||||
|
||||
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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).
|
||||
|
||||
### 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).
|
||||
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
|
||||
turn-state header says its claude has no session. Log it in from the agent's
|
||||
own web UI.
|
||||
turn-state header says its claude has no session, and a **log in** button
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,9 @@
|
|||
services.hyperhive.deploy.builder.package =
|
||||
lib.mkDefault
|
||||
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 =
|
||||
lib.mkDefault
|
||||
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;
|
||||
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 {
|
||||
color: var(--muted);
|
||||
white-space: normal;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@
|
|||
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
|
||||
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
|
||||
// `onClick` and opening the detail panel.
|
||||
import { useState } from "preact/hooks";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import type { ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import { AgentIcon } from "../AgentIcon.js";
|
||||
import { ClaudeLoginDialog } from "../claude-login/ClaudeLoginDialog.js";
|
||||
import { FRESHNESS, showsLoginNeeded, type AgentRow } from "../AgentTypes.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./AgentCard.css";
|
||||
|
|
@ -45,6 +47,7 @@ export function AgentCard({
|
|||
onOpenDetail: (row: AgentRow) => void;
|
||||
}) {
|
||||
const { tone, label } = FRESHNESS[row.freshness];
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
return (
|
||||
<Card selected={selected} onClick={() => onOpenDetail(row)}>
|
||||
<div class="ui-agent-card-row">
|
||||
|
|
@ -72,11 +75,28 @@ export function AgentCard({
|
|||
}
|
||||
/>
|
||||
{showsLoginNeeded(row) ? (
|
||||
<span
|
||||
class="ui-agent-card-login"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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}
|
||||
<span
|
||||
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 crate::events::Bus;
|
||||
use crate::{claude_credentials, paths};
|
||||
|
||||
/// Returns the Claude credentials directory for this agent. Delegates
|
||||
/// 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.
|
||||
let baseline_unreadable = unreadable_mtime_names(claude_dir);
|
||||
let probe = Duration::from_millis(poll_ms.max(2000));
|
||||
let pickup = swarm_pickup(claude_dir);
|
||||
let mut next_pickup = tokio::time::Instant::now();
|
||||
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;
|
||||
// A login that already landed by the time this first poll runs
|
||||
// (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
|
||||
/// `dir` whose mtime is *not* readable (`metadata()`/`modified()` errors —
|
||||
/// 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.
|
||||
|
||||
mod boot_announce;
|
||||
mod claude_credentials;
|
||||
mod claude_md_watch;
|
||||
mod claude_usage_watch;
|
||||
mod db_migrate;
|
||||
|
|
|
|||
|
|
@ -210,10 +210,26 @@ impl<S: SecretSource> AgentSecret<S> {
|
|||
/// # Errors
|
||||
/// 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> {
|
||||
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()) {
|
||||
anyhow::bail!(
|
||||
"{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)
|
||||
|
|
@ -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"))?;
|
||||
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")?;
|
||||
Ok(StoreSource {
|
||||
settings,
|
||||
role: policy::agent_object_name(&agent)?,
|
||||
path: queue::agent_queue_path(&agent)?,
|
||||
})
|
||||
let role = policy::agent_object_name(&agent)?;
|
||||
Ok((settings, role, agent))
|
||||
}
|
||||
|
||||
/// Whether the CA bundle at `path` is a file with bytes in it. `BAO_CACERT`
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue