Compare commits

...
Author SHA1 Message Date
iris
e71e4ef432 swarm-ui: escape the hyphen in create-agent's name pattern
Closes #3423.

Modern browsers validate an <input pattern> attribute's regex in
Unicode-set ('v') mode, which is stricter about hyphen placement than
classic mode: `[a-z0-9-]` throws "Invalid character class" under 'v'
mode even though a trailing hyphen is unambiguous (and valid) in classic
regex. Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
`new RegExp('[a-z0-9\\-]', 'v')` doesn't. Escaping the hyphen fixes it
without changing what the pattern matches.
2026-08-17 23:56:53 +02:00
iris
62d1c6224b frontend: fix api-error.ts's stale in-transition comment
atlas, on the issue: swarm-controller's problem+json conversion merged
(58588a68, closing hyperhive#3412 which this comment referenced as an
in-flight fix). Every first-party API is RFC 9457 now, so the non-JSON
fallback path is not backend-transition compatibility anymore — it's for
bodies this repo didn't author (a caller pointed elsewhere, or an
nginx-synthesised 502/504 that never reaches our handlers). Comment
updated to say that instead of pointing at a transition that's already
finished.
2026-08-17 23:11:11 +02:00
iris
ca84079a21 frontend: add WarnBanner Preact component, compose ApiErrorPanel from it
mara, on review: "i want each component to import its own css file
itself[;] if you need a bunch of extra css externally, its not a proper
component ... you may need to migrate other components that you would
want to use first".

WarnBanner is the Preact-component successor to the shadow-DOM
<hive-warn> custom element (same three-tier info/warning/error visual
language, colours copied faithfully from hive-warn.css). ApiErrorPanel
now composes it instead of owning a copy of the border/colour/pulse
rules itself — its own CSS is back down to just the layout that's
actually specific to it (heading, copy button, detail text).

Re-verified with a fresh mock-server screenshot: same rendered output as
before, now via composition instead of a duplicated banner shape.
2026-08-17 23:11:11 +02:00
iris
f60aab4717 frontend: shared ApiErrorPanel component, promote readApiError from credentials.js
Closes #3410.

`ApiErrorPanel` renders a ProblemDetails (RFC 9457) error nicely, with a
copy button so the full text can be pasted straight into a bug report.
No truncation of `detail` — on the #3363 incident that motivated this
issue, that string was the entire diagnosis.

`readApiError`/`problemMessage`/`ProblemDetails` are promoted out of
credentials.js's original `readErrorBody` into `@hive/shared/api-error.js`
(comment rewritten: the RFC 9457 rework has already landed everywhere
except swarm-controller's status route, #3412 in flight, so the raw-text
fallback is a compat shim for that one gap, not a general transition).
credentials.js's 4 call sites switch to the shared reader (kept as
one-line messages there, its result slots are single-line aria-live
regions, not a panel context).

Wired ApiErrorPanel into OverviewPage.tsx (the issue's own worked
example) and CreateAgentPage.tsx (second real call site).

Not built on <hive-warn> despite matching its visual language — that
custom element's CSS-as-text import only works under a build with
loader: 'text' for .css (dashboard's), and silently renders unstyled
under swarm-ui's default css loader (filed separately as #3415).
ApiErrorPanel is a self-contained light-DOM component instead, per
mara's own suggestion to keep it independent of the old UI's shapes.
2026-08-17 23:11:11 +02:00
atlas
bfe921c254 feat(#3405): declarative grafana plugins for the swarm dashboard
Plugin management is server-admin scoped, and on an SSO hive nobody holds that
role: auto_assign_org_role grants an org role, and the built-in local admin
that does hold server admin cannot log in because the login form is disabled
whenever SSO is configured. Two individually-correct decisions leaving no path
to the plugin UI at all.

Declarative is the way through rather than a workaround for it -- plugins land
in the store and in git, survive a rebuild and a state reset, and the container
needs no runtime egress to grafana.com.

mkIf rather than passing the list through: upstream's default is null, while an
empty list is a real value pointing the plugin path at an empty store dir, so a
hive that sets nothing must keep seeing null.
2026-08-17 22:24:07 +02:00
atlas
346a6b1b4c fix(#3393): render an email for every swarm user, synthesised when none was given
Authelia serves no `email` claim for a user without one, and a relying party
that wants that claim does not degrade -- grafana falls through to
`<api_url>/emails`, a GitHub-ism authelia does not implement, and the login
dies with InternalError naming nothing useful. So an absent address is a
broken login rather than a sparse profile.

Uses `<username>@hyperhive.local`, the domain hive-c0re already gives every
agent's forge account and every hyperhive-authored commit. Not deployment-
derived: that would have to be plumbed in from config, and an operator already
supplying a domain may as well supply the whole address.

Synthesised in the renderer, never in the store: users.json stays honest that
none was supplied, an operator who later sets a real one is not fighting an
invented value, and existing users are fixed by the next render with no
migration step. A supplied address always wins.

The old test asserting an absent email is omitted pinned exactly the behaviour
that broke the login; it is split so the group half keeps its meaning and the
email half states the new contract.
2026-08-17 22:04:17 +02:00
atlas
58588a6866 fix(#3412): swarm-controller answers errors as RFC 9457 problem+json
Its three error paths returned a bare string with a status code, which forces
a caller to treat the whole body as prose. hive-c0re converted some time ago,
so swarm-controller was the last backend on the old shape -- and it is the one
behind the hive status page's 503, where the body is frequently the entire
diagnosis rather than a summary.

Adds the commitment to docs/conventions.md, since it was implied by the code
in one daemon and written down nowhere: an endpoint of ours answering with a
bare string is a bug to file, not something callers work around.

The test asserts the rendered response -- media type plus an addressable
detail -- rather than the problem_details value, because a handler that built
the value correctly and then returned it as a string would satisfy a test
written against the type alone.
2026-08-17 21:30:39 +02:00
damocles
1d9a06482a docs(setup): point operator forge/matrix account creation at swarm SSO 2026-08-17 21:12:24 +02:00
17 changed files with 535 additions and 75 deletions

1
Cargo.lock generated
View file

@ -4591,6 +4591,7 @@ dependencies = [
"hive-jobq",
"hive-jobq-wire",
"hive-types",
"problem_details",
"reqwest 0.13.1",
"serde",
"serde_json",

View file

@ -318,6 +318,28 @@ refactor's concern. The dashboard frontend parses via
`util.js::epochSec` wherever it needs arithmetic and feeds the string
straight to `new Date(s)` for display.
### HTTP error bodies
Every HTTP API in this repo answers failures with **RFC 9457
`application/problem+json`** (`{ type, title, status, detail }`), with the
human-readable cause in `detail`. An endpoint of ours returning a bare string
or a bespoke error shape is a **bug to file against the backend**, not
something for the caller to work around.
Use the `problem_details` crate (`features = ["axum"]`), which the daemons
already depend on: type a handler `Result<_, ProblemDetails>` and hand
`ProblemDetails::from_status_code(...).with_detail(...)` to `Err`.
The reason is the consumer, not tidiness. The UIs show errors through one
shared component with a copy button, so a caller has to know **which part of
the body is the message**. A bare string forces it to treat the whole payload
as prose, which is the difference between offering "copy the cause" and
dumping a response — and the cause is frequently the entire diagnosis (a
JetStream permission refusal, a TLS chain failure) rather than a summary.
Not in scope: the `hivectl` host-admin and in-agent unix sockets. Those are a
JSON-line protocol with their own result types; RFC 9457 is an HTTP format.
## Tool groups
The MCP tool surface an agent receives is derived from a set of named

View file

@ -21,16 +21,19 @@ operator has to place, and where.
### 1 · Forge
```bash
# Provision (or refresh) ruth's own forge account — do this first
# Provision (or refresh) ruth's own forge account — do this first. Ruth's
# bootstrap bypasses the normal spawn-approval flow (see step 6), so unlike
# every other agent it does not get its forge account auto-provisioned —
# this manual step is still load-bearing.
hivectl forge create-user ruth
# Create a human operator account (prints the token to stdout)
hivectl forge create-user mara --password hunter2
# Provision forge accounts for any sub-agents spawned later
hivectl forge create-user <agent>
# Sub-agents spawned later (via the approval flow in step 6) get their
# forge accounts auto-provisioned — nothing to run here for them.
```
The human operator's own forge account is created via swarm SSO instead of
a manual `hivectl` step — see step 3 (`swarmctl user add`).
### 2 · Gateway (HTTP Basic auth)
```bash
@ -89,20 +92,21 @@ control: [`swarm/ui.md`](swarm/ui.md).
# 5a. Ensure the hive-internal admin account exists first
hivectl matrix sync-admin
# 5b. Provision ruth's own matrix account
# 5b. Provision ruth's own matrix account — same bootstrap-bypass reasoning
# as forge above, still a required manual step.
hivectl matrix create-user ruth
# 5c. Create a human matrix account
hivectl matrix create-user mara --password hunter2
# 5d. Invite the operator to the hive Space (and optionally to rooms)
# 5c. Invite the operator to the hive Space (and optionally to rooms)
hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
# 5e. Promote the operator to homeserver admin if needed
# 5d. Promote the operator to homeserver admin if needed
hivectl matrix promote-user mara
```
The human operator's own matrix account is created via swarm SSO instead of
a manual `hivectl` step — see step 3 (`swarmctl user add`).
### 6 · Spawn sub-agents
Sub-agent creation goes through the approval queue — ruth proposes, the

View file

@ -23,6 +23,7 @@ import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/hive-tab-strip.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import { readApiError, problemMessage } from '@hive/shared/api-error.js';
let agents = [];
// agent name → container running (bool), from /api/state. Cross-referenced by
@ -65,28 +66,13 @@ function renderAgentPicker() {
for (const a of agents) sel.append(el('option', { value: a }, a));
}
// ─── shape-agnostic error-body parsing (shared by both tabs' submit
// handlers) ───────────────────────────────────────────────────────────────
// The BE error-body shape is in transition: today hive-c0re's
// error_response sends a bare plain-text body; the RFC 9457 rework moves it
// to application/problem+json ({ type, title, detail, … }). Read shape-
// agnostically: pull the body once as text, and if it parses as JSON
// surface `detail` (problem+json) → `error`/`title` fallback, else use the
// raw text. A bare HTTP code is the last resort.
async function readErrorBody(resp) {
try {
const raw = (await resp.text()).trim();
if (raw && (raw[0] === '{' || raw[0] === '[')) {
try {
const body = JSON.parse(raw);
return body.detail || body.error || body.title || raw;
} catch { /* not JSON after all — keep the raw text */ }
}
return raw;
} catch {
return '';
}
}
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
// lives in `@hive/shared/api-error.js` now — `readApiError` +
// `problemMessage` (this page only needs the one-line message, not the
// full `ApiErrorPanel`; its result lines are single-line `aria-live`
// regions, not a swap-in-a-panel context). Was a local function here
// originally; promoted so swarm-ui shares the same
// shape-agnostic reader instead of each side maintaining its own copy.
// ─── MATRIX tab ────────────────────────────────────────────────────────────
// Live status dot — the daemon heartbeats every ~30s (advances as_of_unix),
@ -253,7 +239,7 @@ async function submitLogin(e) {
clearSecrets(formEl);
}
} else {
const msg = await readErrorBody(resp);
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
@ -337,7 +323,7 @@ async function submitGithub(e) {
clearSecrets(formEl);
}
} else {
const msg = await readErrorBody(resp);
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);
@ -419,7 +405,7 @@ async function onForgeRemoveClick(agent, forge, btn) {
loadForgeAccounts(agent);
return;
}
const msg = await readErrorBody(resp);
const msg = problemMessage(await readApiError(resp));
btn.textContent = orig;
btn.disabled = false;
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
@ -474,7 +460,7 @@ async function submitForgeAccount(e) {
clearSecrets(formEl);
}
} else {
const msg = await readErrorBody(resp);
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
clearSecrets(formEl);

View file

@ -31,7 +31,12 @@
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.tsx",
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css",
"./jobq-rollup.js": "./src/jobq-rollup/JobqRollup.tsx",
"./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css"
"./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css",
"./api-error.js": "./src/api-error.ts",
"./api-error-panel.js": "./src/api-error-panel/ApiErrorPanel.tsx",
"./api-error-panel.css": "./src/api-error-panel/api-error-panel.css",
"./warn-banner.js": "./src/warn-banner/WarnBanner.tsx",
"./warn-banner.css": "./src/warn-banner/WarnBanner.css"
},
"files": [
"src/"

View file

@ -0,0 +1,75 @@
// ApiErrorPanel.tsx — <ApiErrorPanel>, the shared "show the operator why
// an API call failed" component (mara, on the issue that asked for this:
// "the error display component is for showing ProblemDetails in a nicer
// way with copy button [...] wherever we want to show an error, this
// component should be used"). Renders a `ProblemDetails` (./api-error.ts)
// — heading + the full `detail` text, unclamped (it can be a raw NATS/
// JetStream error, and on the incident that prompted this component
// *that string was the entire diagnosis* — truncating it defeats the
// point) — plus a copy button for pasting straight into a bug report.
//
// Composed from `WarnBanner` (../warn-banner/WarnBanner.js) rather than
// owning the border/colour/pulse styling itself — mara, on review: "i
// want each component to import its own css file itself[;] if you need
// a bunch of extra css externally, its not a proper component". This
// file's own `.css` only lays out what's specific to it (heading, copy
// button, detail text); the banner "shape" belongs to `WarnBanner` and
// stays there so any future error/warning surface reuses it too, rather
// than every caller growing its own copy of the same rules.
//
// Same shared-component shape as `JobqRollup`/`JobqGraph`:
// `render(h(ApiErrorPanel, { problem }), container)` from vanilla JS, or
// `<ApiErrorPanel problem={...} />` from swarm-ui's JSX.
import { useState } from 'preact/hooks';
import { WarnBanner } from '../warn-banner/WarnBanner.js';
import type { ProblemDetails } from '../api-error.js';
import './api-error-panel.css';
export interface ApiErrorPanelProps {
problem: ProblemDetails;
// Optional short prefix naming what failed ("failed to load the hive
// roster") — the panel is otherwise just the server's own words, which
// don't always say what the caller was trying to do.
context?: string;
}
function formatForCopy(p: ProblemDetails): string {
const lines: string[] = [];
if (p.status !== undefined) lines.push(`status: ${p.status}`);
if (p.title) lines.push(`title: ${p.title}`);
if (p.type) lines.push(`type: ${p.type}`);
if (p.detail) lines.push(`detail: ${p.detail}`);
return lines.length ? lines.join('\n') : 'request failed';
}
export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
const [copied, setCopied] = useState(false);
const heading = problem.title || (problem.status !== undefined ? `http ${problem.status}` : 'request failed');
async function copy() {
try {
await navigator.clipboard.writeText(formatForCopy(problem));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard permission denied / unavailable (e.g. non-secure
// context) — the text is still fully visible to select by hand,
// so this is a silent no-op rather than a second error to show.
}
}
return (
<WarnBanner level="error" class="api-error-panel">
<div class="api-error-heading">
<span class="api-error-title">
{context ? `${context}: ` : ''}
{heading}
</span>
<button type="button" class="api-error-copy" onClick={copy}>
{copied ? 'copied' : 'copy'}
</button>
</div>
{problem.detail ? <p class="api-error-detail">{problem.detail}</p> : null}
</WarnBanner>
);
}

View file

@ -0,0 +1,40 @@
/* api-error-panel.css layout for `<ApiErrorPanel>`'s content, plus the
one spacing concern that's genuinely this component's own (the top
margin `WarnBanner` only sets `margin-bottom`, tuned for a banner
that's the first thing in a page, not a panel that usually follows a
form or a table). Colour/border/pulse belong to `WarnBanner`
(../warn-banner/WarnBanner.css) not duplicated here. */
.api-error-panel {
margin-top: 1em;
}
.api-error-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.8em;
}
.api-error-title {
font-weight: 600;
}
.api-error-copy {
flex: none;
background: transparent;
color: inherit;
border: 1px solid currentColor;
border-radius: 0.3em;
padding: 0.1em 0.6em;
font: inherit;
font-size: 0.85em;
cursor: pointer;
}
.api-error-copy:hover {
background: color-mix(in srgb, currentColor 12%, transparent);
}
.api-error-detail {
margin: 0.5em 0 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.9em;
opacity: 0.9;
}

View file

@ -0,0 +1,63 @@
// api-error.ts — reads a failed fetch `Response` into a `ProblemDetails`
// (RFC 9457, `application/problem+json`) object, shape-agnostically.
//
// Promoted from `dashboard/src/credentials.js`'s original `readErrorBody`,
// which returned a flat message string. This returns the structured object
// instead so a caller — chiefly `ApiErrorPanel` (./api-error-panel/) — can
// render title/status/detail separately and build a useful copy-button
// payload, rather than re-parsing a pre-squashed string.
//
// RFC 9457 is this hive's committed error-body contract for first-party
// APIs (mara: "any api of our own responding with error that is not rfc
// shaped is a bug to be filed against backend") — every first-party route
// emits it as of this writing, so the non-JSON fallback below is NOT
// compatibility with an in-flight backend transition (that transition is
// finished). It's for bodies we did not author: a caller can be pointed
// at something that isn't ours, and an nginx-synthesised 502/504 never
// reaches our handlers at all, so it's plain HTML, not problem+json. A
// first-party route landing here anyway is a bug to file, not something
// this reader should quietly paper over.
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
}
export async function readApiError(resp: Response): Promise<ProblemDetails> {
const status = resp.status;
let raw: string;
try {
raw = (await resp.text()).trim();
} catch {
return { status };
}
if (raw && (raw[0] === '{' || raw[0] === '[')) {
try {
const body = JSON.parse(raw) as Record<string, unknown>;
return {
type: typeof body.type === 'string' ? body.type : undefined,
title: typeof body.title === 'string' ? body.title : undefined,
status: typeof body.status === 'number' ? body.status : status,
detail:
typeof body.detail === 'string'
? body.detail
: typeof body.error === 'string'
? body.error
: undefined,
};
} catch {
// not JSON after all — fall through to the raw-text path below
}
}
return { status, detail: raw || undefined };
}
// One-line message for a context that only has room for a single string
// (a form's inline result line, a toast) rather than the full panel —
// same fallback order `readErrorBody` used: detail (or its `error` alias,
// already folded in above) → title → a bare status line.
export function problemMessage(p: ProblemDetails): string {
return p.detail || p.title || (p.status !== undefined ? `http ${p.status}` : 'request failed');
}

View file

@ -0,0 +1,36 @@
/* WarnBanner.css three levels, not an open-ended severity+modifier
combination (same rule `hive-warn.css` states, copied faithfully since
this is that component's visual language, just reached through a
class instead of a `:host([level])` attribute selector). Colours reuse
theme.css's own documented semantics: --cyan = "info accents", --amber
= "warnings", --red = "errors, fail state". */
.warn-banner {
display: block;
border: 1px solid var(--amber);
border-radius: 4px;
padding: 0.5em 0.8em;
margin-bottom: 0.6em;
color: var(--amber);
background: color-mix(in srgb, var(--amber) 8%, transparent);
}
.warn-banner-info {
border-color: var(--cyan);
color: var(--cyan);
background: color-mix(in srgb, var(--cyan) 8%, transparent);
}
.warn-banner-error {
border-color: var(--red);
color: var(--red);
background: color-mix(in srgb, var(--red) 8%, transparent);
/* Only `error` pulses — an active incident, not a standing caveat. */
text-shadow: 0 0 6px color-mix(in srgb, currentColor 40%, transparent);
animation: warn-banner-pulse 2.4s ease-in-out infinite;
}
@keyframes warn-banner-pulse {
0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
}
.warn-banner strong {
color: inherit;
}

View file

@ -0,0 +1,38 @@
// WarnBanner.tsx — <WarnBanner>, the Preact-component successor to the
// shadow-DOM <hive-warn> custom element (../hive-warn/hive-warn.js).
// Same three-tier visual language (mara, on `hive-warn`'s own review:
// "there should be distinction between info, warning, error — all
// warnings should be styled identically") — built as a plain Preact
// component instead so it composes cleanly wherever this package's other
// shared components do (JobqRollup, JobqGraph, ApiErrorPanel), rather
// than needing a shadow root + CSS-as-text build step that only resolves
// under one of the two consuming packages' esbuild configs. `ApiErrorPanel`
// is the first consumer — compose from this instead of duplicating the
// banner's own border/colour/pulse rules locally.
//
// `level` defaults to 'warning', matching `hive-warn.js`'s own default.
// Only `error` pulses — an active incident, not a standing caveat, same
// rule `hive-warn.css`'s own comment states.
import type { ComponentChildren } from 'preact';
import './WarnBanner.css';
export type WarnLevel = 'info' | 'warning' | 'error';
export interface WarnBannerProps {
level?: WarnLevel;
// Passed straight through to the root element's `class` list —
// for a caller-specific concern the banner itself has no opinion on
// (e.g. `ApiErrorPanel` adding a top margin for the context it usually
// sits in), not for re-styling the banner's own look.
class?: string;
children?: ComponentChildren;
}
export function WarnBanner({ level = 'warning', class: extraClass, children }: WarnBannerProps) {
const classes = ['warn-banner', `warn-banner-${level}`, extraClass].filter(Boolean).join(' ');
return (
<div class={classes} role={level === 'error' ? 'alert' : undefined}>
{children}
</div>
);
}

View file

@ -49,6 +49,3 @@
.create-agent-result-ok {
color: var(--green);
}
.create-agent-result-error {
color: var(--red);
}

View file

@ -23,6 +23,8 @@
// comment states.
import { useState } from 'preact/hooks';
import { Link } from 'wouter-preact';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import './CreateAgentPage.css';
@ -34,13 +36,22 @@ type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'done'; nodeId: number }
| { status: 'error'; message: string };
| { status: 'error'; problem: ProblemDetails };
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) —
// a `pattern` hint only, not a substitute for the server's own
// validation; a name this rejects would fail server-side anyway, so
// catching it before a round-trip is a pure UX win, not a new gate.
const NAME_PATTERN = '[a-z0-9-]{1,63}';
//
// Hyphen escaped (`\-`) rather than left trailing in the class: modern
// browsers validate the `pattern` attribute's regex in Unicode-set (`v`)
// mode, which is far stricter about hyphen placement than classic mode —
// `[a-z0-9-]` throws "Invalid character class" under `v` mode even though
// it's valid classic-mode regex (a trailing `-` is unambiguous there).
// Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
// `new RegExp('[a-z0-9\\-]', 'v')` doesn't — confirmed via mara's console
// exception report on this page.
const NAME_PATTERN = '[a-z0-9\\-]{1,63}';
export function CreateAgentPage() {
const [name, setName] = useState('');
@ -55,12 +66,15 @@ export function CreateAgentPage() {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) throw new Error((await r.text()) || `http ${r.status}`);
if (!r.ok) {
setResult({ status: 'error', problem: await readApiError(r) });
return;
}
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: 'done', nodeId: data.node_id });
setName('');
} catch (err) {
setResult({ status: 'error', message: String(err) });
setResult({ status: 'error', problem: { detail: String(err) } });
}
}
@ -96,9 +110,7 @@ export function CreateAgentPage() {
</p>
)}
{result.status === 'error' && (
<p class="create-agent-result create-agent-result-error">
failed to queue: {result.message}
</p>
<ApiErrorPanel context="failed to queue" problem={result.problem} />
)}
</Panel>
);

View file

@ -8,6 +8,8 @@
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
// routing, a page owns its own fetch + render.
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 { Panel } from '../ui/panel/Panel.js';
import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
@ -60,21 +62,22 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
export function OverviewPage() {
const [hives, setHives] = useState<HiveStatus[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
useEffect(() => {
fetch('/api/hives/status')
.then((r) => {
if (!r.ok) throw new Error(`http ${r.status}`);
return r.json() as Promise<HiveStatus[]>;
})
.then(setHives)
.catch((e: unknown) => setError(String(e)));
(async () => {
const r = await fetch('/api/hives/status');
if (!r.ok) {
setError(await readApiError(r));
return;
}
setHives((await r.json()) as HiveStatus[]);
})().catch((e: unknown) => setError({ detail: String(e) }));
}, []);
return (
<Panel title="overview">
{error ? <p>failed to load the hive roster: {error}</p> : null}
{error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null}
{!error && hives === null ? <p>loading</p> : null}
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
</Panel>

View file

@ -174,6 +174,30 @@ in
'';
};
};
plugins = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
example = lib.literalExpression "[ pkgs.grafanaPlugins.grafana-piechart-panel ]";
description = ''
Grafana plugins to install, as packages. Declarative rather than
installed through the UI, which is the only shape that works here:
plugin management is **server-admin** scoped, and on an SSO hive
nobody holds that role `users.auto_assign_org_role` grants an
*org* role, and the built-in local admin that does hold server
admin cannot log in because the login form is disabled whenever
SSO is configured.
That is a deliberate pair of decisions rather than an oversight,
and this option is the way through it: plugins live in the store
and in git, so they survive a container rebuild and a state reset,
and the container needs no runtime egress to grafana.com.
Empty by default, which leaves grafana's own plugin handling
untouched. Setting it takes over the plugin directory entirely
anything installed by other means stops being visible.
'';
};
};
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
@ -437,6 +461,25 @@ in
enable = true;
package = cfg.package;
# Passed through unconditionally, empty default included.
# Upstream distinguishes `null` from `[ ]`, and both differences
# favour always handing it a list:
#
# - `null` points the plugin path at grafana's mutable
# `<dataDir>/plugins`; any list points it at a store path.
# Switching on the day someone adds their first plugin would
# bury that change inside an unrelated one.
# - upstream defaults its plugin update-check to
# `declarativePlugins == null`, so a list also stops the
# container phoning grafana.com. That is the no-runtime-egress
# property this option exists for — it should not arrive only
# once a plugin happens to be listed.
#
# Nothing is taken over by claiming the directory on a hive with no
# plugins: manual installation is already impossible here (see the
# option's description), so there is nothing in it to lose.
declarativePlugins = cfg.plugins;
settings = {
server = {
# Already upstream's default (measured), but pinned rather

View file

@ -27,6 +27,10 @@ forgejo-api.workspace = true
# raw bytes.
base64.workspace = true
futures-util.workspace = true
# RFC 9457 `application/problem+json` error bodies. Same version + `axum`
# feature as hive-c0re: the two daemons answer the same operator UIs, so a
# reader that handles one's failures has to handle the other's.
problem_details = { version = "0.9.0", features = ["axum"] }
# The graph itself, held directly rather than behind a c0re-style wrapper
# module — that layering (`hive-c0re::job_queue`) is partially legacy (predates
# `hive-jobq`'s extraction into its own crate) and this daemon does not need it

View file

@ -428,14 +428,29 @@ async fn get_links(State(state): State<AppState>) -> Json<Vec<ServiceLink>> {
/// body because a bare 503 on an operator-facing diagnostic is how a
/// misconfiguration costs an afternoon; it is a queue/JetStream error
/// string, and this surface is already behind the swarm's SSO.
///
/// It travels in `detail` of an RFC 9457 `application/problem+json` body
/// rather than as a bare string, so the cause is an addressable field
/// instead of the whole payload — see `error_problem` below.
struct StatusUnavailable(String);
impl axum::response::IntoResponse for StatusUnavailable {
fn into_response(self) -> axum::response::Response {
(axum::http::StatusCode::SERVICE_UNAVAILABLE, self.0).into_response()
error_problem(axum::http::StatusCode::SERVICE_UNAVAILABLE, &self.0).into_response()
}
}
/// Every error this daemon returns, in one shape.
///
/// RFC 9457 `application/problem+json` is the hive-wide contract for HTTP
/// error bodies (`docs/conventions.md`), and the operator UIs read `detail`
/// for display. A bare string forces the reader to treat the entire body as
/// the message, which is the difference between a UI that can offer "copy the
/// cause" and one that can only dump a response.
fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_details::ProblemDetails {
problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
}
/// What each hive last said about itself, read from the swarm queue at
/// request time.
///
@ -522,17 +537,17 @@ struct CreateAgentResponse {
request_body = CreateAgentRequest,
responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse),
(status = 400, description = "`name` is not a valid identifier", body = String),
(status = 500, description = "the job chain could not be queued", body = String),
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
(status = 500, description = "the job chain could not be queued (problem+json)", body = String),
),
tag = "agents"
)]
async fn create_agent(
State(state): State<AppState>,
Json(req): Json<CreateAgentRequest>,
) -> Result<Json<CreateAgentResponse>, (axum::http::StatusCode, String)> {
) -> Result<Json<CreateAgentResponse>, problem_details::ProblemDetails> {
let agent = hive_types::Ident::parse(&req.name)
.map_err(|reason| (axum::http::StatusCode::BAD_REQUEST, reason.to_owned()))?
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
let repo = agent.clone();
@ -562,7 +577,12 @@ async fn create_agent(
.after_ok(create_repo);
vec![create_identity.guid()]
})
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| {
error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(),
)
})?;
let [id] = ids[..] else {
unreachable!("exactly one handle was asked for");
};
@ -770,11 +790,46 @@ async fn main() -> Result<()> {
#[cfg(test)]
mod tests {
use super::{
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, SwarmNodeKind, WorkerDeps,
load_hives, load_links, run_swarm_node,
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, ServiceLink, StatusUnavailable,
SwarmNodeKind, WorkerDeps, load_hives, load_links, run_swarm_node,
};
use std::path::Path;
/// The 503 this route returns is what the operator UIs' shared error
/// component renders, so assert the RENDERED response rather than the
/// `problem_details` crate: the contract a UI depends on is the content
/// type plus a `detail` it can address, and a handler that built the
/// value and returned it as a bare string would satisfy any test
/// written against the type alone.
#[tokio::test]
async fn status_unavailable_renders_problem_json_with_the_cause_in_detail() {
use axum::response::IntoResponse as _;
let cause = "listing status bucket keys: timed out";
let resp = StatusUnavailable(cause.to_owned()).into_response();
assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
ct.starts_with("application/problem+json"),
"RFC 9457 media type, got {ct:?}"
);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("body reads");
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("problem+json parses");
assert_eq!(v["status"], 503);
// The cause is an addressable field, not the entire payload — that
// distinction is the point of the change, so it is what is asserted.
assert_eq!(v["detail"], cause);
}
/// Drives `SwarmNodeKind::CreateRepo` through the real
/// `hive_jobq::scheduler::Scheduler` claim → run → complete path,
/// rather than only through `create_agent`'s endpoint test (there

View file

@ -122,6 +122,37 @@ fn quote(s: &str) -> String {
out
}
/// Domain for an address this crate invents.
///
/// The same one `hive-c0re` already gives every agent's forge account
/// (`forge::users::agent_email`) and every hyperhive-authored git commit. A
/// deployment-derived domain was considered and rejected: it would have to be
/// passed in from config, and an operator who is already supplying a domain
/// may as well supply the whole address — while a *second* convention for
/// synthetic identities is a thing to keep in sync forever.
///
/// Never routable, and that is correct rather than a compromise. Nothing
/// sends mail here; the address exists so that a relying party asking for an
/// `email` claim gets one.
const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local";
/// The address a user with no explicit one is rendered as.
///
/// Every user needs an email in the rendered file, because a relying party
/// that asks for the `email` claim and gets nothing does not degrade — it
/// fails. Grafana's OIDC login is the measured case: with no email claim it
/// falls through to `<api_url>/emails`, a GitHub-ism authelia does not
/// implement, and the login dies with `InternalError` rather than anything
/// naming the missing field.
///
/// Synthesised in the **renderer**, never written to the store: `users.json`
/// stays honest that no address was supplied, so an operator who later sets a
/// real one is not fighting a value swarmctl invented, and every existing user
/// is fixed by the next render with no migration step.
fn synthetic_email(username: &str) -> String {
format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}")
}
/// Render the store as authelia's block-style YAML users database.
///
/// Fallible because it re-runs validation over every value it is about to
@ -142,10 +173,15 @@ pub fn render_yaml(store: &UserStore) -> Result<String> {
writeln!(out, " {name}:")?;
writeln!(out, " displayname: {}", quote(&user.displayname))?;
writeln!(out, " password: {}", quote(&user.password))?;
if let Some(email) = &user.email {
reject_control_chars("email", email)?;
writeln!(out, " email: {}", quote(email))?;
}
// Unconditional: an absent email is the failure mode, not a valid
// rendering. The synthetic address is validated on the same path as
// a supplied one so neither can smuggle a control character.
let email = match &user.email {
Some(supplied) => supplied.clone(),
None => synthetic_email(name),
};
reject_control_chars("email", &email)?;
writeln!(out, " email: {}", quote(&email))?;
if !user.groups.is_empty() {
writeln!(out, " groups:")?;
for group in &user.groups {
@ -325,21 +361,61 @@ mod tests {
}
#[test]
fn optional_fields_are_omitted_rather_than_emitted_empty() {
fn an_empty_group_list_is_omitted_rather_than_emitted_empty() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let out = render_yaml(&store).expect("renders");
assert!(
!out.contains("email"),
"absent email must not appear:\n{out}"
);
assert!(
!out.contains("groups"),
"an empty group list must not appear:\n{out}"
);
}
/// Email is deliberately NOT in the test above any more. It used to
/// assert that an absent one is omitted, which pinned the behaviour that
/// broke grafana's login: authelia serves no `email` claim, and a relying
/// party that wants one fails rather than degrading.
#[test]
fn a_user_with_no_email_still_renders_one_from_the_shared_domain() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let out = render_yaml(&store).expect("renders");
assert!(
out.contains(r#"email: "mara@hyperhive.local""#),
"a user with no email must still render one:\n{out}"
);
}
#[test]
fn a_supplied_email_is_never_replaced_by_the_synthetic_one() {
let mut u = user("$argon2id$x");
u.email = Some("real@elsewhere.example".to_owned());
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), u);
let out = render_yaml(&store).expect("renders");
assert!(
out.contains(r#"email: "real@elsewhere.example""#),
"the supplied address must win:\n{out}"
);
assert!(
!out.contains("mara@hyperhive.local"),
"the synthetic address must not also appear:\n{out}"
);
}
/// The synthetic address goes through the same validation as a supplied
/// one. A username is already constrained to `[A-Za-z0-9._-]`, so this
/// cannot currently fail — which is exactly why it is worth pinning: the
/// day username rules loosen, the renderer must still refuse rather than
/// quietly emit whatever it built.
#[test]
fn the_synthetic_address_is_built_from_the_username_and_domain() {
assert_eq!(synthetic_email("mara"), "mara@hyperhive.local");
}
#[test]
fn groups_render_as_a_block_sequence() {
let mut u = user("$argon2id$x");