From c74249f37150a5804da50b15f8d22600e2259284 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 18 Sep 2026 03:35:00 +0200 Subject: [PATCH] matrix: make the hive-internal `main` account an ordinary matrixAccounts entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `/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 --- docs/tools/matrix.md | 17 ++- hive-matrix-mcp/src/accounts.rs | 178 +++++++++++++++++++------ nix/agent-modules/matrix.nix | 97 +++++++++----- swarm-controller/src/matrix_account.rs | 8 +- 4 files changed, 220 insertions(+), 80 deletions(-) diff --git a/docs/tools/matrix.md b/docs/tools/matrix.md index 147313a8..b6ea6238 100644 --- a/docs/tools/matrix.md +++ b/docs/tools/matrix.md @@ -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 +`/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. diff --git a/hive-matrix-mcp/src/accounts.rs b/hive-matrix-mcp/src/accounts.rs index c20de1c8..6d3b673f 100644 --- a/hive-matrix-mcp/src/accounts.rs +++ b/hive-matrix-mcp/src/accounts.rs @@ -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 (`/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 (`/matrix-token` + //! `/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> { - // 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 = 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 = 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-` file + // its `matrix-account-.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> { 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) -> anyhow::Result> { + 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-` file plus a /// `matrix-account-.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!( diff --git a/nix/agent-modules/matrix.nix b/nix/agent-modules/matrix.nix index 760ff7c8..33f88053 100644 --- a/nix/agent-modules/matrix.nix +++ b/nix/agent-modules/matrix.nix @@ -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` + `/matrix-token` + - `/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). + `/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 + `/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 `/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*"; }; }; } diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 971c97c6..310c6680 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -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 {