From ab153bda2fce23644600f67d7e51a917eb0f3567 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 25 Sep 2026 01:57:25 +0200 Subject: [PATCH] hive-matrix-mcp: read the main account's token from the store too The daemon reads each account's token from `swarm/agents//matrix/` as the agent itself, inside its own container, and falls back to the file only when the store has none. This is #4519's read, without its `main` carve-out: the swarm now mints `main` there and no hive writes the file. The daemon unit gets the agent's store identity, spelled the way forge-token.nix spells it. A timer re-starts it while it is down: a token the swarm mints or replaces in the store changes no file, so the path watcher never fires for it, and a daemon that exited on a replaced token would otherwise stay down until the container restarts. --- Cargo.lock | 1 + hive-matrix-mcp/Cargo.toml | 1 + hive-matrix-mcp/src/client.rs | 86 +++--- hive-matrix-mcp/src/credential.rs | 429 ++++++++++++++++++++++++++++++ hive-matrix-mcp/src/lib.rs | 1 + hive-matrix-mcp/src/main.rs | 79 ++++-- hive-matrix-mcp/src/paths.rs | 4 +- nix/agent-modules/matrix.nix | 81 ++++-- nix/module-eval/agent-matrix.nix | 73 +++++ 9 files changed, 670 insertions(+), 85 deletions(-) create mode 100644 hive-matrix-mcp/src/credential.rs diff --git a/Cargo.lock b/Cargo.lock index 802740dd..21b3cd8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1989,6 +1989,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "swarm-secret-client", "tokio", "tracing", "tracing-subscriber", diff --git a/hive-matrix-mcp/Cargo.toml b/hive-matrix-mcp/Cargo.toml index 5b83916d..1693d81a 100644 --- a/hive-matrix-mcp/Cargo.toml +++ b/hive-matrix-mcp/Cargo.toml @@ -21,6 +21,7 @@ rmcp.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +swarm-secret-client.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index f77619fe..86eaf7c7 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -1,13 +1,15 @@ -//! matrix-sdk `Client` setup for the daemon: read the per-agent access -//! token from the state-dir file `hive-c0re::matrix::ensure_user_for` -//! wrote, probe `whoami` to recover the agent's matrix `user_id` + -//! `device_id`, restore the matrix-sdk session, return the Client ready -//! to start sync. +//! matrix-sdk `Client` setup for the daemon: take the per-agent access +//! token [`crate::credential`] resolved, probe `whoami` to recover the +//! agent's matrix `user_id` + `device_id`, restore the matrix-sdk +//! session, return the Client ready to start sync. +//! +//! 🩸 The token arrives as a [`Token`] and never as a path this module +//! reads β€” where it came from is [`crate::credential`]'s question, and the +//! only thing said about it here is its [`crate::credential::Origin`]. //! //! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's -//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted -//! the token + user/device as the hive's appservice and handed us the -//! bearer in a file. matrix-sdk's `restore_session` with a constructed +//! ccc.de connection): for the swarm's tuwunel the swarm already minted the +//! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed //! `MatrixSession` skips the login flow entirely. //! //! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`. @@ -34,10 +36,12 @@ use matrix_sdk::{ use serde::Deserialize; use tokio::fs; +use crate::credential::Token; + /// Sentinel returned when `build_and_restore` detects that the token is /// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been -/// removed from disk. Callers should NOT retry β€” the account needs -/// re-provisioning by hive-c0re. +/// discarded ([`Token::discard_stale`]). Callers should NOT retry β€” the +/// account needs re-provisioning. /// /// Distinct from the general `anyhow::Error` path so callers can use /// `err.downcast_ref::()` to distinguish "retry @@ -64,63 +68,53 @@ struct WhoamiResponse { } /// Build + restore a matrix-sdk `Client` for the per-agent bearer -/// token at `token_file`. The Client points at `homeserver`, persists -/// its sqlite cache under `state_dir`, and is ready for sync once -/// returned. +/// `token`. The Client points at `homeserver`, persists its sqlite cache +/// under `state_dir`, and is ready for sync once returned. /// /// Steps: -/// 1. Read the bearer token from `token_file` (trim trailing whitespace). -/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with +/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with /// the bearer β€” this gives us back the matrix `user_id` + -/// `device_id` (the registration response had them but hive-c0re -/// only persisted the token; whoami is the cheapest recovery path +/// `device_id` (the registration response had them but only the +/// token was kept; whoami is the cheapest recovery path /// and avoids matrix-sdk's circular requirement of needing a /// session to call whoami). -/// 3. Build the real Client with the sqlite store + `restore_session` +/// 2. Build the real Client with the sqlite store + `restore_session` /// using a synthetic `MatrixSession`. /// /// # Errors /// -/// Returns an error if the token file is missing or empty, if the -/// `whoami` request fails, or if the matrix-sdk client fails to build -/// or restore the session. +/// Returns an error if the `whoami` request fails, or if the matrix-sdk +/// client fails to build or restore the session. pub async fn build_and_restore( homeserver: &str, - token_file: &Path, + token: &Token, state_dir: &Path, is_primary: bool, ) -> Result { - let token = fs::read_to_string(token_file) - .await - .with_context(|| format!("read matrix token from {}", token_file.display()))? - .trim() - .to_owned(); - if token.is_empty() { - return Err(anyhow!("matrix token at {} is empty", token_file.display())); - } - - let (user_id, device_id) = match whoami(homeserver, &token).await { + let (user_id, device_id) = match whoami(homeserver, token.expose()).await { Ok(ids) => ids, Err(e) => { let msg = format!("{e:#}"); if msg.contains("M_UNKNOWN_TOKEN") { // Homeserver rejected our token β€” stale session after a homeserver - // state wipe or token expiry. Delete the stale token file so we - // don't loop on it. The rest of the handling depends on whether - // this is the primary (hive-internal) account or a secondary one, - // because a single bad secondary token must NOT take down the - // whole daemon (and with it every healthy account). + // state wipe or token expiry. Discard it so we don't loop on it + // (which for a store-held credential is a log line and nothing + // else β€” see `Token::discard_stale`). The rest of the handling + // depends on whether this is the primary (hive-internal) account + // or a secondary one, because a single bad secondary token must + // NOT take down the whole daemon (and with it every healthy + // account). tracing::warn!( - path = %token_file.display(), + credential = %token.origin(), is_primary, - "matrix token rejected (M_UNKNOWN_TOKEN); removing stale token" + "matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token" ); - let _ = fs::remove_file(token_file).await; + token.discard_stale().await; if is_primary { // Primary: also drop the matrix-sdk sqlite state keyed to the - // now-invalid session, then exit 0 so hive-c0re's `ensure_all` - // re-provisions the account and the systemd.paths watcher - // restarts us once the fresh token file appears. Exit 0 (not + // now-invalid session, then exit 0; the swarm's backfill + // re-mints the token and systemd restarts us once it + // exists (store re-check timer, or the path watcher). Exit 0 (not // Err) keeps systemd's Restart=on-failure from looping; the // call site is before any tasks are spawned so there's // nothing to clean up. @@ -133,13 +127,13 @@ pub async fn build_and_restore( } std::process::exit(0); } - // Secondary: token removed (so it's cleanly skipped next boot + // Secondary: token discarded (so it's cleanly skipped next boot // rather than re-erroring); leave the sdk state in place in case // the operator re-provisions a fresh token for the same device. // Return a PermanentBringUpError so the caller can distinguish // "don't retry" from a transient network/DNS failure. return Err(anyhow::Error::new(PermanentBringUpError( - "matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(), + "matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, skipping account".into(), ))); } return Err(e); @@ -159,7 +153,7 @@ pub async fn build_and_restore( let session = MatrixSession { meta: SessionMeta { user_id, device_id }, tokens: SessionTokens { - access_token: token, + access_token: token.expose().to_owned(), refresh_token: None, }, }; diff --git a/hive-matrix-mcp/src/credential.rs b/hive-matrix-mcp/src/credential.rs new file mode 100644 index 00000000..8488ba92 --- /dev/null +++ b/hive-matrix-mcp/src/credential.rs @@ -0,0 +1,429 @@ +//! The matrix access token as a value this process holds, and the two places +//! it comes from. +//! +//! 🩸 **A secret is a path, not a value.** Nothing here writes the token +//! anywhere, interpolates it into a command, or lets it reach a log line or an +//! error message: [`Token`] has no `Debug` derive, and every message below +//! names an [`Origin`] β€” which is a path in the store or a path on disk, never +//! the bytes at either. +//! +//! The store read is this agent reading **its own** credential under **its own +//! identity, from inside its own container** β€” the path +//! `swarm/agents//matrix/` is already agent-scoped, and the +//! certificate the login presents is the one this agent's hive delivered as a +//! systemd credential (`nix/agent-modules/bao.nix`). The hive is not in the +//! path of the value at all. +//! +//! The file arm is the hive-side delivery that still runs beside this one for +//! extra accounts (`hive_c0re::workers::credential`), and the `main` token a +//! hive minted before the swarm did. That is what this replaces, not something +//! it depends on, and it is the arm that goes when the hive-side loop does. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use swarm_secret_client::{ + SecretStore, + client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings}, + matrix, policy, +}; + +/// Names the agent this container belongs to, set by +/// `nix/agent-modules/matrix.nix` from the agent's own unix user name. +/// +/// The name and nothing else: `swarm_secret_client` owns both spellings +/// derived from it β€” the credential's path ([`matrix::account_path`]) and the +/// cert-auth role the login selects ([`policy::agent_object_name`]) β€” so a +/// path or a role name forwarded from nix would be a second copy of a string +/// whose mismatch is a 403 that names neither. +pub const ENV_AGENT: &str = "HIVE_AGENT_NAME"; + +/// Where a token came from. +/// +/// A **path** in both arms, which is what makes it safe to put in a log line: +/// the whole point of this module is that the thing beside it never is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Origin { + /// A file in this agent's state dir, written by the hive-side delivery. + File(PathBuf), + /// A path in the swarm secret store, read by this agent as itself. + Store(String), +} + +impl std::fmt::Display for Origin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::File(p) => write!(f, "{}", p.display()), + Self::Store(p) => write!(f, "the store's {p}"), + } + } +} + +/// A matrix access token, held in memory for as long as it takes to restore a +/// session with it. +/// +/// ⚠️ **No `Debug` derive**, for the reason +/// [`swarm_secret_client::mtls::Credential`] states for the private key it +/// carries: this value is threaded through `anyhow` context chains and +/// `tracing` fields, both of which format whatever they are handed. The +/// hand-written impl below reports the origin and the length, which is every +/// question a reader of a log line actually has. +pub struct Token { + value: String, + origin: Origin, +} + +impl std::fmt::Debug for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Token") + .field("origin", &self.origin) + .field( + "value", + &format_args!("", self.value.len()), + ) + .finish() + } +} + +impl Token { + /// The bytes, for the one caller that has to present them to the + /// homeserver. + #[must_use] + pub fn expose(&self) -> &str { + &self.value + } + + /// Where this token came from β€” the only half of it that may be printed. + #[must_use] + pub fn origin(&self) -> &Origin { + &self.origin + } + + /// Read this agent's credential for `account` out of the swarm secret + /// store, under this agent's own certificate. + /// + /// `Ok(None)` means this deployment has no store: the agent's hive was + /// given no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no + /// identity check and there is nothing here to log in to. That is an absent + /// integration, not a failure β€” the caller falls back to the file the hive + /// delivered. + /// + /// # Errors + /// A name that is not a single path segment, an environment naming an + /// identity that cannot be read, a store that refuses the certificate or + /// the path, or a credential stored empty. + pub async fn from_store(agent: &str, account: &str) -> Result> { + let Some(settings) = store_settings(|k| std::env::var(k).ok())? else { + return Ok(None); + }; + let path = matrix::account_path(agent, account) + .context("building this agent's credential path in the store")?; + let role = policy::agent_object_name(agent) + .context("building this agent's cert-auth role name")?; + + let store = SecretStore::connect(&settings, &role, DEFAULT_CERT_MOUNT) + .await + .context("logging in to the swarm secret store as this agent")?; + // The stored object also carries the account's homeserver, and it is + // deliberately dropped here: the account's URL is already settled by + // the time this runs (`AccountCfg::homeserver`), and taking it from the + // store instead would change which accounts come up at all β€” that is + // the discovery half of this move, which goes with the hive-side loop + // rather than with the read. + let credential: matrix::Credential = store + .read(&path) + .await + .with_context(|| format!("reading {path} from the store"))?; + + let value = credential.value.trim().to_owned(); + if value.is_empty() { + return Err(anyhow!("the credential at {path} in the store is empty")); + } + Ok(Some(Self { + value, + origin: Origin::Store(path), + })) + } + + /// Read a token out of the file the hive-side delivery wrote. + /// + /// `Ok(None)` when the file is not there β€” the account has not been + /// provisioned yet, which the caller treats as "skip", not "fail". + /// + /// # Errors + /// The file existing but being unreadable or empty. + pub async fn from_file(path: &Path) -> Result> { + if !tokio::fs::try_exists(path).await.unwrap_or(false) { + return Ok(None); + } + let value = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("read matrix token from {}", path.display()))? + .trim() + .to_owned(); + if value.is_empty() { + return Err(anyhow!("matrix token at {} is empty", path.display())); + } + Ok(Some(Self { + value, + origin: Origin::File(path.to_owned()), + })) + } + + /// Drop this token after the homeserver rejected it, so a restart does not + /// loop on the same dead value. + /// + /// Only a file can be dropped. A credential in the store is not this + /// agent's to delete β€” its policy grants `read` and nothing else β€” and + /// replacing it is renewal, a different job from this one. Saying so is the + /// whole of what the store arm does. + pub async fn discard_stale(&self) { + match &self.origin { + Origin::File(p) => { + let _ = tokio::fs::remove_file(p).await; + } + Origin::Store(path) => tracing::warn!( + credential = %path, + "the stored matrix credential was rejected; leaving it alone (this agent may only read it)" + ), + } + } +} + +/// This agent's name, or `None` when the harness did not say. +/// +/// `None` is not a failure: it is what an agent whose harness predates +/// [`ENV_AGENT`] looks like, and such an agent keeps working off the file its +/// hive delivers. +#[must_use] +pub fn agent_name() -> Option { + std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty()) +} + +/// The store's coordinates as this container was given them, or `None` when it +/// was given none. +/// +/// Split out of [`Token::from_store`] and taken through a lookup so the two +/// decisions it makes β€” "is there a store at all" and "is the delivered CA +/// usable" β€” can be asserted without a store to talk to or an environment to +/// mutate. +/// +/// # Errors +/// [`swarm_secret_client::Error::MissingEnv`] when an address was forwarded but +/// the identity beside it was not, which is a half-delivered container rather +/// than one without a store. +fn store_settings(get: impl Fn(&str) -> Option) -> Result> { + if get(ENV_ADDR).is_none_or(|v| v.is_empty()) { + return Ok(None); + } + let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v))) + .context("reading the swarm secret store's coordinates from the environment")?; + Ok(Some(settings)) +} + +/// Whether the CA bundle at `path` is a file with bytes in it. +/// +/// `BAO_CACERT` names a systemd credential, and the bare `LoadCredential=` form +/// is non-fatal when the manager received no such credential β€” so the variable +/// can name a file that is not there. Absent means "verify the store's listener +/// against the container's own trust store", which is what a deployment with a +/// real CA wants; handing the path through regardless would fail the TLS +/// handshake on a file that was never meant to exist and blame the store. +fn ca_is_usable(path: &str) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.len() > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A lookup standing in for a container that was handed a store. + fn with_store(k: &str) -> Option { + match k { + ENV_ADDR => Some("https://bao.t.local:8200".to_owned()), + "BAO_CLIENT_CERT" => Some("/run/credentials/x/cert".to_owned()), + "BAO_CLIENT_KEY" => Some("/run/credentials/x/key".to_owned()), + _ => None, + } + } + + /// The property the hand-written `Debug` exists for: a token that reaches a + /// log line is a token in a journal somebody else can read. + #[test] + fn formatting_a_token_does_not_reveal_it() { + let token = Token { + value: "syt_SUPER_SECRET_ACCESS_TOKEN".to_owned(), + origin: Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()), + }; + let rendered = format!("{token:?}"); + assert!( + !rendered.contains("syt_SUPER_SECRET_ACCESS_TOKEN"), + "the token must not survive formatting, got {rendered}" + ); + assert!(rendered.contains(" Result<()> { sync_loops.push(sync_loop); } // No token yet: for the primary that means the daemon isn't - // useful β€” exit 0 like the legacy single-account path so the - // systemd path-watcher restarts us when the token appears. + // useful β€” exit 0, and systemd restarts us once a token exists + // (the path-watcher for a file, the re-check timer for the store). Ok(None) if is_primary => { tracing::warn!( account = %cfg.name, "primary matrix account has no token yet; exiting cleanly \ - (systemd restarts us when hive-c0re provisions it)" + (systemd restarts us once one exists)" ); return Ok(()); } @@ -259,6 +262,46 @@ async fn bring_up_secondary_with_retry( None } +/// Resolve `cfg`'s access token, preferring the copy this agent can fetch +/// itself. +/// +/// πŸ›οΈ The store read is the point: the credential at +/// `swarm/agents//matrix/` is **this agent's**, and it is read +/// from inside this agent's container under this agent's own certificate, +/// never by the hive on the agent's behalf. It is held in memory from here to +/// `restore_session` and is written nowhere. +/// +/// `main` included: `swarm-controller` mints it with the swarm's appservice +/// token and stores it at `swarm/agents//matrix/main`, and no hive +/// mints it any more. An agent whose harness forwards no +/// [`credential::ENV_AGENT`] cannot name its own subtree, so it reads the file. +/// +/// The file is what remains when the store answers nothing: an extra account +/// the hive-side delivery loop still writes, or a `main` token a hive minted +/// before the swarm did. A store read that *fails* falls back too, and says +/// why. +/// +/// # Errors +/// As [`credential::Token::from_file`]: a token file that exists but cannot be +/// read, or is empty. +async fn account_token(cfg: &AccountCfg) -> Result> { + if let Some(agent) = credential::agent_name() { + match Token::from_store(&agent, &cfg.name).await { + Ok(Some(token)) => return Ok(Some(token)), + // No store in this deployment: nothing to report, the hive-side + // delivery is the whole mechanism here. + Ok(None) => {} + Err(e) => tracing::warn!( + account = %cfg.name, + error = %format!("{e:#}"), + "could not read this account's credential from the store as this agent; \ + falling back to the token the hive delivered" + ), + } + } + Token::from_file(&cfg.token_file).await +} + /// Restore one account's client (when its token exists), install its /// message handler, and build its sync loop. Returns /// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)` @@ -280,23 +323,19 @@ async fn bring_up_account( ); return Ok(None); }; - if !tokio::fs::try_exists(&cfg.token_file) - .await - .unwrap_or(false) - { + let Some(token) = account_token(cfg).await? else { return Ok(None); - } + }; tracing::info!( account = %cfg.name, homeserver, - token_file = %cfg.token_file.display(), + credential = %token.origin(), state_dir = %cfg.state_dir.display(), "bringing up matrix account" ); - let client = - client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary) - .await - .with_context(|| format!("build matrix client for account {}", cfg.name))?; + let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary) + .await + .with_context(|| format!("build matrix client for account {}", cfg.name))?; // Best-effort: sync the agent icon to this account's matrix avatar over // the live (authenticated, correct-homeserver) Client. Replaces the old // curl oneshot; failures are swallowed inside sync_avatar. diff --git a/hive-matrix-mcp/src/paths.rs b/hive-matrix-mcp/src/paths.rs index 98257c0f..8847f41f 100644 --- a/hive-matrix-mcp/src/paths.rs +++ b/hive-matrix-mcp/src/paths.rs @@ -8,8 +8,8 @@ use std::path::PathBuf; /// Resolve the matrix access-token file path. Override via /// `HIVE_MATRIX_TOKEN_FILE`; default is `/matrix-token`, -/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent -/// account provisioning. +/// where a hive used to write the `main` account's token (the daemon now reads +/// it from the store first; see `main.rs`'s `account_token`). #[must_use] pub fn token_file() -> PathBuf { if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") { diff --git a/nix/agent-modules/matrix.nix b/nix/agent-modules/matrix.nix index 7da62f78..3dc6af81 100644 --- a/nix/agent-modules/matrix.nix +++ b/nix/agent-modules/matrix.nix @@ -14,12 +14,21 @@ }: 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. + # This agent's own state dir, where a hive used to write the `main` + # account's token (`matrix-token`, now the store's fallback) 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"; accounts = config.services.hyperhive.agent.matrixAccounts; + # This agent's own identity at the swarm secret store (./bao.nix). The daemon + # reads each account's token from the store itself, `main` included (the + # swarm mints that one), so the store's coordinates belong on its unit. The + # same three credential ids ./bao.nix and ./forge-token.nix load. + baoCfg = config.services.hyperhive.agent.bao; + storeConfigured = baoCfg.addr != null; + certCredential = "hive-agent-bao-cert"; + keyCredential = "hive-agent-bao-key"; + serverCaCredential = "hive-agent-bao-server-ca"; # **The enable signal.** Matrix is on for this agent exactly when it has at # least one account, because an account is the only thing the daemon has to # do: no account, no Client, no sync, no tool surface worth injecting. @@ -90,8 +99,9 @@ in 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 the hive-internal `main` account, an - operator-supplied secret for an external one). The daemon + (for `main`, a file only when the store has no token: the + swarm mints `main` into the store; 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. ''; @@ -143,7 +153,7 @@ in homeserver (`homeserver`, else `services.hyperhive.agent.matrix.url`). The daemon auto-skips an account whose URL or token file is missing, and a `systemd.paths` - watcher restarts it the moment hive-c0re provisions the token + watcher restarts it the moment a token file appears (same path-trigger shape as `forge-avatar-sync`). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, @@ -173,8 +183,8 @@ in 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 + `/matrix-token` (an assertion; the file the daemon falls back + to when the store has no `main` token), and the dashboard's link-account route refuses to create an account named `main` --- the entry belongs to the module, not to a provisioner. @@ -215,8 +225,8 @@ in # `main` is not 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 + # token file*: the daemon's file fallback for `main` is + # `/matrix-token` and nothing 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). # @@ -227,8 +237,8 @@ in assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token"; message = "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 " + + "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the " + + "main account's token. Declare a separate account instead of " + "pointing `main` elsewhere."; } # Token files must land at the `matrix-token*` name the daemon @@ -260,7 +270,7 @@ in # 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). + # assertion above, since the daemon's fallback reads that path). # # ⚠️ Gated on the homeserver URL, and that gate is what keeps `matrixEnabled` # from being trivially true for every agent in the hive. A `main` with no @@ -309,6 +319,20 @@ in HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock"; RUST_LOG = "info"; } + # The store's coordinates and which agent this is, so the daemon reads + # `swarm/agents//matrix/` as itself. The NAME and not a + # path: `swarm_secret_client` builds both the path and the cert-auth role + # from it. Paths only β€” `%d` is this unit's own credentials directory. + // lib.optionalAttrs storeConfigured { + HIVE_AGENT_NAME = userName; + BAO_ADDR = baoCfg.addr; + BAO_CLIENT_CERT = "%d/${certCredential}"; + BAO_CLIENT_KEY = "%d/${keyCredential}"; + # Named even when no CA was delivered: a bare `LoadCredential=` is + # non-fatal when absent, and the daemon treats a missing or empty file + # as "use the container's own trust store". + BAO_CACERT = "%d/${serverCaCredential}"; + } # Homeserver URL. hive-c0re writes this option per agent from the # hive's own gateway URL (`gatewayHost`'s vhost, `chat.` # by default β€” agents run in a private @@ -357,7 +381,8 @@ in # `on-failure`, not `always`: the daemon deliberately exits 0 # (a clean, non-failure exit) when no token is provisioned yet # (see the module doc above) β€” the `systemd.paths` watcher - # below re-fires it the moment hive-c0re provisions one, + # below re-fires it the moment a token file appears (the timer + # above, for a token in the store), # instead of `always` busy-looping every `RestartSec` until # then. Once a token exists this is no different from # `hive-bash-daemon`'s reasoning (a down window loses the MCP @@ -367,11 +392,33 @@ in RestartSec = 5; User = userName; Group = userName; + } + # This agent's own certificate, imported by id so systemd materialises it + # under this unit's `User=` β€” the same bare form ./forge-token.nix uses. + // lib.optionalAttrs storeConfigured { + LoadCredential = [ + certCredential + keyCredential + serverCaCredential + ]; }; }; - # Re-fire the daemon when the matrix token appears (hive-c0re - # provisions it after agent containers come up). Without this + # Re-start the daemon while it is down, when its token lives in the store. + # The path unit below only sees files, and a token the swarm mints or + # replaces in the store changes no file: a daemon that exited on a missing + # or replaced token would otherwise stay down until the container restarts. + # Relative to the daemon's last exit, so it never fires while the daemon + # runs; five minutes is the swarm's own re-mint cadence. + systemd.timers.hive-matrix-daemon = lib.mkIf (matrixEnabled && storeConfigured) { + description = "re-start hive-matrix-daemon while it is down, to re-read its token from the store"; + wantedBy = [ "timers.target" ]; + timerConfig.OnUnitInactiveSec = "5min"; + }; + + # Re-fire the daemon when the matrix token appears (a token + # file: an extra account the hive delivers, or a `main` from before the + # swarm minted it). Without this # the daemon would exit 0 silently on first boot and the MCP # would have no backend until next restart. See # `docs/agent-lifecycle/persistence.md` (same section as above). diff --git a/nix/module-eval/agent-matrix.nix b/nix/module-eval/agent-matrix.nix index 2d25857d..529a8766 100644 --- a/nix/module-eval/agent-matrix.nix +++ b/nix/module-eval/agent-matrix.nix @@ -18,6 +18,7 @@ let ; }) agent + agentWith runGroup ; @@ -45,6 +46,15 @@ let homeserver = "https://matrix.example.invalid"; }; }; + # The daemon that reads its tokens from the store, `main` included. Paired + # with `agentMatrix` above β€” same daemon, no store β€” so each case below can + # tell "carries the store's coordinates" from "every matrix daemon does". + agentMatrixBao = agentWith { + services.hyperhive.agent.bao.addr = "https://bao.t.local:8200"; + services.hyperhive.agent.matrix.url = "https://chat.t.local"; + }; + + daemon = machine: machine.systemd.services.hive-matrix-daemon; cases = [ { # A homeserver URL is the whole input: from it the module derives the @@ -102,6 +112,69 @@ let # No hive homeserver, so nothing may claim one. && !(env ? HIVE_MATRIX_URL); } + { + # The daemon reads this agent's tokens from the store ITSELF, as itself, + # from inside this container β€” `main` too, since the swarm mints it and + # no hive does. So it needs the same identity ./agent-forge-bao.nix's + # fetch carries, in its own credentials directory. + name = "the matrix daemon carries this agent's own store identity"; + ok = + let + u = daemon agentMatrixBao; + in + u.serviceConfig.LoadCredential == [ + "hive-agent-bao-cert" + "hive-agent-bao-key" + "hive-agent-bao-server-ca" + ] + && u.environment.BAO_ADDR == "https://bao.t.local:8200" + && u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert" + && u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"; + } + { + # The agent's name, and nothing derived from it: the daemon builds its + # store path and its cert-auth role from this one string. + name = "the matrix daemon is told which agent it is and not where its credentials live"; + ok = + let + e = (daemon agentMatrixBao).environment; + in + e.HIVE_AGENT_NAME == agentMatrixBao.services.hyperhive.agent.user.name + && !(lib.any (lib.hasInfix "swarm/agents") (lib.attrValues e)); + } + { + # 🩸 A secret is a path: every `BAO_*` entry is the store's address or a + # file under this unit's own credentials directory, never bytes in an + # environment `/proc//environ` publishes. + name = "the matrix daemon is handed store paths and never store values"; + ok = + let + store = lib.filterAttrs (n: _: lib.hasPrefix "BAO_" n) (daemon agentMatrixBao).environment; + in + store != { } + && lib.all (n: n == "BAO_ADDR" || lib.hasPrefix "%d/" store.${n}) (lib.attrNames store); + } + { + # A token the swarm mints or replaces in the store changes no file, so + # the path watcher never sees it. The timer is what re-starts a daemon + # that exited on a missing or replaced token. + name = "a store-backed matrix daemon is re-started while it is down"; + ok = + agentMatrixBao.systemd.timers.hive-matrix-daemon.timerConfig.OnUnitInactiveSec or null == "5min"; + } + { + # The absence arm for the four above: with no store, no identity, no + # timer, and the file is the whole mechanism. + name = "a matrix daemon on an agent with no store declares no identity and no timer"; + ok = + let + u = daemon agentMatrix; + in + !(u.serviceConfig ? LoadCredential) + && !(u.environment ? BAO_ADDR) + && !(u.environment ? HIVE_AGENT_NAME) + && !(agentMatrix.systemd.timers ? hive-matrix-daemon); + } ]; in runGroup "agent-matrix" cases