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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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,
|
# 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.
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue