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:
parent
a0a3f9a70c
commit
7dd147adb4
15 changed files with 469 additions and 32 deletions
|
|
@ -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/<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
|
||||
|
||||
`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/<minter client id>/oidc/client` |
|
||||
| the agent, under its own certificate | `read` on its own `swarm/agents/<agent>/*`, 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/<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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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-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-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-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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
}
|
||||
112
frontend/packages/swarm-ui/src/agent/detail/AgentClaudeLogin.tsx
Normal file
112
frontend/packages/swarm-ui/src/agent/detail/AgentClaudeLogin.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }) {
|
|||
<AgentLimits key={agent} agent={agent} />
|
||||
</dd>
|
||||
<GrantRows key={agent} agent={agent} />
|
||||
<dt>claude login</dt>
|
||||
<dd>
|
||||
<AgentClaudeLogin key={agent} agent={agent} />
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
33
frontend/packages/swarm-ui/test/stored-login.test.mjs
Normal file
33
frontend/packages/swarm-ui/test/stored-login.test.mjs
Normal 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/);
|
||||
});
|
||||
|
|
@ -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.
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<AppState>) -> OpenApiRouter<AppState> {
|
||||
router
|
||||
.routes(routes!(
|
||||
|
|
@ -588,6 +589,13 @@ fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppSt
|
|||
linked_accounts::delete_backend_env
|
||||
))
|
||||
.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.
|
||||
|
|
@ -654,11 +662,6 @@ pub(crate) fn build_app(state: AppState) -> 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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
/// A key ending in `/` is a directory below `path` rather than an object;
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
/// 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::<Request>(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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue