hive-matrix-mcp: read the main account's token from the store too

The daemon reads each account's token from `swarm/agents/<agent>/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.
This commit is contained in:
atlas 2026-09-25 01:57:25 +02:00 • committed by mara
commit ab153bda2f
9 changed files with 670 additions and 85 deletions

1
Cargo.lock generated
View file

@ -1989,6 +1989,7 @@ dependencies = [
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
"swarm-secret-client",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",

View file

@ -21,6 +21,7 @@ rmcp.workspace = true
schemars.workspace = true schemars.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
swarm-secret-client.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

View file

@ -1,13 +1,15 @@
//! matrix-sdk `Client` setup for the daemon: read the per-agent access //! matrix-sdk `Client` setup for the daemon: take the per-agent access
//! token from the state-dir file `hive-c0re::matrix::ensure_user_for` //! token [`crate::credential`] resolved, probe `whoami` to recover the
//! wrote, probe `whoami` to recover the agent's matrix `user_id` + //! agent's matrix `user_id` + `device_id`, restore the matrix-sdk
//! `device_id`, restore the matrix-sdk session, return the Client ready //! session, return the Client ready to start sync.
//! 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 //! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted //! ccc.de connection): for the swarm's tuwunel the swarm already minted the
//! the token + user/device as the hive's appservice and handed us the //! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed
//! bearer in a file. matrix-sdk's `restore_session` with a constructed
//! `MatrixSession` skips the login flow entirely. //! `MatrixSession` skips the login flow entirely.
//! //!
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`. //! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
@ -34,10 +36,12 @@ use matrix_sdk::{
use serde::Deserialize; use serde::Deserialize;
use tokio::fs; use tokio::fs;
use crate::credential::Token;
/// Sentinel returned when `build_and_restore` detects that the token is /// Sentinel returned when `build_and_restore` detects that the token is
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been /// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
/// removed from disk. Callers should NOT retry — the account needs /// discarded ([`Token::discard_stale`]). Callers should NOT retry — the
/// re-provisioning by hive-c0re. /// account needs re-provisioning.
/// ///
/// Distinct from the general `anyhow::Error` path so callers can use /// Distinct from the general `anyhow::Error` path so callers can use
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry /// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
@ -64,63 +68,53 @@ struct WhoamiResponse {
} }
/// Build + restore a matrix-sdk `Client` for the per-agent bearer /// Build + restore a matrix-sdk `Client` for the per-agent bearer
/// token at `token_file`. The Client points at `homeserver`, persists /// `token`. The Client points at `homeserver`, persists its sqlite cache
/// its sqlite cache under `state_dir`, and is ready for sync once /// under `state_dir`, and is ready for sync once returned.
/// returned.
/// ///
/// Steps: /// Steps:
/// 1. Read the bearer token from `token_file` (trim trailing whitespace). /// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
/// the bearer — this gives us back the matrix `user_id` + /// the bearer — this gives us back the matrix `user_id` +
/// `device_id` (the registration response had them but hive-c0re /// `device_id` (the registration response had them but only the
/// only persisted the token; whoami is the cheapest recovery path /// token was kept; whoami is the cheapest recovery path
/// and avoids matrix-sdk's circular requirement of needing a /// and avoids matrix-sdk's circular requirement of needing a
/// session to call whoami). /// 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`. /// using a synthetic `MatrixSession`.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the token file is missing or empty, if the /// Returns an error if the `whoami` request fails, or if the matrix-sdk
/// `whoami` request fails, or if the matrix-sdk client fails to build /// client fails to build or restore the session.
/// or restore the session.
pub async fn build_and_restore( pub async fn build_and_restore(
homeserver: &str, homeserver: &str,
token_file: &Path, token: &Token,
state_dir: &Path, state_dir: &Path,
is_primary: bool, is_primary: bool,
) -> Result<Client> { ) -> Result<Client> {
let token = fs::read_to_string(token_file) let (user_id, device_id) = match whoami(homeserver, token.expose()).await {
.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 {
Ok(ids) => ids, Ok(ids) => ids,
Err(e) => { Err(e) => {
let msg = format!("{e:#}"); let msg = format!("{e:#}");
if msg.contains("M_UNKNOWN_TOKEN") { if msg.contains("M_UNKNOWN_TOKEN") {
// Homeserver rejected our token — stale session after a homeserver // Homeserver rejected our token — stale session after a homeserver
// state wipe or token expiry. Delete the stale token file so we // state wipe or token expiry. Discard it so we don't loop on it
// don't loop on it. The rest of the handling depends on whether // (which for a store-held credential is a log line and nothing
// this is the primary (hive-internal) account or a secondary one, // else — see `Token::discard_stale`). The rest of the handling
// because a single bad secondary token must NOT take down the // depends on whether this is the primary (hive-internal) account
// whole daemon (and with it every healthy 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!( tracing::warn!(
path = %token_file.display(), credential = %token.origin(),
is_primary, 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 { if is_primary {
// Primary: also drop the matrix-sdk sqlite state keyed to the // Primary: also drop the matrix-sdk sqlite state keyed to the
// now-invalid session, then exit 0 so hive-c0re's `ensure_all` // now-invalid session, then exit 0; the swarm's backfill
// re-provisions the account and the systemd.paths watcher // re-mints the token and systemd restarts us once it
// restarts us once the fresh token file appears. Exit 0 (not // exists (store re-check timer, or the path watcher). Exit 0 (not
// Err) keeps systemd's Restart=on-failure from looping; the // Err) keeps systemd's Restart=on-failure from looping; the
// call site is before any tasks are spawned so there's // call site is before any tasks are spawned so there's
// nothing to clean up. // nothing to clean up.
@ -133,13 +127,13 @@ pub async fn build_and_restore(
} }
std::process::exit(0); 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 // rather than re-erroring); leave the sdk state in place in case
// the operator re-provisions a fresh token for the same device. // the operator re-provisions a fresh token for the same device.
// Return a PermanentBringUpError so the caller can distinguish // Return a PermanentBringUpError so the caller can distinguish
// "don't retry" from a transient network/DNS failure. // "don't retry" from a transient network/DNS failure.
return Err(anyhow::Error::new(PermanentBringUpError( 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); return Err(e);
@ -159,7 +153,7 @@ pub async fn build_and_restore(
let session = MatrixSession { let session = MatrixSession {
meta: SessionMeta { user_id, device_id }, meta: SessionMeta { user_id, device_id },
tokens: SessionTokens { tokens: SessionTokens {
access_token: token, access_token: token.expose().to_owned(),
refresh_token: None, refresh_token: None,
}, },
}; };

View file

@ -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/<agent>/matrix/<account>` 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!("<redacted, {} bytes>", 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<Option<Self>> {
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<Option<Self>> {
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<String> {
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<String>) -> Result<Option<Settings>> {
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<String> {
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("<redacted"), "got {rendered}");
// The control: the origin IS meant to survive, so the assertion above
// is not passing because the impl prints nothing at all.
assert!(
rendered.contains("swarm/agents/a1/matrix/ccc"),
"got {rendered}"
);
}
/// The same property for the arm a reader actually reaches for: `Display`
/// on the origin is what every message in this crate interpolates.
#[test]
fn an_origin_renders_as_a_path_in_both_arms() {
assert_eq!(
Origin::File(PathBuf::from("/agents/a1/state/matrix-token-ccc")).to_string(),
"/agents/a1/state/matrix-token-ccc"
);
assert_eq!(
Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()).to_string(),
"the store's swarm/agents/a1/matrix/ccc"
);
}
#[test]
fn a_container_with_no_store_address_reads_no_store() {
// The absence arm, and what the file fallback exists for: a hive that
// was given no store forwards nothing, and this must be "there is no
// store" rather than an error about a missing certificate.
assert!(
store_settings(|_| None)
.expect("an absent store is not a failure")
.is_none()
);
// Empty is how systemd delivers an unset nix option, so it has to read
// the same as unset.
assert!(
store_settings(|k| if k == ENV_ADDR {
Some(String::new())
} else {
with_store(k)
})
.expect("an empty address is not an address")
.is_none()
);
}
#[test]
fn an_address_without_an_identity_is_a_half_delivered_container() {
// Not the same thing as having no store: the hive said where the store
// is and then delivered nothing to present to it, which is a deployment
// bug and must be named rather than silently degraded.
let e = store_settings(|k| if k == ENV_ADDR { with_store(k) } else { None })
.expect_err("an address with no certificate beside it is a failure");
assert!(e.to_string().contains("coordinates"), "got {e:#}");
}
#[test]
fn a_store_address_with_an_identity_is_usable() {
// The control for the two arms above.
assert!(
store_settings(with_store)
.expect("a full environment")
.is_some()
);
}
#[test]
fn a_ca_that_was_never_delivered_is_dropped_rather_than_handed_on() {
// `LoadCredential=` in its bare form is non-fatal when the credential
// is absent, so this variable routinely names a file that is not there.
// The resulting settings must be the ones a container with no CA at all
// gets — anything else fails the handshake and blames the store.
let named = store_settings(|k| {
if k == ENV_CACERT {
Some("/nonexistent/server-ca.pem".to_owned())
} else {
with_store(k)
}
})
.expect("a full environment")
.expect("a store is configured");
let absent = store_settings(with_store)
.expect("a full environment")
.expect("a store is configured");
assert_eq!(named, absent);
}
#[test]
fn a_ca_with_bytes_in_it_is_kept() {
// The control: without it, the test above would pass against a
// correction that dropped every CA it was ever given.
let dir = std::env::temp_dir().join(format!(
"hh-cred-ca-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let ca = dir.join("server-ca.pem");
std::fs::write(&ca, "-----BEGIN CERTIFICATE-----\n").unwrap();
assert!(ca_is_usable(ca.to_str().unwrap()));
let kept = store_settings(|k| {
if k == ENV_CACERT {
Some(ca.to_string_lossy().into_owned())
} else {
with_store(k)
}
})
.expect("a full environment")
.expect("a store is configured");
assert_ne!(
kept,
store_settings(with_store)
.expect("a full environment")
.expect("configured"),
"a delivered CA must change the settings it is delivered into"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn an_unprovisioned_account_has_no_token_rather_than_an_error() {
// The daemon skips such an account (a secondary) or waits on the path
// watcher (the primary); neither is a failure.
let missing = std::env::temp_dir().join("hh-cred-definitely-not-here/matrix-token");
assert!(Token::from_file(&missing).await.unwrap().is_none());
}
#[tokio::test]
async fn an_empty_token_file_names_the_file_and_not_its_contents() {
let dir = std::env::temp_dir().join(format!(
"hh-cred-empty-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("matrix-token");
std::fs::write(&path, " \n").unwrap();
let e = Token::from_file(&path)
.await
.expect_err("whitespace is not a token");
assert!(
e.to_string().contains(&path.display().to_string()),
"got {e:#}"
);
// And the control: a real token comes back, trimmed, with the file as
// its origin.
std::fs::write(&path, "syt_abc\n").unwrap();
let token = Token::from_file(&path)
.await
.expect("a readable file")
.expect("a non-empty one");
assert_eq!(token.expose(), "syt_abc");
assert_eq!(token.origin(), &Origin::File(path.clone()));
std::fs::remove_dir_all(&dir).ok();
}
}

View file

@ -9,6 +9,7 @@
pub mod accounts; pub mod accounts;
pub mod client; pub mod client;
pub mod credential;
pub mod handlers; pub mod handlers;
pub mod mcp; pub mod mcp;
pub mod paths; pub mod paths;

View file

@ -7,22 +7,24 @@
//! Lifecycle: //! Lifecycle:
//! 1. Read the configured account list (`accounts::configured()` — //! 1. Read the configured account list (`accounts::configured()` —
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account). //! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
//! 2. For each account: whoami probe → recover `user_id` + `device_id` //! 2. For each account: resolve its access token (`account_token` — the
//! swarm secret store first, read by this agent as itself, then the
//! file its hive delivered) → whoami probe → recover `user_id` + `device_id`
//! → restore matrix-sdk session (no login flow), install the //! → restore matrix-sdk session (no login flow), install the
//! message-event handler, and spawn its own sync loop. //! message-event handler, and spawn its own sync loop.
//! 3. Serve the MCP tools against an account→Client registry; each tool //! 3. Serve the MCP tools against an account→Client registry; each tool
//! call routes to the account named in its `account` arg (the //! call routes to the account named in its `account` arg (the
//! primary account when omitted). //! primary account when omitted).
//! //!
//! Standalone-degraded boot: the PRIMARY account having no token file → //! Standalone-degraded boot: the PRIMARY account having no token →
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re //! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
//! provisions it. A SECONDARY account missing its token is skipped (the //! a token file, the store re-check timer for a stored token). A SECONDARY
//! daemon still serves the others). //! account missing its token is skipped (the daemon still serves the others).
//! //!
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in //! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
//! `client::build_and_restore`, and it mirrors the missing-token policy //! `client::build_and_restore`, and it mirrors the missing-token policy
//! above — a rejected PRIMARY token drops the stale token + sdk state and //! above — a rejected PRIMARY token drops the stale token + sdk state and
//! exits 0 for systemd re-provisioning, while a rejected SECONDARY token //! exits 0 until a fresh token exists, while a rejected SECONDARY token
//! is removed and that one account is skipped so the daemon keeps serving //! is removed and that one account is skipped so the daemon keeps serving
//! the primary and any other healthy account. //! the primary and any other healthy account.
@ -34,7 +36,8 @@ use matrix_sdk::{Client, config::SyncSettings};
use hive_matrix_mcp::accounts::{AccountCfg, Registry}; use hive_matrix_mcp::accounts::{AccountCfg, Registry};
use hive_matrix_mcp::client::PermanentBringUpError; use hive_matrix_mcp::client::PermanentBringUpError;
use hive_matrix_mcp::{accounts, client, mcp, paths, timeline, wake}; use hive_matrix_mcp::credential::Token;
use hive_matrix_mcp::{accounts, client, credential, mcp, paths, timeline, wake};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")] #[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
@ -97,13 +100,13 @@ async fn main() -> Result<()> {
sync_loops.push(sync_loop); sync_loops.push(sync_loop);
} }
// No token yet: for the primary that means the daemon isn't // No token yet: for the primary that means the daemon isn't
// useful — exit 0 like the legacy single-account path so the // useful — exit 0, and systemd restarts us once a token exists
// systemd path-watcher restarts us when the token appears. // (the path-watcher for a file, the re-check timer for the store).
Ok(None) if is_primary => { Ok(None) if is_primary => {
tracing::warn!( tracing::warn!(
account = %cfg.name, account = %cfg.name,
"primary matrix account has no token yet; exiting cleanly \ "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(()); return Ok(());
} }
@ -259,6 +262,46 @@ async fn bring_up_secondary_with_retry(
None 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/<agent>/matrix/<account>` 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/<agent>/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<Option<Token>> {
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 /// Restore one account's client (when its token exists), install its
/// message handler, and build its sync loop. Returns /// message handler, and build its sync loop. Returns
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)` /// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
@ -280,23 +323,19 @@ async fn bring_up_account(
); );
return Ok(None); return Ok(None);
}; };
if !tokio::fs::try_exists(&cfg.token_file) let Some(token) = account_token(cfg).await? else {
.await
.unwrap_or(false)
{
return Ok(None); return Ok(None);
} };
tracing::info!( tracing::info!(
account = %cfg.name, account = %cfg.name,
homeserver, homeserver,
token_file = %cfg.token_file.display(), credential = %token.origin(),
state_dir = %cfg.state_dir.display(), state_dir = %cfg.state_dir.display(),
"bringing up matrix account" "bringing up matrix account"
); );
let client = let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary)
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary) .await
.await .with_context(|| format!("build matrix client for account {}", cfg.name))?;
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
// Best-effort: sync the agent icon to this account's matrix avatar over // Best-effort: sync the agent icon to this account's matrix avatar over
// the live (authenticated, correct-homeserver) Client. Replaces the old // the live (authenticated, correct-homeserver) Client. Replaces the old
// curl oneshot; failures are swallowed inside sync_avatar. // curl oneshot; failures are swallowed inside sync_avatar.

View file

@ -8,8 +8,8 @@ use std::path::PathBuf;
/// Resolve the matrix access-token file path. Override via /// Resolve the matrix access-token file path. Override via
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`, /// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent /// where a hive used to write the `main` account's token (the daemon now reads
/// account provisioning. /// it from the store first; see `main.rs`'s `account_token`).
#[must_use] #[must_use]
pub fn token_file() -> PathBuf { pub fn token_file() -> PathBuf {
if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") { if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") {

View file

@ -14,12 +14,21 @@
}: }:
let let
userName = config.services.hyperhive.agent.user.name; userName = config.services.hyperhive.agent.user.name;
# This agent's own state dir, where hive-c0re provisions the # This agent's own state dir, where a hive used to write the `main`
# hive-internal account's token (`matrix-token`) and the daemon keeps # account's token (`matrix-token`, now the store's fallback) and the
# its matrix-sdk store. Shared by the `main` account entry below and # daemon keeps its matrix-sdk store. Shared by the `main` account entry
# the path-watcher glob at the bottom of this file. # below and the path-watcher glob at the bottom of this file.
stateDir = "/agents/${userName}/state"; stateDir = "/agents/${userName}/state";
accounts = config.services.hyperhive.agent.matrixAccounts; 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 # **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 # 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. # 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 Path to this account's bearer-token file. The daemon reads
the token from here to restore the matrix session; how the the token from here to restore the matrix session; how the
file gets populated is the provisioner's concern file gets populated is the provisioner's concern
(hive-c0re for the hive-internal `main` account, an (for `main`, a file only when the store has no token: the
operator-supplied secret for an external one). The daemon 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 skips an extra account whose token file is absent, and
exits cleanly to wait on the path watcher when `main`'s is. exits cleanly to wait on the path watcher when `main`'s is.
''; '';
@ -143,7 +153,7 @@ in
homeserver (`homeserver`, else homeserver (`homeserver`, else
`services.hyperhive.agent.matrix.url`). The daemon auto-skips an `services.hyperhive.agent.matrix.url`). The daemon auto-skips an
account whose URL or token file is missing, and a `systemd.paths` 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`). (same path-trigger shape as `forge-avatar-sync`).
- exposes the matrix tool surface (send_message, send_dm, - exposes the matrix tool surface (send_message, send_dm,
send_reaction, send_reply, mark_read, list_rooms, 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 like any other, so it shows up in the account list --- what you
add here are the *further* accounts (e.g. an external add here are the *further* accounts (e.g. an external
public-matrix account). Its `tokenFile` stays pinned to public-matrix account). Its `tokenFile` stays pinned to
`<state>/matrix-token` (an assertion; that is the one path `<state>/matrix-token` (an assertion; the file the daemon falls back
hive-c0re provisions the hive-internal token to), and the to when the store has no `main` token), and the
dashboard's link-account route refuses to create an account named dashboard's link-account route refuses to create an account named
`main` --- the entry belongs to the module, not to a provisioner. `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 # `main` is not a forbidden key --- this module declares it
# itself (see the `matrixAccounts.main` definition below), so the # itself (see the `matrixAccounts.main` definition below), so the
# name must be allowed. What stays rejected is retargeting *its # name must be allowed. What stays rejected is retargeting *its
# token file*: hive-c0re writes the hive-internal account's token # token file*: the daemon's file fallback for `main` is
# to `<state>/matrix-token` and nowhere else, so an override there # `<state>/matrix-token` and nothing else, so an override there
# is an account that evaluates fine and then never restores. The # is an account that evaluates fine and then never restores. The
# other two fields are free to override (a `mkDefault` each). # other two fields are free to override (a `mkDefault` each).
# #
@ -227,8 +237,8 @@ in
assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token"; assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token";
message = message =
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay " "services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
+ "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the " + "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the "
+ "hive-internal account's token. Declare a separate account instead of " + "main account's token. Declare a separate account instead of "
+ "pointing `main` elsewhere."; + "pointing `main` elsewhere.";
} }
# Token files must land at the `matrix-token*` name the daemon # 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 # so the one it always has belongs in it. `mkDefault` per field so an
# operator can retarget e.g. the homeserver without a # operator can retarget e.g. the homeserver without a
# conflicting-definition error (the token file is pinned by an # 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` # ⚠️ 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 # 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"; HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info"; RUST_LOG = "info";
} }
# The store's coordinates and which agent this is, so the daemon reads
# `swarm/agents/<agent>/matrix/<account>` 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 # Homeserver URL. hive-c0re writes this option per agent from the
# hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>` # hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>`
# by default — agents run in a private # by default — agents run in a private
@ -357,7 +381,8 @@ in
# `on-failure`, not `always`: the daemon deliberately exits 0 # `on-failure`, not `always`: the daemon deliberately exits 0
# (a clean, non-failure exit) when no token is provisioned yet # (a clean, non-failure exit) when no token is provisioned yet
# (see the module doc above) — the `systemd.paths` watcher # (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 # instead of `always` busy-looping every `RestartSec` until
# then. Once a token exists this is no different from # then. Once a token exists this is no different from
# `hive-bash-daemon`'s reasoning (a down window loses the MCP # `hive-bash-daemon`'s reasoning (a down window loses the MCP
@ -367,11 +392,33 @@ in
RestartSec = 5; RestartSec = 5;
User = userName; User = userName;
Group = 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 # Re-start the daemon while it is down, when its token lives in the store.
# provisions it after agent containers come up). Without this # 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 # the daemon would exit 0 silently on first boot and the MCP
# would have no backend until next restart. See # would have no backend until next restart. See
# `docs/agent-lifecycle/persistence.md` (same section as above). # `docs/agent-lifecycle/persistence.md` (same section as above).

View file

@ -18,6 +18,7 @@ let
; ;
}) })
agent agent
agentWith
runGroup runGroup
; ;
@ -45,6 +46,15 @@ let
homeserver = "https://matrix.example.invalid"; 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 = [ cases = [
{ {
# A homeserver URL is the whole input: from it the module derives the # 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. # No hive homeserver, so nothing may claim one.
&& !(env ? HIVE_MATRIX_URL); && !(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/<pid>/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 in
runGroup "agent-matrix" cases runGroup "agent-matrix" cases