swarm-ui: set the review classifier's keys from the classifier settings
The classifier's Anthropic and ACP provider keys were written by hand in the
secret store's UI. The controller now stores them: GET
/api/review-classifier/keys says whether each is set and when it changed, and
PUT /api/review-classifier/keys/{runtime} writes one. No route answers a key.
The controller's store policy gains create and update on the two leaves with
no read of their values, and a metadata read for the set state. The swarm UI's
classifier settings card gets a key form beside the settings, saved apart
because it goes to the secret store.
This commit is contained in:
parent
c5d601e3a2
commit
6875a4f0f8
16 changed files with 628 additions and 20 deletions
|
|
@ -350,8 +350,11 @@ identity: the host that runs the store has it, and a services host with the
|
||||||
store elsewhere gets the classifier only once it holds a copy of the pair (see
|
store elsewhere gets the classifier only once it holds a copy of the pair (see
|
||||||
below). Set `deploy.review-classifier.enable` directly to override either way.
|
below). Set `deploy.review-classifier.enable` directly to override either way.
|
||||||
|
|
||||||
The operator writes the key of the runtime the swarm settings choose by hand,
|
The operator sets the key of the runtime the swarm settings choose in the
|
||||||
in the secret store's UI under the OIDC login, field `api_key`:
|
swarm UI: the **classifier settings** card on `/jobs` takes a key for the
|
||||||
|
chosen runtime and shows whether one exists and when it changed, never the
|
||||||
|
key. You can also write the same two keys in the secret store's UI under the
|
||||||
|
OIDC login, field `api_key`:
|
||||||
|
|
||||||
- the API key runtime's Anthropic key at
|
- the API key runtime's Anthropic key at
|
||||||
`secret/swarm/services/review-classifier/anthropic-api-key`
|
`secret/swarm/services/review-classifier/anthropic-api-key`
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ place, and every one reads from [`swarm-controller`](../../../swarm-controller/R
|
||||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
||||||
| `/reports` | three folded cards, each read only once opened: the **alerts** the swarm's Grafana holds now, the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token, and a cross-repo **issues** list, and a cross-repo **pull requests** list |
|
| `/reports` | three folded cards, each read only once opened: the **alerts** the swarm's Grafana holds now, the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token, and a cross-repo **issues** list, and a cross-repo **pull requests** list |
|
||||||
| `/jobs` | the controller's job graph, where agent creation and credential mints show progress, and under it the swarm builder's recent builds (agent config PRs, and agents built from `internal/meta` before a deploy), with what nix printed when one failed, and the review classifier's recent runs, a try-out on a real PR, and its settings |
|
| `/jobs` | the controller's job graph, where agent creation and credential mints show progress, and under it the swarm builder's recent builds (agent config PRs, and agents built from `internal/meta` before a deploy), with what nix printed when one failed, and the review classifier's recent runs, a try-out on a real PR, and its settings and keys |
|
||||||
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to more than one agent at once, and a folded **schedules** card holds the scheduled prompts: add, edit, pause, resume and remove |
|
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to more than one agent at once, and a folded **schedules** card holds the scheduled prompts: add, edit, pause, resume and remove |
|
||||||
|
|
||||||
An agent created here or with `swarmctl agent create` starts `paused`; set it
|
An agent created here or with `swarmctl agent create` starts `paused`; set it
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,8 @@ leaves under `secret/data/swarm/services/attic/` (`push-token`, `pull-token`,
|
||||||
`url`, `cache` and `public-key`). That shows the key tree and each key's
|
`url`, `cache` and `public-key`). That shows the key tree and each key's
|
||||||
versions and timestamps, and no value: the store refuses every `secret/data/`
|
versions and timestamps, and no value: the store refuses every `secret/data/`
|
||||||
read. The writes are the leaves the operator fills in by hand: the review
|
read. The writes are the leaves the operator fills in by hand: the review
|
||||||
classifier's keys, and the cache's tokens and facts, which configure an external
|
classifier's keys (which `swarm-controller` also writes for the swarm UI, with
|
||||||
|
`create` and `update` and no `read` of the value), and the cache's tokens and facts, which configure an external
|
||||||
cache or replace the in-swarm cache's own. The cache's host units create those
|
cache or replace the in-swarm cache's own. The cache's host units create those
|
||||||
leaves only while the store holds none, so a saved value survives their next run
|
leaves only while the store holds none, so a saved value survives their next run
|
||||||
and every deploy. The cache's signing key, `attic/jwt-secret`, isn't writable here.
|
and every deploy. The cache's signing key, `attic/jwt-secret`, isn't writable here.
|
||||||
|
|
|
||||||
|
|
@ -284,8 +284,33 @@ does. Every error is problem+json with the reason in `detail`:
|
||||||
- **500** when the controller can't read or write the settings
|
- **500** when the controller can't read or write the settings
|
||||||
|
|
||||||
When the controller can't read the settings, a try-out answers the same 503
|
When the controller can't read the settings, a try-out answers the same 503
|
||||||
or 500, and a review posts a no-verdict comment. The operator writes each
|
or 500, and a review posts a no-verdict comment. The keys aren't in this
|
||||||
runtime's key by hand in the secret store
|
document; see [The keys](#the-keys).
|
||||||
|
|
||||||
|
### The keys
|
||||||
|
|
||||||
|
`GET /api/review-classifier/keys` answers whether each runtime has a key:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_key": { "set": true, "updated": "2026-11-11T10:00:00Z" },
|
||||||
|
"acp": { "set": false, "updated": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PUT /api/review-classifier/keys/{runtime}` (`api-key` or `acp`) takes
|
||||||
|
`{"api_key": "…"}`, stores it at that runtime's leaf and answers the same
|
||||||
|
document. No route answers a key. The controller's store policy holds `create`
|
||||||
|
and `update` on the two leaves with no `read` of their values, and `read` on
|
||||||
|
their metadata, which is where `set` and `updated` come from. The route trims a
|
||||||
|
key and refuses one that's empty, over 4096 bytes or holds whitespace. The
|
||||||
|
PUT needs the `Remote-User` header, and logs the operator and the runtime, never
|
||||||
|
the key. Every error is problem+json: **400** for a body that isn't this shape,
|
||||||
|
a runtime that isn't one of the two or a refused key, **401** for no
|
||||||
|
`Remote-User`, **503** when the store is unreachable or refuses.
|
||||||
|
|
||||||
|
The classifier reads the leaf on every run, so a new key applies from the next
|
||||||
|
run. The operator can still write the leaves in the secret store's UI
|
||||||
([`services.md`](../../2-handbook/swarm/services.md#review-classifier)).
|
([`services.md`](../../2-handbook/swarm/services.md#review-classifier)).
|
||||||
|
|
||||||
The per-agent equivalent is [`inference.md`](inference.md), where the
|
The per-agent equivalent is [`inference.md`](inference.md), where the
|
||||||
|
|
@ -479,7 +504,8 @@ ACP provider, so each runtime has a model field of its own.
|
||||||
|
|
||||||
To switch to ACP, an admin:
|
To switch to ACP, an admin:
|
||||||
|
|
||||||
1. writes the provider key in the secret store's UI under the OIDC login, at
|
1. stores the provider key, in the swarm UI's classifier settings or in the
|
||||||
|
secret store's UI under the OIDC login, at
|
||||||
`secret/swarm/services/review-classifier/acp-provider`, field `api_key`
|
`secret/swarm/services/review-classifier/acp-provider`, field `api_key`
|
||||||
2. PUTs the runtime and the provider:
|
2. PUTs the runtime and the provider:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -327,8 +327,15 @@ limit gets a no-verdict review, and a try-out with more gets a 400.
|
||||||
the runtime, model, effort and limits, and the provider too while the runtime is
|
the runtime, model, effort and limits, and the provider too while the runtime is
|
||||||
ACP, so switching back to API key keeps the stored provider for next time. If
|
ACP, so switching back to API key keeps the stored provider for next time. If
|
||||||
the controller refuses it, the page shows the reason and leaves the form as you
|
the controller refuses it, the page shows the reason and leaves the form as you
|
||||||
typed it. A change takes effect from the next run, with no restart. The page
|
typed it. A change takes effect from the next run, with no restart.
|
||||||
has no API key field: admins set each runtime's key in the bao UI.
|
|
||||||
|
Under the settings form sits a **key** form for the runtime the form chooses.
|
||||||
|
Its label says whether that runtime has a key and when it last changed
|
||||||
|
(`GET /api/review-classifier/keys`, which never answers a key). The field is a
|
||||||
|
password input; **save key** sends it alone with `PUT
|
||||||
|
/api/review-classifier/keys/{runtime}`, apart from the settings, and clears the
|
||||||
|
field once the controller has stored it. Switching the runtime drops a key you
|
||||||
|
typed for the other one.
|
||||||
|
|
||||||
### Scheduled prompts
|
### Scheduled prompts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
// <ClassifierKey> — the key the chosen runtime runs on, set from here instead
|
||||||
|
// of in the bao UI (`GET /api/review-classifier/keys`, `PUT .../keys/{runtime}`).
|
||||||
|
// The page can say whether a key is stored and when it changed, never what it
|
||||||
|
// is: the field takes a new key and is cleared once it's saved. A key is
|
||||||
|
// saved apart from the settings, because it goes to the secret store and not
|
||||||
|
// into the settings document.
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
|
import { ActionRow } from "../../ui/action-row/ActionRow.js";
|
||||||
|
import { Button } from "../../ui/button/Button.js";
|
||||||
|
import { Hint } from "../../ui/hint/Hint.js";
|
||||||
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
|
import { keyOf, keyProblem, keyStatus, type KeysBody } from "./KeyTypes.js";
|
||||||
|
import type { Runtime } from "./SettingsTypes.js";
|
||||||
|
|
||||||
|
const KEYS = "/api/review-classifier/keys";
|
||||||
|
|
||||||
|
export function ClassifierKey({ runtime }: { runtime: Runtime }) {
|
||||||
|
const [keys, setKeys] = useState<KeysBody | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
||||||
|
// The key as typed. Never kept after a save.
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(KEYS);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.ok) {
|
||||||
|
setLoadError(await readApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as KeysBody;
|
||||||
|
if (!cancelled) setKeys(body);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!cancelled) setLoadError({ detail: String(e) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A key typed for one runtime must not be saved to the other.
|
||||||
|
useEffect(() => {
|
||||||
|
setText("");
|
||||||
|
setSaveError(null);
|
||||||
|
setJustSaved(false);
|
||||||
|
}, [runtime]);
|
||||||
|
|
||||||
|
const problem = keyProblem(text);
|
||||||
|
|
||||||
|
async function save(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (problem !== null) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setJustSaved(false);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${KEYS}/${runtime}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ api_key: text.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setSaveError(await readApiError(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setKeys((await res.json()) as KeysBody);
|
||||||
|
setText("");
|
||||||
|
setJustSaved(true);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setSaveError({ detail: String(err) });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = runtime === "acp" ? "provider key" : "Anthropic API key";
|
||||||
|
return (
|
||||||
|
<form class="ui-classifier-settings" onSubmit={(e) => void save(e)}>
|
||||||
|
{loadError ? (
|
||||||
|
<ApiErrorPanel
|
||||||
|
context="the key could not be read"
|
||||||
|
problem={loadError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TextField
|
||||||
|
id="classifier-key"
|
||||||
|
label={`${label}${keys ? ` (${keyStatus(keyOf(keys, runtime))})` : ""}`}
|
||||||
|
type="password"
|
||||||
|
value={text}
|
||||||
|
placeholder={
|
||||||
|
keys && keyOf(keys, runtime).set
|
||||||
|
? "paste a new key to replace it"
|
||||||
|
: "paste the key"
|
||||||
|
}
|
||||||
|
onInput={(t) => {
|
||||||
|
setText(t);
|
||||||
|
setJustSaved(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{saveError ? (
|
||||||
|
<ApiErrorPanel context="the key was not saved" problem={saveError} />
|
||||||
|
) : null}
|
||||||
|
<ActionRow>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || problem !== null}
|
||||||
|
>
|
||||||
|
{saving ? "saving…" : "save key"}
|
||||||
|
</Button>
|
||||||
|
{justSaved ? (
|
||||||
|
<Hint as="span" role="status">
|
||||||
|
Saved. The next run uses it.
|
||||||
|
</Hint>
|
||||||
|
) : null}
|
||||||
|
</ActionRow>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
// fields it carries: the form sends the runtime, model, effort and limit, and
|
// fields it carries: the form sends the runtime, model, effort and limit, and
|
||||||
// the ACP provider only while the runtime is ACP, so the stored provider
|
// the ACP provider only while the runtime is ACP, so the stored provider
|
||||||
// survives a switch back. A refused save shows the route's own reason and
|
// survives a switch back. A refused save shows the route's own reason and
|
||||||
// leaves the form as typed. There is no API key field: admins set each
|
// leaves the form as typed. The runtime's key has a form of its own below
|
||||||
// runtime's key in the bao UI.
|
// (<ClassifierKey>), saved apart because it goes to the secret store.
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
|
|
@ -18,6 +18,7 @@ import { Disclosure } from "../../ui/disclosure/Disclosure.js";
|
||||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||||
import { SelectField } from "../../ui/select-field/SelectField.js";
|
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
|
import { ClassifierKey } from "./ClassifierKey.js";
|
||||||
import {
|
import {
|
||||||
EFFORTS,
|
EFFORTS,
|
||||||
MAX_REVIEWS,
|
MAX_REVIEWS,
|
||||||
|
|
@ -137,8 +138,9 @@ export function ClassifierSettings() {
|
||||||
many requested reviews run at once. The API key runtime runs claude on
|
many requested reviews run at once. The API key runtime runs claude on
|
||||||
the Anthropic key; the ACP runtime runs opencode on the provider given
|
the Anthropic key; the ACP runtime runs opencode on the provider given
|
||||||
here. A change takes effect from the next run, with no restart. Try-outs
|
here. A change takes effect from the next run, with no restart. Try-outs
|
||||||
don't count toward the limit. Each runtime's key is set in the bao UI,
|
don't count toward the limit. The key of the chosen runtime is saved
|
||||||
not here.
|
below, apart from the settings. The page shows whether a key is stored,
|
||||||
|
never the key.
|
||||||
</p>
|
</p>
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<ApiErrorPanel
|
<ApiErrorPanel
|
||||||
|
|
@ -274,6 +276,7 @@ export function ClassifierSettings() {
|
||||||
{problem !== null ? <Hint>{problem}</Hint> : null}
|
{problem !== null ? <Hint>{problem}</Hint> : null}
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
|
{draft ? <ClassifierKey runtime={draft.runtime} /> : null}
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Wire shape of the review classifier's keys
|
||||||
|
// (`GET /api/review-classifier/keys`, `PUT .../keys/{runtime}`), and the
|
||||||
|
// checks the key form makes before a save is worth sending. The routes never
|
||||||
|
// answer a key, only whether one is stored; the backend stays the real gate.
|
||||||
|
|
||||||
|
import type { Runtime } from "./SettingsTypes.js";
|
||||||
|
|
||||||
|
/** Whether one runtime's key is stored. */
|
||||||
|
export interface KeyState {
|
||||||
|
set: boolean;
|
||||||
|
/** When it last changed (RFC 3339), or `null` when none is stored. */
|
||||||
|
updated: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both keys' state, as both routes answer it. */
|
||||||
|
export interface KeysBody {
|
||||||
|
api_key: KeyState;
|
||||||
|
acp: KeyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most bytes a key may have, as the route takes it. */
|
||||||
|
export const MAX_KEY_BYTES = 4096;
|
||||||
|
|
||||||
|
/** The state of the key `runtime` runs on. */
|
||||||
|
export function keyOf(keys: KeysBody, runtime: Runtime): KeyState {
|
||||||
|
return runtime === "acp" ? keys.acp : keys.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why `text` is not ready to save as a key, or null when it is. A pasted
|
||||||
|
* trailing newline is fine: the route drops it. */
|
||||||
|
export function keyProblem(text: string): string | null {
|
||||||
|
const key = text.trim();
|
||||||
|
if (key === "") return "paste the key";
|
||||||
|
if (/\s/.test(key)) return "a key has no whitespace in it";
|
||||||
|
if (new TextEncoder().encode(key).length > MAX_KEY_BYTES) {
|
||||||
|
return `a key is at most ${MAX_KEY_BYTES} bytes`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line on whether the key is stored, for the key form. */
|
||||||
|
export function keyStatus(state: KeyState): string {
|
||||||
|
if (!state.set) return "not set";
|
||||||
|
return state.updated ? `set, last changed ${state.updated}` : "set";
|
||||||
|
}
|
||||||
47
frontend/packages/swarm-ui/test/classifier-key.test.mjs
Normal file
47
frontend/packages/swarm-ui/test/classifier-key.test.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// The classifier key form's pure logic: which key a runtime means, the checks
|
||||||
|
// before a save, and the line saying whether a key is stored. 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 {
|
||||||
|
MAX_KEY_BYTES,
|
||||||
|
keyOf,
|
||||||
|
keyProblem,
|
||||||
|
keyStatus,
|
||||||
|
} from "../src/pages/review-settings/KeyTypes.ts";
|
||||||
|
|
||||||
|
const keys = {
|
||||||
|
api_key: { set: true, updated: "2026-11-11T10:00:00Z" },
|
||||||
|
acp: { set: false, updated: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
test("a runtime means its own key", () => {
|
||||||
|
assert.equal(keyOf(keys, "api-key"), keys.api_key);
|
||||||
|
assert.equal(keyOf(keys, "acp"), keys.acp);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty key or one with whitespace is not ready to save", () => {
|
||||||
|
assert.notEqual(keyProblem(""), null);
|
||||||
|
assert.notEqual(keyProblem(" "), null);
|
||||||
|
assert.notEqual(keyProblem("two words"), null);
|
||||||
|
assert.equal(keyProblem("sk-ant-abc"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pasted trailing newline is fine", () => {
|
||||||
|
assert.equal(keyProblem("sk-ant-abc\n"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a key over the limit is not ready to save", () => {
|
||||||
|
assert.equal(keyProblem("k".repeat(MAX_KEY_BYTES)), null);
|
||||||
|
assert.notEqual(keyProblem("k".repeat(MAX_KEY_BYTES + 1)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the status says whether a key is stored and when it changed", () => {
|
||||||
|
assert.equal(keyStatus(keys.acp), "not set");
|
||||||
|
assert.equal(
|
||||||
|
keyStatus(keys.api_key),
|
||||||
|
"set, last changed 2026-11-11T10:00:00Z",
|
||||||
|
);
|
||||||
|
assert.equal(keyStatus({ set: true, updated: null }), "set");
|
||||||
|
});
|
||||||
|
|
@ -638,7 +638,7 @@ in
|
||||||
description = ''
|
description = ''
|
||||||
Run the review classifier in a `swarm-review-classifier` container on
|
Run the review classifier in a `swarm-review-classifier` container on
|
||||||
this host. It answers the swarm UI's review try-outs with `claude` on
|
this host. It answers the swarm UI's review try-outs with `claude` on
|
||||||
the Anthropic API key the operator writes to the secret store at
|
the Anthropic API key the operator sets in the swarm UI, or writes to the secret store at
|
||||||
`secret/swarm/services/review-classifier/anthropic-api-key`, field
|
`secret/swarm/services/review-classifier/anthropic-api-key`, field
|
||||||
`api_key`.
|
`api_key`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -562,6 +562,22 @@ let
|
||||||
capabilities = ["create", "read", "update"]
|
capabilities = ["create", "read", "update"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path "${credentialMountPath}/data/${classifierApiKeyLeaf}" {
|
||||||
|
capabilities = ["create", "update"]
|
||||||
|
}
|
||||||
|
|
||||||
|
path "${credentialMountPath}/metadata/${classifierApiKeyLeaf}" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
|
||||||
|
path "${credentialMountPath}/data/${classifierAcpKeyLeaf}" {
|
||||||
|
capabilities = ["create", "update"]
|
||||||
|
}
|
||||||
|
|
||||||
|
path "${credentialMountPath}/metadata/${classifierAcpKeyLeaf}" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
|
||||||
path "${credentialMountPath}/data/${builderForgeTokenLeaf}" {
|
path "${credentialMountPath}/data/${builderForgeTokenLeaf}" {
|
||||||
capabilities = ["create", "read", "update"]
|
capabilities = ["create", "read", "update"]
|
||||||
}
|
}
|
||||||
|
|
@ -775,7 +791,10 @@ let
|
||||||
classifierForgeTokenLeaf = "swarm/services/review-classifier/forge/token";
|
classifierForgeTokenLeaf = "swarm/services/review-classifier/forge/token";
|
||||||
|
|
||||||
# The review classifier's Anthropic API key. The operator writes it through
|
# The review classifier's Anthropic API key. The operator writes it through
|
||||||
# the UI's OIDC login; the classifier reads it on every run.
|
# the UI's OIDC login, or the swarm UI's classifier settings, which the
|
||||||
|
# controller writes for them: `create` and `update` with no `read`, and a
|
||||||
|
# metadata `read` for whether one is set and when it changed. The classifier
|
||||||
|
# reads it on every run.
|
||||||
classifierApiKeyLeaf = "swarm/services/review-classifier/anthropic-api-key";
|
classifierApiKeyLeaf = "swarm/services/review-classifier/anthropic-api-key";
|
||||||
|
|
||||||
# The swarm builder's forge token, the nix half of
|
# The swarm builder's forge token, the nix half of
|
||||||
|
|
|
||||||
|
|
@ -1934,15 +1934,18 @@ let
|
||||||
# `forge::classifier_account` mints the review classifier's forge token
|
# `forge::classifier_account` mints the review classifier's forge token
|
||||||
# and reads it back to decide on a re-mint and to post reviews. Pinned
|
# and reads it back to decide on a re-mint and to post reviews. Pinned
|
||||||
# as the whole stanza, and spelled to the leaf, for the reason above.
|
# as the whole stanza, and spelled to the leaf, for the reason above.
|
||||||
name = "the controller's grant on the review classifier is its forge token alone";
|
name = "the controller's grant on the review classifier is its forge token, and a write-only inference key each";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
|
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
|
||||||
|
writeOnly = leaf: [
|
||||||
|
"path \"secret/data/swarm/services/review-classifier/${leaf}\" {\n capabilities = [\"create\", \"update\"]\n}"
|
||||||
|
"path \"secret/metadata/swarm/services/review-classifier/${leaf}\" {\n capabilities = [\"read\"]\n}"
|
||||||
|
];
|
||||||
in
|
in
|
||||||
lib.hasInfix "path \"secret/data/swarm/services/review-classifier/forge/token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s
|
lib.hasInfix "path \"secret/data/swarm/services/review-classifier/forge/token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s
|
||||||
&& !(lib.hasInfix "secret/data/swarm/services/review-classifier/*" s)
|
&& lib.all (p: lib.hasInfix p s) (writeOnly "anthropic-api-key" ++ writeOnly "acp-provider")
|
||||||
&& !(lib.hasInfix "secret/data/swarm/services/review-classifier/anthropic-api-key" s)
|
&& !(lib.hasInfix "secret/data/swarm/services/review-classifier/*" s);
|
||||||
&& !(lib.hasInfix "secret/data/swarm/services/review-classifier/acp-provider" s);
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# The runner's role is the token's only reader. Its policy is that leaf
|
# The runner's role is the token's only reader. Its policy is that leaf
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ use self::settings::{Settings, SettingsSource};
|
||||||
use crate::forge::{self, DefaultBranchFiles, PullForReview};
|
use crate::forge::{self, DefaultBranchFiles, PullForReview};
|
||||||
use crate::routes::{AppState, error_problem};
|
use crate::routes::{AppState, error_problem};
|
||||||
|
|
||||||
|
pub(crate) mod keys;
|
||||||
pub(crate) mod metrics;
|
pub(crate) mod metrics;
|
||||||
pub(crate) mod open_pr;
|
pub(crate) mod open_pr;
|
||||||
pub(crate) mod review;
|
pub(crate) mod review;
|
||||||
|
|
|
||||||
323
swarm-controller/src/review_classifier/keys.rs
Normal file
323
swarm-controller/src/review_classifier/keys.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
//! `GET /api/review-classifier/keys` and `PUT /api/review-classifier/keys/{runtime}`:
|
||||||
|
//! the keys the classifier's two runtimes run on, written from the swarm UI
|
||||||
|
//! instead of by hand in the secret store's UI.
|
||||||
|
//!
|
||||||
|
//! The controller only writes. Its store policy holds `create` and `update`
|
||||||
|
//! on the two leaves and no `read` of their values, so no route here answers a
|
||||||
|
//! key, and the one metadata read tells whether a key is set and when it last
|
||||||
|
//! changed. The classifier reads the key from the same leaf on every run, so
|
||||||
|
//! a new key applies from the next run with no restart.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::rejection::JsonRejection;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use problem_details::ProblemDetails;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use swarm_secret_client::{
|
||||||
|
SecretStore,
|
||||||
|
acp::ProviderKey,
|
||||||
|
review_classifier::{ApiKey, acp_provider_key_path, api_key_path},
|
||||||
|
};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
use crate::routes::{AppState, error_problem};
|
||||||
|
|
||||||
|
/// The most bytes a key may have. Provider keys run to a few hundred.
|
||||||
|
const MAX_KEY_BYTES: usize = 4096;
|
||||||
|
|
||||||
|
/// Which runtime a key is for, as the path spells it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Which {
|
||||||
|
/// The Anthropic API key the `api-key` runtime runs `claude` with.
|
||||||
|
ApiKey,
|
||||||
|
/// The provider key the `acp` runtime runs its agent with.
|
||||||
|
Acp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Which {
|
||||||
|
fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"api-key" => Some(Self::ApiKey),
|
||||||
|
"acp" => Some(Self::Acp),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(self) -> Result<String, swarm_secret_client::Error> {
|
||||||
|
match self {
|
||||||
|
Self::ApiKey => api_key_path(),
|
||||||
|
Self::Acp => acp_provider_key_path(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether one key is stored. Never the key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||||
|
pub(crate) struct KeyState {
|
||||||
|
/// A key is stored.
|
||||||
|
set: bool,
|
||||||
|
/// When the key last changed, as the store spells it (RFC 3339), or
|
||||||
|
/// `null` when none is stored.
|
||||||
|
updated: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both keys' state, what both routes answer.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
|
||||||
|
pub(crate) struct Keys {
|
||||||
|
/// The Anthropic API key of the `api-key` runtime.
|
||||||
|
api_key: KeyState,
|
||||||
|
/// The provider key of the `acp` runtime.
|
||||||
|
acp: KeyState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body of a `PUT`: the key.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct KeyBody {
|
||||||
|
/// The key. Surrounding whitespace is dropped, so a pasted trailing
|
||||||
|
/// newline is harmless.
|
||||||
|
api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two store calls a route makes, so a test can stand in for the store.
|
||||||
|
trait KeyStore {
|
||||||
|
/// Store `key` at `path`, replacing what's there.
|
||||||
|
fn put(&self, which: Which, key: &str) -> impl Future<Output = Result<(), String>> + Send;
|
||||||
|
|
||||||
|
/// When the leaf last changed, or `None` when nothing is stored.
|
||||||
|
fn updated(&self, which: Which) -> impl Future<Output = Result<Option<String>, String>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyStore for SecretStore {
|
||||||
|
async fn put(&self, which: Which, key: &str) -> Result<(), String> {
|
||||||
|
let path = which.path().map_err(|e| e.to_string())?;
|
||||||
|
let result = match which {
|
||||||
|
Which::ApiKey => {
|
||||||
|
self.write(
|
||||||
|
&path,
|
||||||
|
&ApiKey {
|
||||||
|
api_key: key.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Which::Acp => {
|
||||||
|
self.write(
|
||||||
|
&path,
|
||||||
|
&ProviderKey {
|
||||||
|
api_key: key.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
result.map_err(|e| format!("writing {path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn updated(&self, which: Which) -> Result<Option<String>, String> {
|
||||||
|
let path = which.path().map_err(|e| e.to_string())?;
|
||||||
|
self.updated_time(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("reading {path}'s metadata: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key to store, or why it's refused.
|
||||||
|
fn check(key: &str) -> Result<&str, String> {
|
||||||
|
let key = key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("the key is empty".to_owned());
|
||||||
|
}
|
||||||
|
if key.len() > MAX_KEY_BYTES {
|
||||||
|
return Err(format!("the key is over {MAX_KEY_BYTES} bytes"));
|
||||||
|
}
|
||||||
|
if key.chars().any(|c| c.is_whitespace() || c.is_control()) {
|
||||||
|
return Err("the key holds whitespace or a control character".to_owned());
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn state_of(store: &impl KeyStore, which: Which) -> Result<KeyState, String> {
|
||||||
|
let updated = store.updated(which).await?;
|
||||||
|
Ok(KeyState {
|
||||||
|
set: updated.is_some(),
|
||||||
|
updated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn both(store: &impl KeyStore) -> Result<Keys, String> {
|
||||||
|
Ok(Keys {
|
||||||
|
api_key: state_of(store, Which::ApiKey).await?,
|
||||||
|
acp: state_of(store, Which::Acp).await?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_problem(why: &str) -> ProblemDetails {
|
||||||
|
tracing::warn!(error = %why, "review classifier keys: store failure");
|
||||||
|
error_problem(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
&format!("the secret store refused: {why}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect() -> Result<SecretStore, ProblemDetails> {
|
||||||
|
crate::store::connect()
|
||||||
|
.await
|
||||||
|
.map_err(|e| store_problem(&format!("logging in: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether each runtime's key is stored.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/review-classifier/keys",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "whether each key is stored and when it last changed; \
|
||||||
|
never a key", body = Keys),
|
||||||
|
(status = 503, description = "the secret store couldn't be reached, or refused \
|
||||||
|
(problem+json)", body = String),
|
||||||
|
),
|
||||||
|
tag = "review-classifier"
|
||||||
|
)]
|
||||||
|
pub(crate) async fn get_keys(State(_state): State<AppState>) -> Result<Json<Keys>, ProblemDetails> {
|
||||||
|
let store = connect().await?;
|
||||||
|
both(&store).await.map(Json).map_err(|e| store_problem(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a runtime's key.
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/api/review-classifier/keys/{runtime}",
|
||||||
|
params(("runtime" = String, Path, description = "`api-key` or `acp`")),
|
||||||
|
request_body = KeyBody,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "both keys' state after the write; never a key",
|
||||||
|
body = Keys),
|
||||||
|
(status = 400, description = "not JSON or not this shape, a runtime other than \
|
||||||
|
`api-key` or `acp`, or a key that is empty, over 4096 bytes or holds \
|
||||||
|
whitespace (problem+json)", body = String),
|
||||||
|
(status = 401, description = "the request carries no `Remote-User`, so there is \
|
||||||
|
no operator to name (problem+json)", body = String),
|
||||||
|
(status = 503, description = "the secret store couldn't be reached, or refused \
|
||||||
|
(problem+json)", body = String),
|
||||||
|
),
|
||||||
|
tag = "review-classifier"
|
||||||
|
)]
|
||||||
|
pub(crate) async fn put_key(
|
||||||
|
State(_state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(runtime): Path<String>,
|
||||||
|
body: Result<Json<KeyBody>, JsonRejection>,
|
||||||
|
) -> Result<Json<Keys>, ProblemDetails> {
|
||||||
|
let operator = super::operator(&headers).ok_or_else(|| {
|
||||||
|
error_problem(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"no Remote-User header: the gateway names the operator, and this request did not \
|
||||||
|
come through it",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let which = Which::parse(&runtime).ok_or_else(|| {
|
||||||
|
error_problem(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
&format!("{runtime:?} is not a runtime with a key: use `api-key` or `acp`"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let Json(body) = body.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?;
|
||||||
|
let key = check(&body.api_key).map_err(|why| error_problem(StatusCode::BAD_REQUEST, &why))?;
|
||||||
|
let store = connect().await?;
|
||||||
|
store.put(which, key).await.map_err(|e| store_problem(&e))?;
|
||||||
|
// The key itself is never logged.
|
||||||
|
tracing::info!(%operator, ?which, "set a review classifier key");
|
||||||
|
both(&store).await.map(Json).map_err(|e| store_problem(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A store holding what was put, with a leaf's time when it has a key.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Fake {
|
||||||
|
put: Mutex<Vec<(Which, String)>>,
|
||||||
|
refuse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyStore for Fake {
|
||||||
|
async fn put(&self, which: Which, key: &str) -> Result<(), String> {
|
||||||
|
if self.refuse {
|
||||||
|
return Err("denied".to_owned());
|
||||||
|
}
|
||||||
|
self.put.lock().unwrap().push((which, key.to_owned()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn updated(&self, which: Which) -> Result<Option<String>, String> {
|
||||||
|
if self.refuse {
|
||||||
|
return Err("denied".to_owned());
|
||||||
|
}
|
||||||
|
let put = self.put.lock().unwrap();
|
||||||
|
Ok(put
|
||||||
|
.iter()
|
||||||
|
.any(|(w, _)| *w == which)
|
||||||
|
.then(|| "2026-11-11T10:00:00Z".to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_runtime_is_one_of_two_words() {
|
||||||
|
assert_eq!(Which::parse("api-key"), Some(Which::ApiKey));
|
||||||
|
assert_eq!(Which::parse("acp"), Some(Which::Acp));
|
||||||
|
assert_eq!(Which::parse("api_key"), None);
|
||||||
|
assert_eq!(Which::parse(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_leaves_are_the_ones_the_policy_grants() {
|
||||||
|
assert_eq!(
|
||||||
|
Which::ApiKey.path().unwrap(),
|
||||||
|
"swarm/services/review-classifier/anthropic-api-key"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Which::Acp.path().unwrap(),
|
||||||
|
"swarm/services/review-classifier/acp-provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pasted_newline_is_dropped_and_a_bad_key_refused() {
|
||||||
|
assert_eq!(check("sk-ant-x\n"), Ok("sk-ant-x"));
|
||||||
|
assert!(check(" ").is_err());
|
||||||
|
assert!(check("two words").is_err());
|
||||||
|
assert!(check("a\u{0}b").is_err());
|
||||||
|
assert!(check(&"k".repeat(MAX_KEY_BYTES + 1)).is_err());
|
||||||
|
assert!(check(&"k".repeat(MAX_KEY_BYTES)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_state_says_set_once_a_key_is_stored_and_never_holds_it() {
|
||||||
|
let store = Fake::default();
|
||||||
|
let before = both(&store).await.unwrap();
|
||||||
|
assert!(!before.api_key.set && !before.acp.set);
|
||||||
|
assert_eq!(before.api_key.updated, None);
|
||||||
|
|
||||||
|
store.put(Which::ApiKey, "sk-secret").await.unwrap();
|
||||||
|
let after = both(&store).await.unwrap();
|
||||||
|
assert!(after.api_key.set && !after.acp.set);
|
||||||
|
assert!(after.api_key.updated.is_some());
|
||||||
|
let json = serde_json::to_string(&after).unwrap();
|
||||||
|
assert!(!json.contains("sk-secret"), "{json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refusing_store_is_an_error_not_unset() {
|
||||||
|
let store = Fake {
|
||||||
|
refuse: true,
|
||||||
|
..Fake::default()
|
||||||
|
};
|
||||||
|
assert!(both(&store).await.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -702,6 +702,8 @@ pub(crate) fn build_app(state: AppState) -> axum::Router {
|
||||||
review_classifier::settings::get_settings,
|
review_classifier::settings::get_settings,
|
||||||
review_classifier::settings::put_settings
|
review_classifier::settings::put_settings
|
||||||
))
|
))
|
||||||
|
.routes(routes!(review_classifier::keys::get_keys))
|
||||||
|
.routes(routes!(review_classifier::keys::put_key))
|
||||||
.routes(routes!(notify_flows::get_flows, notify_flows::put_flows))
|
.routes(routes!(notify_flows::get_flows, notify_flows::put_flows))
|
||||||
.routes(routes!(notify_router_api::get_deliveries))
|
.routes(routes!(notify_router_api::get_deliveries))
|
||||||
.routes(routes!(
|
.routes(routes!(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
//! client secret and its forge token live in the store, and what each object
|
//! client secret and its forge token live in the store, and what each object
|
||||||
//! holds.
|
//! holds.
|
||||||
//!
|
//!
|
||||||
//! The operator writes the keys by hand in the store's UI; the classifier
|
//! The operator writes the keys in the swarm UI, which `swarm-controller`
|
||||||
|
//! stores for them, or by hand in the store's UI; the classifier
|
||||||
//! reads them and the client secret under its own certificate, a key once per
|
//! reads them and the client secret under its own certificate, a key once per
|
||||||
//! run. Its read stanza in `swarm-bao.nix` names exactly these three paths. The forge token is `swarm-controller`'s alone: it mints it, and
|
//! run. Its read stanza in `swarm-bao.nix` names exactly these three paths. The forge token is `swarm-controller`'s alone: it mints it, and
|
||||||
//! posts the classifier's reviews with it.
|
//! posts the classifier's reviews with it.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue