Watch
0
0
Fork
You've already forked hyperhive
0

hive-dashboard: remove the MATRIX credentials tab and its login route

The CR3D3NTIALS page's MATRIX tab was the only caller of
`POST /api/matrix-account-login` (provision/log in an external matrix
account through the hive) and `GET /api/matrix-accounts` (its account
list). External matrix accounts are linked from the swarm UI now
(`LinkMatrixAccountForm` -> swarm-controller), so the hive-side UI and
both routes go. `priv_client::restart_matrix_daemon` had no other caller
and goes with them.

Already-provisioned credentials keep working: the `matrix-token-<name>`
files and `matrix-account-<name>.json` sidecars the old route wrote are
still discovered by hive-matrix-mcp (`accounts::configured` ->
`discover_token_accounts`), the `matrix-token*` path unit still re-fires
the daemon, and `WriteAgentMatrixToken` stays for the swarm credential
worker. Removing that usage waits on moving the existing creds to
swarm level.

The GITHUB tab is the credentials page's default tab now.

Refs #4348
This commit is contained in:
atlas 2026-09-29 10:40:40 +02:00 • committed by mara
commit 6a1d85c24f
12 changed files with 35 additions and 972 deletions

View file

@ -5,9 +5,8 @@ HTTPS, both authenticated by an operator-supplied personal access token
(PAT) — so it can run GitHub API calls and push commits without any manual
`gh auth login`.
Provisioning is UI-driven, mirroring the dashboard side of the
[matrix account](matrix.md) flow: paste a PAT into the agent's credentials
tab and it works. No per-agent nix declaration, no rebuild — hive-c0re
Provisioning is UI-driven: paste a PAT into the agent's credentials tab
and it works. No per-agent nix declaration, no rebuild — hive-c0re
injects the token into the agent's state dir out of band.
## Enabling

View file

@ -29,7 +29,7 @@ the whole swarm), **Logs** (`/logs.html`, per-agent and host
journals), **Stats** (`/stats.html`, swarm-wide usage stats),
**Builds** (`/builds.html`, the rebuild queue and build history),
**Core** (`/core.html`, tombstones and container resource use), and
**Credentials** (`/credentials.html`, provisioning matrix/GitHub/forge
**Credentials** (`/credentials.html`, provisioning GitHub/forge
accounts per agent). Your local browser preferences (notifications)
live in the dashboard's Y3R C4LL tab now, not a separate page.

View file

@ -296,60 +296,9 @@ on the H0M3 hub, same minimal chrome as `/logs.html` (a `← home` back-link
than `/core.html`'s plain title. Its own esbuild bundle
(`credentials.js`); no SSE — it reads `/api/state` once for the (shared)
agent picker and otherwise works off purpose-built endpoints per tab.
Three sub-tabs:
Two sub-tabs:
### MATRIX tab
<!-- vale write-good.Passive = NO -->
Provision / log in a per-agent **external** matrix account and store its
access token (this half is unchanged from the old `/matrix-accounts.html`
page it replaces — only the URL and surrounding chrome moved).
<!-- vale write-good.Passive = YES -->
An agent picker (populated from `state.containers`, the live roster) drives a list of that
agent's accounts — name, homeserver, user id, and a status dot —
read from `GET /api/matrix-accounts?agent=<name>` →
`{ accounts: [ { name, homeserver, token_present, live, user_id } ], as_of_unix }`.
`token_present` is whether a token is **stored**; the matrix daemon
backfills `live`, `homeserver`, and `user_id` from its
`matrix-accounts.json` snapshot — a host-visible file it
**force-rewrites every ~30s** (a heartbeat), so `as_of_unix` (the
snapshot mtime) advances while the daemon is alive and a *stalled* value
genuinely means "stopped publishing," not just "old snapshot." An account
with a token but absent from the snapshot reports `live: false`.
The status dot renders these states:
- **green** — `live` and the container is running: online.
- **dim green** — `live` but `as_of_unix` hasn't advanced in > ~90s (3
missed heartbeats) while the container is *not* down: the daemon stopped
publishing, so the snapshot's `live` is no longer trustworthy (likely
dead/wedged). Labelled "online · no heartbeat."
- **amber** — `live` but the container is **down** (a stopped container
⟹ a dead daemon, so the snapshot is stale); also the `token_present &&
!live` "provisioned but offline" case.
- **grey** — no token (not provisioned).
The container-down cross-reference (`/api/state`) takes precedence over
the age check. The dashboard tooltips `as_of_unix` ("live as of N ago")
throughout so freshness is always legible. When `live` is absent (an older hive-c0re
without the snapshot) the dot falls back to a token-present rendering.
The provision form (account name, homeserver, login method) posts
`POST /api/matrix-account-login` (`x-www-form-urlencoded`, operator-auth):
fields `agent, account, homeserver, mode=password|token, user_id?,
password?, token?` → `200 { ok, user_id }` on success. Failures come back
as RFC 9457 `application/problem+json` (`{ type, title, status, detail }`)
with the human-readable message in `detail` and the status code reflecting
the cause (400 for a validation error, 500 for a login / `whoami` /
internal failure); the page reads `detail` for display. The host coordinator performs the login
(password) or validates the token (`whoami`) and writes the bearer to
the agent's `matrixAccounts.<account>.tokenFile` via the same
privileged write path as the hive-internal `matrix-token`; the token is
**never** echoed back, and the page clears the secret inputs on submit
regardless of outcome. The account list reflects what's *provisioned*
(an account with a stored token), so a config-declared-but-unprovisioned
account appears only once the operator provisions it through the form.
Link an external matrix account from the swarm UI (`LinkMatrixAccountForm` → swarm-controller). Accounts already linked through the old MATRIX tab keep working until the operator moves them to swarm level.
### GITHUB tab
@ -363,10 +312,10 @@ minimally scoped token) and a link to
Status reads `GET /api/github-account?agent=<name>` →
`{ present: bool }` — whether the agent's `github-token` file exists.
There's no live/heartbeat concept for a static PAT, so this is just a
"token stored ✓" / "not set" line, unlike MATRIX's status-dot taxonomy.
"token stored ✓" / "not set" line.
Provisioning posts `POST /api/github-account` (form-encoded `agent`,
`token`) → `200 { ok: true }` on success, or the same `error_response`
shape `/api/matrix-account-login` uses on failure. The token is never
`token`) → `200 { ok: true }` on success; failures come back as RFC 9457
`application/problem+json` with the message in `detail`. The token is never
echoed back in either direction.
### FORGES tab

View file

@ -2,9 +2,9 @@
(.page-header / .page-back / .page-title) comes from the shared
chrome.css imported by common.css; base tab styling lives in
@hive/shared/tabs.css (.hive-tab*) same as /logs.html. This file holds
the account-list + provision-form styling specific to this surface,
carried over from the old /matrix-accounts.html (`.ma-*` classes) plus
the tab-strip layout delta + github-tab additions (`.cred-*`). */
the account-list + provision-form styling specific to this surface
(`.ma-*` classes) plus the tab-strip layout delta + github-tab
additions (`.cred-*`). */
body.cred-shell {
margin: 0;
@ -81,29 +81,6 @@ body.cred-shell {
border-color: var(--purple);
}
.ma-mode {
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.45rem 0.75rem 0.6rem;
margin: 0.85rem 0;
}
.ma-mode legend {
font-size: 0.8rem;
color: var(--muted);
padding: 0 0.3rem;
}
.ma-mode label {
margin-right: 1.3rem;
cursor: pointer;
}
.ma-modefields {
margin: 0.4rem 0;
}
.ma-list {
margin: 0.5rem 0 1.2rem;
}
.ma-accounts {
list-style: none;
padding: 0;
@ -122,38 +99,13 @@ body.cred-shell {
border-radius: 50%;
flex: none;
}
/* `ok` is the v1 (pre BE-4) token-present green; `live` is the v2 online green.
`offline`/`stale` are the amber states (provisioned-not-live / container-down
⟹ daemon-down). `absent` = no token. */
.ma-dot.ok,
.ma-dot.live {
.ma-dot.ok {
background: var(--green);
}
.ma-dot.offline,
.ma-dot.stale {
background: var(--amber);
}
.ma-dot.absent {
background: var(--muted);
}
/* `live stale-age`: snapshot still says live but the daemon heartbeat stalled
(> ~90s). Keep the green hue but dim + desaturate so it reads "was online,
now uncertain" — visually distinct from the solid amber container-down
`stale`. More-specific (3 classes) so it overrides `.ma-dot.live`. */
.ma-dot.live.stale-age {
background: var(--green);
opacity: 0.4;
filter: saturate(0.45);
}
.ma-name {
font-weight: 600;
color: var(--fg);
}
.ma-uid {
color: var(--muted);
font-size: 0.8rem;
margin-left: 0.4em;
}
.ma-hs {
color: var(--muted);
font-size: 0.85rem;
@ -162,21 +114,9 @@ body.cred-shell {
margin-left: auto;
font-size: 0.8rem;
}
.ma-status.ok,
.ma-status.live {
.ma-status.ok {
color: var(--green);
}
.ma-status.offline,
.ma-status.stale {
color: var(--amber);
}
.ma-status.absent {
color: var(--muted);
}
.ma-status.live.stale-age {
color: var(--green);
opacity: 0.6;
}
.ma-result {
margin-top: 0.7rem;
@ -189,8 +129,3 @@ body.cred-shell {
.ma-result.err {
color: var(--red);
}
.ma-list .err {
color: var(--red);
font-size: 0.9rem;
}

View file

@ -12,7 +12,7 @@
</head>
<body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
logs.html (MATRIX / GITHUB instead of AGENT/INFRA/SYSTEM). Back
logs.html (GITHUB / FORGES instead of AGENT/INFRA/SYSTEM). Back
link points to the H0M3 hub (served at /). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
@ -25,123 +25,14 @@
</header>
<main class="cred-main">
<!-- Agent picker: shared across both tabs (one agent selected at a
time drives both the matrix account list and the github status). -->
<!-- Agent picker: shared across the tabs (one agent selected at a
time drives both the github status and the forge list). -->
<h3>◇ agent</h3>
<label class="ma-field">
<span>agent</span>
<select id="ma-agent"></select>
</label>
<!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just
moved under a tab pane. -->
<section
class="cred-pane"
id="cred-pane-matrix"
data-tab-pane="matrix"
role="tabpanel"
aria-labelledby="cred-tab-matrix"
>
<p class="meta">
provision or log in an <strong>external</strong> matrix account for an
agent and store its access token. the token is written to the agent's
<code>matrixAccounts.&lt;account&gt;.tokenFile</code> by the host
coordinator &mdash; it is never displayed back on this page.
</p>
<h3>◇ provisioned accounts</h3>
<p class="meta">
accounts that have a stored token (provision one below to add it
here); a config-declared account that hasn't been provisioned yet
won't appear until it has a token. status reflects whether a
<em>token is stored</em>, not a live session &mdash; a true
online/offline indicator is a follow-up that needs the daemon's
account registry.
</p>
<div id="ma-list" class="ma-list">
<p class="meta">select an agent to see its matrix accounts.</p>
</div>
<h3>◇ provision / log in</h3>
<form id="ma-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>account name</span>
<input
type="text"
name="account"
placeholder="e.g. public"
required
/>
</label>
<label class="ma-field">
<span>homeserver</span>
<input
type="text"
name="homeserver"
placeholder="https://matrix.org"
required
/>
</label>
<fieldset class="ma-mode">
<legend>login method</legend>
<label
><input type="radio" name="mode" value="password" checked />
password</label
>
<label
><input type="radio" name="mode" value="token" /> existing
token</label
>
</fieldset>
<div id="ma-pw-fields" class="ma-modefields">
<label class="ma-field">
<span>user id</span>
<input
type="text"
name="user_id"
placeholder="@user:matrix.org"
autocomplete="username"
/>
</label>
<label class="ma-field">
<span>password</span>
<input
type="password"
name="password"
autocomplete="new-password"
/>
</label>
</div>
<div id="ma-token-fields" class="ma-modefields" hidden>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" />
</label>
<label class="ma-field">
<span
>user id
<span class="meta"
>(optional &mdash; derived via whoami)</span
></span
>
<input
type="text"
name="user_id"
placeholder="@user:matrix.org"
/>
</label>
</div>
<button type="submit" class="btn btn-spawn">
log in &amp; store token
</button>
<p id="ma-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<!-- GITHUB tab: single-account PAT paste. No login flow — the
operator pastes an existing PAT for a dedicated bot account.
Security-warning banner + a link to generate a PAT. -->
@ -151,7 +42,6 @@
data-tab-pane="github"
role="tabpanel"
aria-labelledby="cred-tab-github"
hidden
>
<hive-warn level="warning">
⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash;
@ -194,7 +84,7 @@
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
Entirely dashboard-provisioned, no host-side nix config &mdash; same
shape as GITHUB plus a base-URL field (like MATRIX's homeserver).
shape as GITHUB plus a base-URL field.
The operator creates a token on the external forge themselves
(however that forge lets them) and pastes label + URL + token
below. No remote account minting/revoking &mdash; purely local. -->

View file

@ -2,12 +2,9 @@
//
// Operator surface to provision per-agent credentials without editing the
// agent's config repo. Two sub-tabs, sharing one agent picker:
// MATRIX — external matrix account login (carried over verbatim from the
// old /matrix-accounts.html — see matrix_accounts.rs backend doc
// comments for the account/status contract + endpoint shapes).
// GITHUB — single-account PAT paste against /api/github-account
// (GET -> {present}, POST form-encoded {agent, token} ->
// {ok:true}; same error_response shape as matrix-account-login).
// {ok:true}; error_response shape on failure).
// No account name / homeserver / login mode, and no
// live/heartbeat concept for a static PAT — just present/absent.
// FORGES — external forge accounts, entirely dashboard-provisioned (no
@ -19,19 +16,13 @@
// themselves and pastes it in, same trust model as GITHUB.
// Per-tab detail comments live next to their section below.
import { $, esc, fmtAgeSecs, renderServerWarnings } from "./common.js";
import { $, esc, 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
// the live dot: a `live: true` account whose container is DOWN is definitively
// stale (the daemon can't be up if the container isn't), so we flag it rather
// than show a lying green. `undefined` (agent not in the map) = unknown → we
// don't flag stale.
const containerRunning = new Map();
async function loadState() {
try {
@ -46,8 +37,6 @@ async function loadState() {
.map((a) => (typeof a === "string" ? { name: a } : a))
.filter((c) => c && c.name);
agents = containers.map((c) => c.name).sort();
containerRunning.clear();
for (const c of containers) containerRunning.set(c.name, !!c.running);
} catch {
// best-effort: on a failed state read the picker renders empty
// ("— no agents —") and the submit guard blocks until an agent is
@ -74,151 +63,6 @@ function renderAgentPicker() {
// 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),
// so a stalled as_of = daemon dead, not just stale snapshot:
// green live + running + fresh = online
// dim green live but as_of stale > ~90s = heartbeat stopped
// amber live + container DOWN = definitively stale
// amber token_present + !live = provisioned but offline
// grey no token = not provisioned
// Container state takes precedence; as_of_unix is tooltipped for freshness.
// v1 backend (no `live` field) falls back to token-present rendering.
async function loadAccounts(agent) {
const list = $("ma-list");
if (!agent) {
list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its matrix accounts."),
);
return;
}
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data;
try {
const resp = await fetch(
"/api/matrix-accounts?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json();
} catch (err) {
list.replaceChildren(
el(
"p",
{ class: "err" },
"could not load accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
const accounts = data.accounts || [];
const asOf = typeof data.as_of_unix === "number" ? data.as_of_unix : null;
// `false` only when the container is explicitly down; `undefined` (unknown,
// e.g. a failed /api/state read) is treated as not-down so we never flag a
// false stale.
const running = containerRunning.get(agent);
// The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances
// while it's alive — this is a heartbeat, and a stalled value is meaningful.
const ageSecs =
asOf != null ? Math.max(0, Math.floor(Date.now() / 1000) - asOf) : null;
const asOfText =
asOf != null
? "matrix snapshot · live as of " + fmtAgeSecs(ageSecs) + " ago"
: "no daemon snapshot yet";
// 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is
// NOT down means the daemon stopped publishing (dead/wedged) — dim its dot.
const STALE_AGE_SECS = 90;
const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS;
list.replaceChildren();
if (!accounts.length) {
list.append(
el(
"p",
{ class: "meta" },
"no matrix accounts configured for this agent.",
),
);
return;
}
const ul = el("ul", { class: "ma-accounts" });
for (const acc of accounts) {
const present = !!acc.token_present;
// 3-state dot. `live` is absent on the v1 backend (pre BE-4); when
// undefined, fall back to the v1 token-present rendering so the page
// degrades cleanly before the snapshot backend deploys.
let cls;
let statusText;
let dotTitle;
if (acc.live === undefined) {
cls = present ? "ok" : "absent";
statusText = present ? "token stored ✓" : "no token";
dotTitle = present ? "token stored" : "no token yet";
} else if (acc.live && running === false) {
// container down ⟹ daemon down ⟹ a "live" snapshot is stale.
cls = "stale";
statusText = "container stopped";
dotTitle = "container is stopped — live status is stale. " + asOfText;
} else if (acc.live && staleByAge) {
// Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't
// advanced in > ~90s while the container is NOT down — the daemon stopped
// publishing, so the "live" is no longer trustworthy. Keep the green
// family but dim it (distinct from the amber container-down 'stale').
cls = "live stale-age";
statusText = "online · no heartbeat";
dotTitle =
"snapshot says live but the daemon heartbeat stalled " +
fmtAgeSecs(ageSecs) +
" ago (publishes every ~30s) — likely dead or wedged. " +
asOfText;
} else if (acc.live) {
cls = "live";
statusText = "online ✓";
dotTitle = asOfText;
} else if (present) {
cls = "offline";
statusText = "token stored · offline";
dotTitle = "provisioned but not live. " + asOfText;
} else {
cls = "absent";
statusText = "no token";
dotTitle = "no token yet";
}
ul.append(
el(
"li",
{ class: "ma-account" },
el("span", { class: "ma-dot " + cls, title: dotTitle }),
el("span", { class: "ma-name" }, acc.name || "(unnamed)"),
acc.user_id ? el("span", { class: "ma-uid" }, acc.user_id) : null,
el("span", { class: "ma-hs" }, acc.homeserver || "—"),
el("span", { class: "ma-status " + cls, title: asOfText }, statusText),
),
);
}
list.append(ul);
}
// Show only the fields for the selected login method, and DISABLE the
// hidden section's inputs so they don't ride along in the FormData (both
// sections carry a `user_id` field, so without this the wrong one — or
// both — would be submitted).
function toggleModeFields() {
const mode = document.querySelector('input[name="mode"]:checked');
const value = mode ? mode.value : "password";
const pw = $("ma-pw-fields");
const tok = $("ma-token-fields");
pw.hidden = value !== "password";
tok.hidden = value !== "token";
pw.querySelectorAll("input").forEach((i) => {
i.disabled = pw.hidden;
});
tok.querySelectorAll("input").forEach((i) => {
i.disabled = tok.hidden;
});
}
function clearSecrets(formEl) {
formEl
.querySelectorAll('input[type="password"], input[name="token"]')
@ -227,75 +71,6 @@ function clearSecrets(formEl) {
});
}
async function submitLogin(e) {
e.preventDefault();
const formEl = e.target;
const out = $("ma-result");
out.className = "ma-result";
out.textContent = "";
const agent = $("ma-agent").value;
if (!agent) {
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set("agent", agent);
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = "logging in…";
try {
const resp = await fetch("/api/matrix-account-login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
// Success is 200 + JSON { ok, user_id }.
let body = {};
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = "ma-result ok";
out.textContent =
"✓ logged in as " +
(body.user_id || "(unknown)") +
" — token stored.";
clearSecrets(formEl);
loadAccounts(agent);
} else {
out.className = "ma-result err";
out.textContent = "✗ login failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "login failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
// ─── GITHUB tab ─────────────────────────────────────────────────────────
async function loadGithubStatus(agent) {
@ -581,7 +356,6 @@ async function submitForgeAccount(e) {
// ─── init ─────────────────────────────────────────────────────────────
async function onAgentChange(agent) {
loadAccounts(agent);
loadGithubStatus(agent);
loadForgeAccounts(agent);
}
@ -592,21 +366,15 @@ async function init() {
$("ma-agent").addEventListener("change", (e) =>
onAgentChange(e.target.value),
);
document
.querySelectorAll('input[name="mode"]')
.forEach((r) => r.addEventListener("change", toggleModeFields));
toggleModeFields();
$("ma-form").addEventListener("submit", submitLogin);
$("gh-form").addEventListener("submit", submitGithub);
$("ef-form").addEventListener("submit", submitForgeAccount);
document.getElementById("cred-tabbar").configure({
tabs: [
{ id: "matrix", label: "MATRIX" },
{ id: "github", label: "GITHUB" },
{ id: "forges", label: "FORGES" },
],
defaultId: "matrix",
defaultId: "github",
});
onAgentChange("");

View file

@ -96,7 +96,7 @@
<span class="home-tile-label">Credentials</span>
</span>
<span class="home-tile-desc"
>provision per-agent matrix + github accounts</span
>provision per-agent github + forge accounts</span
>
</a>

View file

@ -5,17 +5,15 @@
//! the external forge themselves (however that forge lets them: PAT UI, a
//! teammate with admin, whatever) and pastes a label + base URL + token into
//! the dashboard's FORGES tab, same shape as the GitHub PAT flow
//! (`post_github_account`) plus the homeserver field from the matrix extra-
//! account flow (`post_matrix_account_login`).
//! (`post_github_account`) plus a base URL.
//!
//! No remote account minting, no admin API, no revoke-on-the-remote-side —
//! this module only ever touches the *local* agent state dir. hive-c0re
//! persists the token to `<state>/forge-<label>-token` (0600) and the base
//! URL to a `<state>/forge-<label>.json` sidecar (not secret, but kept next
//! to the token so both survive together) via hive-priv. Listing derives the
//! configured set from those files, mirroring `matrix_accounts.rs`'s
//! filename-scan approach — there is no separate "catalog" now that there's
//! no nix config to enumerate.
//! configured set from those files — there is no separate "catalog", since
//! there is no nix config to enumerate.
use std::path::Path;
@ -61,8 +59,7 @@ pub(super) struct ExtraForgesQuery {
/// List the external forge
/// accounts currently provisioned for `agent`.
///
/// Derived from every `forge-<label>-token` file in its state dir
/// (mirrors `matrix_accounts.rs`'s filename-scan listing). `base_url`
/// Derived from every `forge-<label>-token` file in its state dir. `base_url`
/// is backfilled from the matching `forge-<label>.json` sidecar when
/// present. Never returns a token.
#[utoipa::path(

View file

@ -1,23 +1,6 @@
//! Matrix-account listing for the dashboard (BE-1 of the external
//! matrix-account provisioning backend).
//!
//! `GET /api/matrix-accounts?agent=<name>` returns the matrix accounts an
//! agent currently has a token provisioned for. v1 derives the list cheaply
//! from the agent's state dir — every `matrix-token*` file is a provisioned
//! account (`matrix-token` = the hive-internal `main` account;
//! `matrix-token-<name>` = an extra account, matching the daemon's
//! path-watcher glob). It does not read the nix config, so it lists what is
//! *provisioned*, not the full configured set: a config-declared account
//! without a token yet appears once it is provisioned via the login form.
//! Homeserver + live up/down status (v2) come from the daemon's
//! `matrix-accounts.json` snapshot (written at startup after restores): the
//! response backfills `homeserver`, `user_id`, and `live` per account from
//! it, and carries the snapshot's `as_of_unix` (file mtime) so a reader can
//! judge freshness. An account with a token but absent from the snapshot
//! reports `live: false` (provisioned but not restored / daemon down).
use std::collections::HashMap;
use std::path::Path;
//! Per-agent GitHub PAT provisioning for the dashboard's GITHUB tab:
//! `POST /api/github-account` stores it, `GET /api/github-account` reports
//! whether one is stored.
use axum::extract::{Form, Query};
use axum::response::{IntoResponse, Response};
@ -27,262 +10,9 @@ use utoipa::{IntoParams, ToSchema};
use super::{Ident, error_response};
use crate::coordinator::Coordinator;
#[derive(Deserialize, IntoParams)]
pub(super) struct MatrixAccountsQuery {
agent: String,
}
#[derive(Serialize, ToSchema)]
struct MatrixAccount {
name: String,
/// Effective homeserver, backfilled from the daemon snapshot; `None` when
/// the account isn't in the snapshot (token present but not restored).
homeserver: Option<String>,
/// A token file for this account exists in the agent state dir.
token_present: bool,
/// The account restored a live client per the daemon snapshot. `false`
/// when provisioned but absent from the snapshot (not up / daemon down).
live: bool,
/// The account's matrix user id, from the snapshot when known.
user_id: Option<String>,
}
#[derive(Serialize, ToSchema)]
struct MatrixAccountsResponse {
accounts: Vec<MatrixAccount>,
/// Unix mtime of the daemon's `matrix-accounts.json` snapshot (when the
/// live data was last published), or `None` when no snapshot exists yet.
/// Lets the dashboard show "live as of N ago" without treating a stale
/// snapshot as definitely down.
as_of_unix: Option<i64>,
}
/// One entry of the daemon's `matrix-accounts.json` snapshot
/// (`hive-matrix-mcp::accounts::AccountStatus`). Only the fields the
/// dashboard backfills are read; extra fields (e.g. `is_primary`) are
/// ignored by serde.
#[derive(Deserialize)]
struct SnapshotAccount {
name: String,
homeserver: String,
#[serde(default)]
user_id: Option<String>,
live: bool,
}
/// Read the daemon's live-account snapshot from the agent state dir. Returns
/// the per-name entries plus the file's unix mtime (`as_of`). A missing or
/// unparseable file yields an empty map (every provisioned account then
/// reports `live: false`); `as_of` is `None` only when the file is absent.
fn read_accounts_snapshot(dir: &Path) -> (HashMap<String, SnapshotAccount>, Option<i64>) {
let path = dir.join("matrix-accounts.json");
let as_of = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
let map = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<Vec<SnapshotAccount>>(&s).ok())
.map(|v| v.into_iter().map(|a| (a.name.clone(), a)).collect())
.unwrap_or_default();
(map, as_of)
}
/// Map a state-dir filename to the matrix account name it provisions, or
/// `None` if it is not a token file. `matrix-token` → the hive-internal
/// `main` account; `matrix-token-<name>` → the extra account `<name>`.
fn account_name_from_filename(fname: &str) -> Option<String> {
if fname == "matrix-token" {
return Some("main".to_owned());
}
let suffix = fname.strip_prefix("matrix-token-")?;
if suffix.is_empty() {
return None;
}
Some(suffix.to_owned())
}
/// Matrix accounts provisioned
/// for `agent`.
///
/// Backfilled with `homeserver`/`live`/`user_id` from the daemon's
/// snapshot.
#[utoipa::path(
get,
path = "/api/matrix-accounts",
params(MatrixAccountsQuery),
responses(
(status = 200, description = "provisioned matrix accounts for the agent", body = MatrixAccountsResponse),
(status = 500, description = "invalid agent name, or a state-dir read failed"),
),
tag = "matrix_accounts"
)]
pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) -> Response {
let agent = q.agent.trim();
// Validate through the single `Ident` type so a crafted `agent` can't
// escape the per-agent state root via path components — the same guard
// every other agent-path builder goes through.
let Ok(agent) = Ident::parse(agent) else {
return error_response(&format!("matrix-accounts: invalid agent name {agent:?}"));
};
let dir = Coordinator::agent_notes_dir(&agent);
let (snapshot, as_of_unix) = read_accounts_snapshot(&dir);
let mut accounts = Vec::new();
match std::fs::read_dir(&dir) {
Ok(entries) => {
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|ft| ft.is_file()) {
continue;
}
let fname = entry.file_name();
let Some(fname) = fname.to_str() else {
continue;
};
if let Some(name) = account_name_from_filename(fname) {
// Backfill live status + homeserver + user id from the
// daemon snapshot; absent => provisioned but not restored.
let snap = snapshot.get(&name);
accounts.push(MatrixAccount {
homeserver: snap.map(|s| s.homeserver.clone()),
token_present: true,
live: snap.is_some_and(|s| s.live),
user_id: snap.and_then(|s| s.user_id.clone()),
name,
});
}
}
}
// No state dir / no tokens yet is a normal empty result, not an error.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return error_response(&format!("matrix-accounts: read {}: {e}", dir.display()));
}
}
accounts.sort_by(|a, b| a.name.cmp(&b.name));
axum::Json(MatrixAccountsResponse {
accounts,
as_of_unix,
})
.into_response()
}
/// Form body for `POST /matrix-account-login` (urlencoded, the dashboard's
/// mutation convention). `mode` is `"password"` (needs `user_id` +
/// `password`) or `"token"` (needs `token`; `user_id` is recovered via
/// whoami).
#[derive(Deserialize, ToSchema)]
pub(super) struct MatrixLoginForm {
agent: String,
account: String,
homeserver: String,
mode: String,
user_id: Option<String>,
password: Option<String>,
token: Option<String>,
}
#[derive(Serialize, ToSchema)]
struct MatrixLoginResult {
ok: bool,
user_id: String,
}
/// Provision (or refresh) the token for an agent's extra matrix account.
///
/// password mode → `m.login.password`; token mode → validate via `whoami`.
/// On success writes the token to `matrix-token-<account>` via hive-priv and
/// kicks the daemon. Operator-authenticated (dashboard). Never echoes the
/// token back — only `{ ok, user_id }`.
#[utoipa::path(
post,
path = "/api/matrix-account-login",
request_body(content = MatrixLoginForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "account provisioned", body = MatrixLoginResult),
(status = 500, description = "invalid input, or the homeserver login/whoami failed"),
),
tag = "matrix_accounts"
)]
pub(super) async fn post_matrix_account_login(Form(f): Form<MatrixLoginForm>) -> Response {
let agent = f.agent.trim();
let account = f.account.trim();
let homeserver = f.homeserver.trim().trim_end_matches('/');
let Ok(agent) = Ident::parse(agent) else {
return error_response(&format!("matrix-account-login: invalid agent {agent:?}"));
};
let Ok(account) = Ident::parse(account) else {
return error_response(&format!(
"matrix-account-login: invalid account {account:?}"
));
};
if account.as_str() == "main" {
return error_response(
"matrix-account-login: 'main' is the hive-internal account; it is \
provisioned via the normal flow, not this form",
);
}
if !(homeserver.starts_with("http://") || homeserver.starts_with("https://")) {
return error_response(&format!(
"matrix-account-login: homeserver must be an http(s) URL, got {homeserver:?}"
));
}
let (token, user_id) = match f.mode.as_str() {
"password" => {
let (Some(uid), Some(pw)) = (f.user_id.as_deref(), f.password.as_deref()) else {
return error_response(
"matrix-account-login: password mode needs user_id + password",
);
};
match matrix_password_login(homeserver, uid, pw).await {
Ok(pair) => pair,
Err(e) => return error_response(&format!("matrix-account-login: {e}")),
}
}
"token" => {
let Some(tok) = f.token.as_deref() else {
return error_response("matrix-account-login: token mode needs token");
};
match matrix_whoami(homeserver, tok).await {
Ok(uid) => (tok.to_owned(), uid),
Err(e) => return error_response(&format!("matrix-account-login: {e}")),
}
}
other => {
return error_response(&format!(
"matrix-account-login: unknown mode {other:?} (want password|token)"
));
}
};
if let Err(e) = crate::priv_client::write_agent_matrix_token(
agent.as_str(),
&token,
Some(account.as_str()),
Some(homeserver),
)
.await
{
return error_response(&format!("matrix-account-login: write token failed: {e:#}"));
}
// Best-effort kick so the daemon picks up the new account without a full
// container restart; not fatal if the container isn't running.
if let Err(e) = crate::priv_client::restart_matrix_daemon(agent.as_str()).await {
tracing::warn!(
%agent, %account, error = ?e,
"matrix-account-login: daemon restart failed (token written; loads on next start)"
);
}
tracing::info!(%agent, %account, "matrix-account-login: provisioned extra matrix account");
axum::Json(MatrixLoginResult { ok: true, user_id }).into_response()
}
/// Form body for `POST /api/github-account` (urlencoded, the dashboard's
/// mutation convention). Writes the operator-supplied PAT to the agent's
/// `github-token` file. The GitHub counterpart of the matrix login form, but
/// far simpler: no account creation, no homeserver, no login modes — the
/// `github-token` file. No account creation and no login modes — the
/// operator pastes a PAT for an existing account.
#[derive(Deserialize, ToSchema)]
pub(super) struct GithubAccountForm {
@ -368,191 +98,3 @@ pub(super) async fn get_github_account(Query(q): Query<GithubAccountQuery>) -> R
.exists();
axum::Json(GithubAccountStatus { present }).into_response()
}
/// Bound on reaching the homeserver.
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Bound on one whole homeserver round trip, body included. A password
/// login makes the homeserver hash the password before it answers, so this
/// is looser than a plain API call needs.
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// A client with both homeserver bounds applied.
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.connect_timeout(HTTP_CONNECT_TIMEOUT)
.timeout(HTTP_TIMEOUT)
.build()
.map_err(|e| format!("build HTTP client: {e}"))
}
/// `what` failed with `e`; a timeout names the bound that fired.
fn http_error(what: &str, e: &reqwest::Error) -> String {
if e.is_connect() && e.is_timeout() {
format!("{what}: connect timed out after {HTTP_CONNECT_TIMEOUT:?}")
} else if e.is_timeout() {
format!("{what}: timed out after {HTTP_TIMEOUT:?}")
} else {
format!("{what}: {e}")
}
}
/// POST `m.login.password` to `<homeserver>/_matrix/client/v3/login`.
/// Returns `(access_token, user_id)`.
async fn matrix_password_login(
homeserver: &str,
user_id: &str,
password: &str,
) -> Result<(String, String), String> {
let url = format!("{homeserver}/_matrix/client/v3/login");
let body = serde_json::json!({
"type": "m.login.password",
"identifier": { "type": "m.id.user", "user": user_id },
"password": password,
"initial_device_display_name": "hyperhive",
});
let resp = http_client()?
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| http_error("POST /login", &e))?;
let status = resp.status();
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| http_error("parse /login response", &e))?;
if !status.is_success() {
let err = json
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("login failed");
return Err(format!("/login HTTP {status}: {err}"));
}
let token = json
.get("access_token")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "login response missing access_token".to_owned())?;
let uid = json
.get("user_id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "login response missing user_id".to_owned())?;
Ok((token.to_owned(), uid.to_owned()))
}
/// GET `<homeserver>/_matrix/client/v3/account/whoami` with the bearer token
/// to validate it and recover the `user_id`.
async fn matrix_whoami(homeserver: &str, token: &str) -> Result<String, String> {
let url = format!("{homeserver}/_matrix/client/v3/account/whoami");
let resp = http_client()?
.get(&url)
.bearer_auth(token)
.send()
.await
.map_err(|e| http_error("GET /whoami", &e))?;
let status = resp.status();
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| http_error("parse /whoami response", &e))?;
if !status.is_success() {
let err = json
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("token rejected");
return Err(format!("/whoami HTTP {status}: {err}"));
}
json.get("user_id")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| "whoami response missing user_id".to_owned())
}
#[cfg(test)]
mod tests {
use super::{account_name_from_filename, read_accounts_snapshot};
fn unique_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!(
"hh-c0re-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn snapshot_parses_entries_and_reports_as_of() {
let dir = unique_dir("snap");
std::fs::write(
dir.join("matrix-accounts.json"),
r#"[
{"name":"main","homeserver":"http://hs","user_id":"@a:hs","live":true,"is_primary":true},
{"name":"pub","homeserver":"https://matrix.org","user_id":null,"live":true,"is_primary":false}
]"#,
)
.unwrap();
let (map, as_of) = read_accounts_snapshot(&dir);
assert_eq!(map.len(), 2);
assert_eq!(map["main"].homeserver, "http://hs");
assert!(map["main"].live);
assert_eq!(map["main"].user_id.as_deref(), Some("@a:hs"));
assert_eq!(map["pub"].user_id, None);
// is_primary is present in the file but ignored — no panic on the
// extra field.
assert!(as_of.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn snapshot_absent_yields_empty_map_and_no_as_of() {
let dir = unique_dir("nosnap");
let (map, as_of) = read_accounts_snapshot(&dir);
assert!(map.is_empty());
assert!(as_of.is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn snapshot_unparseable_is_empty_but_as_of_set() {
let dir = unique_dir("badsnap");
std::fs::write(dir.join("matrix-accounts.json"), "not json").unwrap();
let (map, as_of) = read_accounts_snapshot(&dir);
assert!(map.is_empty());
// File exists, so freshness is still reported even though it can't
// be parsed (every account then reports live: false).
assert!(as_of.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn main_account_from_bare_token() {
assert_eq!(
account_name_from_filename("matrix-token").as_deref(),
Some("main")
);
}
#[test]
fn extra_account_from_suffixed_token() {
assert_eq!(
account_name_from_filename("matrix-token-catgirl").as_deref(),
Some("catgirl")
);
}
#[test]
fn empty_suffix_is_not_an_account() {
assert_eq!(account_name_from_filename("matrix-token-"), None);
}
#[test]
fn non_token_files_ignored() {
assert_eq!(account_name_from_filename("matrix-sdk-state"), None);
assert_eq!(account_name_from_filename("notes.md"), None);
assert_eq!(account_name_from_filename("matrix-avatar-icon-hash"), None);
}
}

View file

@ -39,7 +39,7 @@ use crate::lifecycle;
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "matrix + github account provisioning for agents"),
(name = "matrix_accounts", description = "github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
@ -139,8 +139,6 @@ pub async fn serve(
.routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(matrix_accounts::get_matrix_accounts))
.routes(routes!(matrix_accounts::post_matrix_account_login))
.routes(routes!(
matrix_accounts::post_github_account,
matrix_accounts::get_github_account
@ -378,8 +376,6 @@ mod router_build_probe {
.routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(matrix_accounts::get_matrix_accounts))
.routes(routes!(matrix_accounts::post_matrix_account_login))
.routes(routes!(
matrix_accounts::post_github_account,
matrix_accounts::get_github_account

View file

@ -445,17 +445,6 @@ pub async fn delete_agent_extra_forge_account(agent_name: &str, label: &str) ->
.await?)
}
/// Restart `hive-matrix-daemon.service` inside an agent container via
/// `systemctl --machine=h-<agent_name> restart hive-matrix-daemon.service`.
/// Non-fatal: callers should handle errors gracefully — if the container is
/// not running the restart will fail (the unit starts naturally on next boot).
pub async fn restart_matrix_daemon(agent_name: &str) -> Result<()> {
ok(call(&PrivRequest::RestartMatrixDaemon {
agent_name: agent_name.to_owned(),
})
.await?)
}
/// Register the hive-ci Forgejo Actions runner: hand the freshly-minted
/// registration token to hive-priv, which writes it to the host-side
/// `/run/hive-ci/runner-token` env-file and restarts the in-container runner.

View file

@ -24,10 +24,9 @@
//! already has a bearer token) and `password` (this daemon performs
//! `m.login.password` against the caller-given homeserver itself and stores
//! the resulting token; the password is never stored, and is not sent to the
//! hive either — only the derived token is). Mirrors what hive-c0re's own
//! `/api/matrix-account-login` does for a *hive-local* account, done here
//! instead so the browser never has to hold the password long enough to call
//! an arbitrary homeserver directly.
//! hive either — only the derived token is). Done here so the browser never
//! has to hold the password long enough to call an arbitrary homeserver
//! directly.
use axum::Json;
use axum::extract::State;
@ -177,9 +176,8 @@ pub async fn put_matrix_account(
// module owns that `matrixAccounts` entry, which is why this route
// refuses to write one: an extra account literally named `main` would
// not overwrite the real one (it lands at a different token-file suffix)
// but would confuse anything that lists accounts by name. Same guard
// hive-c0re's own `/api/matrix-account-login` applies, checked before any
// mode-specific work (including a network login) runs.
// but would confuse anything that lists accounts by name. Checked
// before any mode-specific work (including a network login) runs.
if is_reserved_account(&account) {
return Err(error_problem(
StatusCode::BAD_REQUEST,