Watch
0
0
Fork
You've already forked hyperhive
0

hivectl, hive-c0re: remove dead matrix create-user/promote-user/reset-password

Human matrix accounts come from SSO, not hivectl. Matrix homeserver
admin will come from authelia's admins group (sync tracked in #4585);
password reset moves to swarm level (#4798). promote-user and
reset-password were already broken from the hive: the hive's sender
account has no admin sender to call the admin room with, only the
swarm's does.

Removes the three hivectl matrix verbs, their HostRequest variants,
their hive-c0re handlers, and the admin-room helpers (discover room id,
send-and-poll, event-id extraction, password/success parsing) that
only they used. sync-admin and invite are unchanged.

Refs #4585
This commit is contained in:
atlas 2026-09-29 10:55:51 +02:00 • committed by mara
commit 93bbec015f
12 changed files with 37 additions and 748 deletions

View file

@ -124,8 +124,7 @@ pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_sender_token()
}
/// Password file for a matrix account this hive holds a password for: its own
/// `@hive-<hive>:` account, or one reset through the admin room. Stored OUTSIDE
/// Password file for the hive's own `@hive-<hive>:` account. Stored OUTSIDE
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
///
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
@ -244,11 +243,9 @@ async fn register_post(
}
/// Generate a throwaway random password for matrix UIAA registration.
/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. Agents authenticate
/// by `access_token` so the password is protocol overhead we never
/// persist; the operator path in `hivectl` lets the caller supply a
/// real password instead so they can log into a matrix web client
/// (`m.login.password`).
/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. The hive sender
/// account authenticates by `access_token`, so this password is
/// protocol overhead never used for login.
pub fn random_password() -> Result<String> {
random_hex(PASSWORD_BYTES)
}
@ -263,9 +260,9 @@ pub fn random_password() -> Result<String> {
/// agent authenticates with that rather than with anything the hive
/// holds. The `as_token` never leaves the host.
///
/// Caller picks the password: agents use [`random_password`] (throwaway
/// — they auth by `access_token`), operators on the `hivectl` path
/// supply their own so they can log into matrix web clients.
/// Its one caller, the hive sender account's provisioning, always passes
/// a [`random_password`] throwaway — the account authenticates by
/// `access_token`, never `m.login.password`.
///
/// # Errors
/// Propagates the homeserver's own body, which is what the
@ -351,9 +348,9 @@ fn extract_access_token(body: &serde_json::Value) -> Result<String> {
/// 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.
/// if the stored password no longer matches (e.g. homeserver wiped);
/// the only caller is the hive sender account's own recovery path
/// (`hivectl matrix sync-admin`).
async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result<String> {
let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/login");
@ -384,303 +381,12 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
extract_access_token(&json)
}
// ---------------------------------------------------------------------------
// Admin-room fallback for password reset
// ---------------------------------------------------------------------------
/// Percent-encode a matrix room ID for use in a URL path segment.
/// Only `:` needs encoding; `!` and alphanumerics are path-safe.
fn encode_room_id_for_url(room_id: &str) -> String {
room_id.replace(':', "%3A")
}
/// Look up the room ID for the `#admins:<server>` alias.
async fn discover_admin_room_id(
client: &reqwest::Client,
sender_token: &str,
server_name: &str,
) -> Result<String> {
let base = matrix_base()?;
// #admins:server → %23admins%3A<server>
let encoded_alias = format!("%23admins%3A{server_name}");
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
let resp = client
.get(&url)
.bearer_auth(sender_token)
.send()
.await
.context("matrix: GET admin room alias")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("matrix: parse admin room alias response")?;
if !status.is_success() {
anyhow::bail!("matrix: admin room alias lookup failed: HTTP {status}, body: {json}");
}
json["room_id"]
.as_str()
.map(ToString::to_string)
.ok_or_else(|| anyhow::anyhow!("matrix: admin room alias response missing room_id: {json}"))
}
/// Extract the new password from a conduit/tuwunel admin-room reset reply.
///
/// The admin bot always renders the new password as a backtick code span.
/// The live reply observed in the `#admins` room is:
/// "Successfully reset the password for user @x:server: `<password>`"
/// The surrounding prose varies between builds (the delimiter is `: ` after
/// the user id, not `" to:"`), so we anchor on the code span rather than
/// parsing the prose. Returns the content of the first backtick pair when the
/// message is a password-reset success.
///
/// Guard: an error reply can also code-span the *user id* ("@x:server"); a
/// real password has no whitespace and isn't a `@localpart:server` id, so we
/// reject that shape and return `None`. On `None` the caller surfaces the
/// timeout and `admin_room_send_and_poll` logs the unparsed body — so a
/// future format change is visible rather than silently mis-parsed.
fn extract_new_password(bot_message: &str) -> Option<String> {
// Only consider password-reset success replies.
if !bot_message.to_ascii_lowercase().contains("password") {
return None;
}
// Content of the first backtick code span.
let open = bot_message.find('`')?;
let after = &bot_message[open + 1..];
let close = after.find('`')?;
let pw = &after[..close];
// Reject a code-spanned matrix user id from an error reply, and any
// multi-token span — generated passwords are a single whitespace-free run.
if pw.is_empty()
|| pw.contains(char::is_whitespace)
|| (pw.starts_with('@') && pw.contains(':'))
{
return None;
}
Some(pw.to_owned())
}
#[cfg(test)]
mod extract_new_password_tests {
use super::extract_new_password;
#[test]
fn conduit_live_admin_room_format() {
// The exact reply observed in the live #admins room: ": " after the
// user id, password in a backtick code span.
let msg = "Successfully reset the password for user @triage:pr1ma.darkest.space: `hVfa6TpvIKnADoEJNWn9saHoI`";
assert_eq!(
extract_new_password(msg).as_deref(),
Some("hVfa6TpvIKnADoEJNWn9saHoI")
);
}
#[test]
fn backtick_span_anywhere_in_prose() {
// Wording around the code span is irrelevant — we anchor on the span.
let msg = "Done. New password is: `hunter2` (store it now)";
assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2"));
}
#[test]
fn password_with_symbols_inside_span() {
// '@' mid-token is fine — only a leading "@…:…" user-id shape is rejected.
let msg =
"Successfully reset the password for user @atlas:pr1ma.darkest.space: `N3wP@ss-w0rd!`";
assert_eq!(extract_new_password(msg).as_deref(), Some("N3wP@ss-w0rd!"));
}
#[test]
fn codespan_userid_in_error_not_mistaken_for_password() {
// An error that code-spans the user id must not yield it as a password.
let msg = "Failed to reset password for `@sock:pr1ma.darkest.space` — user not found";
assert_eq!(extract_new_password(msg), None);
}
#[test]
fn no_codespan_returns_none() {
// No backtick span → unparseable here; the caller logs the raw body
// so a genuinely new format surfaces instead of being mis-parsed.
let msg = "Password reset complete. New password is: abc123XYZ";
assert_eq!(extract_new_password(msg), None);
}
#[test]
fn non_password_message_returns_none() {
let msg = "Command not recognised. Please try again.";
assert_eq!(extract_new_password(msg), None);
}
#[test]
fn empty_codespan_returns_none() {
let msg = "Successfully reset the password for user @x:server: ``";
assert_eq!(extract_new_password(msg), None);
}
}
/// The `event_id` of an admin-room command, from its `PUT .../send`
/// response. It is the anchor separating the bot's reply to this command
/// from older replies in the room, so a response without one is an error:
/// unanchored, an earlier reply (an older reset password) would be returned
/// as this command's result.
async fn sent_event_id(resp: reqwest::Response) -> Result<String> {
let status = resp.status();
if !status.is_success() {
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
anyhow::bail!("matrix: admin room send failed: HTTP {status}, body: {body}");
}
let body = resp
.json::<serde_json::Value>()
.await
.context("matrix: parse admin room send response")?;
body["event_id"]
.as_str()
.filter(|id| !id.is_empty())
.map(str::to_owned)
.with_context(|| format!("matrix: admin room send response has no event_id: {body}"))
}
/// Send a command to the Matrix admin room and poll for a bot response.
///
/// Strategy: send the command, capture its `event_id`, then poll backwards
/// (`dir=b&limit=20`) on each tick. Events in a backward response are
/// newest-first; we walk the list until we find our own command `event_id`,
/// then stop — everything before that marker in the list is a response that
/// arrived *after* our command. We check `body` and `formatted_body` of
/// every non-self message in that window.
///
/// This avoids forward-pagination token direction issues that occur with
/// some tuwunel builds: backward fetches are always anchored at the live
/// timeline end and need no stored token.
///
/// Generic over `T` so both password-returning and `()` callers share the loop.
async fn admin_room_send_and_poll<T>(
client: &reqwest::Client,
sender_token: &str,
server_name: &str,
room_url: &str,
command: &str,
check: impl Fn(&str) -> Option<T>,
) -> Result<T> {
let base = matrix_base()?;
// Send the command; record the event_id so we can use it as an anchor.
let txn_id = random_hex(8)?;
let send_url =
format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
let send_resp = client
.put(&send_url)
.bearer_auth(sender_token)
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
.send()
.await
.context("matrix: PUT admin room message")?;
let our_event_id = sent_event_id(send_resp).await?;
// Poll for bot response: fetch the 20 most recent events (newest-first)
// on each tick. Walk the list until we hit our own command event_id;
// everything *before* that marker arrived after our command.
let own_user_id = format!("@{}:{server_name}", hive_localpart()?);
let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
for _ in 0..15_u8 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let poll_json = client
.get(&poll_url)
.bearer_auth(sender_token)
.send()
.await
.context("matrix: admin room poll")?
.json::<serde_json::Value>()
.await
.context("matrix: parse admin room poll response")?;
if let Some(events) = poll_json["chunk"].as_array() {
for event in events {
// Stop as soon as we reach our own command — everything
// older (further into the list) predates our request.
if event["event_id"].as_str() == Some(our_event_id.as_str()) {
break;
}
if event["type"].as_str() != Some("m.room.message") {
continue;
}
if event["sender"].as_str() == Some(own_user_id.as_str()) {
continue;
}
// Check both plain body and formatted_body (HTML) — some
// admin bots put the password only in formatted_body.
let body = event["content"]["body"].as_str().unwrap_or_default();
let formatted = event["content"]["formatted_body"]
.as_str()
.unwrap_or_default();
for text in [body, formatted] {
if let Some(result) = check(text) {
return Ok(result);
}
}
}
}
}
anyhow::bail!(
"matrix: admin room command timed out after 15 seconds. \
Command: '{command}'. No matching bot response received."
)
}
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
/// Sends `!admin users reset-password @<localpart>:<server>` as `@hive-<hive>:`, polls for
/// the bot's response containing the new password.
///
/// Returns the new password; caller is responsible for persisting it.
async fn admin_room_reset_password(
client: &reqwest::Client,
sender_token: &str,
server_name: &str,
localpart: &str,
) -> Result<String> {
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
let room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users reset-password @{localpart}:{server_name}");
admin_room_send_and_poll(
client,
sender_token,
server_name,
&room_url,
&command,
extract_new_password,
)
.await
.with_context(|| {
format!(
"matrix: admin room reset-password for @{localpart}:{server_name}: \
no password response received within 15 seconds. \
Verify the admin room accepts '!admin users reset-password @user:server' commands."
)
})
}
/// Register a matrix account for `name` with the supplied `password`
/// and return the freshly-minted access token. The token is **not**
/// persisted to disk — the caller is responsible for storing it. Used by
/// `hivectl matrix create-user` for human (non-agent) accounts. For operator accounts the caller passes a real
/// password so the operator can `m.login.password` into matrix web
/// clients afterwards; without one the caller passes
/// [`random_password`].
///
/// **Not idempotent**: the matrix `/register` endpoint returns
/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart,
/// appservice-authorised or not.
/// Callers re-running this for a known-existing matrix user should expect
/// a hard error from this fn and route to a password-reset path instead.
pub async fn provision_user_token(
client: &reqwest::Client,
name: &str,
as_token: &str,
password: &str,
) -> Result<String> {
register_user(client, name, as_token, password).await
}
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
/// persisted at [`sender_token_path()`].
///
@ -843,141 +549,6 @@ async fn stored_sender_token() -> Option<String> {
}
}
/// Whether an admin-room reply says a `make-user-admin` succeeded.
///
/// Three spellings, because the reply is prose and prose changes between
/// builds. tuwunel v1.9.0's is `"<user id> has been granted admin
/// privileges."` — which the original two patterns here (`done…`,
/// `made…admin`) do not match at all, so a promotion that had already
/// worked was reported as a 15-second timeout. The older spellings are
/// kept: a homeserver is not necessarily the version this was written
/// against.
fn is_make_admin_success(body: &str) -> Option<()> {
let lower = body.to_ascii_lowercase();
let says_ok = lower.starts_with("done")
|| lower.contains("granted admin privileges")
|| (lower.contains("made") && lower.contains("admin"));
says_ok.then_some(())
}
#[cfg(test)]
mod is_make_admin_success_tests {
use super::is_make_admin_success;
/// The reply tuwunel v1.9.0 actually sends
/// (`src/admin/user/make_user_admin.rs`). This is the case the
/// pre-existing matcher missed.
#[test]
fn tuwunel_1_9_grant_reply() {
let msg = "@hive:pr1ma.darkest.space has been granted admin privileges.";
assert_eq!(is_make_admin_success(msg), Some(()));
}
#[test]
fn older_spellings_still_match() {
assert_eq!(
is_make_admin_success("Done: user is now an admin"),
Some(())
);
assert_eq!(is_make_admin_success("Made @x:y an admin"), Some(()));
}
/// The control: an unrelated or failing reply must not read as
/// success, or a failed promotion returns Ok and the warning that
/// would have named it never fires.
#[test]
fn failures_and_noise_do_not_match() {
assert_eq!(is_make_admin_success("Command not recognised."), None);
assert_eq!(is_make_admin_success("User @x:y does not exist"), None);
}
}
/// Promote a user to homeserver admin via the Matrix admin room
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
/// `@hive-<hive>:`, polls for the bot's success reply.
///
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only
/// treats a message as a command when its sender is in the admin room.
/// `@hive-<hive>:` is an ordinary account (`hive-matrix.nix` grants it no
/// `admin_execute` promotion), so this call has no working sender from
/// the hive and fails with the admin room's refusal. Promotion is a
/// swarm-level operation and is being rehomed as such; this stays here,
/// failing loudly, rather than justifying an over-privileged token that
/// all 13 ordinary call sites would also carry.
///
/// Goes through the admin room rather than a direct HTTP call because
/// tuwunel implements parts of the Synapse admin API but not user
/// creation, and upstream does not intend to add it. This is the
/// intended long-term mechanism, not a stopgap awaiting an upstream fix.
pub async fn promote_user_to_admin(
client: &reqwest::Client,
sender_token: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
let room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users make-user-admin @{localpart}:{server_name}");
admin_room_send_and_poll(
client,
sender_token,
server_name,
&room_url,
&command,
is_make_admin_success,
)
.await
.with_context(|| {
format!(
"matrix: admin room make-user-admin for @{localpart}:{server_name}: \
no success response within 15 seconds. \
Verify the admin room accepts '!admin users make-user-admin @user:server' commands."
)
})
}
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
///
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
/// `@hive-<hive>:`, polls for the bot's response containing the new password, and persists
/// it to the non-purgeable creds path.
///
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
/// sender, and reset, like promotion, is a swarm-level operation rehomed
/// to the swarm tier rather than granted here — so it has no working
/// sender from the hive either.
///
/// Returns the new password for use in subsequent `login_user` calls.
pub async fn reset_user_password(
client: &reqwest::Client,
sender_token: &str,
localpart: &str,
server_name: &str,
) -> Result<String> {
let pw = admin_room_reset_password(client, sender_token, server_name, localpart)
.await
.with_context(|| {
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
})?;
persist_password(localpart, &pw);
Ok(pw)
}
/// Persist the matrix password for `localpart` to the non-purgeable creds path.
fn persist_password(localpart: &str, password: &str) {
use std::os::unix::fs::PermissionsExt;
let pw_path = password_path(localpart);
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!(%localpart, error = ?e, "matrix: failed to persist reset password");
} else {
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
}
}
/// 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.
@ -1915,27 +1486,6 @@ mod tests {
assert!(outcome.is_err(), "a 500 is not excused by membership");
}
#[tokio::test]
async fn an_admin_send_without_an_event_id_is_an_error() {
for (status, body) in [
(500, r#"{"event_id":"$e"}"#),
(200, "not json"),
(200, "{}"),
(200, r#"{"event_id":""}"#),
] {
let outcome = sent_event_id(response(status, body)).await;
assert!(outcome.is_err(), "HTTP {status} {body:?} must fail");
}
}
#[tokio::test]
async fn an_admin_send_returns_its_event_id() {
let id = sent_event_id(response(200, r#"{"event_id":"$e"}"#))
.await
.expect("well-formed send response");
assert_eq!(id, "$e");
}
#[test]
fn extract_access_token_errors_on_missing_field() {
let body = serde_json::json!({"user_id": "@alice:matrix.example.org"});

View file

@ -133,9 +133,9 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
}
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive
/// Space room id, per-agent password creds). The shared registration
/// token is bind-mounted into the tuwunel container via nix and stays
/// at its own path (tracked separately).
/// Space room id, the hive sender account's password creds). The shared
/// registration token is bind-mounted into the tuwunel container via nix
/// and stays at its own path (tracked separately).
#[must_use]
pub fn matrix_dir() -> PathBuf {
state_root().join("matrix")
@ -160,8 +160,9 @@ pub fn matrix_chat_room_id() -> PathBuf {
matrix_dir().join("chat-room-id")
}
/// `matrix/creds/` — per-agent throwaway matrix passwords (survive
/// `destroy --purge`; agents auth by token, this is recovery only).
/// `matrix/creds/` — the hive sender account's throwaway matrix password
/// (survives `destroy --purge`; it authenticates by token, this is
/// recovery only).
#[must_use]
pub fn matrix_creds_dir() -> PathBuf {
matrix_dir().join("creds")

View file

@ -220,16 +220,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
)
.await?
}
HostRequest::MatrixCreateUser { name, password } => {
handle_matrix_create_user(name, password.as_deref()).await?
}
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
HostRequest::MatrixPromoteUser { name } => {
handle_matrix_promote_user(name.as_str()).await?
}
HostRequest::MatrixResetPassword { name } => {
handle_matrix_reset_password(name.as_str()).await?
}
HostRequest::MatrixInvite { user, room } => {
handle_matrix_invite(user, room.as_deref()).await?
}
@ -536,46 +527,6 @@ fn require_matrix_present() -> Result<()> {
)
}
async fn handle_matrix_create_user(
name: &hive_types::Ident,
password: Option<&str>,
) -> Result<HostResponse> {
require_matrix_present()?;
if agent_exists(name)? {
// The swarm mints an agent's account and stores its token where the
// agent reads it; a second minter here would replace that token on the
// same device.
anyhow::bail!(
"matrix create-user: '{name}' is an agent, and an agent's matrix account comes from \
the swarm: swarm-controller creates it and re-checks it every five minutes"
);
}
let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?;
let mut out = Vec::new();
let effective_password = match password {
Some(p) => p.to_owned(),
None => crate::matrix::random_password().context("generate random matrix password")?,
};
let token =
crate::matrix::provision_user_token(&client, name.as_str(), &as_token, &effective_password)
.await
.with_context(|| format!("matrix create-user {name}"))?;
out.push(format!(
"matrix: provisioned user '{name}' (not an agent — token not persisted)"
));
out.push(format!("token: {token}"));
if password.is_some() {
out.push("password: set as supplied — use it to log into a matrix web client".to_owned());
} else {
out.push(
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
);
}
Ok(HostResponse::messages(out))
}
async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result<HostResponse> {
crate::priv_client::write_agent_github_token(agent, token)
.await
@ -712,21 +663,6 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
]))
}
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name)
.await
.with_context(|| format!("matrix promote-user {name}"))?;
Ok(HostResponse::messages(vec![format!(
"matrix: promoted @{name}:{server_name} to admin"
)]))
}
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?;
@ -747,24 +683,6 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
)]))
}
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
crate::matrix::reset_user_password(&client, &sender_token, name, &server_name)
.await
.with_context(|| format!("matrix reset-password {name}"))?;
// Password is persisted by reset_user_password.
let pw_path = crate::paths::matrix_creds_dir().join(format!("{name}-password"));
Ok(HostResponse::messages(vec![
format!("matrix: password for @{name}:{server_name} reset"),
format!("password persisted at: {}", pw_path.display()),
]))
}
/// Single-agent queue verbs the admin socket exposes. Each submits the
/// matching DAG (persisting the `wanted` intent, serializing on the
/// agent's lease, with the transient/crash-watch suppression the old