From 7dd147adb4d9550e89f8379d3d99e448e5fa0c0a Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 9 Nov 2026 21:39:17 +0100 Subject: [PATCH] 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. --- docs/2-handbook/swarm/claude-login.md | 36 ++++-- .../2-handbook/swarm/credentials-reference.md | 2 +- docs/2-handbook/swarm/ui.md | 4 +- .../src/agent/claude-login/StoredLogin.ts | 31 +++++ .../src/agent/detail/AgentClaudeLogin.tsx | 112 ++++++++++++++++++ .../swarm-ui/src/agent/detail/AgentDetail.tsx | 5 + .../swarm-ui/test/stored-login.test.mjs | 33 ++++++ nix/host-modules/swarm-bao.nix | 13 +- nix/module-eval/bao-grants.nix | 16 ++- swarm-claude-login/src/main.rs | 73 +++++++++++- swarm-claude-login/src/metrics.rs | 7 +- swarm-controller/src/claude_login.rs | 101 ++++++++++++++++ swarm-controller/src/startup.rs | 15 ++- swarm-secret-client/src/client.rs | 21 ++++ swarm-wire/src/claude_login.rs | 32 +++++ 15 files changed, 469 insertions(+), 32 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/agent/claude-login/StoredLogin.ts create mode 100644 frontend/packages/swarm-ui/src/agent/detail/AgentClaudeLogin.tsx create mode 100644 frontend/packages/swarm-ui/test/stored-login.test.mjs diff --git a/docs/2-handbook/swarm/claude-login.md b/docs/2-handbook/swarm/claude-login.md index 74661cff2..f2b23d3ec 100644 --- a/docs/2-handbook/swarm/claude-login.md +++ b/docs/2-handbook/swarm/claude-login.md @@ -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 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//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//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 `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 -| Principal | Grant | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `swarm-claude-login`, in its container | `create`, `update` on `swarm/agents/+/claude-credentials`; `read` on `swarm/services//oidc/client` | -| the agent, under its own certificate | `read` on its own `swarm/agents//*`, which covers the leaf | +| Principal | Grant | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `swarm-claude-login`, in its container | `create`, `update` on `swarm/agents/+/claude-credentials`; `read`, `delete` on that leaf's metadata; `read` on `swarm/services//oidc/client` | +| the agent, under its own certificate | `read` on its own `swarm/agents//*`, which covers the leaf | -The minter has no read on the leaf 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 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 gauges (`bao.client_cert.*`) come with them. -| Metric | Unit | Kind | Attributes | -| ----------------------------------------- | ---- | --------- | --------------------------------------------------------------------------------------------------- | -| `hyperhive.claude_login.steps` | — | counter | `step`: `start`, `code`, `cancel`, `invalid`; `outcome`: `started`, `stored`, `cancelled`, `failed` | -| `hyperhive.claude_login.step.duration` | `s` | histogram | `step` | -| `hyperhive.claude_login.sessions.expired` | — | counter | — | -| `hyperhive.claude_login.sessions.active` | — | gauge | logins waiting for a code, as of the last step | +| Metric | Unit | Kind | Attributes | +| ----------------------------------------- | ---- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `hyperhive.claude_login.steps` | — | counter | `step`: `start`, `code`, `cancel`, `status`, `delete`, `invalid`; `outcome`: `started`, `stored`, `cancelled`, `login`, `deleted`, `failed` | +| `hyperhive.claude_login.step.duration` | `s` | histogram | `step` | +| `hyperhive.claude_login.sessions.expired` | — | counter | — | +| `hyperhive.claude_login.sessions.active` | — | gauge | logins waiting for a code, as of the last step | `invalid` is a request that didn't decode or named no agent. A `start` spawns `claude auth login` and a `code` stores the credentials, so those two are the diff --git a/docs/2-handbook/swarm/credentials-reference.md b/docs/2-handbook/swarm/credentials-reference.md index 282c3de3d..a34edaf29 100644 --- a/docs/2-handbook/swarm/credentials-reference.md +++ b/docs/2-handbook/swarm/credentials-reference.md @@ -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//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//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//oidc/client` | -| `swarm-claude-login`, in its container | `claudeLoginClientCertFile` / `claudeLoginClientKeyFile` | creates and updates `swarm/agents/+/claude-credentials`; reads `swarm/services//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//oidc/client` | | `swarm-notify-router`, in its container | `notifyRouterClientCertFile` / `notifyRouterClientKeyFile` | `swarm/services//oidc/client` | | `swarm-builder`, in its container | `builderClientCertFile` / `builderClientKeyFile` | `swarm/services/builder/forge/token`, `swarm/services/attic/push-token` and `swarm/services//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` | diff --git a/docs/2-handbook/swarm/ui.md b/docs/2-handbook/swarm/ui.md index 56b1c3b3a..48a7b8c04 100644 --- a/docs/2-handbook/swarm/ui.md +++ b/docs/2-handbook/swarm/ui.md @@ -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 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 -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 diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/StoredLogin.ts b/frontend/packages/swarm-ui/src/agent/claude-login/StoredLogin.ts new file mode 100644 index 000000000..57d858455 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/claude-login/StoredLogin.ts @@ -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.`; +} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentClaudeLogin.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentClaudeLogin.tsx new file mode 100644 index 000000000..e2c338b60 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentClaudeLogin.tsx @@ -0,0 +1,112 @@ +// — 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(null); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(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 ? ( + + ) : null; + if (login === null) { + return errorPanel ?? …; + } + const at = storedAtMs(login); + return ( + <> + {errorPanel} + {login.stored_at === null ? ( + none stored + ) : ( + <> + + stored {at === null ? login.stored_at : } + {" "} + { + setDeleteError(null); + setConfirming(true); + }} + /> + + )} + setConfirming(false)} + onConfirm={() => void confirmDelete()} + > +

{deleteMessage(agent)}

+ {deleteError ? ( + + ) : null} +
+ + ); +} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx index fdaafe0d4..f265b1648 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx @@ -17,6 +17,7 @@ import { ExternalLink } from "../../ui/external-link/ExternalLink.js"; import { Hint } from "../../ui/hint/Hint.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import type { AgentActions } from "../roster/AgentActions.js"; +import { AgentClaudeLogin } from "./AgentClaudeLogin.js"; import { AgentInference } from "./AgentInference.js"; import { AgentInstructions } from "./AgentInstructions.js"; import { AgentLimits } from "./AgentLimits.js"; @@ -186,6 +187,10 @@ function SettingsRows({ agent }: { agent: string }) { +
claude login
+
+ +
); } diff --git a/frontend/packages/swarm-ui/test/stored-login.test.mjs b/frontend/packages/swarm-ui/test/stored-login.test.mjs new file mode 100644 index 000000000..5a45f8d9b --- /dev/null +++ b/frontend/packages/swarm-ui/test/stored-login.test.mjs @@ -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/); +}); diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 0f184d285..04a053efc 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -1353,7 +1353,10 @@ 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. + # 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 = [ { name = "swarm-claude-login"; @@ -1362,6 +1365,9 @@ let path "${credentialMountPath}/data/swarm/agents/+/claude-credentials" { 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"; } @@ -2294,8 +2300,9 @@ in 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. + Its grant is create and update on `swarm/agents/+/claude-credentials`, + 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. ''; diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index c1b7b2957..1bbba9ae4 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -2098,9 +2098,10 @@ let } { # 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"; + # relogin replaces the last, reads and deletes the leaf's metadata (when it + # was written; every version removed), and reads its own queue client + # 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 = let s = baoGrantHere.systemd.services.swarm-bao-claude-login-policy.script; @@ -2110,8 +2111,13 @@ let "create" "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" ] - && lib.length grants == 2 + && lib.length grants == 3 && !(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 @@ -2147,7 +2153,7 @@ let in (grantFor grants "secret/data/swarm/services/swarm-notify-router/oidc/client").caps == [ "read" ] && lib.length grants == 1 - && lib.length minter == 2 + && lib.length minter == 3 && lib.hasInfix "auth/cert/certs/swarm-notify-router" s && lib.hasInfix "allowed_common_names=swarm-notify-router" s && lib.hasInfix "token_policies=swarm-notify-router" s; diff --git a/swarm-claude-login/src/main.rs b/swarm-claude-login/src/main.rs index 67cbc265a..3859915a3 100644 --- a/swarm-claude-login/src/main.rs +++ b/swarm-claude-login/src/main.rs @@ -36,6 +36,53 @@ const PREFIX: &str = "SWARM_CLAUDE_LOGIN"; /// The agent's store leaf, written under this service's certificate. 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> { + 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 { async fn put(&self, agent: &str, credentials: &Credentials) -> Result<()> { 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 - // a store path, and this service holds write on every agent's leaf. - let (Request::Start { agent, .. } | Request::Code { agent, .. } | Request::Cancel { agent }) = - &request; + // 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 } + | Request::Status { agent } + | Request::Delete { agent }) = &request; if let Err(reason) = hive_types::Ident::parse(agent) { tracing::warn!(agent = ?agent, reason, "a login step for an invalid agent name; refused"); let error = format!("{agent:?} is not an agent name: {reason}"); @@ -176,11 +226,22 @@ async fn run_step( let reply = logins.cancel(&agent).await; ("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 { 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::Login { .. } => {} + Reply::Deleted => tracing::info!(%agent, "claude login deleted"), Reply::Failed { error } => tracing::warn!(%agent, %error, "claude login step failed"), } (step, reply) @@ -242,6 +303,12 @@ mod tests { Request::Cancel { 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 reply = handle(&payload, &logins, &Arc::new(Metrics::noop())).await; diff --git a/swarm-claude-login/src/metrics.rs b/swarm-claude-login/src/metrics.rs index 024a4b4b3..853fba118 100644 --- a/swarm-claude-login/src/metrics.rs +++ b/swarm-claude-login/src/metrics.rs @@ -2,8 +2,9 @@ //! collector. //! //! - `hyperhive.claude_login.steps`: login steps answered, by `step` (`start`, -//! `code`, `cancel`, `invalid` for a request that did not decode or named no -//! agent) and `outcome` (`started`, `stored`, `cancelled`, `failed`). +//! `code`, `cancel`, `status`, `delete`, `invalid` for a request that did not +//! decode or named no agent) and `outcome` (`started`, `stored`, `cancelled`, +//! `login`, `deleted`, `failed`). //! - `hyperhive.claude_login.step.duration`: seconds to answer one step, by //! `step`. A `start` spawns `claude auth login` and a `code` stores the //! credentials, so these are the slow ones. @@ -87,6 +88,8 @@ impl Metrics { Reply::Started { .. } => "started", Reply::Stored => "stored", Reply::Cancelled => "cancelled", + Reply::Login { .. } => "login", + Reply::Deleted => "deleted", Reply::Failed { .. } => "failed", }; self.steps.add( diff --git a/swarm-controller/src/claude_login.rs b/swarm-controller/src/claude_login.rs index dfd98f4ca..89a476acf 100644 --- a/swarm-controller/src/claude_login.rs +++ b/swarm-controller/src/claude_login.rs @@ -9,6 +9,9 @@ //! installs them. //! - `DELETE /api/agents/{name}/claude-login` stops a login started and not //! 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 //! [`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, +} + +/// 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, + Path(agent): Path, + headers: HeaderMap, +) -> Result, 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, + Path(agent): Path, + headers: HeaderMap, +) -> Result { + 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. type Refusal = (StatusCode, String); @@ -282,6 +358,31 @@ mod tests { 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] async fn a_failed_step_is_a_bad_gateway_naming_why() { let s = sink(Ok(Reply::Failed { diff --git a/swarm-controller/src/startup.rs b/swarm-controller/src/startup.rs index ae4f53ae6..574cceae1 100644 --- a/swarm-controller/src/startup.rs +++ b/swarm-controller/src/startup.rs @@ -564,7 +564,8 @@ pub(crate) async fn start_forge( } /// 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) -> OpenApiRouter { router .routes(routes!( @@ -588,6 +589,13 @@ fn linked_account_routes(router: OpenApiRouter) -> OpenApiRouter axum::Router { .routes(routes!(agent_input::post_agents_broadcast)) .routes(routes!(agent_control::post_agent_cancel)) .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::edit_schedule, diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index f1be2e8fa..23ab2899b 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -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/` + /// 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, 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. /// /// A key ending in `/` is a directory below `path` rather than an object; diff --git a/swarm-wire/src/claude_login.rs b/swarm-wire/src/claude_login.rs index bac6d6cea..a0005e2a5 100644 --- a/swarm-wire/src/claude_login.rs +++ b/swarm-wire/src/claude_login.rs @@ -50,6 +50,10 @@ pub enum Request { Code { agent: String, code: String }, /// Stop the running login for `agent`, if any. 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`]. @@ -64,6 +68,11 @@ pub enum Reply { Stored, /// The running login, if there was one, is stopped. 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 }, + /// The agent's stored login is gone, or was not there. + Deleted, /// The step failed: why, with the last lines `claude` printed where it /// printed any. Failed { error: String }, @@ -98,6 +107,18 @@ mod tests { }, 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!(serde_json::from_value::(wire).expect("decodes") == req); @@ -115,6 +136,17 @@ mod tests { ), (Reply::Stored, json!({"state": "stored"})), (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 { error: "e".to_owned(),