From 513530ae358c8df5da92822101207fbc46605487 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 7 Nov 2026 13:54:56 +0100 Subject: [PATCH] matrix-mcp: auto-join only on accounts that live on the swarm homeserver --- docs/4-implementation/agent-tools/matrix.md | 10 ++-- hive-matrix-mcp/src/timeline.rs | 53 ++++++++++++++++++--- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/4-implementation/agent-tools/matrix.md b/docs/4-implementation/agent-tools/matrix.md index 0cb376b2..ee6e6388 100644 --- a/docs/4-implementation/agent-tools/matrix.md +++ b/docs/4-implementation/agent-tools/matrix.md @@ -35,10 +35,12 @@ is what an agent reads at call time. They fall into four groups: `join_room`. `resolve_invite` accepts or rejects a pending invite and is the path for invites; `join_room` is for a public room the agent wasn't invited to. `list_invites` gives the inviter and the topic, so an agent can judge an - invite before it answers. The daemon joins an invite from a user of the - agent's own homeserver by itself, with an info log line and no todo. An invite - from any other homeserver stays a todo that names the inviter as an external - matrix user, and the agent decides. If an automatic join fails, the invite + invite before it answers. On an account that lives on the swarm + homeserver, the daemon joins an invite from a user of that same server by itself, + with an info log line and no todo. Every other invite stays a todo that names the + inviter as an external matrix user, and the agent decides: that covers invites from + other homeservers and every invite to a linked account on a public server such as + matrix.org. If an automatic join fails, the invite falls back to a todo too. - **Receipts:** `mark_read` advances the read receipt. diff --git a/hive-matrix-mcp/src/timeline.rs b/hive-matrix-mcp/src/timeline.rs index fc7233d7..5b53277b 100644 --- a/hive-matrix-mcp/src/timeline.rs +++ b/hive-matrix-mcp/src/timeline.rs @@ -130,7 +130,7 @@ pub async fn sweep_invites( // from the unread sweep's `` key) so the two never collide; the // harness coalesces the wake iff the summary is new or changed, so // re-upserting an unchanged invite every tick is a cheap no-op. - let own_server = client.user_id().map(|u| u.server_name().to_owned()); + let own_server = swarm_server(client); for room in ¤t { let room_id = room.room_id().to_owned(); let inviter = room @@ -139,8 +139,8 @@ pub async fn sweep_invites( .ok() .and_then(|d| d.inviter) .map(|m| m.user_id().to_owned()); - // An invite from the swarm's own homeserver is trusted: join it - // without waking the agent. A failed join falls through to the todo + // An invite from a user of the swarm's own homeserver, to an account + // that lives on it, is trusted: join it without waking the agent. A failed join falls through to the todo // below, so the invite is never lost. if is_local(own_server.as_deref(), inviter.as_deref()) { match room.join().await { @@ -179,8 +179,25 @@ pub async fn sweep_invites( } } -/// Whether `inviter` is a user of the agent's own homeserver. No known -/// inviter or no known own server counts as external. +/// The swarm homeserver's server name when `client` is an account on it, else +/// `None`. The swarm homeserver is the daemon-wide `HIVE_MATRIX_URL`; a linked +/// account on another server (matrix.org, catgirl.cloud, ...) is not on it, so +/// its same-server inviters are strangers, not swarm members. +fn swarm_server(client: &Client) -> Option { + let swarm = reqwest::Url::parse(&crate::paths::homeserver_url()?).ok()?; + same_homeserver(&swarm, &client.homeserver()) + .then(|| client.user_id().map(|u| u.server_name().to_owned())) + .flatten() +} + +/// Whether two homeserver URLs point at the same host and port. +fn same_homeserver(a: &reqwest::Url, b: &reqwest::Url) -> bool { + a.host_str() == b.host_str() && a.port_or_known_default() == b.port_or_known_default() +} + +/// Whether `inviter` is a user of the swarm homeserver `own` (the account's +/// server when the account is on it, else `None`). No known inviter or no +/// swarm server counts as external. fn is_local(own: Option<&ServerName>, inviter: Option<&UserId>) -> bool { matches!((own, inviter), (Some(own), Some(u)) if u.server_name() == own) } @@ -197,7 +214,7 @@ pub(crate) fn invite_key(room_id: &matrix_sdk::ruma::RoomId) -> String { mod invite_tests { use matrix_sdk::ruma::{server_name, user_id}; - use super::is_local; + use super::{is_local, same_homeserver}; #[test] fn only_an_inviter_on_the_own_homeserver_is_local() { @@ -216,4 +233,28 @@ mod invite_tests { assert!(!is_local(Some(own), None)); assert!(!is_local(None, Some(user_id!("@mara:pr1ma.darkest.space")))); } + + #[test] + fn an_account_is_on_the_swarm_homeserver_only_by_host_and_port() { + let u = |s: &str| reqwest::Url::parse(s).expect("url"); + let swarm = u("https://matrix.pr1ma.darkest.space"); + assert!(same_homeserver( + &swarm, + &u("https://matrix.pr1ma.darkest.space/") + )); + assert!(same_homeserver( + &swarm, + &u("https://matrix.pr1ma.darkest.space:443") + )); + // A public server is a different host, and a different port is a + // different server. + assert!(!same_homeserver(&swarm, &u("https://catgirl.cloud"))); + assert!(!same_homeserver( + &swarm, + &u("https://matrix.pr1ma.darkest.space:8448") + )); + // An external account gets no swarm server, so even its own + // same-server inviter is not local. + assert!(!is_local(None, Some(user_id!("@x:catgirl.cloud")))); + } }