hyperhive/hive-matrix-mcp/src/accounts.rs
atlas 0e9b1c563d fix(#2860): no loopback default for the matrix homeserver
Third and last of #2860's agent-facing URL fallbacks. The operator's
ruling was "any special casing is done on the nix side - same binaries,
no hard coded fallback", so the default is deleted rather than replaced.

Every layer guessed the same wrong thing, and each guess was only ever
correct for a process sharing the host netns:

- nix/agent-modules/matrix.nix: matrixUrlDefault = localhost:8008, both
  as the option's default and as a sentinel the daemon unit compared
  against to decide whether to write HIVE_MATRIX_URL. Now nullOr str,
  default null, the guard is != null, and the doc says what forge.url's
  already says: null means "no matrix", not "guess one".
- nix/host-modules/hive-c0re/environment.nix: forwarded
  http://127.0.0.1:<port> when no gatewayHost was set. hive-c0re shares
  the host netns so it reads as harmless, but the value is handed to
  agents, which do not -- there it names the agent itself. Now forwarded
  only when there is a gateway vhost to name, matching the guard
  HIVE_MATRIX_PUBLIC_URL already uses twelve lines below.
- hive-matrix-mcp: paths::DEFAULT_HOMESERVER was the same address
  compiled in, so dropping the nix defaults alone would have left the
  daemon dialling loopback inside the agent's own netns -- the very bug,
  one layer down. homeserver_url() is now Option, and an account with no
  homeserver is skipped with a log, exactly as one with no token is.
  discover_token_accounts already refused to guess for the same reason.

Two comments taught the assumption back to the next reader ("shared host
netns means every agent container resolves localhost to the same
machine"); both now say which side of the netns boundary they describe.
MATRIX_HTTP keeps its value -- hive-c0re really does share the host
netns -- but no longer claims agents do.

Gated with nix eval against the extended agent-base config, as a pair:
with no url set the daemon unit carries no HIVE_MATRIX_URL, and with one
set it carries exactly that. Either check alone passes on a broken guard.
2026-08-03 20:34:36 +02:00

590 lines
24 KiB
Rust

//! Multi-account configuration + the dispatch registry.
//!
//! A single `hive-matrix-daemon` can serve N matrix accounts (one
//! matrix-sdk `Client` each, with its own session/store dir + sync
//! loop). The **hive-internal account** (named `main`) is always
//! present and is the primary: it is synthesized from the per-agent
//! single-account paths (`<state>/matrix-token` +
//! `<state>/matrix-sdk-state`) and the daemon-wide `HIVE_MATRIX_URL`.
//! Any **extra** accounts come from the `HIVE_MATRIX_ACCOUNTS` env var
//! (JSON, written by the nix harness module from
//! `hyperhive.matrixAccounts`) and are appended after `main`.
//!
//! So a single-account agent (no `HIVE_MATRIX_ACCOUNTS`) gets exactly
//! `main` — zero config, same behaviour as before: omitting `account` on
//! a tool call resolves to `main`. When **multiple** accounts are
//! configured, omitting `account` is rejected with the list of names (see
//! `Registry::resolve` / `pick_name`) — the agent can't tell more than one
//! account exists, so it must choose explicitly.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context as _;
use matrix_sdk::Client;
use serde::{Deserialize, Serialize};
use crate::paths;
/// One declared matrix account. `homeserver` is optional per account
/// (defaults to the daemon-wide `HIVE_MATRIX_URL`) so accounts on the
/// same homeserver need not repeat it.
#[derive(Debug, Clone, Deserialize)]
pub struct AccountCfg {
/// Logical name the agent uses to address this account
/// (`account` arg on the MCP tools). Unique within the daemon.
pub name: String,
/// Path to the bearer-token file for this account (hive-c0re
/// writes it; the daemon reads it).
pub token_file: PathBuf,
/// Per-account matrix-sdk sqlite store dir (crypto keys + cache).
pub state_dir: PathBuf,
/// Homeserver URL; falls back to [`paths::homeserver_url`] when absent.
#[serde(default)]
pub homeserver: Option<String>,
}
impl AccountCfg {
/// The effective homeserver URL — this account's own, else the
/// daemon-wide `HIVE_MATRIX_URL` — or `None` when neither is set.
///
/// `None` is a real answer, not a failure: the account is skipped, the
/// same way [`discover_token_accounts_in`] already skips a discovered
/// token whose homeserver sidecar is missing.
#[must_use]
pub fn homeserver(&self) -> Option<String> {
self.homeserver.clone().or_else(paths::homeserver_url)
}
}
/// Build the account list: the always-present hive-internal `main`
/// account (primary, synthesized from the per-agent single-account
/// paths) followed by any extras declared in `HIVE_MATRIX_ACCOUNTS`.
///
/// With no `HIVE_MATRIX_ACCOUNTS` set this returns just `main`, so a
/// single-account agent is unchanged. When set, the env var carries
/// only the *extra* accounts (the nix `matrixAccounts` option never
/// redeclares the hive account); they are appended after `main`, which
/// stays index 0 = primary.
///
/// # Errors
///
/// Returns an error if `HIVE_MATRIX_ACCOUNTS` is set but is not valid
/// JSON, or if an extra account's name collides with `main` or another
/// extra.
pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
// The hive-internal account: always primary, named `main`, built
// from the legacy single-account paths + the daemon-wide homeserver.
let hive = AccountCfg {
name: "main".to_owned(),
token_file: paths::token_file(),
state_dir: paths::matrix_state_dir(),
homeserver: None,
};
let mut accounts = vec![hive];
if let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") {
let extras: Vec<AccountCfg> = serde_json::from_str(&raw.to_string_lossy())
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
accounts.extend(extras);
}
// Reject duplicate names among the *configured* set (hive + extras).
let mut seen = std::collections::HashSet::new();
for a in &accounts {
if !seen.insert(a.name.as_str()) {
anyhow::bail!(
"duplicate matrix account name {:?} (the hive-internal account is named \"main\")",
a.name
);
}
}
// Append dashboard-provisioned accounts (a `matrix-token-<name>` file +
// its `matrix-account-<name>.json` homeserver sidecar) that aren't
// already declared in config, so an account logged in via the dashboard
// form works without a `matrixAccounts` edit + rebuild. Explicit config
// wins on name collision — pass the configured set so discovery skips
// those names silently (a statically-configured account keeps its token
// on disk but is brought up from config, not discovery, so it must not
// log a spurious "no homeserver sidecar" warning).
let configured: std::collections::HashSet<String> =
accounts.iter().map(|a| a.name.clone()).collect();
accounts.extend(discover_token_accounts(&configured));
Ok(accounts)
}
/// Scan the agent state dir for extra matrix accounts provisioned via the
/// dashboard login form: each is a `matrix-token-<name>` file plus a
/// `matrix-account-<name>.json` sidecar carrying the homeserver. Returns one
/// [`AccountCfg`] per discovered account that has BOTH files — a token
/// without a sidecar is skipped, because the homeserver is then unknown and
/// defaulting to the hive homeserver would be wrong for an external account.
/// Best-effort: an unreadable dir or malformed sidecar yields fewer
/// accounts, never an error — explicit `HIVE_MATRIX_ACCOUNTS` config stays
/// authoritative.
///
/// `configured` is the set of names already declared in
/// `HIVE_MATRIX_ACCOUNTS`; those are brought up from config regardless of
/// their on-disk sidecar, so discovery skips them silently rather than
/// warning about a missing sidecar for an account that isn't actually down.
fn discover_token_accounts(configured: &std::collections::HashSet<String>) -> Vec<AccountCfg> {
let token_path = paths::token_file();
let Some(state_dir) = token_path.parent() else {
return Vec::new();
};
discover_token_accounts_in(state_dir, configured)
}
/// Body of [`discover_token_accounts`] with the state dir injected, so the
/// scan (and the configured-name skip) is unit-testable against a tempdir.
fn discover_token_accounts_in(
state_dir: &Path,
configured: &std::collections::HashSet<String>,
) -> Vec<AccountCfg> {
let Ok(rd) = std::fs::read_dir(state_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let fname = entry.file_name();
let Some(fname) = fname.to_str() else {
continue;
};
// `matrix-token` (no suffix) is the hive account, handled separately;
// only `matrix-token-<name>` files are extra accounts.
let Some(name) = fname.strip_prefix("matrix-token-") else {
continue;
};
if name.is_empty() {
continue;
}
// Already declared in config: it's brought up from `HIVE_MATRIX_ACCOUNTS`,
// not discovery, and its on-disk token needs no sidecar. Skip silently so
// a statically-configured account doesn't log a spurious missing-sidecar
// warning.
if configured.contains(name) {
continue;
}
let sidecar = state_dir.join(format!("matrix-account-{name}.json"));
let Some(homeserver) = read_account_homeserver(&sidecar) else {
tracing::warn!(
account = name,
sidecar = %sidecar.display(),
"matrix: discovered token but no homeserver sidecar; skipping account \
(re-login via the dashboard to write it)"
);
continue;
};
out.push(AccountCfg {
name: name.to_owned(),
token_file: entry.path(),
state_dir: state_dir.join(format!("matrix-sdk-state-{name}")),
homeserver: Some(homeserver),
});
}
out
}
/// Read `{"homeserver": "<url>"}` from a sidecar file. `None` when the file
/// is missing, unreadable, not valid JSON, or the `homeserver` field is
/// absent / empty.
fn read_account_homeserver(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
json.get("homeserver")?
.as_str()
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
}
/// Live status of one matrix account, as reported by [`Registry::list`]
/// (the `list_accounts` daemon op). Only accounts that successfully
/// restored a session appear, so `live` is always `true` today; the
/// field is kept so a future "configured but down" entry can report
/// `false` without a wire-shape change.
#[derive(Debug, Serialize)]
pub struct AccountStatus {
/// Logical account name (the `account` arg on the MCP tools).
pub name: String,
/// Effective homeserver URL the restored client is talking to.
pub homeserver: String,
/// The account's own matrix user id (`@user:server`), when known.
pub user_id: Option<String>,
/// Whether the account has a live, restored client. Always `true`
/// for registry entries today (the registry only holds restored
/// accounts); reserved for future configured-but-down reporting.
pub live: bool,
/// Whether this is the primary account (selected when a tool call
/// omits `account`).
pub is_primary: bool,
}
/// Account name → live `Client` map plus the primary-account name used
/// when a request omits `account`. Built once at daemon startup from
/// the accounts that successfully restored a session.
pub struct Registry {
primary: String,
by_name: HashMap<String, Arc<Client>>,
}
/// Pick which account name a request resolves to, or an error. Pure (no
/// `Client`) so the ambiguity rule is unit-testable. `names` is the sorted
/// set of configured account names.
///
/// - `Some(name)` → that name (membership is checked by the caller).
/// - `None` with a single account → the primary (zero-config default).
/// - `None` with multiple accounts → an error: the agent can't tell more
/// than one account exists, so force an explicit `account` and list the
/// choices.
fn pick_name<'a>(
account: Option<&'a str>,
primary: &'a str,
names: &[&str],
) -> Result<&'a str, String> {
match account {
Some(name) => Ok(name),
None if names.len() <= 1 => Ok(primary),
None => Err(format!(
"multiple matrix accounts configured; pass `account` explicitly — available: [{}]",
names.join(", ")
)),
}
}
impl Registry {
/// Build an empty registry whose primary is `primary`. Clients are
/// added with [`Registry::insert`] as each account restores.
#[must_use]
pub fn new(primary: String) -> Self {
Self {
primary,
by_name: HashMap::new(),
}
}
/// Register a restored client under `name`.
pub fn insert(&mut self, name: String, client: Client) {
self.by_name.insert(name, Arc::new(client));
}
/// Whether any account restored successfully.
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
/// Snapshot every restored account: name, homeserver, user id, and
/// primary flag. Registry membership == a session restored, so every
/// entry is reported `live`. Sorted primary-first then by name for a
/// stable order in the dashboard. Account-agnostic — the caller does
/// not resolve a single client (see `MatrixMcp::resolve` in
/// `crate::mcp`).
#[must_use]
pub fn list(&self) -> Vec<AccountStatus> {
let mut out: Vec<AccountStatus> = self
.by_name
.iter()
.map(|(name, client)| AccountStatus {
name: name.clone(),
homeserver: client.homeserver().to_string(),
user_id: client.user_id().map(ToString::to_string),
live: true,
is_primary: *name == self.primary,
})
.collect();
out.sort_by(|a, b| {
b.is_primary
.cmp(&a.is_primary)
.then_with(|| a.name.cmp(&b.name))
});
out
}
/// Resolve a request's `account` to a client. `None` selects the
/// primary account *only when it's unambiguous* — a single configured
/// account. With multiple accounts, omitting `account` is an error
/// (see `pick_name`): the agent can't tell more than one exists, so we
/// force an explicit choice and list the names. Returns a
/// human-readable error (listing known accounts) surfaced to the agent
/// as a tool error.
///
/// # Errors
///
/// Errors when `account` is omitted but multiple accounts are
/// configured, or when the named account (or the primary, if `None`)
/// has no live client — e.g. an unknown name, or the primary failed to
/// restore at startup.
pub fn resolve(&self, account: Option<&str>) -> Result<&Arc<Client>, String> {
let mut names: Vec<&str> = self.by_name.keys().map(String::as_str).collect();
names.sort_unstable();
let name = pick_name(account, &self.primary, &names)?;
self.by_name.get(name).ok_or_else(|| {
format!(
"unknown matrix account {name:?}; available accounts: [{}]",
names.join(", ")
)
})
}
/// Publish the live-account snapshot ([`Registry::list`]) to `path`.
/// Thin wrapper over [`write_accounts_snapshot`] (split out so the
/// atomic-write logic is unit-testable without a live `Client`).
/// Called once at daemon startup after all restores.
///
/// # Errors
/// Propagates filesystem errors from the atomic write.
pub fn write_snapshot(&self, path: &Path) -> anyhow::Result<()> {
write_accounts_snapshot(path, &self.list())
}
/// Heartbeat the snapshot: rewrite it unconditionally so the file
/// mtime advances even when the account set is unchanged. The daemon
/// calls this on a timer (see `main`) so the dashboard's `as_of`
/// (derived from the file mtime) tracks real daemon liveness instead
/// of freezing at the boot-time write — a stalled mtime then means
/// the daemon is down, which the dashboard can render as stale/dimmed.
///
/// # Errors
/// Propagates filesystem errors from the atomic write.
pub fn heartbeat_snapshot(&self, path: &Path) -> anyhow::Result<()> {
heartbeat_accounts_snapshot(path, &self.list())
}
}
/// Atomically write `accounts` as pretty-printed JSON to `path`
/// (`<path>.tmp` + rename) so a dashboard reader never sees a partial
/// file. Idempotent — skips the rewrite when the on-disk content already
/// matches, keeping the mtime stable. Used for the boot-time publish.
///
/// The daemon rebuilds this file fresh on every boot (and is restarted
/// by the `matrix-token*` path-watcher when a new account is
/// provisioned), so the file lists exactly the accounts that restored at
/// the last start. Real-time liveness is conveyed by the file mtime,
/// which the daemon advances on a timer via
/// [`heartbeat_accounts_snapshot`] — a stalled mtime means the daemon is
/// down, so a reader can treat an old snapshot as stale.
///
/// # Errors
/// Returns an error if the parent dir can't be created or the
/// write/rename fails.
pub fn write_accounts_snapshot(path: &Path, accounts: &[AccountStatus]) -> anyhow::Result<()> {
write_accounts_snapshot_inner(path, accounts, false)
}
/// Like [`write_accounts_snapshot`] but always rewrites (tmp + rename)
/// even when the on-disk content is byte-identical, so the file mtime
/// advances. The daemon calls this on a periodic heartbeat so the
/// dashboard's `as_of` (the file mtime) reflects daemon liveness rather
/// than freezing at the boot-time write.
///
/// # Errors
/// Returns an error if the parent dir can't be created or the
/// write/rename fails.
pub fn heartbeat_accounts_snapshot(path: &Path, accounts: &[AccountStatus]) -> anyhow::Result<()> {
write_accounts_snapshot_inner(path, accounts, true)
}
/// Shared body for [`write_accounts_snapshot`] (idempotent) and
/// [`heartbeat_accounts_snapshot`] (`force`). When `force` is false the
/// rewrite is skipped if the on-disk content already matches, keeping the
/// mtime stable; when true the tmp + rename always runs so the mtime
/// advances.
fn write_accounts_snapshot_inner(
path: &Path,
accounts: &[AccountStatus],
force: bool,
) -> anyhow::Result<()> {
let body =
serde_json::to_string_pretty(accounts).expect("Vec<AccountStatus> is always serialisable");
if !force && std::fs::read_to_string(path).ok().as_deref() == Some(body.as_str()) {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| {
format!(
"rename {} -> {} (atomic publish)",
tmp.display(),
path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::{
AccountStatus, discover_token_accounts_in, heartbeat_accounts_snapshot, pick_name,
write_accounts_snapshot,
};
#[test]
fn discovery_skips_configured_names_and_orphan_tokens() {
let dir = std::env::temp_dir().join(format!(
"hh-acct-disc-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
// A statically-configured account: token on disk, NO sidecar.
std::fs::write(dir.join("matrix-token-catgirl"), "tok").unwrap();
// An orphan dashboard token: no sidecar, not configured.
std::fs::write(dir.join("matrix-token-orphan"), "tok").unwrap();
// A fully dashboard-provisioned account: token + sidecar.
std::fs::write(dir.join("matrix-token-good"), "tok").unwrap();
std::fs::write(
dir.join("matrix-account-good.json"),
r#"{"homeserver":"https://good.example"}"#,
)
.unwrap();
// The hive account's own token must never be treated as an extra.
std::fs::write(dir.join("matrix-token"), "tok").unwrap();
let configured: HashSet<String> = ["main", "catgirl"]
.iter()
.map(|s| (*s).to_owned())
.collect();
let mut found: Vec<String> = discover_token_accounts_in(&dir, &configured)
.into_iter()
.map(|a| a.name)
.collect();
found.sort();
// catgirl (configured) + orphan (no sidecar) skipped; only `good` discovered.
assert_eq!(found, vec!["good".to_owned()]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn pick_name_single_account_defaults_to_primary() {
assert_eq!(pick_name(None, "main", &["main"]).unwrap(), "main");
}
#[test]
fn pick_name_multi_account_omitted_errors_with_menu() {
let err = pick_name(None, "main", &["main", "public"]).unwrap_err();
assert!(err.contains("pass `account` explicitly"));
assert!(err.contains("main"));
assert!(err.contains("public"));
}
#[test]
fn pick_name_explicit_passes_through_even_with_multiple() {
assert_eq!(
pick_name(Some("public"), "main", &["main", "public"]).unwrap(),
"public"
);
}
fn sample() -> Vec<AccountStatus> {
vec![
AccountStatus {
name: "main".to_owned(),
homeserver: "http://localhost:8008".to_owned(),
user_id: Some("@agent:hive".to_owned()),
live: true,
is_primary: true,
},
AccountStatus {
name: "public".to_owned(),
homeserver: "https://matrix.org".to_owned(),
user_id: None,
live: true,
is_primary: false,
},
]
}
#[test]
fn snapshot_writes_json_and_cleans_up_tmp() {
let dir = std::env::temp_dir().join(format!(
"hh-acct-snap-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("matrix-accounts.json");
write_accounts_snapshot(&path, &sample()).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
// Round-trips to the same shape the dashboard consumes.
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed[0]["name"], "main");
assert_eq!(parsed[0]["live"], true);
assert_eq!(parsed[0]["is_primary"], true);
assert_eq!(parsed[1]["homeserver"], "https://matrix.org");
assert!(parsed[1]["user_id"].is_null());
// No leftover temp file after the atomic publish.
assert!(!path.with_extension("json.tmp").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn snapshot_is_idempotent_when_unchanged() {
let dir = std::env::temp_dir().join(format!(
"hh-acct-idem-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("matrix-accounts.json");
write_accounts_snapshot(&path, &sample()).unwrap();
let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap();
// Second write with identical content must skip the rename (mtime
// stays put), so inotify watchers don't see a spurious change.
write_accounts_snapshot(&path, &sample()).unwrap();
let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_eq!(mtime1, mtime2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn heartbeat_advances_mtime_even_when_unchanged() {
let dir = std::env::temp_dir().join(format!(
"hh-acct-hb-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("matrix-accounts.json");
write_accounts_snapshot(&path, &sample()).unwrap();
let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap();
// The heartbeat must rewrite (tmp + rename) even when the content
// is byte-identical, so the mtime advances and the dashboard reads
// a fresh `as_of`. Small sleep so the new mtime is strictly later
// than the first (tmpfs/ext4 have sub-second mtime resolution).
std::thread::sleep(std::time::Duration::from_millis(20));
heartbeat_accounts_snapshot(&path, &sample()).unwrap();
let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(mtime2 > mtime1, "heartbeat should advance mtime");
// Content is still the same shape, and no leftover temp file.
let written = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed[0]["name"], "main");
assert!(!path.with_extension("json.tmp").exists());
std::fs::remove_dir_all(&dir).ok();
}
}