Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-14 22:26:53 +02:00 • committed by mara
commit d5de1e4943
48 changed files with 2680 additions and 42 deletions

View file

@ -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).

15
Cargo.lock generated
View file

@ -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"

View file

@ -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",

View file

@ -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

View 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.

View file

@ -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 |

View file

@ -405,10 +405,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 +418,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 +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.
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.

View file

@ -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).

View file

@ -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

View file

@ -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;

View file

@ -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);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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) ? (
<Badge
tone="warning"
value="login needed"
title="the agent's claude has no session; its turns wait until it is logged in"
/>
<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"

View 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);
});

View 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);
}
}

View file

@ -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

View file

@ -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;

View file

@ -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`

View file

@ -47,6 +47,7 @@
./glue-nats-wireguard.nix
./glue-queue-agent-credential.nix
./glue-builder-oidc-client.nix
./glue-claude-login-oidc-client.nix
./glue-review-classifier-oidc-client.nix
./glue-secret-publisher-authelia.nix
./glue-secret-publisher-bao-identity.nix
@ -57,6 +58,7 @@
./swarm-bao-service.nix
./swarm-bao.nix
./swarm-builder.nix
./swarm-claude-login.nix
./swarm-ci-runner.nix
./swarm-review-classifier.nix
./swarm-secret-publisher.nix

View file

@ -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 {
type = lib.types.bool;
default = false;

View file

@ -117,6 +117,8 @@ in
reviewClassifierClientKeyFile = lib.mkDefault "${pkiDir}/review-classifier-key.pem";
builderClientCertFile = lib.mkDefault "${pkiDir}/builder.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";
forgePublisherClientKeyFile = lib.mkDefault "${pkiDir}/forge-publisher-key.pem";
servicesIssuerClientCertFile = lib.mkDefault "${pkiDir}/services-issuer.pem";
@ -303,6 +305,13 @@ in
[ -s ${pkiDir}/builder.pem ] || ${signLeaf} ${pkiDir} builder \
${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
# reason: the forge is a swarm singleton, so on a forge host without the
# store this is the file an operator copies.

View 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 ];
}
];
};
}

View file

@ -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 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.
@ -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 {
type = lib.types.str;
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 {
type = lib.types.nullOr lib.types.str;
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-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;
# A FOURTH sibling, same shape and same reasons as the two above. This

View 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;
};
};
};
};
};
}

View file

@ -795,6 +795,8 @@ in
"--review-classifier-client ${lib.escapeShellArg config.services.hyperhive.swarm.review-classifier.clientId}"
# Hears builds and answers each by reply, and nothing else.
"--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
# terminal rows and its turn-state header
# (`hive-agent::swarm_agent_state`), two subject families so a

View file

@ -126,4 +126,12 @@ in
&& deployCfg.bao.builderClientCertFile != 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
);
}

View file

@ -96,6 +96,8 @@ let
hyperhiveCfg.swarm.review-classifier.clientId
# The swarm builder, which reads it itself inside its container.
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

View file

@ -59,6 +59,7 @@ let
deployCfg.bao.metricsAuthCommonName
deployCfg.bao.reviewClassifierCommonName
deployCfg.bao.builderCommonName
deployCfg.bao.claudeLoginCommonName
deployCfg.bao.forgePublisherCommonName
]
# The per-hive subjects, spelled out per hive rather than as the prefix.

View file

@ -169,7 +169,7 @@ let
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
) 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.
grantingUnitNames = [
"swarm-bao-controller-policy"
@ -191,6 +191,7 @@ let
"swarm-bao-forge-publisher-policy"
"swarm-bao-review-classifier-policy"
"swarm-bao-builder-policy"
"swarm-bao-claude-login-policy"
];
# 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
# 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
# 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 =
let
s = baoGranterOptOut.systemd.services;
in
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;
}
{
# 🩸 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.
# 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.
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 =
let
s = baoGranterNoToken.systemd.services;
@ -1595,6 +1596,44 @@ let
in
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";
ok =
@ -1826,8 +1865,8 @@ let
}
{
# What makes the case above mean something: discovery by the granter's
# certificate reaches all nineteen units, and each yields calls.
name = "the granter-policy check sees all nineteen granting units, and parses calls from each";
# certificate reaches all twenty units, and each yields calls.
name = "the granter-policy check sees all twenty granting units, and parses calls from each";
ok =
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)

View file

@ -365,6 +365,27 @@ let
&& lib.hasInfix socket l."/webhook/forge/".proxyPass
&& !(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";
ok =

View file

@ -163,8 +163,9 @@ let
lib.filter (l: swarmServiceLinkDomains ? ${l.label}) cfg.services.hyperhive.swarm.controller.links;
# Every service container at once: the services-only host plus the CI
# runner. The CI runner, the review classifier, the builder, the metrics
# store and the homeserver are the five containers on a netns of their own.
# runner. The CI runner, the review classifier, the builder, the claude login
# minter, the metrics store and the homeserver are the six containers on a
# netns of their own.
serviceContainersWithCi = hive {
deploy.hive-controller.enable = false;
deploy.allSwarmServices = true;
@ -247,6 +248,30 @@ let
&& !(swarmServicesBaoElsewhere.containers ? swarm-builder)
&& 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
# one who has — that is what keeps a shared service placeable on a host

View file

@ -182,6 +182,7 @@ in
# 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-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 —
# same "runs *inside* a container, not on the host" placement as

View 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

View 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.

View 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}");
}
}
}

View 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"
),
}
}

View 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));
}
}

View file

@ -52,6 +52,7 @@ mod agent_state_stream;
mod agent_status;
mod auth;
mod builder;
mod claude_login;
mod config_pr;
mod forge;
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_input::post_agent_input))
.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::edit_schedule,

View file

@ -167,6 +167,11 @@ struct Args {
#[arg(long = "builder-client")]
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
/// as to read the callout keys, its own OIDC client secret and agent
/// 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_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 settings = swarm_secret_client::client::Settings::from_env()
.context("reading the secret store's address and this responder's identity")?;

View file

@ -51,6 +51,7 @@ pub struct Policy {
agent_token_subjects: Vec<String>,
review_classifiers: Vec<String>,
builders: Vec<String>,
claude_logins: Vec<String>,
}
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
@ -122,6 +123,7 @@ impl Policy {
agent_token_subjects: Vec::new(),
review_classifiers: Vec::new(),
builders: Vec::new(),
claude_logins: Vec::new(),
})
}
@ -157,6 +159,13 @@ impl Policy {
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
/// `None` when none are configured.
///
@ -229,6 +238,9 @@ impl Policy {
if self.builders.iter().any(|c| c == 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
}
@ -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,
/// and its own reply inbox.
///
@ -603,6 +629,8 @@ impl Policy {
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
// A build of an agent config, sent as a request the same way.
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
// 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]
fn the_prefix_alone_names_no_hive() {
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody

View 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);
}
}

View file

@ -462,6 +462,10 @@ pub mod stored_secret;
/// The builder's build subject and the request and reply it carries.
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.
#[derive(serde::Deserialize)]
struct TokenResponse {

View file

@ -13,12 +13,12 @@ reqwest.workspace = true
rustify.workspace = true
rustify_derive.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
thiserror.workspace = true
vaultrs.workspace = true
[dev-dependencies]
serde_json.workspace = true
[lints]
workspace = true

View 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}");
}
}

View file

@ -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.
///
/// A key ending in `/` is a directory below `path` rather than an object;

View file

@ -6,8 +6,9 @@
//! 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
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`acp`], [`ci`],
//! [`review_classifier`], [`builder`]). Each of those is a thing the controller
//! and a hive must say identically, so it is said once here.
//! [`review_classifier`], [`builder`], [`claude_login`]). Each of those is a
//! 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
//! those paths a given principal's own token may read — a hive's, and an
@ -26,6 +27,7 @@
pub mod acp;
pub mod builder;
pub mod ci;
pub mod claude_login;
pub mod client;
pub mod forge;
pub mod github;