//! Optional matrix-tuwunel wiring: the hive's `@hive-:` sender token, //! taken from the swarm secret store, and the hive Space, chat room and //! invites provisioned with it. No-op when no homeserver is configured, so //! operators who haven't flipped `services.hyperhive.deploy.matrix.enable = //! true` pay nothing. //! //! This module creates no accounts: `swarm-controller` mints every hive's //! sender account and every agent's account with the swarm's appservice token. use std::path::PathBuf; use anyhow::{Context, Result}; /// Client-server API base this daemon provisions against, from /// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from /// `services.hyperhive.swarm.matrix.apiUrl`). /// /// `None` means **this hive has no homeserver to provision against** and /// every matrix path no-ops — see [`is_present`]. There is deliberately no /// fallback: a loopback default is right only when the homeserver happens to /// share this daemon's netns, and an address baked into the binary is one /// that builds fine and then talks to the wrong machine. The nix module /// defaults it to `https://` on the homeserver's gateway vhost, on every /// hive whichever host runs tuwunel. /// /// Also not an agent-facing address either way. An agent has its own netns; /// agents are handed the gateway vhost via `HIVE_MATRIX_URL`, and get /// nothing at all when the hive has no vhost to offer. fn matrix_http() -> Option<&'static str> { static BASE: std::sync::OnceLock> = std::sync::OnceLock::new(); BASE.get_or_init(|| std::env::var("HIVE_MATRIX_API_URL").ok()) .as_deref() } /// [`matrix_http`] for the call sites that propagate with `?`. /// /// # Errors /// When no homeserver is configured. Reaching one of these paths at all /// means an [`is_present`] gate was skipped, so the message names that /// rather than the missing variable. fn matrix_base() -> Result<&'static str> { matrix_http().context( "matrix: no homeserver configured \ (services.hyperhive.swarm.matrix.apiUrl / HIVE_MATRIX_API_URL) — \ this path should have been gated on matrix::is_present()", ) } /// Matrix localpart this hive acts as. Not an agent; has no state dir. /// /// **Per hive, derived from the hive's name** — `hive-`, built by /// `swarm_secret_client::matrix::hive_localpart`, which is the one place the /// derivation lives. It used to be the bare constant `hive`, and a swarm runs /// one homeserver: every hive on it logged in as the same `@hive:`, so the /// homeserver could not attribute an action to a hive and revoking one hive's /// standing revoked all of them. /// /// `swarm-controller` creates this account with the swarm's appservice token /// (`matrix_account::hive_sender`) and stores its token where /// [`stored_sender_token`] reads it; hive-c0re never creates it. /// /// An ordinary account, with no homeserver-admin standing: what it /// provisions — the Space, the chat room, the invites — it provisions as /// the creator of those rooms. /// /// # Errors /// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable /// [`stored_sender_token`] logs in with and `hive-c0re`'s meta flake asserts /// for every hive, so its absence is a broken deployment rather than a /// supported one — and there is no safe default to fall back to: the only one /// available is the shared account this replaced. pub fn hive_localpart() -> Result { let hive = std::env::var("HYPERHIVE_HIVE_NAME") .ok() .filter(|h| !h.is_empty()) .context( "matrix: HYPERHIVE_HIVE_NAME is unset, so this hive has no matrix account of its own \ — the localpart is derived from the hive name and has no swarm-wide fallback", )?; Ok(swarm_secret_client::matrix::hive_localpart(&hive)) } /// Display name of the hive Space. Plain text, no special characters, so /// the Space stays rediscoverable by name (no room alias needed) even when /// the persisted room-id file is lost — preventing duplicate spaces from /// being created on the next sweep. pub const HIVE_SPACE_NAME: &str = "hive"; /// Display name of the default "hive chat" room — the `m.space.child` of /// the hive Space that every agent + the operator can join. Plain text so /// it stays rediscoverable by name (mirrors [`HIVE_SPACE_NAME`]) when the /// persisted room-id file is lost, preventing duplicate chat rooms. pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat"; /// Topic for the default hive chat room. const HIVE_CHAT_ROOM_TOPIC: &str = "Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re."; /// Host path for the appservice sender account's matrix access token. Outside every /// purgeable path — not deleted by `destroy --purge` on any agent. #[must_use] pub fn sender_token_path() -> PathBuf { crate::paths::matrix_sender_token() } /// Host path where the hive Matrix Space room ID is persisted. /// Outside every purgeable path — not deleted by `destroy --purge`. #[must_use] pub fn hive_space_room_id_path() -> PathBuf { crate::paths::matrix_space_room_id() } /// Host path where the default hive chat room ID is persisted. Outside /// every purgeable path — not deleted by `destroy --purge`. #[must_use] pub fn hive_chat_room_id_path() -> PathBuf { crate::paths::matrix_chat_room_id() } /// Whether this hive has a homeserver to provision against. /// /// **A configured API URL, not a local container**: whether the homeserver /// runs on this host is a different question, and a remote homeserver is /// provisioned through the same URL. #[must_use] pub fn is_present() -> bool { matrix_http().is_some() } /// Bring the hive's `@hive-:` sender token at [`sender_token_path()`] in /// line with the swarm secret store. /// /// `swarm-controller` is the only minter: it creates the account with the /// swarm's appservice token, keeps the stored token while it is live and /// replaces it when it is not. The file follows the store, and is kept as it /// is when the store has nothing or cannot be reached, so a store outage does /// not take the hive's matrix provisioning down with it. /// /// # Errors /// When neither the store nor the file holds a token, or the file write fails. pub async fn ensure_hive_user() -> Result<()> { let path = sender_token_path(); let on_disk = std::fs::read_to_string(&path).ok(); let stored = stored_sender_token().await; match sender_source(stored.as_deref(), on_disk.as_deref()) { SenderSource::Keep => { tracing::debug!("matrix: the sender token is already present"); Ok(()) } SenderSource::Store(token) => persist_sender_token(&path, token), SenderSource::Unavailable => anyhow::bail!( "matrix: no sender token in the swarm store or at {}; swarm-controller \ mints it into the store", path.display() ), } } /// What [`ensure_hive_user`] does about the sender token this sweep. #[derive(Debug, PartialEq, Eq)] enum SenderSource<'a> { /// The file already holds the token to use. Keep, /// Write the store's token to the file. Store(&'a str), /// No token in the store or the file. Unavailable, } /// Decide [`ensure_hive_user`]'s step from the store's token and the file's /// content, each `None` when absent. Blank counts as absent. fn sender_source<'a>(stored: Option<&'a str>, on_disk: Option<&str>) -> SenderSource<'a> { let present = |s: &&str| !s.trim().is_empty(); let stored = stored.map(str::trim).filter(present); let on_disk = on_disk.map(str::trim).filter(present); match (stored, on_disk) { (Some(s), Some(d)) if s == d => SenderSource::Keep, (Some(s), _) => SenderSource::Store(s), (None, Some(_)) => SenderSource::Keep, (None, None) => SenderSource::Unavailable, } } /// Write the appservice sender account's access token to `path`, 0600, creating the /// directory if it is not there. The file's mode is the only thing keeping an /// unprivileged reader off the hive's matrix credential. fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> { crate::secret_file::write_secret_0600(path, format!("{access_token}\n").as_bytes()) .with_context(|| format!("matrix: write the sender token to {}", path.display()))?; tracing::info!(path = %path.display(), "matrix: provisioned the sender token"); Ok(()) } /// Fetch the sender token `swarm-controller` published, under this hive's own /// store identity. /// /// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — /// the same role string `lifecycle::agent_identity` logs in with, and already in /// this process's environment, so the store read costs no plumbing through /// [`ensure_all`]. That name is also the path's own hive segment, which is /// what makes this read reach **this** hive's token and 403 on any other's: /// the grant behind it is the hive's own `swarm/hives//*` stanza. /// /// `None`, never an error, for every way this can come up empty — no hive /// name, no `BAO_*` identity, an unreachable store, nothing at the path. All /// four mean the same thing to the caller ("keep the file"), and three of /// them are the ordinary state of a swarm with no store token for this hive /// yet, so raising would turn a supported deployment into a warning every /// sweep. /// /// 🩸 Logs the store **path** and never the value. async fn stored_sender_token() -> Option { let hive = std::env::var("HYPERHIVE_HIVE_NAME") .ok() .filter(|h| !h.is_empty())?; let path = match swarm_secret_client::matrix::sender_token_path(&hive) { Ok(path) => path, Err(e) => { tracing::warn!(error = %e, "matrix: this hive's name does not form a store path"); return None; } }; let store = match swarm_secret_client::SecretStore::from_env(&hive).await { Ok(store) => store, Err(e) => { tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from"); return None; } }; match store .read::(&path) .await { Ok(credential) if !credential.value.trim().is_empty() => { tracing::debug!(%path, "matrix: read the sender token from the swarm store"); Some(credential.value) } Ok(_) => { tracing::warn!(%path, "matrix: the stored sender token is empty"); None } Err(e) => { tracing::debug!(%path, error = %e, "matrix: no sender token in the store"); None } } } /// Discover the matrix `server_name` from the running homeserver via /// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint). /// The response JSON always includes `"server_name"` per the matrix spec. pub async fn discover_server_name(client: &reqwest::Client) -> Result { let base = matrix_base()?; let url = format!("{base}/_matrix/key/v2/server"); let resp = client .get(&url) .send() .await .context("matrix: GET /_matrix/key/v2/server")?; let status = resp.status(); let body = resp .json::() .await .context("matrix: parse /_matrix/key/v2/server response")?; if !status.is_success() { anyhow::bail!("matrix: /_matrix/key/v2/server returned HTTP {status}, body: {body}"); } body["server_name"] .as_str() .map(str::to_owned) .with_context(|| { format!("matrix: /_matrix/key/v2/server response missing server_name field: {body}") }) } /// Read the sender token from disk. Returns an error if it /// is absent — callers should gate their homeserver calls on this. pub fn read_sender_token() -> Result { let path = sender_token_path(); std::fs::read_to_string(&path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( "the matrix sender token was not found at {} — \ ensure hive-c0re has started at least once with matrix enabled \ (it provisions the hive's @hive-: account on boot)", path.display() ) }) } /// Persist the hive Space room id to [`hive_space_room_id_path()`] (0600). fn persist_space_room_id(room_id: &str) -> Result<()> { let path = hive_space_room_id_path(); crate::secret_file::write_secret_0600(&path, format!("{room_id}\n").as_bytes()) .with_context(|| format!("matrix: write space room_id to {}", path.display())) } /// Walk the admin account's joined rooms at `base` and return the first room /// named `name` that is an `m.space` exactly when `want_space` is set (the /// `m.room.create` `type`). `None` if the homeserver is unreachable or no /// room matches. async fn find_joined_room( client: &reqwest::Client, base: &str, sender_token: &str, want_space: bool, name: &str, ) -> Option { let rooms = swarm_matrix_client::joined_rooms(client, base, sender_token) .await .ok()?; for room_id in rooms { // m.room.create `type` says whether this is a Space. let is_space = keyless_state(client, base, sender_token, &room_id, "m.room.create") .await .is_some_and(|c| c["type"].as_str() == Some("m.space")); if is_space != want_space { continue; } // …and named `name` (m.room.name `name`). let name_matches = keyless_state(client, base, sender_token, &room_id, "m.room.name") .await .is_some_and(|n| n["name"].as_str() == Some(name)); if name_matches { return Some(room_id); } } None } /// The content of a state event with an empty state key, or `None` when the /// room has none or the read failed. async fn keyless_state( client: &reqwest::Client, base: &str, sender_token: &str, room_id: &str, event_type: &str, ) -> Option { swarm_matrix_client::room_state(client, base, sender_token, room_id, event_type, "") .await .ok() .flatten() } /// Scan the admin account's joined rooms for the canonical hive Space: the /// `m.space` whose name is [`HIVE_SPACE_NAME`]. Returns the first match /// (deterministic per the homeserver's joined-rooms order) so a lost /// room-id file recovers the existing space instead of spawning a /// duplicate. `None` if the homeserver is unreachable or no match exists. /// /// Name-based (not alias-based) rediscovery keeps the Space free of any /// special-char room alias — the hardcoded plain name is the anchor. async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option { find_joined_room(client, matrix_http()?, sender_token, true, HIVE_SPACE_NAME).await } /// Create (or recover) the hive Matrix Space and persist its room ID to /// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by /// `@hive-:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias). /// /// Dedup strategy (single canonical space): /// 1. If the room-id file exists, reuse it. /// 2. Otherwise, rediscover by scanning the admin's joined rooms for the /// `m.space` named [`HIVE_SPACE_NAME`] and adopt it (re-persisting the /// file). This recovers the existing space after a state wipe instead /// of creating a duplicate. /// 3. Only if neither yields a room do we `createRoom`. /// /// # Errors /// /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result { let base = matrix_base()?; // 1. Stored room id wins (fast path). if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { let trimmed = existing.trim().to_owned(); if !trimmed.is_empty() { tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned"); return Ok(trimmed); } } // 2. No stored id — rediscover the existing space by its hardcoded name // before creating a new one (prevents duplicate spaces after a wipe). if let Some(room_id) = find_space_by_name(client, sender_token).await { persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: recovered hive space by name"); return Ok(room_id); } // 3. Create the space (plain hardcoded name, no alias). let body = serde_json::json!({ "name": HIVE_SPACE_NAME, "creation_content": { "type": "m.space" }, "preset": "private_chat", "visibility": "private", }); let room_id = swarm_matrix_client::create_room(client, base, sender_token, &body) .await .context("matrix: create hive space")?; persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: created hive space"); Ok(room_id) } /// Persist the hive chat room id to [`hive_chat_room_id_path()`] (0600). fn persist_chat_room_id(room_id: &str) -> Result<()> { let path = hive_chat_room_id_path(); crate::secret_file::write_secret_0600(&path, format!("{room_id}\n").as_bytes()) .with_context(|| format!("matrix: write chat room_id to {}", path.display())) } /// Scan the admin account's joined rooms for the canonical hive chat room: /// a non-space room named [`HIVE_CHAT_ROOM_NAME`]. Mirrors /// [`find_space_by_name`] so a lost room-id file recovers the existing chat /// room instead of spawning a duplicate. `None` if the homeserver is /// unreachable or no match exists. async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option { find_joined_room( client, matrix_http()?, sender_token, false, HIVE_CHAT_ROOM_NAME, ) .await } /// Whether the state event still has to be written. /// /// `current` is the room's existing content for this `(type, state_key)`, /// or `None` when it could not be read. **Unreadable means write**: the /// re-apply exists so a missing link gets repaired, and one redundant /// event is cheaper than a hierarchy that never reconverges. /// /// Comparison is `serde_json::Value` equality, which compares objects as /// maps — key order in the response does not matter. fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::Value) -> bool { current != Some(desired) } /// Read the room's current content for one state event. `None` on any /// failure — absent state, transport error, a refusal, or an unparseable body /// are all "we don't know", and [`state_needs_write`] turns that into a write. /// /// A failure other than absent state is logged at `warn`: **a read that breaks /// while the write still works** makes the guard degrade to writing every /// time, and the sweep resumes waking the hive with nothing else to show for /// it. An absent state event (404) is the expected first-setup case. async fn current_room_state( client: &reqwest::Client, base: &str, sender_token: &str, room_id: &str, event_type: &str, state_key: &str, ) -> Option { match swarm_matrix_client::room_state( client, base, sender_token, room_id, event_type, state_key, ) .await { Ok(content) => content, Err(e) => { tracing::warn!( error = %format!("{e:#}"), %room_id, event_type, "matrix: state read failed while writes still work — the \ skip-if-unchanged guard is off and every sweep will re-emit" ); None } } } /// PUT a state event into `room_id` using the sender token, **skipping the /// write when the room already carries identical content**. /// /// The read is not an optimisation. A PUT of identical content is a no-op /// on the room's *state*, and the homeserver still appends an event to the /// *timeline* — so "idempotent" was true one level too high. Downstream, /// an event is unread activity, which is a todo, which is a turn: a caller /// re-applying a link on a periodic sweep wakes every agent in the room on /// that sweep's cadence, forever. Measured at ~30 minutes per wake per /// agent before this guard existed. /// /// The self-healing property the re-apply exists for is unaffected: a /// missing or divergent link still gets written. async fn set_room_state( client: &reqwest::Client, sender_token: &str, room_id: &str, event_type: &str, state_key: &str, content: &serde_json::Value, ) -> Result<()> { let base = matrix_base()?; let current = current_room_state(client, base, sender_token, room_id, event_type, state_key).await; if !state_needs_write(current.as_ref(), content) { tracing::debug!( %room_id, event_type, "matrix: state already current, skipping PUT (no timeline event)" ); return Ok(()); } swarm_matrix_client::put_state( client, base, sender_token, room_id, event_type, state_key, content, ) .await .context("matrix: set room state") } /// Create (or recover) the default hive chat room and wire it as a child of /// the hive Space, persisting its room id to [`hive_chat_room_id_path()`]. /// /// The room is a normal room (not an `m.space`) named [`HIVE_CHAT_ROOM_NAME`] /// with a `restricted` join rule allowing any member of the hive Space to /// join — so the operator (who is in the Space) and every agent can chat /// without needing an explicit invite. It's linked bidirectionally to the /// Space: `m.space.child` on the Space points at the room, `m.space.parent` /// on the room points back. Joining a Space does NOT auto-join its children /// (Matrix semantics), so this gives clients a concrete room to surface + /// join instead of an empty Space. /// /// Dedup mirrors [`ensure_hive_space`]: persisted id wins, else rediscover /// by name, else create. The space-child link is re-checked on every call /// so a recovered room reconverges its hierarchy link — and written only /// when it differs, see [`set_room_state`]. /// /// # Errors /// /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. A failure wiring the space-child /// link is logged but not fatal (the room still exists + is joinable). pub async fn ensure_hive_chat_room( client: &reqwest::Client, sender_token: &str, space_room_id: &str, server_name: &str, ) -> Result { let base = matrix_base()?; // 1. Stored room id wins (fast path). 2. Rediscover by name before // creating (prevents duplicates after a state wipe). 3. Create. let room_id = if let Some(id) = std::fs::read_to_string(hive_chat_room_id_path()) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) { tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned"); id } else if let Some(id) = find_chat_room_by_name(client, sender_token).await { persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); id } else { // `initial_state` is applied after the preset-derived state, so the // restricted join rule overrides private_chat's invite-only default. let body = serde_json::json!({ "name": HIVE_CHAT_ROOM_NAME, "topic": HIVE_CHAT_ROOM_TOPIC, // Pin the room version explicitly: the `restricted` join rule // below needs room version >= 8. tuwunel's default is currently // higher, but pinning makes the dependency explicit so a future // homeserver-default change can't silently invalidate the // restricted rule (which would quietly fall back to invite-only // and break the "operator joins from the Space" path). "room_version": "10", "preset": "private_chat", "visibility": "private", "initial_state": [ { "type": "m.room.join_rules", "state_key": "", "content": { "join_rule": "restricted", "allow": [ { "type": "m.room_membership", "room_id": space_room_id } ] } }, { "type": "m.space.parent", "state_key": space_room_id, "content": { "via": [server_name], "canonical": true } } ] }); let id = swarm_matrix_client::create_room(client, base, sender_token, &body) .await .context("matrix: create hive chat room")?; persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: created hive chat room"); id }; // Wire the Space → room child link. Without an `m.space.child` carrying // a `via`, the room won't surface in the Space hierarchy. `suggested` // hints clients to surface it prominently. let child_content = serde_json::json!({ "via": [server_name], "suggested": true, }); if let Err(e) = set_room_state( client, sender_token, space_room_id, "m.space.child", &room_id, &child_content, ) .await { tracing::warn!(error = ?e, "matrix: set m.space.child on hive space failed"); } Ok(room_id) } /// Invite `@{localpart}:{server_name}` to `room_id` using the admin /// account. Idempotent — treats already-member responses as success. async fn invite_to_room( client: &reqwest::Client, sender_token: &str, room_id: &str, localpart: &str, server_name: &str, ) -> Result<()> { let user_id = format!("@{localpart}:{server_name}"); invite_user_id(client, sender_token, room_id, &user_id).await } /// Fetch a user's current membership in a room via the sender token, or /// `None` if there is no membership event (never invited) or the lookup /// fails. Returns the raw membership string (`invite`, `join`, `leave`, …). async fn room_membership( client: &reqwest::Client, sender_token: &str, room_id: &str, user_id: &str, ) -> Option { let base = matrix_http()?; // A refused or failed read is "unknown": the caller falls through to the // invite attempt. let member = swarm_matrix_client::room_state( client, base, sender_token, room_id, "m.room.member", user_id, ) .await .ok()??; member["membership"].as_str().map(ToOwned::to_owned) } /// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id` /// using the sender token. Idempotent: a user who is already a member or /// already has a pending invite is left untouched (no fresh invite is sent, /// so they are not re-notified). A refused invite is an error unless the /// user turns out to be invited or joined anyway — see [`forbidden_invite`]. async fn invite_user_id( client: &reqwest::Client, sender_token: &str, room_id: &str, user_id: &str, ) -> Result<()> { let base = matrix_base()?; // Skip the invite entirely when the user is already invited or joined. // Re-POSTing an invite to a pending member re-sends the invite event, // which re-notifies the agent on every provisioning sweep. if let Some(membership) = room_membership(client, sender_token, room_id, user_id).await && matches!(membership.as_str(), "invite" | "join") { tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); return Ok(()); } match swarm_matrix_client::invite(client, base, sender_token, room_id, user_id) .await .context("matrix: invite")? { swarm_matrix_client::Invited::Sent => { tracing::debug!(%user_id, %room_id, "matrix: invited to room"); Ok(()) } swarm_matrix_client::Invited::Forbidden => { // The pre-check misses a member when its read failed or a // concurrent invite landed after it, so a refusal re-reads the // membership. let membership = room_membership(client, sender_token, room_id, user_id).await; forbidden_invite(membership.as_deref(), user_id, room_id) } } } /// Outcome of an invite the homeserver refused with 403. That is success only /// when `membership`, re-read after the refusal, shows the user invited or /// joined: `M_FORBIDDEN` covers both "already in the room" and real refusals /// (banned target, sender lacks power), so the errcode can't tell them apart. fn forbidden_invite(membership: Option<&str>, user_id: &str, room_id: &str) -> Result<()> { if matches!(membership, Some("invite" | "join")) { 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" ) } /// Invite an arbitrary Matrix user to the hive Space (default) or an /// explicit `room_id` / alias. `user` may be a fully-qualified id /// (`@name:server`) or a bare localpart, which is qualified with the /// homeserver's `server_name`. Returns the resolved room id the invite /// targeted. Used by `hivectl matrix invite`. /// /// # Errors /// /// Returns an error if the sender token or `server_name` can't be read, /// the target room can't be resolved (no `--room` and no persisted hive /// space), or the invite POST fails for a reason other than the user /// already being a member / invited. pub async fn invite_user( client: &reqwest::Client, sender_token: &str, user: &str, room_override: Option<&str>, server_name: &str, ) -> Result { // Qualify a bare localpart to a full user id on the hive homeserver. let user_id = if user.starts_with('@') { user.to_owned() } else { format!("@{user}:{server_name}") }; // Resolve the room: explicit override (id or #alias) wins; otherwise // the persisted hive Space. let room_id = match room_override { Some(r) if r.starts_with('#') => { swarm_matrix_client::resolve_alias(client, matrix_base()?, sender_token, r).await? } Some(r) if r.starts_with('!') => r.to_owned(), Some(r) => anyhow::bail!( "matrix: --room {r:?} is neither a room id nor an alias; \ prefix with '!' for a room id (!abc:server) or '#' for an \ alias (#name:server)" ), None => std::fs::read_to_string(hive_space_room_id_path()) .map(|s| s.trim().to_owned()) .context( "matrix: no --room given and no persisted hive space \ (run the hive-c0re matrix sweep first)", )?, }; invite_user_id(client, sender_token, &room_id, &user_id).await?; Ok(room_id) } /// Make sure the hive's own `@hive-:` account exists, then provision /// the hive Space and chat room and invite every agent container to both. /// Agents' own accounts are not created here: `swarm-controller` mints them /// with the swarm's appservice token. Runs only as a `MatrixSweep` job node /// (see `job_queue::templates::matrix_sweep`). No-op when the /// hive-matrix container isn't running. Per-step failures are logged /// but don't abort the sweep. /// /// Returns `true` when every step of the sweep succeeded, `false` when at /// least one step failed — the caller feeds this into a /// [`crate::stats::sweep_health::SweepHealth`] to raise a debounced /// dashboard banner on persistent failure (this sweep re-runs every 30 /// minutes, so a one-off blip self-heals without ever bannering). pub async fn ensure_all() -> bool { if !is_present() { tracing::debug!("matrix: hive-matrix container absent, skipping user sweep"); return true; } let mut ok = true; // One HTTP client for the whole sweep. let client = match swarm_matrix_client::client() { Ok(c) => c, Err(e) => { tracing::warn!(error = ?e, "matrix: build HTTP client failed; skipping sweep"); return false; } }; // The hive's `@hive-:` account first, because everything below provisions // THROUGH it (the Space, the chat room and every invite are sent with // its token) — as an ordinary user that created those rooms, not as a // homeserver admin. if let Err(e) = ensure_hive_user().await { tracing::warn!(error = ?e, "matrix: ensure_hive_user failed"); ok = false; } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("matrix: nixos-container list failed; skipping user sweep"); return false; }; let mut agent_names: Vec = Vec::new(); for c in &containers { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; agent_names.push(name.to_owned()); } if !provision_space(&client, &agent_names).await { ok = false; } ok } /// Provision the hive Space + default chat room and invite every agent /// (+ the admin account) to both. Split out of [`ensure_all`] purely to /// keep that function under the `too_many_lines` threshold — this is the /// tail half of the same sequential sweep and shares its aggregate-bool, /// log-and-continue failure handling. async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool { let mut ok = true; // server_name is needed to form full Matrix user IDs for invites. let sender_token = match read_sender_token() { Ok(t) => t, Err(e) => { tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)"); return false; } }; // server_name first — the agent invites need it (fully-qualified user ids). let server_name = match discover_server_name(client).await { Ok(s) => s, Err(e) => { tracing::warn!(error = ?e, "matrix: discover_server_name failed; skipping space provisioning"); return false; } }; let room_id = match ensure_hive_space(client, &sender_token).await { Ok(id) => id, Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); return false; } }; // This hive's own account first, then all agents. let localpart = match hive_localpart() { Ok(l) => l, Err(e) => { tracing::warn!(error = ?e, "matrix: skipping hive space provisioning"); return false; } }; if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await { tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed"); ok = false; } for name in agent_names { if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed"); ok = false; } } // Provision the default hive chat room as an m.space.child of the Space // and invite `@hive-:` + every agent. Joining a Space alone surfaces no // rooms to chat in (Matrix semantics — children aren't auto-joined), so // without this the Space is empty. The restricted join rule additionally // lets the operator (a Space member) join from the Space hierarchy. match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await { Ok(chat_room_id) => { if let Err(e) = invite_to_room( client, &sender_token, &chat_room_id, &localpart, &server_name, ) .await { tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed"); ok = false; } for name in agent_names { if let Err(e) = invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed"); ok = false; } } } Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_hive_chat_room failed"); ok = false; } } ok } #[cfg(test)] mod tests { use super::*; #[test] fn with_no_file_the_store_token_is_taken() { assert_eq!(sender_source(Some("tok"), None), SenderSource::Store("tok")); } #[test] fn a_store_token_replaces_a_different_file_token() { // The swarm re-minted a dead token; the file must follow it. assert_eq!( sender_source(Some("new\n"), Some("old\n")), SenderSource::Store("new") ); } #[test] fn a_file_matching_the_store_is_kept() { // Trailing newline on disk is how `persist_sender_token` writes it. assert_eq!( sender_source(Some("tok"), Some("tok\n")), SenderSource::Keep ); } #[test] fn with_nothing_in_the_store_the_file_is_kept() { assert_eq!(sender_source(None, Some("tok\n")), SenderSource::Keep); } #[test] fn with_no_token_anywhere_nothing_is_available() { assert_eq!( sender_source(Some(""), Some(" \n")), SenderSource::Unavailable ); assert_eq!(sender_source(None, None), SenderSource::Unavailable); } /// The steady state. This is the whole point of the guard: the sweep /// runs forever, and every write it makes is a wake for every agent /// in the room. #[test] fn state_matching_the_room_is_not_rewritten() { let desired = serde_json::json!({ "via": ["example.org"], "suggested": true }); let current = desired.clone(); assert!(!state_needs_write(Some(¤t), &desired)); } /// Key order in the homeserver's response must not force a write — /// the guard leans on `serde_json::Value` comparing objects as maps, /// and a byte- or order-sensitive comparison would silently degrade /// to writing every time while still looking correct. #[test] fn state_matching_but_reordered_is_not_rewritten() { let desired = serde_json::json!({ "via": ["example.org"], "suggested": true }); let current = serde_json::json!({ "suggested": true, "via": ["example.org"] }); assert!(!state_needs_write(Some(¤t), &desired)); } #[test] fn diverged_state_is_rewritten() { let desired = serde_json::json!({ "via": ["example.org"], "suggested": true }); let current = serde_json::json!({ "via": ["old.example.org"], "suggested": true }); assert!(state_needs_write(Some(¤t), &desired)); } /// Fail-open, and deliberately so: an unreadable current state must /// write. The re-apply exists to repair a missing link, so "we don't /// know" has to behave like "it's missing", not like "it's fine". #[test] fn unknown_state_is_written() { let desired = serde_json::json!({ "via": ["example.org"], "suggested": true }); assert!(state_needs_write(None, &desired)); } /// A banned target or a sender without power gets a 403; with no /// membership behind it, the invite did not happen. #[test] fn a_refused_invite_without_membership_is_an_error() { for membership in [None, Some("ban"), Some("leave")] { let outcome = forbidden_invite(membership, "@a:x", "!r:x"); assert!(outcome.is_err(), "membership {membership:?} must not pass"); } } #[test] fn a_refused_invite_for_a_member_is_success() { for membership in ["invite", "join"] { forbidden_invite(Some(membership), "@a:x", "!r:x") .expect("already invited/joined is the goal state"); } } /// The homeserver's reply body reaches no error: `invite_user_id` is /// driven against a stub whose 500 and 403 bodies carry a marker. #[tokio::test] async fn an_invite_refusal_error_does_not_quote_the_response_body() { const BODY: &str = r#"{"errcode":"M_UNKNOWN","error":"free-form-marker"}"#; for status in [500_u16, 403] { let app = axum::Router::new().fallback(move || async move { ( axum::http::StatusCode::from_u16(status).expect("a status"), [(axum::http::header::CONTENT_TYPE, "application/json")], BODY, ) }); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let base = format!("http://{}", listener.local_addr().expect("local addr")); let server = tokio::spawn(async move { axum::serve(listener, app).await.expect("serve"); }); let client = swarm_matrix_client::client().expect("client"); let outcome = swarm_matrix_client::invite(&client, &base, "tok", "!r:x", "@a:x").await; server.abort(); let message = match (status, outcome) { (403, Ok(swarm_matrix_client::Invited::Forbidden)) => { format!("{:#}", forbidden_invite(None, "@a:x", "!r:x").unwrap_err()) } (500, Err(e)) => format!("{e:#}"), (status, other) => panic!("HTTP {status}: unexpected {other:?}"), }; assert!(message.contains(&status.to_string()), "{message}"); assert!(!message.contains("free-form-marker"), "{message}"); } } /// 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. async fn fake_homeserver() -> (String, tokio::task::JoinHandle<()>) { let app = axum::Router::new().fallback(|uri: axum::http::Uri| async move { let path = uri.path(); let body = if path.ends_with("/joined_rooms") { serde_json::json!({"joined_rooms": ["!decoy", "!space", "!chat"]}) } else if path.ends_with("/m.room.create/") { if path.contains("space") { serde_json::json!({"type": "m.space"}) } else { serde_json::json!({}) } } else if path.ends_with("/m.room.name/") { let name = if path.contains("decoy") { "other" } else { "shared" }; serde_json::json!({"name": name}) } else { serde_json::json!({}) }; axum::Json(body) }); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let base = format!("http://{}", listener.local_addr().expect("local addr")); let server = tokio::spawn(async move { axum::serve(listener, app).await.expect("serve"); }); (base, server) } #[tokio::test] async fn find_joined_room_matches_a_space_only_when_a_space_is_wanted() { let (base, server) = fake_homeserver().await; let client = reqwest::Client::new(); let want_space = find_joined_room(&client, &base, "tok", true, "shared").await; let want_chat = find_joined_room(&client, &base, "tok", false, "shared").await; let no_name = find_joined_room(&client, &base, "tok", true, "absent").await; server.abort(); assert_eq!(want_space.as_deref(), Some("!space")); assert_eq!(want_chat.as_deref(), Some("!chat")); assert_eq!(no_name, None); } }