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,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.