Watch
0
0
Fork
You've already forked hyperhive
0

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");