Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: show when an agent's claude login was stored, and delete it

The agent's settings get a claude login row: when the swarm stored the
login, read by the minter from the leaf's metadata, or that none is, with a
delete that removes every version after a confirmation. New minter steps
(status, delete), controller routes (GET claude-login, DELETE
claude-login/stored) and a read and delete grant on the leaf's metadata,
pinned in the module-eval check.
This commit is contained in:
iris 2026-11-09 21:39:17 +01:00 • committed by mara
commit 7dd147adb4
15 changed files with 469 additions and 32 deletions

View file

@ -39,6 +39,20 @@ 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 has a minute to finish; a refused code fails the login, and the dialog shows
what claude printed. what claude printed.
## The stored login
The agent's card shows when the swarm stored its login for it, or
that none is. The swarm UI reads it with
`GET /api/agents/<agent>/claude-login`, which the minter answers from the
leaf's metadata: it can say the login exists and when it changed, and never
reads the credential.
**delete login** on the card calls
`DELETE /api/agents/<agent>/claude-login/stored`. The minter removes the leaf
and every version of it, not just the newest: an older version is still a
session. It doesn't log out an agent that already holds a session; that agent
keeps running on what's in its own `~/.claude`.
## Why the harness takes only a newer version ## Why the harness takes only a newer version
`claude` rotates the refresh token in `.credentials.json` as it uses the `claude` rotates the refresh token in `.credentials.json` as it uses the
@ -55,12 +69,12 @@ version it installed (`claude-credentials-version` under the harness dir):
## Credentials ## Credentials
| Principal | Grant | | Principal | Grant |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `swarm-claude-login`, in its container | `create`, `update` on `swarm/agents/+/claude-credentials`; `read` on `swarm/services/<minter client id>/oidc/client` | | `swarm-claude-login`, in its container | `create`, `update` on `swarm/agents/+/claude-credentials`; `read`, `delete` on that leaf's metadata; `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 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 The minter has no read on the leaf's value and nothing else under an agent. Every
hive's policy reads `swarm/agents/*`, so every hive can read every agent's hive's policy reads `swarm/agents/*`, so every hive can read every agent's
claude session, as it can read the GitHub token. claude session, as it can read the GitHub token.
@ -92,12 +106,12 @@ collector, labelled `service.name=hyperhive-claude-login`. With no
`OTEL_EXPORTER_OTLP_ENDPOINT` it exports nothing. The client-certificate `OTEL_EXPORTER_OTLP_ENDPOINT` it exports nothing. The client-certificate
gauges (`bao.client_cert.*`) come with them. gauges (`bao.client_cert.*`) come with them.
| Metric | Unit | Kind | Attributes | | Metric | Unit | Kind | Attributes |
| ----------------------------------------- | ---- | --------- | --------------------------------------------------------------------------------------------------- | | ----------------------------------------- | ---- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `hyperhive.claude_login.steps` | — | counter | `step`: `start`, `code`, `cancel`, `invalid`; `outcome`: `started`, `stored`, `cancelled`, `failed` | | `hyperhive.claude_login.steps` | — | counter | `step`: `start`, `code`, `cancel`, `status`, `delete`, `invalid`; `outcome`: `started`, `stored`, `cancelled`, `login`, `deleted`, `failed` |
| `hyperhive.claude_login.step.duration` | `s` | histogram | `step` | | `hyperhive.claude_login.step.duration` | `s` | histogram | `step` |
| `hyperhive.claude_login.sessions.expired` | — | counter | — | | `hyperhive.claude_login.sessions.expired` | — | counter | — |
| `hyperhive.claude_login.sessions.active` | — | gauge | logins waiting for a code, as of the last step | | `hyperhive.claude_login.sessions.active` | — | gauge | logins waiting for a code, as of the last step |
`invalid` is a request that didn't decode or named no agent. A `start` spawns `invalid` is a request that didn't decode or named no agent. A `start` spawns
`claude auth login` and a `code` stores the credentials, so those two are the `claude auth login` and a `code` stores the credentials, so those two are the

View file

@ -177,7 +177,7 @@ login minter's, the forge host's and Grafana's, which reach more than one:
| `swarm-bao-forwarder-oidc` | `forwarderOidcClientCertFile` / `forwarderOidcClientKeyFile` | `swarm/services/<store forwarder client id>/oidc/client` | | `swarm-bao-forwarder-oidc` | `forwarderOidcClientCertFile` / `forwarderOidcClientKeyFile` | `swarm/services/<store forwarder client id>/oidc/client` |
| `swarm-bao-ci-runner`, and the runner's journal forwarder in its container | `ciRunnerClientCertFile` / `ciRunnerClientKeyFile` | `swarm/services/ci-runner/registration-token` and `swarm/services/<runner forwarder client id>/oidc/client` | | `swarm-bao-ci-runner`, and the runner's journal forwarder in its container | `ciRunnerClientCertFile` / `ciRunnerClientKeyFile` | `swarm/services/ci-runner/registration-token` and `swarm/services/<runner forwarder client id>/oidc/client` |
| `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-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-claude-login`, in its container | `claudeLoginClientCertFile` / `claudeLoginClientKeyFile` | creates and updates `swarm/agents/+/claude-credentials`, reads and deletes its metadata; reads `swarm/services/<minter client id>/oidc/client` |
| `swarm-notify-router`, in its container | `notifyRouterClientCertFile` / `notifyRouterClientKeyFile` | `swarm/services/<router client id>/oidc/client` | | `swarm-notify-router`, in its container | `notifyRouterClientCertFile` / `notifyRouterClientKeyFile` | `swarm/services/<router client id>/oidc/client` |
| `swarm-builder`, in its container | `builderClientCertFile` / `builderClientKeyFile` | `swarm/services/builder/forge/token`, `swarm/services/attic/push-token` and `swarm/services/<builder client id>/oidc/client` | | `swarm-builder`, in its container | `builderClientCertFile` / `builderClientKeyFile` | `swarm/services/builder/forge/token`, `swarm/services/attic/push-token` and `swarm/services/<builder client id>/oidc/client` |
| `swarm-bao-attic-secret` and `swarm-bao-attic-tokens` | `atticClientCertFile` / `atticClientKeyFile` | creates and reads `swarm/services/attic/jwt-secret`, `swarm/services/attic/push-token` and `swarm/services/attic/pull-token` | | `swarm-bao-attic-secret` and `swarm-bao-attic-tokens` | `atticClientCertFile` / `atticClientKeyFile` | creates and reads `swarm/services/attic/jwt-secret`, `swarm/services/attic/push-token` and `swarm/services/attic/pull-token` |

View file

@ -70,7 +70,9 @@ that runs the claude login from a dialog: open the URL it shows, then paste
the code back. When the secret store has the login, the dialog closes and a the code back. When the secret store has the login, the dialog closes and a
short toast in the top-right corner says so. The credentials go to the agent through the secret store short toast in the top-right corner says so. The credentials go to the agent through the secret store
([`claude-login.md`](claude-login.md)). The agent's own web UI login still ([`claude-login.md`](claude-login.md)). The agent's own web UI login still
works too. works too. The settings section of an opened card has a **claude login** row: when
the swarm stored the agent's login, or that none is, with a **delete login**
action that asks first.
### The agents page ### The agents page

View file

@ -0,0 +1,31 @@
// The swarm's stored claude login for an agent, as the agent's detail shows it:
// when it was written, and the delete. Kept out of the component so `npm test`
// runs it. The routes are swarm-controller's `claude_login` module.
/** What `GET …/claude-login` answers. */
export interface StoredLogin {
/** RFC 3339, as the secret store spells it; null when nothing is stored. */
stored_at: string | null;
}
export function storedLoginPath(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/claude-login`;
}
export function deleteStoredLoginPath(agent: string): string {
return `${storedLoginPath(agent)}/stored`;
}
// The instant a stored login was written, in ms since the epoch, or null when
// nothing is stored or the store's time does not parse.
export function storedAtMs(login: StoredLogin): number | null {
if (login.stored_at === null) return null;
const ms = Date.parse(login.stored_at);
return Number.isNaN(ms) ? null : ms;
}
// What the delete's confirmation says: it removes the swarm's copy, and an
// agent that already holds a session keeps it.
export function deleteMessage(agent: string): string {
return `Delete the stored claude login for ${agent}? ${agent} has to be logged in again to get a new one.`;
}

View file

@ -0,0 +1,112 @@
// <AgentClaudeLogin> — the swarm's stored claude login for one agent: when it
// was written (`GET …/claude-login`, read from the secret store's metadata),
// or that none is, and a delete that removes every version of it
// (`DELETE …/claude-login/stored`) after a confirmation.
//
// Its state belongs to one agent: callers key it by agent name.
import { useCallback, 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 { Badge } from "@hive/shared/badge.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Hint } from "../../ui/hint/Hint.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { showToast } from "../../ui/toast/toasts.js";
import {
deleteMessage,
deleteStoredLoginPath,
storedAtMs,
storedLoginPath,
type StoredLogin,
} from "../claude-login/StoredLogin.js";
export function AgentClaudeLogin({ agent }: { agent: string }) {
const [login, setLogin] = useState<StoredLogin | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<ProblemDetails | null>(null);
const load = useCallback(async () => {
try {
const r = await fetch(storedLoginPath(agent));
if (!r.ok) {
setError(await readApiError(r));
return;
}
setLogin((await r.json()) as StoredLogin);
setError(null);
} catch (err) {
setError({ detail: String(err) });
}
}, [agent]);
useEffect(() => {
void load();
}, [load]);
async function confirmDelete() {
setDeleting(true);
setDeleteError(null);
try {
const r = await fetch(deleteStoredLoginPath(agent), { method: "DELETE" });
if (!r.ok) {
setDeleteError(await readApiError(r));
return;
}
setConfirming(false);
showToast(`Stored login for ${agent} deleted.`, "ok");
await load();
} catch (err) {
setDeleteError({ detail: String(err) });
} finally {
setDeleting(false);
}
}
// Shown as text, not a hover title. A login already read stays under a
// failed refetch.
const errorPanel = error ? (
<ApiErrorPanel context="reading the stored login failed" problem={error} />
) : null;
if (login === null) {
return errorPanel ?? <Hint as="span">…</Hint>;
}
const at = storedAtMs(login);
return (
<>
{errorPanel}
{login.stored_at === null ? (
<Hint as="span">none stored</Hint>
) : (
<>
<span title={login.stored_at}>
stored {at === null ? login.stored_at : <RelativeTime epochMs={at} />}
</span>{" "}
<Badge
variant="quiet"
value="delete login"
title={`delete the stored claude login for ${agent}`}
onClick={() => {
setDeleteError(null);
setConfirming(true);
}}
/>
</>
)}
<ConfirmDialog
open={confirming}
label={`delete the stored claude login for ${agent}`}
confirmLabel={deleting ? "deleting…" : "delete login"}
confirmDisabled={deleting}
onCancel={() => setConfirming(false)}
onConfirm={() => void confirmDelete()}
>
<p>{deleteMessage(agent)}</p>
{deleteError ? (
<ApiErrorPanel context="delete failed" problem={deleteError} />
) : null}
</ConfirmDialog>
</>
);
}

View file

@ -17,6 +17,7 @@ import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
import { Hint } from "../../ui/hint/Hint.js"; import { Hint } from "../../ui/hint/Hint.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import type { AgentActions } from "../roster/AgentActions.js"; import type { AgentActions } from "../roster/AgentActions.js";
import { AgentClaudeLogin } from "./AgentClaudeLogin.js";
import { AgentInference } from "./AgentInference.js"; import { AgentInference } from "./AgentInference.js";
import { AgentInstructions } from "./AgentInstructions.js"; import { AgentInstructions } from "./AgentInstructions.js";
import { AgentLimits } from "./AgentLimits.js"; import { AgentLimits } from "./AgentLimits.js";
@ -186,6 +187,10 @@ function SettingsRows({ agent }: { agent: string }) {
<AgentLimits key={agent} agent={agent} /> <AgentLimits key={agent} agent={agent} />
</dd> </dd>
<GrantRows key={agent} agent={agent} /> <GrantRows key={agent} agent={agent} />
<dt>claude login</dt>
<dd>
<AgentClaudeLogin key={agent} agent={agent} />
</dd>
</> </>
); );
} }

View file

@ -0,0 +1,33 @@
// The stored claude login's routes and wording. 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 {
deleteMessage,
deleteStoredLoginPath,
storedAtMs,
storedLoginPath,
} from "../src/agent/claude-login/StoredLogin.ts";
test("the routes name the agent, escaped", () => {
assert.equal(storedLoginPath("atlas"), "/api/agents/atlas/claude-login");
assert.equal(
deleteStoredLoginPath("atlas"),
"/api/agents/atlas/claude-login/stored",
);
assert.equal(storedLoginPath("a/b"), "/api/agents/a%2Fb/claude-login");
});
test("the time the store gives is read as an instant, or as nothing", () => {
assert.equal(
storedAtMs({ stored_at: "2026-11-09T20:00:00.123456789Z" }),
Date.parse("2026-11-09T20:00:00.123Z"),
);
assert.equal(storedAtMs({ stored_at: null }), null);
assert.equal(storedAtMs({ stored_at: "not a time" }), null);
});
test("the delete's confirmation names the agent", () => {
assert.match(deleteMessage("atlas"), /stored claude login for atlas/);
});

View file

@ -1353,7 +1353,10 @@ let
# The claude login minter: every agent's `claude-credentials` leaf, # The claude login minter: every agent's `claude-credentials` leaf,
# create and update so a relogin replaces the last, and nothing else under # create and update so a relogin replaces the last, and nothing else under
# an agent; and its own queue client secret, read at start. # an agent; the leaf's metadata, to say when it was written (`read`) and to
# delete every version of it (`delete`; the `data/` path's delete would leave
# the older versions, each a live session), never the credential itself; and
# its own queue client secret, read at start.
claudeLoginWriters = [ claudeLoginWriters = [
{ {
name = "swarm-claude-login"; name = "swarm-claude-login";
@ -1362,6 +1365,9 @@ let
path "${credentialMountPath}/data/swarm/agents/+/claude-credentials" { path "${credentialMountPath}/data/swarm/agents/+/claude-credentials" {
capabilities = ["create", "update"] capabilities = ["create", "update"]
} }
path "${credentialMountPath}/metadata/swarm/agents/+/claude-credentials" {
capabilities = ["read", "delete"]
}
'' ''
+ readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.claude-login.clientId}/oidc/client"; + readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.claude-login.clientId}/oidc/client";
} }
@ -2294,8 +2300,9 @@ in
description = '' description = ''
Subject the store's `swarm-claude-login` cert-auth role accepts: the Subject the store's `swarm-claude-login` cert-auth role accepts: the
identity the claude login minter presents from inside its container. identity the claude login minter presents from inside its container.
Its grant is create and update on `swarm/agents/+/claude-credentials` Its grant is create and update on `swarm/agents/+/claude-credentials`,
and read on its own queue client secret. read and delete on that leaf's metadata, and read on its own queue client
secret.
⚠️ Reserved as a hive name by ./swarm.nix, like its siblings. ⚠️ Reserved as a hive name by ./swarm.nix, like its siblings.
''; '';

View file

@ -2098,9 +2098,10 @@ let
} }
{ {
# The minter writes one leaf under every agent, create and update so a # 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 # relogin replaces the last, reads and deletes the leaf's metadata (when it
# read on the leaf, and nothing else under an agent. # was written; every version removed), and reads its own queue client
name = "the claude login minter's grant is create and update on agents' claude-credentials, and its queue client secret"; # secret. No read on the leaf's data, and nothing else under an agent.
name = "the claude login minter's grant is create and update on agents' claude-credentials, read and delete on its metadata, and its queue client secret";
ok = ok =
let let
s = baoGrantHere.systemd.services.swarm-bao-claude-login-policy.script; s = baoGrantHere.systemd.services.swarm-bao-claude-login-policy.script;
@ -2110,8 +2111,13 @@ let
"create" "create"
"update" "update"
] ]
&&
(grantFor grants "secret/metadata/swarm/agents/+/claude-credentials").caps == [
"read"
"delete"
]
&& (grantFor grants "secret/data/swarm/services/swarm-claude-login/oidc/client").caps == [ "read" ] && (grantFor grants "secret/data/swarm/services/swarm-claude-login/oidc/client").caps == [ "read" ]
&& lib.length grants == 2 && lib.length grants == 3
&& !(lib.hasInfix "secret/data/swarm/agents/*" s) && !(lib.hasInfix "secret/data/swarm/agents/*" s)
&& lib.hasInfix "auth/cert/certs/swarm-claude-login" s && lib.hasInfix "auth/cert/certs/swarm-claude-login" s
&& lib.hasInfix "allowed_common_names=swarm-claude-login" s && lib.hasInfix "allowed_common_names=swarm-claude-login" s
@ -2147,7 +2153,7 @@ let
in in
(grantFor grants "secret/data/swarm/services/swarm-notify-router/oidc/client").caps == [ "read" ] (grantFor grants "secret/data/swarm/services/swarm-notify-router/oidc/client").caps == [ "read" ]
&& lib.length grants == 1 && lib.length grants == 1
&& lib.length minter == 2 && lib.length minter == 3
&& lib.hasInfix "auth/cert/certs/swarm-notify-router" s && lib.hasInfix "auth/cert/certs/swarm-notify-router" s
&& lib.hasInfix "allowed_common_names=swarm-notify-router" s && lib.hasInfix "allowed_common_names=swarm-notify-router" s
&& lib.hasInfix "token_policies=swarm-notify-router" s; && lib.hasInfix "token_policies=swarm-notify-router" s;

View file

@ -36,6 +36,53 @@ const PREFIX: &str = "SWARM_CLAUDE_LOGIN";
/// The agent's store leaf, written under this service's certificate. /// The agent's store leaf, written under this service's certificate.
struct AgentLeaf; struct AgentLeaf;
impl AgentLeaf {
/// When the agent's stored login was written, read from the leaf's
/// metadata: this service may say it exists, and never reads it back.
async fn status(&self, agent: &str) -> Reply {
match self.stored_at(agent).await {
Ok(stored_at) => Reply::Login { stored_at },
Err(e) => Reply::Failed {
error: format!("reading when {agent}'s login was stored failed: {e:#}"),
},
}
}
async fn stored_at(&self, agent: &str) -> Result<Option<String>> {
let path = credentials_path(agent)?;
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
.await
.context("logging in to the secret store")?;
store
.updated_time(&path)
.await
.with_context(|| format!("reading the metadata of {path}"))
}
/// Delete the agent's stored login, every version: an older one would
/// still be a live session. The agent's harness sees the leaf gone on its
/// next read.
async fn delete(&self, agent: &str) -> Reply {
match self.delete_all(agent).await {
Ok(()) => Reply::Deleted,
Err(e) => Reply::Failed {
error: format!("deleting {agent}'s stored login failed: {e:#}"),
},
}
}
async fn delete_all(&self, agent: &str) -> Result<()> {
let path = credentials_path(agent)?;
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
.await
.context("logging in to the secret store")?;
store
.delete_all_versions(&path)
.await
.with_context(|| format!("deleting {path}"))
}
}
impl login::CredentialSink for AgentLeaf { impl login::CredentialSink for AgentLeaf {
async fn put(&self, agent: &str, credentials: &Credentials) -> Result<()> { async fn put(&self, agent: &str, credentials: &Credentials) -> Result<()> {
let path = credentials_path(agent)?; let path = credentials_path(agent)?;
@ -142,9 +189,12 @@ async fn run_step(
} }
}; };
// Before anything else: the name becomes a scratch directory's name and // Before anything else: the name becomes a scratch directory's name and
// a store path, and this service holds write on every agent's leaf. // a store path, and this service holds write and delete on every agent's leaf.
let (Request::Start { agent, .. } | Request::Code { agent, .. } | Request::Cancel { agent }) = let (Request::Start { agent, .. }
&request; | Request::Code { agent, .. }
| Request::Cancel { agent }
| Request::Status { agent }
| Request::Delete { agent }) = &request;
if let Err(reason) = hive_types::Ident::parse(agent) { if let Err(reason) = hive_types::Ident::parse(agent) {
tracing::warn!(agent = ?agent, reason, "a login step for an invalid agent name; refused"); tracing::warn!(agent = ?agent, reason, "a login step for an invalid agent name; refused");
let error = format!("{agent:?} is not an agent name: {reason}"); let error = format!("{agent:?} is not an agent name: {reason}");
@ -176,11 +226,22 @@ async fn run_step(
let reply = logins.cancel(&agent).await; let reply = logins.cancel(&agent).await;
("cancel", agent, reply) ("cancel", agent, reply)
} }
Request::Status { agent } => {
let reply = AgentLeaf.status(&agent).await;
("status", agent, reply)
}
Request::Delete { agent } => {
tracing::info!(%agent, "deleting the agent's stored claude login");
let reply = AgentLeaf.delete(&agent).await;
("delete", agent, reply)
}
}; };
match &reply { match &reply {
Reply::Started { .. } => tracing::info!(%agent, "claude login waiting for its code"), Reply::Started { .. } => tracing::info!(%agent, "claude login waiting for its code"),
Reply::Stored => tracing::info!(%agent, "claude credentials stored"), Reply::Stored => tracing::info!(%agent, "claude credentials stored"),
Reply::Cancelled => tracing::info!(%agent, "claude login cancelled"), Reply::Cancelled => tracing::info!(%agent, "claude login cancelled"),
Reply::Login { .. } => {}
Reply::Deleted => tracing::info!(%agent, "claude login deleted"),
Reply::Failed { error } => tracing::warn!(%agent, %error, "claude login step failed"), Reply::Failed { error } => tracing::warn!(%agent, %error, "claude login step failed"),
} }
(step, reply) (step, reply)
@ -242,6 +303,12 @@ mod tests {
Request::Cancel { Request::Cancel {
agent: agent.to_owned(), agent: agent.to_owned(),
}, },
Request::Status {
agent: agent.to_owned(),
},
Request::Delete {
agent: agent.to_owned(),
},
] { ] {
let payload = serde_json::to_vec(&request).expect("encodes"); let payload = serde_json::to_vec(&request).expect("encodes");
let reply = handle(&payload, &logins, &Arc::new(Metrics::noop())).await; let reply = handle(&payload, &logins, &Arc::new(Metrics::noop())).await;

View file

@ -2,8 +2,9 @@
//! collector. //! collector.
//! //!
//! - `hyperhive.claude_login.steps`: login steps answered, by `step` (`start`, //! - `hyperhive.claude_login.steps`: login steps answered, by `step` (`start`,
//! `code`, `cancel`, `invalid` for a request that did not decode or named no //! `code`, `cancel`, `status`, `delete`, `invalid` for a request that did not
//! agent) and `outcome` (`started`, `stored`, `cancelled`, `failed`). //! decode or named no agent) and `outcome` (`started`, `stored`, `cancelled`,
//! `login`, `deleted`, `failed`).
//! - `hyperhive.claude_login.step.duration`: seconds to answer one step, by //! - `hyperhive.claude_login.step.duration`: seconds to answer one step, by
//! `step`. A `start` spawns `claude auth login` and a `code` stores the //! `step`. A `start` spawns `claude auth login` and a `code` stores the
//! credentials, so these are the slow ones. //! credentials, so these are the slow ones.
@ -87,6 +88,8 @@ impl Metrics {
Reply::Started { .. } => "started", Reply::Started { .. } => "started",
Reply::Stored => "stored", Reply::Stored => "stored",
Reply::Cancelled => "cancelled", Reply::Cancelled => "cancelled",
Reply::Login { .. } => "login",
Reply::Deleted => "deleted",
Reply::Failed { .. } => "failed", Reply::Failed { .. } => "failed",
}; };
self.steps.add( self.steps.add(

View file

@ -9,6 +9,9 @@
//! installs them. //! installs them.
//! - `DELETE /api/agents/{name}/claude-login` stops a login started and not //! - `DELETE /api/agents/{name}/claude-login` stops a login started and not
//! finished. //! finished.
//! - `GET /api/agents/{name}/claude-login` says when the agent's stored login
//! was written, and `DELETE /api/agents/{name}/claude-login/stored` deletes
//! it.
//! //!
//! The operator is the `Remote-User` header the gateway sets, as for //! The operator is the `Remote-User` header the gateway sets, as for
//! [`crate::agent_input`]. Nothing here touches the store: the minter holds //! [`crate::agent_input`]. Nothing here touches the store: the minter holds
@ -169,6 +172,79 @@ pub(crate) async fn delete_claude_login(
} }
} }
/// When the swarm's stored claude login for an agent was written.
#[derive(Serialize, ToSchema)]
pub struct ClaudeLoginStatus {
/// RFC 3339, as the secret store spells it; `null` when nothing is stored.
pub stored_at: Option<String>,
}
/// When an agent's stored claude login was written.
#[utoipa::path(
get,
path = "/api/agents/{name}/claude-login",
params(("name" = String, Path, description = "agent whose login to look up")),
responses(
(status = 200, description = "the time the login was stored, or null", body = ClaudeLoginStatus),
(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 read the store (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 get_claude_login(
State(state): State<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
) -> Result<Json<ClaudeLoginStatus>, 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::Status { agent }).await? {
Reply::Login { stored_at } => Ok(Json(ClaudeLoginStatus { stored_at })),
other => Err(unexpected(&other)),
}
}
/// Delete an agent's stored claude login, every version of it.
#[utoipa::path(
delete,
path = "/api/agents/{name}/claude-login/stored",
params(("name" = String, Path, description = "agent whose stored login to delete")),
responses(
(status = 204, description = "nothing is stored 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 = 502, description = "the minter could not delete it (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_stored_claude_login(
State(state): State<AppState>,
Path(agent): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, 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::Delete {
agent: agent.clone(),
},
)
.await?
{
Reply::Deleted => {
tracing::info!(%agent, %operator, "claude credentials deleted for the agent");
Ok(StatusCode::NO_CONTENT)
}
other => Err(unexpected(&other)),
}
}
/// A refusal decided before the queue is touched. /// A refusal decided before the queue is touched.
type Refusal = (StatusCode, String); type Refusal = (StatusCode, String);
@ -282,6 +358,31 @@ mod tests {
assert_eq!(got["code"], "Abc_9.x-Q"); assert_eq!(got["code"], "Abc_9.x-Q");
} }
#[tokio::test]
async fn a_status_and_a_delete_reach_the_minter_by_their_names() {
let s = sink(Ok(Reply::Login { stored_at: None }));
for (request, step) in [
(
Request::Status {
agent: "atlas".to_owned(),
},
"status",
),
(
Request::Delete {
agent: "atlas".to_owned(),
},
"delete",
),
] {
assert!(relay(&s, &request).await.is_ok());
let got = s.got.lock().expect("lock").pop().expect("a step was sent");
let got: serde_json::Value = serde_json::from_slice(&got).expect("json");
assert_eq!(got["step"], step);
assert_eq!(got["agent"], "atlas");
}
}
#[tokio::test] #[tokio::test]
async fn a_failed_step_is_a_bad_gateway_naming_why() { async fn a_failed_step_is_a_bad_gateway_naming_why() {
let s = sink(Ok(Reply::Failed { let s = sink(Ok(Reply::Failed {

View file

@ -564,7 +564,8 @@ pub(crate) async fn start_forge(
} }
/// The routes that write, delete and list an agent's linked accounts and /// The routes that write, delete and list an agent's linked accounts and
/// backend environment, kept apart so `build_app` stays under the line limit. /// backend environment, and those of its claude login, kept apart so
/// `build_app` stays under the line limit.
fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppState> { fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppState> {
router router
.routes(routes!( .routes(routes!(
@ -588,6 +589,13 @@ fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppSt
linked_accounts::delete_backend_env linked_accounts::delete_backend_env
)) ))
.routes(routes!(linked_accounts::get_linked_accounts)) .routes(routes!(linked_accounts::get_linked_accounts))
.routes(routes!(
claude_login::get_claude_login,
claude_login::post_claude_login,
claude_login::delete_claude_login
))
.routes(routes!(claude_login::delete_stored_claude_login))
.routes(routes!(claude_login::post_claude_login_code))
} }
/// Every route this daemon serves, wired to its state. /// Every route this daemon serves, wired to its state.
@ -654,11 +662,6 @@ pub(crate) fn build_app(state: AppState) -> axum::Router {
.routes(routes!(agent_input::post_agents_broadcast)) .routes(routes!(agent_input::post_agents_broadcast))
.routes(routes!(agent_control::post_agent_cancel)) .routes(routes!(agent_control::post_agent_cancel))
.routes(routes!(agent_custom::post_agent_custom)) .routes(routes!(agent_custom::post_agent_custom))
.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::list_schedules, schedules::add_schedule))
.routes(routes!( .routes(routes!(
schedules::edit_schedule, schedules::edit_schedule,

View file

@ -262,6 +262,27 @@ impl SecretStore {
} }
} }
/// When the newest version at `path` was written, as the store spells the
/// time (RFC 3339), or `None` when nothing is stored there.
///
/// Reads the metadata only, so it needs `read` on `secret/metadata/<path>`
/// and never sees the value: the verb for a caller that may say a secret
/// exists and when it changed, but not read it.
///
/// **Only a 404 is "nothing stored"**, as in
/// [`read_optional`][Self::read_optional]: a denial stays an error.
///
/// # Errors
/// [`Error::Vault`] for anything that is not a 404: a denial, or an
/// unreachable store.
pub async fn updated_time(&self, path: &str) -> Result<Option<String>, Error> {
match vaultrs::kv2::read_metadata(&self.inner, MOUNT, path).await {
Ok(meta) => Ok(Some(meta.updated_time)),
Err(e) if is_absent(&e) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// The keys stored directly under `path`, or none when nothing is. /// The keys stored directly under `path`, or none when nothing is.
/// ///
/// A key ending in `/` is a directory below `path` rather than an object; /// A key ending in `/` is a directory below `path` rather than an object;

View file

@ -50,6 +50,10 @@ pub enum Request {
Code { agent: String, code: String }, Code { agent: String, code: String },
/// Stop the running login for `agent`, if any. /// Stop the running login for `agent`, if any.
Cancel { agent: String }, Cancel { agent: String },
/// Ask when `agent`'s stored login was written.
Status { agent: String },
/// Delete `agent`'s stored login, every version of it.
Delete { agent: String },
} }
/// The minter's answer to one [`Request`]. /// The minter's answer to one [`Request`].
@ -64,6 +68,11 @@ pub enum Reply {
Stored, Stored,
/// The running login, if there was one, is stopped. /// The running login, if there was one, is stopped.
Cancelled, Cancelled,
/// When the agent's stored login was written, as the store spells the
/// time (RFC 3339), or `None` when nothing is stored.
Login { stored_at: Option<String> },
/// The agent's stored login is gone, or was not there.
Deleted,
/// The step failed: why, with the last lines `claude` printed where it /// The step failed: why, with the last lines `claude` printed where it
/// printed any. /// printed any.
Failed { error: String }, Failed { error: String },
@ -98,6 +107,18 @@ mod tests {
}, },
json!({"step": "cancel", "agent": "atlas"}), json!({"step": "cancel", "agent": "atlas"}),
), ),
(
Request::Status {
agent: "atlas".to_owned(),
},
json!({"step": "status", "agent": "atlas"}),
),
(
Request::Delete {
agent: "atlas".to_owned(),
},
json!({"step": "delete", "agent": "atlas"}),
),
] { ] {
assert_eq!(serde_json::to_value(&req).expect("encodes"), wire); assert_eq!(serde_json::to_value(&req).expect("encodes"), wire);
assert!(serde_json::from_value::<Request>(wire).expect("decodes") == req); assert!(serde_json::from_value::<Request>(wire).expect("decodes") == req);
@ -115,6 +136,17 @@ mod tests {
), ),
(Reply::Stored, json!({"state": "stored"})), (Reply::Stored, json!({"state": "stored"})),
(Reply::Cancelled, json!({"state": "cancelled"})), (Reply::Cancelled, json!({"state": "cancelled"})),
(
Reply::Login {
stored_at: Some("2026-11-09T20:00:00Z".to_owned()),
},
json!({"state": "login", "stored_at": "2026-11-09T20:00:00Z"}),
),
(
Reply::Login { stored_at: None },
json!({"state": "login", "stored_at": null}),
),
(Reply::Deleted, json!({"state": "deleted"})),
( (
Reply::Failed { Reply::Failed {
error: "e".to_owned(), error: "e".to_owned(),