matrixAccounts: hive 'main' is implicit primary, option declares extras only

This commit is contained in:
damocles 2026-06-15 21:09:15 +02:00 committed by mara
commit 36fb041c55
2 changed files with 79 additions and 115 deletions

View file

@ -2,16 +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 account list comes from the `HIVE_MATRIX_ACCOUNTS` env
//! var (JSON, written by the nix harness module from
//! `hyperhive.matrixAccounts`); when that var is absent the daemon
//! synthesizes the single legacy account from the existing
//! `HIVE_MATRIX_*` env so every current single-account agent keeps
//! working with zero config change.
//! 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`.
//!
//! The FIRST configured account is the **primary**: it is selected when
//! a tool call omits `account`, so single-account callers never specify
//! one.
//! So a single-account agent (no `HIVE_MATRIX_ACCOUNTS`) gets exactly
//! `main` — zero config, same behaviour as before. The **primary** is
//! `main`: it is selected when a tool call omits `account`, so callers
//! never specify one for the hive account.
use std::collections::HashMap;
use std::path::PathBuf;
@ -51,36 +53,43 @@ impl AccountCfg {
}
}
/// Read the configured account list. Parses `HIVE_MATRIX_ACCOUNTS`
/// (JSON array) when set; otherwise returns the single legacy account
/// built from `HIVE_MATRIX_*` / `HYPERHIVE_STATE_DIR`.
/// 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, is empty, or contains duplicate account names.
/// JSON, or if an extra account's name collides with `main` or another
/// extra.
pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") else {
// Legacy single-account fallback — the only deployed shape until
// the nix `matrixAccounts` option lands.
return Ok(vec![AccountCfg {
name: "default".to_owned(),
token_file: paths::token_file(),
state_dir: paths::matrix_state_dir(),
homeserver: None,
}]);
// 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 raw = raw.to_string_lossy();
let accounts: Vec<AccountCfg> = serde_json::from_str(&raw)
let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") else {
return Ok(vec![hive]);
};
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}"))?;
if accounts.is_empty() {
anyhow::bail!("HIVE_MATRIX_ACCOUNTS is an empty array — declare at least one account");
}
let mut accounts = Vec::with_capacity(extras.len() + 1);
accounts.push(hive);
accounts.extend(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 {:?} in HIVE_MATRIX_ACCOUNTS",
"duplicate matrix account name {:?} (the hive-internal account is named \"main\")",
a.name
);
}

View file

@ -309,11 +309,9 @@ 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 (hive-c0re
for in-hive accounts, an operator-supplied secret for an
external account). The daemon skips an account whose token
file is absent (the primary account being absent makes the
daemon exit cleanly until it appears).
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.
'';
};
sessionDir = lib.mkOption {
@ -342,10 +340,6 @@ in
default = { };
example = lib.literalExpression ''
{
main = {
tokenFile = "/agents/dmatrix/state/matrix-token";
sessionDir = "/agents/dmatrix/state/matrix-sdk-state";
};
ccc = {
tokenFile = "/agents/dmatrix/state/matrix-token-ccc";
sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc";
@ -354,35 +348,27 @@ in
}
'';
description = ''
Declare multiple matrix accounts served by a single
Declare *additional* matrix accounts served by the single
`hive-matrix-daemon` (one matrix-sdk Client + sync loop each),
replacing the wasteful "one MCP server + daemon per account"
pattern. The attribute name keys each account (unique by
construction) and is the handle the matrix MCP tools target via
their `account` argument; omitting `account` on a tool call selects
`hyperhive.matrixPrimaryAccount`.
beyond the agent's built-in hive-internal account. This replaces
the wasteful "one MCP server + daemon per account" pattern. 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
`hyperhive.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).
Leave empty (the default) for the common single-account case: the
daemon then synthesizes one account from `hyperhive.matrix.url` +
`<state>/matrix-token` + `<state>/matrix-sdk-state`, so existing
agents need no change. When non-empty, the set is serialized to the
daemon's `HIVE_MATRIX_ACCOUNTS` environment variable
(primary-account-first) and the legacy single-account fallback is
bypassed.
'';
};
options.hyperhive.matrixPrimaryAccount = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "main";
description = ''
Which `hyperhive.matrixAccounts` key is the agent's primary
account --- the one the matrix MCP tools act as when a tool call
omits its `account` argument. May be left null when exactly one
account is declared (that sole account is then primary); it is
required (asserted) when more than one account is declared. Ignored
when `matrixAccounts` is empty.
agent then has only `main`, exactly as before. When non-empty, the
extras are serialized to the daemon's `HIVE_MATRIX_ACCOUNTS`
environment variable and the daemon appends them after `main`.
Requires `hyperhive.matrix.enable` (there is no `main` to extend
otherwise).
'';
};
@ -774,27 +760,15 @@ in
assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon);
message = "hyperhive.icon must point to an .svg file";
}
# With more than one matrix account, the primary must be named
# explicitly --- there is no inherent order in the attrset to pick
# one from.
# Extra matrix accounts only make sense alongside the hive-internal
# `main` account they extend, which exists only when matrix is
# enabled.
{
assertion =
builtins.length (builtins.attrNames config.hyperhive.matrixAccounts) <= 1
|| config.hyperhive.matrixPrimaryAccount != null;
assertion = config.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable;
message =
"hyperhive.matrixPrimaryAccount must be set when more than one "
+ "hyperhive.matrixAccounts entry is declared (it selects the account "
+ "used when a matrix tool call omits `account`).";
}
# When set, the primary must name an actual declared account.
{
assertion =
config.hyperhive.matrixPrimaryAccount == null
|| builtins.hasAttr config.hyperhive.matrixPrimaryAccount config.hyperhive.matrixAccounts;
message =
"hyperhive.matrixPrimaryAccount (\"${toString config.hyperhive.matrixPrimaryAccount}\") "
+ "must be one of the hyperhive.matrixAccounts keys "
+ "([ ${lib.concatStringsSep " " (builtins.attrNames config.hyperhive.matrixAccounts)} ]).";
"hyperhive.matrixAccounts requires hyperhive.matrix.enable = true "
+ "(the extras extend the hive-internal `main` account, which only "
+ "exists when matrix is enabled).";
}
# hyperhive.frontend.extraFiles[*].target is concatenated into
# $out during the mergedDist build. The option's strMatching
@ -1349,45 +1323,26 @@ in
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
RUST_LOG = "info";
}
# Multi-account: serialize the declared accounts to the JSON the
# daemon parses (`accounts::configured`). Only set when accounts
# are declared, so the single-account agents (the common case)
# keep hitting the daemon's legacy fallback (which is bypassed the
# moment HIVE_MATRIX_ACCOUNTS is present). The `matrixAccounts`
# attrset is keyed by account name (unique by construction); we
# emit a primary-first JSON array (the daemon treats index 0 as the
# primary) with each entry in the daemon's `AccountCfg` serde shape:
# name / token_file / state_dir / optional homeserver.
// lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) (
let
accts = config.hyperhive.matrixAccounts;
names = builtins.attrNames accts;
# Primary: the explicit option, or the sole account's name when
# only one is declared. The assertions below guarantee this
# resolves to a real key.
primary =
if config.hyperhive.matrixPrimaryAccount != null then
config.hyperhive.matrixPrimaryAccount
else
builtins.head names;
# Primary first, then the remaining names (attrNames is sorted).
ordered = [ primary ] ++ builtins.filter (n: n != primary) names;
toEntry =
name:
let
a = accts.${name};
in
# 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.
// lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) {
HIVE_MATRIX_ACCOUNTS = builtins.toJSON (
lib.mapAttrsToList (
name: a:
{
inherit name;
token_file = a.tokenFile;
state_dir = a.sessionDir;
}
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; };
in
{
HIVE_MATRIX_ACCOUNTS = builtins.toJSON (map toEntry ordered);
}
);
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }
) config.hyperhive.matrixAccounts
);
};
serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
Restart = "on-failure";