diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index a0b31240..1d4b2336 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -46,6 +46,13 @@ fn token_path(name: &str) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-token") } +/// Password file alongside the token. Persisted so we can fall back to +/// `m.login.password` if the token file is deleted but the homeserver +/// account still exists. Mode 0600, same dir as the token. +fn password_path(name: &str) -> PathBuf { + Coordinator::agent_notes_dir(name).join("matrix-password") +} + /// 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`. @@ -211,10 +218,51 @@ fn extract_access_token(body: &serde_json::Value) -> Result { .with_context(|| format!("matrix: missing access_token in response: {body}")) } +/// Login with `m.login.password` and return the access token. Fallback +/// for when registration fails with `M_USER_IN_USE` — the account +/// already exists in the homeserver but the token file was lost. Fails +/// if the stored password no longer matches (e.g. homeserver wiped), +/// in which case manual recovery via `hivectl matrix create-user` is +/// required. +async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result { + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/login"); + let body = serde_json::json!({ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": user_localpart(agent), + }, + "password": password, + "device_id": format!("hyperhive-{agent}"), + "initial_device_display_name": format!("hyperhive ({agent})"), + }); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .context("matrix: POST /login")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("matrix: parse /login response")?; + if !status.is_success() { + anyhow::bail!("matrix: /login HTTP {status} for agent {agent}, body: {json}"); + } + extract_access_token(&json) +} + /// Ensure `name` has a matrix user + token file on the local -/// homeserver. Skips registration entirely if the token file already +/// homeserver. Skips provisioning entirely if the token file already /// exists (treating a present token as proof the account is good). -/// To force re-registration, delete the token file. +/// To force re-provisioning, delete the token file. +/// +/// When registration fails with `M_USER_IN_USE` (account exists in the +/// homeserver but the token file was deleted) this falls back to +/// `m.login.password` using the persisted `matrix-password` file. If +/// that file is also missing, recovery requires manual intervention: +/// `hivectl matrix create-user --password `. /// /// `client` is shared across the sweep so we build one reqwest /// connection pool for all agents rather than one per call. @@ -232,8 +280,48 @@ pub async fn ensure_user_for( tracing::debug!(%name, "matrix: token already present"); return Ok(()); } + let password = random_password()?; - let access_token = register_user(client, name, register_token, &password).await?; + let access_token = match register_user(client, name, register_token, &password).await { + Ok(token) => { + // Successful registration — persist the password so we can + // fall back to login if the token file is deleted later. + let pw_path = password_path(name); + if let Some(parent) = pw_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { + tracing::warn!(%name, error = ?e, "matrix: failed to persist password (token still saved)"); + } else { + let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); + crate::lifecycle::chown_to_agent(name, &pw_path, "matrix"); + } + token + } + Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => { + // Account already exists — try to re-login with the stored password. + tracing::info!(%name, "matrix: user already exists, attempting login with stored password"); + let pw_path = password_path(name); + let stored = std::fs::read_to_string(&pw_path) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .with_context(|| { + format!( + "matrix: user {name} already exists in homeserver but matrix-password \ + is missing — manual recovery: hivectl matrix create-user {name} --password " + ) + })?; + login_user(client, name, &stored).await.with_context(|| { + format!( + "matrix: login fallback for {name} failed — if homeserver was wiped, delete \ + the matrix-password file and retry" + ) + })? + } + Err(other) => return Err(other), + }; + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } @@ -241,7 +329,7 @@ pub async fn ensure_user_for( .with_context(|| format!("matrix: write token to {}", path.display()))?; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); crate::lifecycle::chown_to_agent(name, &path, "matrix"); - tracing::info!(%name, path = %path.display(), "matrix: registered user + persisted access token"); + tracing::info!(%name, path = %path.display(), "matrix: provisioned access token"); Ok(()) }