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:
parent
c7f6c33321
commit
c74249f371
4 changed files with 220 additions and 80 deletions
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Reference in a new issue