From 60582c0be4e7c44da0af7ae57bce5e32d38a6c0f Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 3 Jun 2026 21:56:13 +0200 Subject: [PATCH] feat: create hive Matrix Space on boot and invite all agents --- hive-c0re/src/matrix.rs | 144 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index f44e62e0..901eac4a 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -82,6 +82,13 @@ fn legacy_password_path(name: &str) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-password") } +/// 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 { + PathBuf::from("/var/lib/hyperhive/matrix-space-room-id") +} + /// Probe whether `hive-matrix` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Same shape /// as `forge::is_present` — routed through hive-priv since @@ -639,6 +646,102 @@ pub fn read_admin_token() -> Result { }) } +/// Create the hive Matrix Space room using the admin account and persist +/// its room ID to [`hive_space_room_id_path()`]. Idempotent — returns +/// the stored room ID immediately if the file already exists. +/// +/// The Space is a private `m.space` room owned by `@hive`. All agents +/// are invited after their accounts are provisioned in [`ensure_all`]. +pub async fn ensure_hive_space( + client: &reqwest::Client, + admin_token: &str, +) -> Result { + use std::os::unix::fs::PermissionsExt; + let path = hive_space_room_id_path(); + if let Ok(existing) = std::fs::read_to_string(&path) { + let trimmed = existing.trim().to_owned(); + if !trimmed.is_empty() { + tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned"); + return Ok(trimmed); + } + } + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); + let body = serde_json::json!({ + "name": "hive", + "creation_content": { "type": "m.space" }, + "preset": "private_chat", + "visibility": "private", + }); + let resp = client + .post(&url) + .bearer_auth(admin_token) + .json(&body) + .send() + .await + .context("matrix: POST /createRoom (hive space)")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("matrix: parse /createRoom response")?; + if !status.is_success() { + anyhow::bail!("matrix: createRoom HTTP {status}, body: {json}"); + } + let room_id = json["room_id"] + .as_str() + .with_context(|| format!("matrix: createRoom missing room_id: {json}"))? + .to_owned(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(&path, format!("{room_id}\n")) + .with_context(|| format!("matrix: write space room_id to {}", path.display()))?; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + tracing::info!(%room_id, "matrix: created hive space"); + 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, + admin_token: &str, + room_id: &str, + localpart: &str, + server_name: &str, +) -> Result<()> { + // `:` must be percent-encoded in the room-id path segment; `!` is + // permitted in URL path characters per RFC 3986. + let encoded_room_id = room_id.replace(':', "%3A"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); + let user_id = format!("@{localpart}:{server_name}"); + let resp = client + .post(&url) + .bearer_auth(admin_token) + .json(&serde_json::json!({ "user_id": user_id })) + .send() + .await + .with_context(|| format!("matrix: POST /rooms/.../invite for {user_id}"))?; + let status = resp.status(); + if status.is_success() { + tracing::debug!(%user_id, %room_id, "matrix: invited to hive space"); + return Ok(()); + } + // 403 with M_FORBIDDEN or M_BAD_STATE typically means the user is + // already a member or has a pending invite — both are fine. + if status == StatusCode::FORBIDDEN { + let body = resp.json::().await.unwrap_or_default(); + let errcode = body["errcode"].as_str().unwrap_or(""); + if errcode == "M_FORBIDDEN" || errcode == "M_BAD_STATE" { + tracing::debug!(%user_id, %room_id, %errcode, "matrix: invite skipped (already member/invited)"); + return Ok(()); + } + anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}"); + } + let body = resp.json::().await.unwrap_or_default(); + anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}") +} + /// Sweep every existing container (manager + sub-agents) and ensure /// each has a matrix user + token on the local homeserver. Called once /// at hive-c0re startup, alongside `forge::ensure_all`. No-op when the @@ -678,11 +781,50 @@ pub async fn ensure_all() { tracing::warn!("matrix: nixos-container list failed; skipping user sweep"); return; }; - for c in containers { + let mut agent_names: Vec = Vec::new(); + for c in &containers { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; sync_agent(&client, name, ®ister_token).await; + agent_names.push(name.to_owned()); + } + + // Provision the hive Space and invite all agents (+ the admin account). + // server_name is needed to form full Matrix user IDs for invites. + let admin_token = match read_admin_token() { + Ok(t) => t, + Err(e) => { + tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)"); + return; + } + }; + let room_id = match ensure_hive_space(&client, &admin_token).await { + Ok(id) => id, + Err(e) => { + tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); + return; + } + }; + 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 invites"); + return; + } + }; + // Invite @hive admin first, then all agents. + if let Err(e) = + invite_to_room(&client, &admin_token, &room_id, HIVE_ADMIN_LOCALPART, &server_name).await + { + tracing::warn!(error = ?e, "matrix: invite @hive to space failed"); + } + for name in &agent_names { + if let Err(e) = + invite_to_room(&client, &admin_token, &room_id, name, &server_name).await + { + tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed"); + } } }