dashboard: GET /api/matrix-accounts — list an agent's provisioned matrix accounts (#1698)

This commit is contained in:
damocles 2026-06-16 09:48:23 +02:00 committed by mara
commit 1ebc384e66
2 changed files with 133 additions and 0 deletions

View file

@ -33,6 +33,7 @@ mod approvals;
mod build_logs;
mod journal;
mod lifecycle_ops;
mod matrix_accounts;
pub(crate) mod permissions;
mod questions;
mod reminders;
@ -100,6 +101,10 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/api/journal-host", get(journal::get_journal_host))
.route("/api/approval-diff/{id}", get(approvals::get_approval_diff))
.route("/api/state-file", get(state_files::get_state_file))
.route(
"/api/matrix-accounts",
get(matrix_accounts::get_matrix_accounts),
)
.route("/api/reminders", get(reminders::api_reminders))
.route("/api/operator-inbox", get(api_operator_inbox))
.route("/api/stats-hive", get(api_stats_hive))

View file

@ -0,0 +1,128 @@
//! Matrix-account listing for the dashboard (#1698 backend, BE-1).
//!
//! `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 arrive in v2 from the daemon's account
//! registry; here `homeserver` is always `null`.
use axum::extract::Query;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use super::error_response;
use crate::coordinator::Coordinator;
#[derive(Deserialize)]
pub(super) struct MatrixAccountsQuery {
agent: String,
}
#[derive(Serialize)]
struct MatrixAccount {
name: String,
/// Always `None` in v1 (derived from the token filename, which carries no
/// homeserver); populated in v2 from the daemon's account registry.
homeserver: Option<String>,
token_present: bool,
}
#[derive(Serialize)]
struct MatrixAccountsResponse {
accounts: Vec<MatrixAccount>,
}
/// 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())
}
pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) -> Response {
let agent = q.agent.trim();
// Agent names are simple identifiers; reject anything else so a crafted
// `agent` can't escape the per-agent state root via path components.
if agent.is_empty()
|| !agent
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return error_response(&format!("matrix-accounts: invalid agent name {agent:?}"));
}
let dir = Coordinator::agent_notes_dir(agent);
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) {
accounts.push(MatrixAccount {
name,
homeserver: None,
token_present: true,
});
}
}
}
// 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 }).into_response()
}
#[cfg(test)]
mod tests {
use super::account_name_from_filename;
#[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);
}
}