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

@ -69,17 +69,22 @@ room you haven't read yet.
## Multiple accounts
`services.hyperhive.agent.matrixAccounts` (declared in `agent.nix`) gives an agent
*additional* matrix identities beyond the hive-internal one — for example an
`services.hyperhive.agent.matrixAccounts` (declared in `agent.nix`) is
the agent's full set of matrix identities — for example an
external-facing account alongside the internal one. Each entry is
keyed by account name and specifies `tokenFile` (bearer token,
provisioned out-of-band; basename must start with `matrix-token`),
`sessionDir` (per-account matrix-sdk sqlite state — crypto keys +
cache), and an optional `homeserver` (defaults to
`services.hyperhive.agent.matrix.url`). The hive-internal account is always named
`main`, synthesized from `services.hyperhive.agent.matrix.url` + agent state — this
option only declares extras, and `main` is a reserved key here.
Requires `services.hyperhive.agent.matrix.enable = true`.
`services.hyperhive.agent.matrix.url`). The hive-internal account is
always named `main` and is always the primary; the matrix module
declares it for you as an ordinary entry of this map, from
`services.hyperhive.agent.matrix.url` + agent state, so what you add
here are the *further* accounts. Its `tokenFile` is pinned to
`<state>/matrix-token` (hive-c0re provisions it there), and the
dashboard's link-account route refuses to create an account by that
name. Declaring extras requires
`services.hyperhive.agent.matrix.enable = true`.
Every matrix tool above takes an optional `account` parameter (a name
from this map) to act as that identity instead of the primary one.

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!(

View file

@ -11,6 +11,11 @@
}:
let
userName = config.services.hyperhive.agent.user.name;
# This agent's own state dir, where hive-c0re provisions the
# hive-internal account's token (`matrix-token`) and the daemon keeps
# its matrix-sdk store. Shared by the `main` account entry below and
# the path-watcher glob at the bottom of this file.
stateDir = "/agents/${userName}/state";
# Rasterize the operator-set agent icon (`services.hyperhive.agent.icon`, an SVG) to a
# 512x512 PNG so the matrix daemon can upload it as each account's avatar
# over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar).
@ -92,9 +97,11 @@ in
description = ''
Path to this account's bearer-token file. The daemon reads
the token from here to restore the matrix session; how the
file gets populated is the provisioner's concern (an
operator-supplied secret for an external account). The
daemon skips an extra account whose token file is absent.
file gets populated is the provisioner's concern
(hive-c0re for the hive-internal `main` account, an
operator-supplied secret for an external one). The daemon
skips an extra account whose token file is absent, and
exits cleanly to wait on the path watcher when `main`'s is.
'';
};
sessionDir = lib.mkOption {
@ -131,24 +138,29 @@ in
}
'';
description = ''
Declare *additional* matrix accounts served by the single
`hive-matrix-daemon` (one matrix-sdk Client + sync loop each),
beyond the agent's built-in hive-internal account. The
attribute name keys each account (unique by construction) and is
the handle the matrix MCP tools target via their `account`
argument.
Every matrix account served by the single `hive-matrix-daemon`
(one matrix-sdk Client + sync loop each). The attribute name keys
each account (unique by construction) and is the handle the matrix
MCP tools target via their `account` argument.
The **hive-internal account is always present and is the primary**:
it is named `main`, synthesized by the daemon from
it is named `main`, and this module declares it for you (when
`services.hyperhive.agent.matrix.enable` is set) from
`services.hyperhive.agent.matrix.url` + `<state>/matrix-token` +
`<state>/matrix-sdk-state`, and is the account a tool call acts as
when it omits `account`. You never declare it here --- this option
is only for the extras (e.g. an external public-matrix account).
`<state>/matrix-sdk-state`. It is the account a tool call acts as
when it omits `account`. It is an ordinary entry of this option
like any other, so it shows up in the account list --- what you
add here are the *further* accounts (e.g. an external
public-matrix account). Its `tokenFile` stays pinned to
`<state>/matrix-token` (an assertion; that is the one path
hive-c0re provisions the hive-internal token to), and the
dashboard's link-account route refuses to create an account named
`main` --- the entry belongs to the module, not to a provisioner.
Leave empty (the default) for the common single-account case: the
agent then has only `main`. When non-empty, the extras are
serialized to the daemon's `HIVE_MATRIX_ACCOUNTS` environment
variable and the daemon appends them after `main`. Requires
Leave it alone (the default) for the common single-account case:
the agent then has only `main`. The whole set is serialized to the
daemon's `HIVE_MATRIX_ACCOUNTS` environment variable, `main`
first. Declaring extras requires
`services.hyperhive.agent.matrix.enable` (there is no `main` to extend otherwise).
'';
};
@ -185,13 +197,22 @@ in
+ "(the extras extend the hive-internal `main` account, which only "
+ "exists when matrix is enabled).";
}
# `main` is reserved for the synthesized hive-internal account; a
# declared extra by that name would silently collide with it.
# `main` is no longer a forbidden key --- this module declares it
# itself (see the `matrixAccounts.main` definition below), so the
# name must be allowed. What stays rejected is retargeting *its
# token file*: hive-c0re writes the hive-internal account's token
# to `<state>/matrix-token` and nowhere else, so an override there
# is an account that evaluates fine and then never restores. The
# other two fields are free to override (a `mkDefault` each).
{
assertion = !builtins.hasAttr "main" config.services.hyperhive.agent.matrixAccounts;
assertion =
!config.services.hyperhive.agent.matrix.enable
|| config.services.hyperhive.agent.matrixAccounts.main.tokenFile == "${stateDir}/matrix-token";
message =
"services.hyperhive.agent.matrixAccounts cannot contain a key named \"main\" "
+ "--- that name is reserved for the hive-internal account.";
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
+ "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the "
+ "hive-internal account's token. Declare a separate account instead of "
+ "pointing `main` elsewhere.";
}
# Token files must land at the `matrix-token*` name the daemon
# path-watcher globs (`matrix-token*` inside this agent's own state
@ -218,6 +239,21 @@ in
}
];
# The hive-internal account as an ordinary `matrixAccounts` entry,
# rather than something the daemon conjures behind the option's
# back: `matrixAccounts` is the list of *all* this agent's accounts,
# so the one it always has belongs in it. `mkDefault` per field so an
# operator can retarget e.g. the homeserver without a
# conflicting-definition error (the token file is pinned by an
# assertion above, since hive-c0re owns that path).
services.hyperhive.agent.matrixAccounts = lib.mkIf config.services.hyperhive.agent.matrix.enable {
main = {
tokenFile = lib.mkDefault "${stateDir}/matrix-token";
sessionDir = lib.mkDefault "${stateDir}/matrix-sdk-state";
homeserver = lib.mkDefault config.services.hyperhive.agent.matrix.url;
};
};
# Auto-inject the matrix MCP entry alongside the bash entry from
# ./mcp.nix. `lib.mkDefault` so the operator's own agent.nix can
# override it. Points at the daemon's own persistent
@ -260,13 +296,14 @@ in
// lib.optionalAttrs (config.services.hyperhive.agent.matrix.url != null) {
HIVE_MATRIX_URL = config.services.hyperhive.agent.matrix.url;
}
# Multi-account: serialize the *extra* accounts to the JSON the
# daemon parses (`accounts::configured`). Only set when extras are
# declared; the daemon always synthesizes the primary `main`
# (hive-internal) account itself from the per-agent paths and
# prepends it, so we emit extras only. Each entry is in the
# daemon's `AccountCfg` serde shape: name (the attr key) /
# token_file / state_dir / optional homeserver.
# Serialize the whole account set --- `main` included --- to the
# JSON the daemon parses (`accounts::configured`). Each entry is in
# the daemon's `AccountCfg` serde shape: name (the attr key) /
# token_file / state_dir / optional homeserver. The daemon hoists
# `main` to primary wherever the attr key sorted, and falls back to
# synthesizing it from the per-agent paths only when this JSON
# carries no `main` --- which is how an agent whose harness
# predates this entry keeps working.
// lib.optionalAttrs (config.services.hyperhive.agent.matrixAccounts != { }) {
HIVE_MATRIX_ACCOUNTS = builtins.toJSON (
lib.mapAttrsToList (
@ -325,7 +362,7 @@ in
# sibling's token still matches, and it fires again until the start
# limit stops it. Scoped to this agent, the condition is false exactly
# when the daemon would have nothing to do.
pathConfig.PathExistsGlob = "/agents/${userName}/state/matrix-token*";
pathConfig.PathExistsGlob = "${stateDir}/matrix-token*";
};
};
}

View file

@ -163,8 +163,8 @@ pub async fn put_matrix_account(
let secret_path = matrix::account_path(&agent, &account)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
// `main` is the hive-internal account `nix/agent-modules/matrix.nix`
// synthesizes per agent from `services.hyperhive.agent.matrix.url` — the schema there
// forbids declaring a key by that name for the same reason this route
// declares per agent from `services.hyperhive.agent.matrix.url` — the
// module owns that `matrixAccounts` entry, which is why this route
// refuses to write one: an extra account literally named `main` would
// not overwrite the real one (it lands at a different token-file suffix)
// but would confuse anything that lists accounts by name. Same guard
@ -173,7 +173,7 @@ pub async fn put_matrix_account(
if is_reserved_account(&account) {
return Err(error_problem(
StatusCode::BAD_REQUEST,
"'main' is the hive-internal account, synthesized per agent from \
"'main' is the hive-internal account, declared per agent from \
services.hyperhive.agent.matrix.url it cannot be set through this route.",
));
}
@ -236,7 +236,7 @@ pub async fn put_matrix_account(
Ok(Json(PutMatrixAccountResponse { user_id }))
}
/// Whether `account` is the hive-internal name every hive synthesizes per
/// Whether `account` is the hive-internal name every hive declares per
/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment
/// for why this route must never write one.
fn is_reserved_account(account: &str) -> bool {