Watch
0
0
Fork
You've already forked hyperhive
0

also stop provisioning the hive space and make hivectl matrix invite take a room

This commit is contained in:
damocles 2026-10-28 20:57:41 +01:00
commit fd01848d4d
8 changed files with 83 additions and 437 deletions

View file

@ -106,7 +106,7 @@ pub(super) trait Host: Sync {
fn purge_dirs(&self, name: &str) -> Vec<PathBuf>;
/// 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<Coordinator>) -> 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
)
}

View file

@ -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

View file

@ -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,
}

View file

@ -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

View file

@ -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();

View file

@ -1,8 +1,7 @@
//! Optional matrix-tuwunel wiring: the hive's `@hive-<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-<name>`, 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<String> {
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<String> {
})
}
/// 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<String> {
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<serde_json::Value> {
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<String> {
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-<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<String> {
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<String> {
// 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-<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-<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-<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<String> = 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);
}
}

View file

@ -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 {

View file

@ -203,9 +203,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> 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<HostResponse> {
async fn handle_matrix_invite(user: &str, room: &str) -> Result<HostResponse> {
require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?;
let client = matrix_http_client()?;