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

View file

@ -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

View file

@ -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::<PermanentBringUpError>()` 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<Client> {
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,
},
};

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 client;
pub mod credential;
pub mod handlers;
pub mod mcp;
pub mod paths;

View file

@ -7,22 +7,24 @@
//! Lifecycle:
//! 1. Read the configured account list (`accounts::configured()` —
//! `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
//! message-event handler, and spawn its own sync loop.
//! 3. Serve the MCP tools against an account→Client registry; each tool
//! call routes to the account named in its `account` arg (the
//! primary account when omitted).
//!
//! Standalone-degraded boot: the PRIMARY account having no token file →
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re
//! provisions it. A SECONDARY account missing its token is skipped (the
//! daemon still serves the others).
//! Standalone-degraded boot: the PRIMARY account having no token →
//! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
//! a token file, the store re-check timer for a stored token). A SECONDARY
//! account missing its token is skipped (the daemon still serves the others).
//!
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
//! `client::build_and_restore`, and it mirrors the missing-token policy
//! 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
//! 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::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)]
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
@ -97,13 +100,13 @@ async fn main() -> 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/<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
/// 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.

View file

@ -8,8 +8,8 @@ use std::path::PathBuf;
/// Resolve the matrix access-token file path. Override via
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/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") {