matrix: make the hive-internal main account an ordinary matrixAccounts entry

`matrixAccounts` is meant to be the agent's full account list, but the
hive-internal `main` account was outside it: the nix module emitted only
the extras and `hive-matrix-daemon` prepended a `main` it synthesized
from the per-agent paths, with the option schema forbidding the name
outright.

nix/agent-modules/matrix.nix now declares `main` itself, as an ordinary
entry under `matrix.enable`, from the state-dir paths the module already
used for its token path-watcher (now a shared `stateDir` binding) plus
`matrix.url`. The whole set, `main` included, is serialized to
HIVE_MATRIX_ACCOUNTS.

accounts::configured therefore synthesizes `main` only when the parsed
list carries none, and otherwise takes the declared one verbatim —
hoisting it to index 0, since the daemon reads index 0 as the primary
and nix serializes an attrset, so `main` sorts wherever its key falls.
Declared xor synthesized: an agent whose harness predates this entry
keeps working, a current one gets its own, and there is no arrangement
where `main` is duplicated or missing.

The reserved-name assertion is replaced rather than dropped: the name
must now be legal (the module uses it), but `main`'s tokenFile stays
pinned to `<state>/matrix-token`, since hive-c0re provisions the
hive-internal token there and nowhere else — a retarget would evaluate
fine and then never restore. The other two fields are mkDefault and free
to override.

Refs #4475
This commit is contained in:
atlas 2026-09-18 03:35:00 +02:00 • committed by mara
commit c74249f371
4 changed files with 220 additions and 80 deletions

View file

@ -2,13 +2,18 @@
//!
//! 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` +
//! loop). Accounts come from the `HIVE_MATRIX_ACCOUNTS` env var (JSON,
//! written by the nix harness module from
//! `services.hyperhive.agent.matrixAccounts`), which declares the
//! **hive-internal account** (named `main`) as an ordinary entry
//! alongside any others.
//!
//! `main` is always present and is always the primary: it is moved to
//! index 0 whichever position it was declared at, and if the env var
//! declares no `main` at all (it is unset, or an agent's harness
//! predates the nix module emitting it) one 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
//! `services.hyperhive.agent.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
@ -58,46 +63,29 @@ impl AccountCfg {
}
}
/// 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`.
/// Name of the hive-internal account: the primary, and what a tool call
/// resolves to when it omits `account`.
const HIVE_ACCOUNT: &str = "main";
/// Build the account list: everything declared in `HIVE_MATRIX_ACCOUNTS`,
/// with the hive-internal `main` account hoisted to index 0 (= primary)
/// or synthesized there when the declaration doesn't carry one, plus any
/// dashboard-provisioned accounts discovered on disk.
///
/// 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.
/// single-account agent is unchanged.
///
/// # 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.
/// JSON, or if two declared accounts share a name.
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 declared: Vec<AccountCfg> = match std::env::var_os("HIVE_MATRIX_ACCOUNTS") {
Some(raw) => serde_json::from_str(&raw.to_string_lossy())
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?,
None => Vec::new(),
};
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
);
}
}
let mut accounts = ensure_hive_account(declared)?;
// 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
@ -112,6 +100,56 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
Ok(accounts)
}
/// Put the hive-internal `main` account at index 0 of `declared`, then
/// reject duplicate names.
///
/// `main` is synthesized from the per-agent single-account paths **only
/// if `declared` doesn't already carry one** — the nix harness module
/// emits it as an ordinary `matrixAccounts` entry, so on a current
/// harness the declaration is authoritative and is used verbatim (just
/// moved to the front, since the module serializes an attrset and `main`
/// sorts wherever its key falls). A harness that predates that emits
/// nothing for it, and the synthesized account keeps that agent working
/// unchanged. Never both: `main` is declared xor synthesized, so there
/// is no arrangement where it is duplicated or missing.
///
/// Index 0 is load-bearing: the daemon's startup loop (`main.rs`) takes
/// index 0 as the primary — the account a tool call acts as when it
/// omits `account`, and the only one whose failure to restore is fatal.
///
/// # Errors
///
/// Returns an error if two declared accounts share a name.
fn ensure_hive_account(mut declared: Vec<AccountCfg>) -> anyhow::Result<Vec<AccountCfg>> {
match declared.iter().position(|a| a.name == HIVE_ACCOUNT) {
// Declared: keep it verbatim, just make it the primary. `remove` +
// `insert` rather than `swap`, so the other accounts keep their
// declared order.
Some(idx) => {
let hive = declared.remove(idx);
declared.insert(0, hive);
}
// Not declared: synthesize it from the legacy single-account paths
// + the daemon-wide homeserver.
None => declared.insert(
0,
AccountCfg {
name: HIVE_ACCOUNT.to_owned(),
token_file: paths::token_file(),
state_dir: paths::matrix_state_dir(),
homeserver: None,
},
),
}
let mut seen = std::collections::HashSet::new();
for a in &declared {
if !seen.insert(a.name.as_str()) {
anyhow::bail!("duplicate matrix account name {:?}", a.name);
}
}
Ok(declared)
}
/// 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
@ -417,11 +455,71 @@ fn write_accounts_snapshot_inner(
mod tests {
use std::collections::HashSet;
use std::path::PathBuf;
use super::{
AccountStatus, discover_token_accounts_in, heartbeat_accounts_snapshot, pick_name,
write_accounts_snapshot,
AccountCfg, AccountStatus, discover_token_accounts_in, ensure_hive_account,
heartbeat_accounts_snapshot, pick_name, write_accounts_snapshot,
};
/// A declared account, as the nix module would serialize it.
fn cfg(name: &str) -> AccountCfg {
AccountCfg {
name: name.to_owned(),
token_file: PathBuf::from(format!("/agents/a/state/matrix-token-{name}")),
state_dir: PathBuf::from(format!("/agents/a/state/matrix-sdk-state-{name}")),
homeserver: Some(format!("https://{name}.example")),
}
}
#[test]
fn declared_main_is_used_verbatim_and_not_duplicated() {
// A current harness declares `main` itself; it must be taken as-is
// (its own paths, not the synthesized ones) and must not gain a
// second, synthesized copy.
let out = ensure_hive_account(vec![cfg("main")]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "main");
assert_eq!(
out[0].token_file,
PathBuf::from("/agents/a/state/matrix-token-main")
);
assert_eq!(out[0].homeserver.as_deref(), Some("https://main.example"));
}
#[test]
fn missing_main_is_synthesized_as_primary() {
// A harness predating the nix module's `main` entry declares extras
// only (or nothing at all): `main` is synthesized at index 0 from
// the per-agent paths, so such an agent keeps working.
let out = ensure_hive_account(Vec::new()).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "main");
let out = ensure_hive_account(vec![cfg("public")]).unwrap();
let names: Vec<&str> = out.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["main", "public"]);
// Synthesized, so it carries no per-account homeserver and falls
// back to the daemon-wide `HIVE_MATRIX_URL`.
assert!(out[0].homeserver.is_none());
}
#[test]
fn declared_main_alongside_extras_is_not_a_collision() {
// The nix module serializes an attrset, so `main` arrives wherever
// its key sorts. It is hoisted to index 0 (= primary), the extras
// keep their declared order, and nothing trips the duplicate check.
let out = ensure_hive_account(vec![cfg("ccc"), cfg("main"), cfg("public")]).unwrap();
let names: Vec<&str> = out.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["main", "ccc", "public"]);
}
#[test]
fn two_accounts_of_the_same_name_are_rejected() {
let err = ensure_hive_account(vec![cfg("public"), cfg("public")]).unwrap_err();
assert!(err.to_string().contains("duplicate matrix account name"));
}
#[test]
fn discovery_skips_configured_names_and_orphan_tokens() {
let dir = std::env::temp_dir().join(format!(