diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index c6339f52..7622e027 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -111,12 +111,11 @@ container keeps starting with the hive meanwhile. ## 5 · Matrix -Your matrix account comes from SSO. Invite it to the hive Space, and -optionally to rooms: +Your matrix account comes from SSO. Invite it to the rooms you want it in: ```bash -hivectl matrix invite mara -hivectl matrix invite @mara:yourserver --room '#some-room:yourserver' +hivectl matrix invite mara --room '#some-room:yourserver' +hivectl matrix invite @mara:yourserver --room '!abc:yourserver' ``` ## 6 · Your first agent diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 4c488139..99fd7da5 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -96,8 +96,7 @@ and federation peers all reach the homeserver at `chat.` through the gateway; from outside the host that takes `gateway.openFirewall`. -hive-c0re provisions matrix (its own sender login, the hive Space and -invites) through `swarm.matrix.apiUrl`, which defaults to +hive-c0re talks to matrix (its own sender login, invites) through `swarm.matrix.apiUrl`, which defaults to `https://` on every hive. That login carries the hive's matrix access token, and on a hive that doesn't run the homeserver it crosses the network, so the default is TLS; hive-c0re @@ -258,9 +257,8 @@ token and gets a refusal on any other hive's. The name says what it authenticates as — an account the swarm's appservice creates — rather than any privilege level, because it carries none. -It needs no promotion for what the hive does with it. Creating the hive -Space and inviting agents into it are ordinary client calls that ride on -being the Space's own creator at power level 100 — there is no homeserver +It needs no promotion for what the hive does with it. Inviting a user into a +room it has the power in is an ordinary client call — there is no homeserver admin in any of it, and no Synapse admin API to reach for either, since tuwunel has none. @@ -346,27 +344,17 @@ Initial rollout settings: stored yet the account comes up without backup and picks it up on the next daemon start. A failure is a `WARN` and never stops the daemon. -## Hive Matrix Space +## Hive sender token -On first boot hive-c0re creates a private **Matrix Space** named `"hive"` using its own hive -account (`@hive-:`) and invites every provisioned agent -into it. This gives the operator a single Space in FluffyChat or any -Matrix client that groups all agent-to-agent + operator rooms in one -place. +hive-c0re keeps the hive's `@hive-:` sender token on disk for +`hivectl matrix invite`, refreshed from the swarm secret store by the +matrix sweep (at boot, then every 30 minutes). -**State**: hive-c0re persists the Space's room ID to -`/var/lib/hyperhive/matrix/space-room-id` (mode `0600`, owned by the -hive-c0re service user). The path is **outside** every agent state dir and -**isn't** deleted by `nixos-container destroy --purge`, so it survives full -agent purges, and hive-c0re reuses it on re-provision. - -An earlier version also provisioned a `hive-chat` room and kept its id in -`chat-room-id`. The sweep no longer touches either; an existing room and -file are harmless leftovers, so delete them by hand if you like. - -**Idempotent**: if the file exists and is non-empty, hive-c0re considers the -Space already created. Delete the file to force re-creation (for example -after a homeserver wipe). +Earlier versions also created a private "hive" Matrix Space and a +`hive-chat` room, invited every agent to both, and kept their ids in +`/var/lib/hyperhive/matrix/space-room-id` and `chat-room-id`. The sweep no +longer touches any of them. The rooms and files are harmless leftovers, so +delete them by hand if you like. ## Configuration tuning diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 130334fa..e5a8ee4c 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -73,7 +73,7 @@ Cheap — no build slot: | `EmitRebuilt` | tail of a rebuild — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome | | `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload | | `ForgeSweep` | one-shot boot-time publish of the hive's forge read token for nix (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless | -| `MatrixSweep` | matrix user/space sweep (`matrix::ensure_all`): the boot-time instance, plus one every 30 min from a loop in `main.rs`. Holds `Resource::MatrixSweep` (capacity 1), so two passes never overlap; each tick queues its own pass, which waits for the resource if one is already live. Agentless | +| `MatrixSweep` | matrix sender-token sweep (`matrix::ensure_all`): the boot-time instance, plus one every 30 min from a loop in `main.rs`. Holds `Resource::MatrixSweep` (capacity 1), so two passes never overlap; each tick queues its own pass, which waits for the resource if one is already live. Agentless | | `KnowledgePull` | `/knowledge` pull (`knowledge::pull`): at boot (commits that landed while `hive-c0re` was down), on the swarm knowledge-changed event, and hourly as a fallback. Holds `Resource::KnowledgeTree` (capacity 1), so two pulls never overlap on the working tree; each trigger queues its own pass, which waits for the resource if one is already live. Agentless | | `WantedPull` | pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. Runs at boot, waiting up to a minute in total for the queue connection, and again after every attach of the wanted-state watch (which retries with a backoff when the attach fails or the stream ends); the deploy event (`swarm_status`) and the watch are the fast path, this repairs a missed one. A first deploy already queued or running for an agent isn't queued again. Agentless | diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index eda3044d..8655f703 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -116,21 +116,21 @@ Always prints the diff first. `--from forge` resets the local checkout to forge matrix-tuwunel invites. -Manual invites into the hive Space and its rooms; c0re's periodic matrix sweep provisions the Space and the agents' invites on its own. +Manual invites into a room by id or alias. **Usage:** `hivectl matrix ` ###### **Subcommands:** -* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent +* `invite` — Invite a matrix user to a room. Idempotent ## `hivectl matrix invite` -Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent +Invite a matrix user to a room. Idempotent -**Usage:** `hivectl matrix invite [OPTIONS] ` +**Usage:** `hivectl matrix invite --room ` ###### **Arguments:** @@ -138,7 +138,7 @@ Invite a matrix user to the hive Space, or a specific room with `--room`. Idempo ###### **Options:** -* `--room ` — Target room id (`!abc:server`) or alias (`#name:server`). Omit to invite to the hive Space +* `--room ` — Target room id (`!abc:server`) or alias (`#name:server`) diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 491bd03a..43822c89 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -44,13 +44,12 @@ hivectl forge reconcile-config iris --verbose # include the full diff, not ## Matrix -Manual entry to the same idempotent matrix provisioning flow -`hive-c0re` runs at boot. Requires the `hive-matrix` container to be +Manual invites into a room. Requires the `hive-matrix` container to be running (`services.hyperhive.deploy.matrix.enable = true`). ```bash -hivectl matrix invite mara # invite a user to the hive Space -hivectl matrix invite @mara:server --room '#some-room:server' # ...or to a specific room/alias +hivectl matrix invite mara --room '#some-room:server' # a room alias +hivectl matrix invite @mara:server --room '!abc:server' # ...or a room id ``` Human matrix accounts come from SSO, not `hivectl`; matrix homeserver @@ -58,10 +57,9 @@ admin should eventually come from membership in authelia's `admins` group; nobody has built that sync yet. - `invite`: invites a matrix user (full `@user:server` or a bare - localpart, qualified with the homeserver's `server_name`) to the hive - Space by default, or to a `--room` id / `#alias`. Uses the sender - token; the account must be a member of the target room with - invite power (it owns the hive Space, so that case always works). + localpart, qualified with the homeserver's `server_name`) to the + `--room` id / `#alias`. Uses the sender token; the account must be a + member of the target room with invite power. Idempotent — already-member / already-invited is a no-op. ## Agents diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index fe4ff0b1..4c2ae705 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -106,7 +106,7 @@ pub(super) trait Host: Sync { fn purge_dirs(&self, name: &str) -> Vec; /// Publish the hive's forge credential for nix. async fn forge_ensure_all(&self); - /// One matrix user/space pass; whether every agent synced. + /// One matrix sender-token pass; whether the token is in place. async fn matrix_ensure_all(&self) -> bool; /// One `/knowledge` pull. async fn knowledge_pull(&self, coord: &Arc) -> Result<()>; @@ -367,7 +367,7 @@ async fn run_forge_sweep(host: &impl Host) -> Result<()> { Ok(()) } -/// Matrix user/space sweep as a DAG node — see [`NodeKind::MatrixSweep`]. +/// Matrix sender-token sweep as a DAG node — see [`NodeKind::MatrixSweep`]. /// Every pass reports here, whoever submitted it: as the node's own error on /// the dashboard, and into the debounced warning. async fn run_matrix_sweep(host: &impl Host, deadline: std::time::Duration) -> Result<()> { @@ -411,8 +411,8 @@ fn matrix_sweep_warning(ctx: sweep_health::SweepFailure) -> String { |d| format!("last ok {} ago", sweep_health::fmt_age(d)), ); format!( - "matrix user/space sweep failing ({} consecutive, {age}) \ - — some agents may be missing matrix accounts or space membership", + "matrix sender-token sweep failing ({} consecutive, {age}) \ + — `hivectl matrix invite` may not work", ctx.consecutive ) } diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index fa755a45..dd9e3b35 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -99,7 +99,7 @@ pub enum NodeKind { /// One-shot boot-time publish of the hive's forge read token for nix /// (`forge::ensure_all`). Agentless. ForgeSweep, - /// Matrix user/space sweep (`matrix::ensure_all`): at boot and every 30 + /// Matrix sender-token sweep (`matrix::ensure_all`): at boot and every 30 /// minutes. Agentless. MatrixSweep, /// `/knowledge` pull (`knowledge::pull`): at boot, on the swarm diff --git a/hive-c0re/src/job_queue/resource.rs b/hive-c0re/src/job_queue/resource.rs index c2a6b549..433b2992 100644 --- a/hive-c0re/src/job_queue/resource.rs +++ b/hive-c0re/src/job_queue/resource.rs @@ -31,10 +31,8 @@ pub enum Resource { /// overlapping `reset` / `clean` / `pull` passes there fail on /// `.git/index.lock` and the remote-tracking ref lock. KnowledgeTree, - /// The hive's matrix provisioning, held by every `MatrixSweep`. Two - /// overlapping passes on a hive with no persisted Space id both - /// miss the by-name lookup and both `createRoom`, leaving a duplicate room - /// with agents invited to both. + /// The hive's sender-token file, held by every `MatrixSweep`. Two + /// overlapping passes would race on writing it. MatrixSweep, } diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 681b1422..7afb4246 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -370,7 +370,7 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) { // `Reconcile` root per drifted agent) — no anchor node and no per-agent child // DAGs. -/// One matrix user/space sweep. Holding [`Resource::MatrixSweep`] is what keeps +/// One matrix sender-token sweep. Holding [`Resource::MatrixSweep`] is what keeps /// two passes from overlapping, whichever caller submitted each. pub fn matrix_sweep(builder: &JobBuilder) -> Handle<'_> { builder diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 14c8909f..d7c8c627 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -307,9 +307,9 @@ async fn cmd_serve( } } }); - // Matrix sweep (`matrix::ensure_all`): the hive's own account, the hive - // Space, and every agent container's invite to it. It - // creates no agent accounts or tokens — swarm-controller mints those. + // Matrix sweep (`matrix::ensure_all`): the hive's own sender token, kept + // on disk for `hivectl matrix invite`. It + // creates no accounts or tokens — swarm-controller mints those. // No-op when the hive-matrix container isn't running. Re-submitted every // 30 minutes; the startup pass is the boot `MatrixSweep` node. let mut matrix_shutdown = coord.shutdown_rx(); diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 0e5cc385..0318d639 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -1,8 +1,7 @@ //! Optional matrix-tuwunel wiring: the hive's `@hive-:` sender token, -//! taken from the swarm secret store, and the hive Space 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. +//! taken from the swarm secret store and kept on disk for `hivectl matrix +//! invite`. 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. @@ -46,46 +45,6 @@ fn matrix_base() -> Result<&'static str> { ) } -/// 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 and the invites — it provisions as the -/// creator of the Space. -/// -/// # Errors -/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable -/// [`stored_sender_token`] logs in with and the swarm 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"; - /// 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] @@ -93,13 +52,6 @@ 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() -} - /// Whether this hive has a homeserver to provision against. /// /// **A configured API URL, not a local container**: whether the homeserver @@ -274,136 +226,6 @@ pub fn read_sender_token() -> Result { }) } -/// 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) -} - -/// 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`, …). @@ -484,23 +306,21 @@ fn forbidden_invite(membership: Option<&str>, user_id: &str, room_id: &str) -> R ) } -/// 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`. +/// Invite an arbitrary Matrix user to `room` (a room id or 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. +/// Returns an error if `room` is neither a room id nor an alias, 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>, + room: &str, server_name: &str, ) -> Result { // Qualify a bare localpart to a full user id on the hive homeserver. @@ -509,39 +329,29 @@ pub async fn invite_user( } 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; \ + let room_id = if room.starts_with('#') { + swarm_matrix_client::resolve_alias(client, matrix_base()?, sender_token, room).await? + } else if room.starts_with('!') { + room.to_owned() + } else { + anyhow::bail!( + "matrix: --room {room:?} 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 invite every agent container to it. -/// 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. +/// Make sure the hive's own `@hive-:` sender token is on disk. +/// 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 no homeserver is +/// configured. /// -/// Returns `true` when every step of the sweep succeeded, `false` when at -/// least one step failed — the caller feeds this into a +/// Returns `true` when the token is in place, `false` when it could not be +/// fetched — the caller feeds this into a /// [`crate::stats::sweep_health::SweepHealth`] to raise a debounced /// hive warning on persistent failure (this sweep re-runs every 30 /// minutes, so a one-off blip self-heals without ever warning). @@ -550,90 +360,11 @@ pub async fn ensure_all() -> bool { 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 and every invite are sent with its token) — as - // an ordinary user that created the Space, 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 and invite every agent (+ the admin account) -/// to it. 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; - } - } - ok + true } #[cfg(test)] @@ -729,53 +460,4 @@ mod tests { 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); - } } diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index fdc7876d..52bcd89a 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -104,12 +104,6 @@ pub fn matrix_sender_token() -> PathBuf { matrix_dir().join("access-token") } -/// `matrix/space-room-id` — persisted hive Space room id. -#[must_use] -pub fn matrix_space_room_id() -> PathBuf { - matrix_dir().join("space-room-id") -} - /// `run/` — runtime maps hive-c0re regenerates on every meta sync. #[must_use] pub fn run_dir() -> PathBuf { @@ -258,11 +252,10 @@ pub fn agent_runtime_dir(name: &str) -> PathBuf { /// within the same filesystem is atomic. pub fn relocate_legacy_state() { let root = state_root(); - let moves: [(&str, PathBuf); 5] = [ + let moves: [(&str, PathBuf); 4] = [ ("broker.sqlite", db_dir().join("broker.sqlite")), ("build_logs.sqlite", db_dir().join("build_logs.sqlite")), ("matrix-sender-token", matrix_sender_token()), - ("matrix-space-room-id", matrix_space_room_id()), ("agent-sockets.json", agent_sockets_file()), ]; for (old_rel, new) in &moves { diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index db306529..34cd6af7 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -203,9 +203,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { // federation peer-config block) and open the web surfaces // (`hivectl open`). HostRequest::Urls => HostResponse::urls(hive_urls()), - HostRequest::MatrixInvite { user, room } => { - handle_matrix_invite(user, room.as_deref()).await? - } + HostRequest::MatrixInvite { user, room } => handle_matrix_invite(user, room).await?, HostRequest::ReconcileConfigStatus { agent, verbose } => { crate::forge::reconcile_config_status(agent.as_str(), *verbose).await? } @@ -500,7 +498,7 @@ async fn handle_push_snapshot( Ok(HostResponse::success()) } -async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result { +async fn handle_matrix_invite(user: &str, room: &str) -> Result { require_matrix_present()?; let sender_token = crate::matrix::read_sender_token()?; let client = matrix_http_client()?; diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 4d5af3f3..374c6caa 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -223,14 +223,9 @@ pub enum HostRequest { #[serde(default)] scope: LifecycleScope, }, - /// Invite a matrix user to the hive Space (default) or a specific - /// `room`. Uses the daemon's admin token; idempotent - /// (already-member / already-invited is a no-op). - MatrixInvite { - user: String, - #[serde(default)] - room: Option, - }, + /// Invite a matrix user to `room` (id or alias). Uses the daemon's + /// admin token; idempotent (already-member / already-invited is a no-op). + MatrixInvite { user: String, room: String }, /// Report the divergence between agent `agent`'s local applied config /// checkout and its forge `agent-configs/` `main`. The daemon /// fetches forge `main` read-only and returns a human-readable report diff --git a/hivectl/README.md b/hivectl/README.md index 7fdec9f4..ef9c944e 100644 --- a/hivectl/README.md +++ b/hivectl/README.md @@ -27,7 +27,7 @@ dispatch: container. - **`forge.rs`** — reconciles an agent's config against its forge repo. -- **`matrix.rs`** — invite a matrix user to the hive Space or a room. +- **`matrix.rs`** — invite a matrix user to a room. - **`wg.rs`** — WireGuard mesh helpers. - **`subvol.rs`** — btrfs state-subvolume ops. - **`quota.rs`**, **`util.rs`**, **`completions.rs`**, **`open.rs`** — diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 1c1c349f..cbb75fbc 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -38,9 +38,7 @@ pub enum Cmd { }, /// matrix-tuwunel invites. /// - /// Manual invites into the hive Space and its rooms; c0re's periodic - /// matrix sweep provisions the Space and the agents' invites on - /// its own. + /// Manual invites into a room by id or alias. Matrix { #[command(subcommand)] cmd: MatrixCmd, @@ -250,16 +248,14 @@ impl From for hive_host_sock::ReconcileDirection { #[derive(Subcommand)] pub enum MatrixCmd { - /// Invite a matrix user to the hive Space, or a specific room with - /// `--room`. Idempotent. + /// Invite a matrix user to a room. Idempotent. Invite { /// User to invite: a full id (`@mara:server`) or a bare /// localpart (qualified with the homeserver's `server_name`). user: String, /// Target room id (`!abc:server`) or alias (`#name:server`). - /// Omit to invite to the hive Space. #[arg(long)] - room: Option, + room: String, }, } diff --git a/hivectl/src/matrix.rs b/hivectl/src/matrix.rs index 4fd27a23..ee77f9e0 100644 --- a/hivectl/src/matrix.rs +++ b/hivectl/src/matrix.rs @@ -12,7 +12,7 @@ use crate::cli::MatrixCmd; /// dispatch match so the top-level router stays small. pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> { match cmd { - MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await, + MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, &room).await, } } @@ -36,12 +36,12 @@ async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Resu Ok(()) } -async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> { +async fn matrix_invite(socket: &Path, user: &str, room: &str) -> Result<()> { matrix_request( socket, hive_host_sock::HostRequest::MatrixInvite { user: user.to_owned(), - room: room.map(str::to_owned), + room: room.to_owned(), }, ) .await diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 0848b498..d691cbba 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -913,8 +913,8 @@ in # the swarm appservice's sender. Its token is read by # matrix-ctl and swarm-controller only. Each `@hive-` # stays an ordinary user, because its hive reads its - # credential; what it does (the Space and the invites) - # rides on being the Space's creator, not on admin. + # credential; what it does (the invites) rides on its + # power in the room, not on admin. # # The registration load creates the sender inside # `Services::start()`, and `admin_execute` runs after that and diff --git a/swarm-matrix-client/README.md b/swarm-matrix-client/README.md index 8636de9d..28f91911 100644 --- a/swarm-matrix-client/README.md +++ b/swarm-matrix-client/README.md @@ -15,9 +15,8 @@ device's token instead of adding a device. the operator names. Neither call is an appservice call, so neither carries the appservice token. -`hive-c0re` provisions the hive Space with the room calls -(`joined_rooms`, `room_state`, `put_state`, `create_room`, `invite`, -`resolve_alias`), and `hive-matrix-mcp` recovers a session's user and device id +`hive-c0re` invites users into rooms with the room calls (`room_state`, +`invite`, `resolve_alias`), and `hive-matrix-mcp` recovers a session's user and device id with `whoami_session`. Each takes the caller's `reqwest::Client`, so the caller sets its own timeout; `client()` is the 10 second default. diff --git a/swarm-matrix-client/src/lib.rs b/swarm-matrix-client/src/lib.rs index 8e126330..bbcc0b4f 100644 --- a/swarm-matrix-client/src/lib.rs +++ b/swarm-matrix-client/src/lib.rs @@ -1,7 +1,7 @@ //! The calls against a Matrix homeserver's client-server API shared by the //! swarm and the hive: the appservice calls (register, log in, ask a token who //! it is), the room calls `swarm-controller`'s admin-room commands and -//! `hive-c0re`'s Space provisioning need (resolve an alias, list +//! `hive-c0re`'s invites need (resolve an alias, list //! members and joined rooms, read and set room state, create a room, invite, send //! a message, check an account exists), and the password login and logout //! `swarm-controller` runs for an operator-linked account on any homeserver.