Swagger UI's endpoint-list row already shows the HTTP method badge + path for every row, so restating `METHOD /path` at the start of a handler's own summary is pure duplication. Strips that self-referential prefix from every summary that has it and re-capitalizes what follows as a standalone sentence. Left two false positives untouched: misc_api.rs's operator-inbox summary cross-references a *different* sibling endpoint (mark-all-read) for context, and topology.rs's SetParentForm struct doc happens to mention its endpoint's path but isn't a handler summary line. Both are legitimate, not redundant.
531 lines
20 KiB
Rust
531 lines
20 KiB
Rust
//! 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;
|
|
|
|
use axum::extract::{Form, Query};
|
|
use axum::response::{IntoResponse, Response};
|
|
use serde::{Deserialize, Serialize};
|
|
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
|
|
/// operator pastes a PAT for an existing account.
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub(super) struct GithubAccountForm {
|
|
agent: String,
|
|
token: String,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
struct GithubAccountResult {
|
|
ok: bool,
|
|
}
|
|
|
|
/// Provision (or refresh) an agent's GitHub PAT from the dashboard
|
|
/// credentials tab.
|
|
///
|
|
/// Validates the agent name, then writes the PAT to
|
|
/// `<state>/github-token` (`0600`, agent-owned) via hive-priv. No account
|
|
/// creation and no daemon to kick — the agent's `gh` wrapper / git credential
|
|
/// helper read the file live, so the new token takes effect immediately.
|
|
/// Operator-authenticated (dashboard). Never echoes the token back — only
|
|
/// `{ ok: true }`.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/github-account",
|
|
request_body(content = GithubAccountForm, content_type = "application/x-www-form-urlencoded"),
|
|
responses(
|
|
(status = 200, description = "PAT provisioned", body = GithubAccountResult),
|
|
(status = 500, description = "invalid agent name, empty token, or the write failed"),
|
|
),
|
|
tag = "matrix_accounts"
|
|
)]
|
|
pub(super) async fn post_github_account(Form(f): Form<GithubAccountForm>) -> Response {
|
|
let agent = f.agent.trim();
|
|
let token = f.token.trim();
|
|
let Ok(agent) = Ident::parse(agent) else {
|
|
return error_response(&format!("github-account: invalid agent {agent:?}"));
|
|
};
|
|
if token.is_empty() {
|
|
return error_response("github-account: token is required");
|
|
}
|
|
if let Err(e) = crate::priv_client::write_agent_github_token(agent.as_str(), token).await {
|
|
return error_response(&format!("github-account: write token failed: {e:#}"));
|
|
}
|
|
tracing::info!(%agent, "github-account: provisioned github PAT");
|
|
axum::Json(GithubAccountResult { ok: true }).into_response()
|
|
}
|
|
|
|
#[derive(Deserialize, IntoParams)]
|
|
pub(super) struct GithubAccountQuery {
|
|
agent: String,
|
|
}
|
|
|
|
#[derive(Serialize, ToSchema)]
|
|
struct GithubAccountStatus {
|
|
/// A `github-token` file exists in the agent's state dir (a PAT has been
|
|
/// provisioned). A static PAT has no live/heartbeat concept, so this is
|
|
/// the only status the credentials tab needs.
|
|
present: bool,
|
|
}
|
|
|
|
/// Whether the agent has a GitHub
|
|
/// PAT provisioned (its `github-token` file exists).
|
|
///
|
|
/// Lets the credentials tab show "token stored" vs "not set" instead of a
|
|
/// black-hole paste field. Never returns the token itself.
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/github-account",
|
|
params(GithubAccountQuery),
|
|
responses(
|
|
(status = 200, description = "whether a github PAT is provisioned", body = GithubAccountStatus),
|
|
(status = 500, description = "invalid agent name"),
|
|
),
|
|
tag = "matrix_accounts"
|
|
)]
|
|
pub(super) async fn get_github_account(Query(q): Query<GithubAccountQuery>) -> Response {
|
|
let agent = q.agent.trim();
|
|
let Ok(agent) = Ident::parse(agent) else {
|
|
return error_response(&format!("github-account: invalid agent {agent:?}"));
|
|
};
|
|
let present = Coordinator::agent_notes_dir(&agent)
|
|
.join("github-token")
|
|
.exists();
|
|
axum::Json(GithubAccountStatus { present }).into_response()
|
|
}
|
|
|
|
/// 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 = reqwest::Client::new()
|
|
.post(&url)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("POST /login: {e}"))?;
|
|
let status = resp.status();
|
|
let json: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("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 = reqwest::Client::new()
|
|
.get(&url)
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("GET /whoami: {e}"))?;
|
|
let status = resp.status();
|
|
let json: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("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);
|
|
}
|
|
}
|