Watch
0
0
Fork
You've already forked hyperhive
0

hive-matrix-mcp: probe whoami through swarm-matrix-client

This commit is contained in:
atlas 2026-10-18 07:31:26 +02:00 • committed by mara
commit b17da10cfc
2 changed files with 67 additions and 95 deletions

View file

@ -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"); tracing::debug!(%user_id, %room_id, "matrix: invite refused, user already member/invited");
return Ok(()); 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 /// 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 /// Serve a fake homeserver on loopback: `!space` is an `m.space` and
/// `!chat` a plain room, both named "shared"; `!decoy` is a plain room /// `!chat` a plain room, both named "shared"; `!decoy` is a plain room
/// named "other". Returns the base URL and the server task. /// named "other". Returns the base URL and the server task.

View file

@ -36,7 +36,7 @@ use matrix_sdk::{
encryption::{BackupDownloadStrategy, EncryptionSettings}, encryption::{BackupDownloadStrategy, EncryptionSettings},
ruma::{OwnedDeviceId, OwnedUserId}, ruma::{OwnedDeviceId, OwnedUserId},
}; };
use serde::Deserialize; use swarm_matrix_client::WhoamiSession;
use tokio::fs; use tokio::fs;
use crate::credential::{Passphrase, Token}; use crate::credential::{Passphrase, Token};
@ -61,22 +61,12 @@ impl std::fmt::Display for PermanentBringUpError {
impl std::error::Error 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<String>,
}
/// Build + restore a matrix-sdk `Client` for the per-agent bearer /// Build + restore a matrix-sdk `Client` for the per-agent bearer
/// `token`. The Client points at `homeserver`, persists its sqlite cache /// `token`. The Client points at `homeserver`, persists its sqlite cache
/// under `state_dir`, and is ready for sync once returned. /// under `state_dir`, and is ready for sync once returned.
/// ///
/// Steps: /// 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` + /// the bearer — this gives us back the matrix `user_id` +
/// `device_id` (the registration response had them but only the /// `device_id` (the registration response had them but only the
/// token was kept; whoami is the cheapest recovery path /// token was kept; whoami is the cheapest recovery path
@ -95,59 +85,53 @@ pub async fn build_and_restore(
state_dir: &Path, state_dir: &Path,
is_primary: bool, is_primary: bool,
) -> Result<Client> { ) -> Result<Client> {
let (user_id, device_id) = match whoami(homeserver, token.expose()).await { let Some((user_id, device_id)) = whoami(homeserver, token.expose()).await? else {
Ok(ids) => ids, // Homeserver rejected our token — stale session after a homeserver
Err(e) => { // state wipe or token expiry. Discard it so we don't loop on it
let msg = format!("{e:#}"); // (which for a store-held credential is a log line and nothing
if msg.contains("M_UNKNOWN_TOKEN") { // else — see `Token::discard_stale`). The rest of the handling
// Homeserver rejected our token — stale session after a homeserver // depends on whether this is the primary (hive-internal) account
// state wipe or token expiry. Discard it so we don't loop on it // or a secondary one, because a single bad secondary token must
// (which for a store-held credential is a log line and nothing // NOT take down the whole daemon (and with it every healthy
// else — see `Token::discard_stale`). The rest of the handling // account).
// depends on whether this is the primary (hive-internal) account token.discard_stale().await;
// or a secondary one, because a single bad secondary token must if is_primary {
// NOT take down the whole daemon (and with it every healthy tracing::warn!(
// account). credential = %token.origin(),
token.discard_stale().await; "matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token"
if is_primary { );
tracing::warn!( // Primary: also drop the matrix-sdk sqlite state keyed to the
credential = %token.origin(), // now-invalid session, then exit 0; the swarm's backfill
"matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token" // re-mints the token and systemd restarts us once it
); // exists (store re-check timer, or the path watcher). Exit 0 (not
// Primary: also drop the matrix-sdk sqlite state keyed to the // Err) keeps systemd's Restart=on-failure from looping; the
// now-invalid session, then exit 0; the swarm's backfill // call site is before any tasks are spawned so there's
// re-mints the token and systemd restarts us once it // nothing to clean up.
// exists (store re-check timer, or the path watcher). Exit 0 (not if let Err(re) = fs::remove_dir_all(state_dir).await {
// Err) keeps systemd's Restart=on-failure from looping; the tracing::warn!(
// call site is before any tasks are spawned so there's path = %state_dir.display(),
// nothing to clean up. err = %re,
if let Err(re) = fs::remove_dir_all(state_dir).await { "failed to remove sdk state dir; next startup may fail with stale state"
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"
); );
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) fs::create_dir_all(state_dir)
@ -182,38 +166,25 @@ pub async fn build_and_restore(
Ok(client) Ok(client)
} }
/// Bare-reqwest whoami probe — used at startup to recover the /// Whoami probe — used at startup to recover the `user_id` + `device_id` the
/// `user_id` + `device_id` the registration response carried but /// registration response carried but hive-c0re didn't persist alongside the
/// hive-c0re didn't persist alongside the access token. Cheaper than /// access token. Cheaper than teaching the hive-c0re side to persist them,
/// teaching the hive-c0re side to persist them, plus matches what a /// plus matches what a fresh deployment with a hand-rolled token
/// fresh deployment with a hand-rolled token (`HIVE_MATRIX_TOKEN_FILE` /// (`HIVE_MATRIX_TOKEN_FILE` pointing somewhere unexpected) needs anyway.
/// pointing somewhere unexpected) needs anyway. ///
async fn whoami(homeserver: &str, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> { /// `None` is the homeserver's `M_UNKNOWN_TOKEN` verdict on the token.
let url = format!("{homeserver}/_matrix/client/v3/account/whoami"); async fn whoami(homeserver: &str, token: &str) -> Result<Option<(OwnedUserId, OwnedDeviceId)>> {
let resp = reqwest::Client::builder() let http = swarm_matrix_client::client()?;
.timeout(std::time::Duration::from_secs(10)) let (user_id, device_id) =
.build() match swarm_matrix_client::whoami_session(&http, homeserver, token).await? {
.context("build whoami reqwest client")? WhoamiSession::UnknownToken => return Ok(None),
.get(&url) WhoamiSession::Session { user_id, device_id } => (user_id, device_id),
.bearer_auth(token) };
.send() let parsed: OwnedUserId = user_id
.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
.parse() .parse()
.with_context(|| format!("invalid user_id in whoami: {}", body.user_id))?; .with_context(|| format!("invalid user_id in whoami: {user_id}"))?;
let device_id_raw = body let device_id = device_id.ok_or_else(|| anyhow!("whoami response missing device_id"))?;
.device_id Ok(Some((parsed, device_id.into())))
.ok_or_else(|| anyhow!("whoami response missing device_id"))?;
let device_id: OwnedDeviceId = device_id_raw.into();
Ok((user_id, device_id))
} }
/// Best-effort: bootstrap this account's cross-signing identity so its /// Best-effort: bootstrap this account's cross-signing identity so its