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:
parent
93bbec015f
commit
6a1d85c24f
12 changed files with 35 additions and 972 deletions
|
|
@ -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
|
(PAT) — so it can run GitHub API calls and push commits without any manual
|
||||||
`gh auth login`.
|
`gh auth login`.
|
||||||
|
|
||||||
Provisioning is UI-driven, mirroring the dashboard side of the
|
Provisioning is UI-driven: paste a PAT into the agent's credentials tab
|
||||||
[matrix account](matrix.md) flow: paste a PAT into the agent's credentials
|
and it works. No per-agent nix declaration, no rebuild — hive-c0re
|
||||||
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.
|
injects the token into the agent's state dir out of band.
|
||||||
|
|
||||||
## Enabling
|
## Enabling
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ the whole swarm), **Logs** (`/logs.html`, per-agent and host
|
||||||
journals), **Stats** (`/stats.html`, swarm-wide usage stats),
|
journals), **Stats** (`/stats.html`, swarm-wide usage stats),
|
||||||
**Builds** (`/builds.html`, the rebuild queue and build history),
|
**Builds** (`/builds.html`, the rebuild queue and build history),
|
||||||
**Core** (`/core.html`, tombstones and container resource use), and
|
**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)
|
accounts per agent). Your local browser preferences (notifications)
|
||||||
live in the dashboard's Y3R C4LL tab now, not a separate page.
|
live in the dashboard's Y3R C4LL tab now, not a separate page.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
than `/core.html`'s plain title. Its own esbuild bundle
|
||||||
(`credentials.js`); no SSE — it reads `/api/state` once for the (shared)
|
(`credentials.js`); no SSE — it reads `/api/state` once for the (shared)
|
||||||
agent picker and otherwise works off purpose-built endpoints per tab.
|
agent picker and otherwise works off purpose-built endpoints per tab.
|
||||||
Three sub-tabs:
|
Two sub-tabs:
|
||||||
|
|
||||||
### MATRIX tab
|
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.
|
||||||
|
|
||||||
<!-- 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.
|
|
||||||
|
|
||||||
### GITHUB tab
|
### GITHUB tab
|
||||||
|
|
||||||
|
|
@ -363,10 +312,10 @@ minimally scoped token) and a link to
|
||||||
Status reads `GET /api/github-account?agent=<name>` →
|
Status reads `GET /api/github-account?agent=<name>` →
|
||||||
`{ present: bool }` — whether the agent's `github-token` file exists.
|
`{ 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
|
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`,
|
Provisioning posts `POST /api/github-account` (form-encoded `agent`,
|
||||||
`token`) → `200 { ok: true }` on success, or the same `error_response`
|
`token`) → `200 { ok: true }` on success; failures come back as RFC 9457
|
||||||
shape `/api/matrix-account-login` uses on failure. The token is never
|
`application/problem+json` with the message in `detail`. The token is never
|
||||||
echoed back in either direction.
|
echoed back in either direction.
|
||||||
|
|
||||||
### FORGES tab
|
### FORGES tab
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
(.page-header / .page-back / .page-title) comes from the shared
|
(.page-header / .page-back / .page-title) comes from the shared
|
||||||
chrome.css imported by common.css; base tab styling lives in
|
chrome.css imported by common.css; base tab styling lives in
|
||||||
@hive/shared/tabs.css (.hive-tab*) same as /logs.html. This file holds
|
@hive/shared/tabs.css (.hive-tab*) same as /logs.html. This file holds
|
||||||
the account-list + provision-form styling specific to this surface,
|
the account-list + provision-form styling specific to this surface
|
||||||
carried over from the old /matrix-accounts.html (`.ma-*` classes) plus
|
(`.ma-*` classes) plus the tab-strip layout delta + github-tab
|
||||||
the tab-strip layout delta + github-tab additions (`.cred-*`). */
|
additions (`.cred-*`). */
|
||||||
|
|
||||||
body.cred-shell {
|
body.cred-shell {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -81,29 +81,6 @@ body.cred-shell {
|
||||||
border-color: var(--purple);
|
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 {
|
.ma-accounts {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -122,38 +99,13 @@ body.cred-shell {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
/* `ok` is the v1 (pre BE-4) token-present green; `live` is the v2 online green.
|
.ma-dot.ok {
|
||||||
`offline`/`stale` are the amber states (provisioned-not-live / container-down
|
|
||||||
⟹ daemon-down). `absent` = no token. */
|
|
||||||
.ma-dot.ok,
|
|
||||||
.ma-dot.live {
|
|
||||||
background: var(--green);
|
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 {
|
.ma-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
.ma-uid {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-left: 0.4em;
|
|
||||||
}
|
|
||||||
.ma-hs {
|
.ma-hs {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
@ -162,21 +114,9 @@ body.cred-shell {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.ma-status.ok,
|
.ma-status.ok {
|
||||||
.ma-status.live {
|
|
||||||
color: var(--green);
|
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 {
|
.ma-result {
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.7rem;
|
||||||
|
|
@ -189,8 +129,3 @@ body.cred-shell {
|
||||||
.ma-result.err {
|
.ma-result.err {
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ma-list .err {
|
|
||||||
color: var(--red);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
</head>
|
</head>
|
||||||
<body class="cred-shell">
|
<body class="cred-shell">
|
||||||
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
|
<!-- 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 /). -->
|
link points to the H0M3 hub (served at /). -->
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<a class="page-back" href="/">← home</a>
|
<a class="page-back" href="/">← home</a>
|
||||||
|
|
@ -25,123 +25,14 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="cred-main">
|
<main class="cred-main">
|
||||||
<!-- Agent picker: shared across both tabs (one agent selected at a
|
<!-- Agent picker: shared across the tabs (one agent selected at a
|
||||||
time drives both the matrix account list and the github status). -->
|
time drives both the github status and the forge list). -->
|
||||||
<h3>◇ agent</h3>
|
<h3>◇ agent</h3>
|
||||||
<label class="ma-field">
|
<label class="ma-field">
|
||||||
<span>agent</span>
|
<span>agent</span>
|
||||||
<select id="ma-agent"></select>
|
<select id="ma-agent"></select>
|
||||||
</label>
|
</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.<account>.tokenFile</code> by the host
|
|
||||||
coordinator — 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 — 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 — 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 & 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
|
<!-- GITHUB tab: single-account PAT paste. No login flow — the
|
||||||
operator pastes an existing PAT for a dedicated bot account.
|
operator pastes an existing PAT for a dedicated bot account.
|
||||||
Security-warning banner + a link to generate a PAT. -->
|
Security-warning banner + a link to generate a PAT. -->
|
||||||
|
|
@ -151,7 +42,6 @@
|
||||||
data-tab-pane="github"
|
data-tab-pane="github"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="cred-tab-github"
|
aria-labelledby="cred-tab-github"
|
||||||
hidden
|
|
||||||
>
|
>
|
||||||
<hive-warn level="warning">
|
<hive-warn level="warning">
|
||||||
⚠ use a <strong>dedicated bot account</strong>, not a human's —
|
⚠ use a <strong>dedicated bot account</strong>, not a human's —
|
||||||
|
|
@ -194,7 +84,7 @@
|
||||||
|
|
||||||
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
|
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
|
||||||
Entirely dashboard-provisioned, no host-side nix config — same
|
Entirely dashboard-provisioned, no host-side nix config — 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
|
The operator creates a token on the external forge themselves
|
||||||
(however that forge lets them) and pastes label + URL + token
|
(however that forge lets them) and pastes label + URL + token
|
||||||
below. No remote account minting/revoking — purely local. -->
|
below. No remote account minting/revoking — purely local. -->
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,9 @@
|
||||||
//
|
//
|
||||||
// Operator surface to provision per-agent credentials without editing the
|
// Operator surface to provision per-agent credentials without editing the
|
||||||
// agent's config repo. Two sub-tabs, sharing one agent picker:
|
// 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
|
// GITHUB — single-account PAT paste against /api/github-account
|
||||||
// (GET -> {present}, POST form-encoded {agent, token} ->
|
// (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
|
// No account name / homeserver / login mode, and no
|
||||||
// live/heartbeat concept for a static PAT — just present/absent.
|
// live/heartbeat concept for a static PAT — just present/absent.
|
||||||
// FORGES — external forge accounts, entirely dashboard-provisioned (no
|
// FORGES — external forge accounts, entirely dashboard-provisioned (no
|
||||||
|
|
@ -19,19 +16,13 @@
|
||||||
// themselves and pastes it in, same trust model as GITHUB.
|
// themselves and pastes it in, same trust model as GITHUB.
|
||||||
// Per-tab detail comments live next to their section below.
|
// 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 { el } from "@hive/shared/dom.js";
|
||||||
import "@hive/shared/hive-tab-strip.js";
|
import "@hive/shared/hive-tab-strip.js";
|
||||||
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
|
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
|
||||||
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
|
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
|
||||||
|
|
||||||
let agents = [];
|
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() {
|
async function loadState() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -46,8 +37,6 @@ async function loadState() {
|
||||||
.map((a) => (typeof a === "string" ? { name: a } : a))
|
.map((a) => (typeof a === "string" ? { name: a } : a))
|
||||||
.filter((c) => c && c.name);
|
.filter((c) => c && c.name);
|
||||||
agents = containers.map((c) => c.name).sort();
|
agents = containers.map((c) => c.name).sort();
|
||||||
containerRunning.clear();
|
|
||||||
for (const c of containers) containerRunning.set(c.name, !!c.running);
|
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort: on a failed state read the picker renders empty
|
// best-effort: on a failed state read the picker renders empty
|
||||||
// ("— no agents —") and the submit guard blocks until an agent is
|
// ("— 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
|
// originally; promoted so swarm-ui shares the same
|
||||||
// shape-agnostic reader instead of each side maintaining its own copy.
|
// 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) {
|
function clearSecrets(formEl) {
|
||||||
formEl
|
formEl
|
||||||
.querySelectorAll('input[type="password"], input[name="token"]')
|
.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 ─────────────────────────────────────────────────────────
|
// ─── GITHUB tab ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadGithubStatus(agent) {
|
async function loadGithubStatus(agent) {
|
||||||
|
|
@ -581,7 +356,6 @@ async function submitForgeAccount(e) {
|
||||||
// ─── init ─────────────────────────────────────────────────────────────
|
// ─── init ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function onAgentChange(agent) {
|
async function onAgentChange(agent) {
|
||||||
loadAccounts(agent);
|
|
||||||
loadGithubStatus(agent);
|
loadGithubStatus(agent);
|
||||||
loadForgeAccounts(agent);
|
loadForgeAccounts(agent);
|
||||||
}
|
}
|
||||||
|
|
@ -592,21 +366,15 @@ async function init() {
|
||||||
$("ma-agent").addEventListener("change", (e) =>
|
$("ma-agent").addEventListener("change", (e) =>
|
||||||
onAgentChange(e.target.value),
|
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);
|
$("gh-form").addEventListener("submit", submitGithub);
|
||||||
$("ef-form").addEventListener("submit", submitForgeAccount);
|
$("ef-form").addEventListener("submit", submitForgeAccount);
|
||||||
|
|
||||||
document.getElementById("cred-tabbar").configure({
|
document.getElementById("cred-tabbar").configure({
|
||||||
tabs: [
|
tabs: [
|
||||||
{ id: "matrix", label: "MATRIX" },
|
|
||||||
{ id: "github", label: "GITHUB" },
|
{ id: "github", label: "GITHUB" },
|
||||||
{ id: "forges", label: "FORGES" },
|
{ id: "forges", label: "FORGES" },
|
||||||
],
|
],
|
||||||
defaultId: "matrix",
|
defaultId: "github",
|
||||||
});
|
});
|
||||||
|
|
||||||
onAgentChange("");
|
onAgentChange("");
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
<span class="home-tile-label">Credentials</span>
|
<span class="home-tile-label">Credentials</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="home-tile-desc"
|
<span class="home-tile-desc"
|
||||||
>provision per-agent matrix + github accounts</span
|
>provision per-agent github + forge accounts</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,15 @@
|
||||||
//! the external forge themselves (however that forge lets them: PAT UI, a
|
//! the external forge themselves (however that forge lets them: PAT UI, a
|
||||||
//! teammate with admin, whatever) and pastes a label + base URL + token into
|
//! teammate with admin, whatever) and pastes a label + base URL + token into
|
||||||
//! the dashboard's FORGES tab, same shape as the GitHub PAT flow
|
//! the dashboard's FORGES tab, same shape as the GitHub PAT flow
|
||||||
//! (`post_github_account`) plus the homeserver field from the matrix extra-
|
//! (`post_github_account`) plus a base URL.
|
||||||
//! account flow (`post_matrix_account_login`).
|
|
||||||
//!
|
//!
|
||||||
//! No remote account minting, no admin API, no revoke-on-the-remote-side —
|
//! 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
|
//! this module only ever touches the *local* agent state dir. hive-c0re
|
||||||
//! persists the token to `<state>/forge-<label>-token` (0600) and the base
|
//! 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
|
//! 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
|
//! to the token so both survive together) via hive-priv. Listing derives the
|
||||||
//! configured set from those files, mirroring `matrix_accounts.rs`'s
|
//! configured set from those files — there is no separate "catalog", since
|
||||||
//! filename-scan approach — there is no separate "catalog" now that there's
|
//! there is no nix config to enumerate.
|
||||||
//! no nix config to enumerate.
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -61,8 +59,7 @@ pub(super) struct ExtraForgesQuery {
|
||||||
/// List the external forge
|
/// List the external forge
|
||||||
/// accounts currently provisioned for `agent`.
|
/// accounts currently provisioned for `agent`.
|
||||||
///
|
///
|
||||||
/// Derived from every `forge-<label>-token` file in its state dir
|
/// Derived from every `forge-<label>-token` file in its state dir. `base_url`
|
||||||
/// (mirrors `matrix_accounts.rs`'s filename-scan listing). `base_url`
|
|
||||||
/// is backfilled from the matching `forge-<label>.json` sidecar when
|
/// is backfilled from the matching `forge-<label>.json` sidecar when
|
||||||
/// present. Never returns a token.
|
/// present. Never returns a token.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,6 @@
|
||||||
//! Matrix-account listing for the dashboard (BE-1 of the external
|
//! Per-agent GitHub PAT provisioning for the dashboard's GITHUB tab:
|
||||||
//! matrix-account provisioning backend).
|
//! `POST /api/github-account` stores it, `GET /api/github-account` reports
|
||||||
//!
|
//! whether one is stored.
|
||||||
//! `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;
|
|
||||||
|
|
||||||
use axum::extract::{Form, Query};
|
use axum::extract::{Form, Query};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
@ -27,262 +10,9 @@ use utoipa::{IntoParams, ToSchema};
|
||||||
use super::{Ident, error_response};
|
use super::{Ident, error_response};
|
||||||
use crate::coordinator::Coordinator;
|
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
|
/// Form body for `POST /api/github-account` (urlencoded, the dashboard's
|
||||||
/// mutation convention). Writes the operator-supplied PAT to the agent's
|
/// mutation convention). Writes the operator-supplied PAT to the agent's
|
||||||
/// `github-token` file. The GitHub counterpart of the matrix login form, but
|
/// `github-token` file. No account creation and no login modes — the
|
||||||
/// far simpler: no account creation, no homeserver, no login modes — the
|
|
||||||
/// operator pastes a PAT for an existing account.
|
/// operator pastes a PAT for an existing account.
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub(super) struct GithubAccountForm {
|
pub(super) struct GithubAccountForm {
|
||||||
|
|
@ -368,191 +98,3 @@ pub(super) async fn get_github_account(Query(q): Query<GithubAccountQuery>) -> R
|
||||||
.exists();
|
.exists();
|
||||||
axum::Json(GithubAccountStatus { present }).into_response()
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ use crate::lifecycle;
|
||||||
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
||||||
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
|
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
|
||||||
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
(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 = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||||
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
|
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
|
||||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
(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!(journal::get_journal_host))
|
||||||
.routes(routes!(state_snapshot::api_state))
|
.routes(routes!(state_snapshot::api_state))
|
||||||
.routes(routes!(state_files::get_state_file))
|
.routes(routes!(state_files::get_state_file))
|
||||||
.routes(routes!(matrix_accounts::get_matrix_accounts))
|
|
||||||
.routes(routes!(matrix_accounts::post_matrix_account_login))
|
|
||||||
.routes(routes!(
|
.routes(routes!(
|
||||||
matrix_accounts::post_github_account,
|
matrix_accounts::post_github_account,
|
||||||
matrix_accounts::get_github_account
|
matrix_accounts::get_github_account
|
||||||
|
|
@ -378,8 +376,6 @@ mod router_build_probe {
|
||||||
.routes(routes!(journal::get_journal_host))
|
.routes(routes!(journal::get_journal_host))
|
||||||
.routes(routes!(state_snapshot::api_state))
|
.routes(routes!(state_snapshot::api_state))
|
||||||
.routes(routes!(state_files::get_state_file))
|
.routes(routes!(state_files::get_state_file))
|
||||||
.routes(routes!(matrix_accounts::get_matrix_accounts))
|
|
||||||
.routes(routes!(matrix_accounts::post_matrix_account_login))
|
|
||||||
.routes(routes!(
|
.routes(routes!(
|
||||||
matrix_accounts::post_github_account,
|
matrix_accounts::post_github_account,
|
||||||
matrix_accounts::get_github_account
|
matrix_accounts::get_github_account
|
||||||
|
|
|
||||||
|
|
@ -445,17 +445,6 @@ pub async fn delete_agent_extra_forge_account(agent_name: &str, label: &str) ->
|
||||||
.await?)
|
.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
|
/// Register the hive-ci Forgejo Actions runner: hand the freshly-minted
|
||||||
/// registration token to hive-priv, which writes it to the host-side
|
/// 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.
|
/// `/run/hive-ci/runner-token` env-file and restarts the in-container runner.
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,9 @@
|
||||||
//! already has a bearer token) and `password` (this daemon performs
|
//! already has a bearer token) and `password` (this daemon performs
|
||||||
//! `m.login.password` against the caller-given homeserver itself and stores
|
//! `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
|
//! 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
|
//! hive either — only the derived token is). Done here so the browser never
|
||||||
//! `/api/matrix-account-login` does for a *hive-local* account, done here
|
//! has to hold the password long enough to call an arbitrary homeserver
|
||||||
//! instead so the browser never has to hold the password long enough to call
|
//! directly.
|
||||||
//! an arbitrary homeserver directly.
|
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
|
|
@ -177,9 +176,8 @@ pub async fn put_matrix_account(
|
||||||
// module owns that `matrixAccounts` entry, which is why this route
|
// module owns that `matrixAccounts` entry, which is why this route
|
||||||
// refuses to write one: an extra account literally named `main` would
|
// refuses to write one: an extra account literally named `main` would
|
||||||
// not overwrite the real one (it lands at a different token-file suffix)
|
// not overwrite the real one (it lands at a different token-file suffix)
|
||||||
// but would confuse anything that lists accounts by name. Same guard
|
// but would confuse anything that lists accounts by name. Checked
|
||||||
// hive-c0re's own `/api/matrix-account-login` applies, checked before any
|
// before any mode-specific work (including a network login) runs.
|
||||||
// mode-specific work (including a network login) runs.
|
|
||||||
if is_reserved_account(&account) {
|
if is_reserved_account(&account) {
|
||||||
return Err(error_problem(
|
return Err(error_problem(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue