Watch
0
0
Fork
You've already forked hyperhive
0

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

@ -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,
},
};