From b17da10cfcdd9af4be443d0afb121db113884280 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 18 Oct 2026 07:31:26 +0200 Subject: [PATCH] hive-matrix-mcp: probe whoami through swarm-matrix-client --- hive-c0re/src/matrix.rs | 5 +- hive-matrix-mcp/src/client.rs | 157 ++++++++++++++-------------------- 2 files changed, 67 insertions(+), 95 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 16897c22..50928906 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -723,7 +723,9 @@ fn forbidden_invite(membership: Option<&str>, user_id: &str, room_id: &str) -> R tracing::debug!(%user_id, %room_id, "matrix: invite refused, user already member/invited"); return Ok(()); } - anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP 403 and the user is not invited or joined") + anyhow::bail!( + "matrix: invite {user_id} to {room_id}: HTTP 403 and the user is not invited or joined" + ) } /// Invite an arbitrary Matrix user to the hive Space (default) or an @@ -1044,7 +1046,6 @@ mod tests { } } - /// Serve a fake homeserver on loopback: `!space` is an `m.space` and /// `!chat` a plain room, both named "shared"; `!decoy` is a plain room /// named "other". Returns the base URL and the server task. diff --git a/hive-matrix-mcp/src/client.rs b/hive-matrix-mcp/src/client.rs index 5b478fda..11caabe6 100644 --- a/hive-matrix-mcp/src/client.rs +++ b/hive-matrix-mcp/src/client.rs @@ -36,7 +36,7 @@ use matrix_sdk::{ encryption::{BackupDownloadStrategy, EncryptionSettings}, ruma::{OwnedDeviceId, OwnedUserId}, }; -use serde::Deserialize; +use swarm_matrix_client::WhoamiSession; use tokio::fs; use crate::credential::{Passphrase, Token}; @@ -61,22 +61,12 @@ impl std::fmt::Display for PermanentBringUpError { impl std::error::Error for PermanentBringUpError {} -/// Subset of the `/_matrix/client/v3/account/whoami` response we care -/// about. matrix-spec field names; `device_id` is optional per spec -/// (servers MAY omit it for legacy bearer scopes) but tuwunel always -/// returns it. -#[derive(Debug, Deserialize)] -struct WhoamiResponse { - user_id: String, - device_id: Option, -} - /// Build + restore a matrix-sdk `Client` for the per-agent bearer /// `token`. The Client points at `homeserver`, persists its sqlite cache /// under `state_dir`, and is ready for sync once returned. /// /// Steps: -/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with +/// 1. A `swarm-matrix-client` whoami call with /// the bearer — this gives us back the matrix `user_id` + /// `device_id` (the registration response had them but only the /// token was kept; whoami is the cheapest recovery path @@ -95,59 +85,53 @@ pub async fn build_and_restore( state_dir: &Path, is_primary: bool, ) -> Result { - 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. 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). - token.discard_stale().await; - if is_primary { - tracing::warn!( - credential = %token.origin(), - "matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token" - ); - // Primary: also drop the matrix-sdk sqlite state keyed to the - // 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. - if let Err(re) = fs::remove_dir_all(state_dir).await { - tracing::warn!( - path = %state_dir.display(), - err = %re, - "failed to remove sdk state dir; next startup may fail with stale state" - ); - } - std::process::exit(0); - } - // Secondary: a file token is gone, a store token is untouched; the - // daemon tries the account again later and reads the token afresh. - // The sdk state stays in place in case the operator re-provisions a - // token for the same device. - // Return a PermanentBringUpError so the caller can distinguish - // "retrying now won't help" from a transient network/DNS failure. - // Every try of a down secondary lands here, so the warning is the - // caller's, once per outage. - tracing::debug!( - credential = %token.origin(), - "matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token" + let Some((user_id, device_id)) = whoami(homeserver, token.expose()).await? else { + // Homeserver rejected our token — stale session after a homeserver + // 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). + token.discard_stale().await; + if is_primary { + tracing::warn!( + credential = %token.origin(), + "matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token" + ); + // Primary: also drop the matrix-sdk sqlite state keyed to the + // 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. + if let Err(re) = fs::remove_dir_all(state_dir).await { + tracing::warn!( + path = %state_dir.display(), + err = %re, + "failed to remove sdk state dir; next startup may fail with stale state" ); - return Err(anyhow::Error::new(PermanentBringUpError( - "matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, leaving account down".into(), - ))); } - return Err(e); + std::process::exit(0); } + // Secondary: a file token is gone, a store token is untouched; the + // daemon tries the account again later and reads the token afresh. + // The sdk state stays in place in case the operator re-provisions a + // token for the same device. + // Return a PermanentBringUpError so the caller can distinguish + // "retrying now won't help" from a transient network/DNS failure. + // Every try of a down secondary lands here, so the warning is the + // caller's, once per outage. + tracing::debug!( + credential = %token.origin(), + "matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token" + ); + return Err(anyhow::Error::new(PermanentBringUpError( + "matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, leaving account down" + .into(), + ))); }; fs::create_dir_all(state_dir) @@ -182,38 +166,25 @@ pub async fn build_and_restore( Ok(client) } -/// Bare-reqwest whoami probe — used at startup to recover the -/// `user_id` + `device_id` the registration response carried but -/// hive-c0re didn't persist alongside the access token. Cheaper than -/// teaching the hive-c0re side to persist them, plus matches what a -/// fresh deployment with a hand-rolled token (`HIVE_MATRIX_TOKEN_FILE` -/// pointing somewhere unexpected) needs anyway. -async fn whoami(homeserver: &str, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> { - let url = format!("{homeserver}/_matrix/client/v3/account/whoami"); - let resp = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("build whoami reqwest client")? - .get(&url) - .bearer_auth(token) - .send() - .await - .with_context(|| format!("GET {url}"))?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("whoami GET {url} → HTTP {status}, body: {body}")); - } - let body: WhoamiResponse = resp.json().await.context("parse whoami response")?; - let user_id: OwnedUserId = body - .user_id +/// Whoami probe — used at startup to recover the `user_id` + `device_id` the +/// registration response carried but hive-c0re didn't persist alongside the +/// access token. Cheaper than teaching the hive-c0re side to persist them, +/// plus matches what a fresh deployment with a hand-rolled token +/// (`HIVE_MATRIX_TOKEN_FILE` pointing somewhere unexpected) needs anyway. +/// +/// `None` is the homeserver's `M_UNKNOWN_TOKEN` verdict on the token. +async fn whoami(homeserver: &str, token: &str) -> Result> { + let http = swarm_matrix_client::client()?; + let (user_id, device_id) = + match swarm_matrix_client::whoami_session(&http, homeserver, token).await? { + WhoamiSession::UnknownToken => return Ok(None), + WhoamiSession::Session { user_id, device_id } => (user_id, device_id), + }; + let parsed: OwnedUserId = user_id .parse() - .with_context(|| format!("invalid user_id in whoami: {}", body.user_id))?; - let device_id_raw = body - .device_id - .ok_or_else(|| anyhow!("whoami response missing device_id"))?; - let device_id: OwnedDeviceId = device_id_raw.into(); - Ok((user_id, device_id)) + .with_context(|| format!("invalid user_id in whoami: {user_id}"))?; + let device_id = device_id.ok_or_else(|| anyhow!("whoami response missing device_id"))?; + Ok(Some((parsed, device_id.into()))) } /// Best-effort: bootstrap this account's cross-signing identity so its