From 9674fd42acfc859bd199b09b91cdfe834682aa95 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 12 Jul 2026 03:02:38 +0200 Subject: [PATCH] refactor(#2352): move matrix provisioning behind host-socket wire commands --- hive-c0re/src/bin/hivectl.rs | 229 ++++++++++++----------------------- hive-c0re/src/server.rs | 170 ++++++++++++++++++++++++++ hive-host-sock/src/lib.rs | 49 ++++++++ 3 files changed, 296 insertions(+), 152 deletions(-) diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index a2f4ccb6..92085413 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -640,17 +640,7 @@ async fn main() -> Result<()> { password_stdin, } => forge_create_user(&name, password.as_deref(), password_stdin).await, }, - Cmd::Matrix { cmd } => match cmd { - MatrixCmd::CreateUser { - name, - password, - password_stdin, - } => matrix_create_user(&name, password.as_deref(), password_stdin).await, - MatrixCmd::SyncAdmin => matrix_sync_admin().await, - MatrixCmd::PromoteUser { name } => matrix_promote_user(&name).await, - MatrixCmd::ResetPassword { name } => matrix_reset_password(&name).await, - MatrixCmd::Invite { user, room } => matrix_invite(&user, room.as_deref()).await, - }, + Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await, Cmd::Github { cmd } => match cmd { GithubCmd::SetToken { agent, @@ -725,6 +715,22 @@ async fn main() -> Result<()> { } } +/// Route a `matrix` subcommand to its handler. Extracted from `main`'s +/// dispatch match so the top-level router stays small. +async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> { + match cmd { + MatrixCmd::CreateUser { + name, + password, + password_stdin, + } => matrix_create_user(socket, &name, password.as_deref(), password_stdin).await, + MatrixCmd::SyncAdmin => matrix_sync_admin(socket).await, + MatrixCmd::PromoteUser { name } => matrix_promote_user(socket, &name).await, + MatrixCmd::ResetPassword { name } => matrix_reset_password(socket, &name).await, + MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await, + } +} + /// `open ` — resolve the surface URL from the daemon, /// print it, then best-effort `xdg-open` it. Printing is the reliable /// core (headless / SSH hosts where no browser opener exists); the open @@ -1254,160 +1260,79 @@ fn resolve_password(password: Option<&str>, password_stdin: bool) -> Result Result<()> { + let resp = hive_c0re::client::request(socket, req) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + if !resp.ok { + bail!( + "matrix: {}", + resp.error.as_deref().unwrap_or("unknown error") + ); + } + for line in &resp.messages { + println!("{line}"); + } + Ok(()) +} + async fn matrix_create_user( + socket: &Path, name: &str, password: Option<&str>, password_stdin: bool, ) -> Result<()> { - if !hive_c0re::matrix::is_present().await { - bail!( - "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users" - ); - } - let register_token = - hive_c0re::matrix::ensure_register_token().context("read matrix register token")?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build reqwest client")?; - let user_password = resolve_password(password, password_stdin)?; - if agent_exists(name)? { - if user_password.is_some() { - // The boot-sweep / approval-time agent provisioning path - // doesn't accept a password — agents auth by access_token, - // never by password. Refuse rather than silently dropping it. - bail!( - "matrix create-user: --password / --password-stdin is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" - ); - } - hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token) - .await - .with_context(|| format!("matrix create-user {name}"))?; - let path = Coordinator::agent_notes_dir(name).join("matrix-token"); - println!("matrix: provisioned agent user '{name}'"); - println!("token persisted at: {}", path.display()); - } else { - let effective_password = match user_password { - Some(p) => p, - None => { - hive_c0re::matrix::random_password().context("generate random matrix password")? - } - }; - let token = hive_c0re::matrix::provision_user_token( - &client, - name, - ®ister_token, - &effective_password, - ) - .await - .with_context(|| format!("matrix create-user {name}"))?; - println!("matrix: provisioned user '{name}' (not an agent — token not persisted)"); - println!("token: {token}"); - if password.is_some() || password_stdin { - println!("password: set as supplied — use it to log into a matrix web client"); - } else { - println!( - "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)" - ); - } - } - Ok(()) + // Resolve the password client-side (an inline flag or a stdin read); + // the daemon never touches this process's stdin. The agent-vs-operator + // branch + throwaway-password handling now live in the daemon handler. + let password = resolve_password(password, password_stdin)?; + matrix_request( + socket, + hive_host_sock::HostRequest::MatrixCreateUser { + name: name.to_owned(), + password, + }, + ) + .await } -async fn matrix_sync_admin() -> Result<()> { - if !hive_c0re::matrix::is_present().await { - bail!( - "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" - ); - } - let register_token = - hive_c0re::matrix::ensure_register_token().context("read matrix register token")?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build reqwest client")?; - hive_c0re::matrix::ensure_admin_user(&client, ®ister_token) - .await - .context("matrix sync-admin")?; - let path = hive_c0re::matrix::admin_token_path(); - println!( - "matrix: hive admin user '@{}' provisioned", - hive_c0re::matrix::HIVE_ADMIN_LOCALPART - ); - println!("token persisted at: {}", path.display()); - Ok(()) +async fn matrix_sync_admin(socket: &Path) -> Result<()> { + matrix_request(socket, hive_host_sock::HostRequest::MatrixSyncAdmin).await } -async fn matrix_promote_user(name: &str) -> Result<()> { - if !hive_c0re::matrix::is_present().await { - bail!( - "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" - ); - } - let admin_token = hive_c0re::matrix::read_admin_token()?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build reqwest client")?; - let server_name = hive_c0re::matrix::discover_server_name(&client) - .await - .context("discover matrix server_name")?; - hive_c0re::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name) - .await - .with_context(|| format!("matrix promote-user {name}"))?; - println!("matrix: promoted @{name}:{server_name} to admin"); - Ok(()) +async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> { + matrix_request( + socket, + hive_host_sock::HostRequest::MatrixPromoteUser { + name: name.to_owned(), + }, + ) + .await } -async fn matrix_invite(user: &str, room: Option<&str>) -> Result<()> { - if !hive_c0re::matrix::is_present().await { - bail!( - "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" - ); - } - let admin_token = hive_c0re::matrix::read_admin_token()?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build reqwest client")?; - let server_name = hive_c0re::matrix::discover_server_name(&client) - .await - .context("discover matrix server_name")?; - let room_id = hive_c0re::matrix::invite_user(&client, &admin_token, user, room, &server_name) - .await - .with_context(|| format!("matrix invite {user}"))?; - let target = if user.starts_with('@') { - user.to_owned() - } else { - format!("@{user}:{server_name}") - }; - println!("matrix: invited {target} to {room_id}"); - Ok(()) +async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> { + matrix_request( + socket, + hive_host_sock::HostRequest::MatrixInvite { + user: user.to_owned(), + room: room.map(str::to_owned), + }, + ) + .await } -async fn matrix_reset_password(name: &str) -> Result<()> { - if !hive_c0re::matrix::is_present().await { - bail!( - "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" - ); - } - let admin_token = hive_c0re::matrix::read_admin_token()?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build reqwest client")?; - let server_name = hive_c0re::matrix::discover_server_name(&client) - .await - .context("discover matrix server_name")?; - hive_c0re::matrix::reset_user_password(&client, &admin_token, name, &server_name) - .await - .with_context(|| format!("matrix reset-password {name}"))?; - // Password is persisted by reset_user_password. - let pw_path = hive_c0re::paths::matrix_creds_dir().join(format!("{name}-password")); - println!("matrix: password for @{name}:{server_name} reset"); - println!("password persisted at: {}", pw_path.display()); - println!("next: hivectl matrix create-user {name} # mints a fresh access token"); - Ok(()) +async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> { + matrix_request( + socket, + hive_host_sock::HostRequest::MatrixResetPassword { + name: name.to_owned(), + }, + ) + .await } // --------------------------------------------------------------------------- diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index cd0ccdf8..7a9c3a9e 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -175,6 +175,15 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { .map_err(anyhow::Error::msg)?; HostResponse::success() } + 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).await?, + HostRequest::MatrixResetPassword { name } => handle_matrix_reset_password(name).await?, + HostRequest::MatrixInvite { user, room } => { + handle_matrix_invite(user, room.as_deref()).await? + } }) } .await; @@ -245,6 +254,167 @@ async fn handle_agent_status(coord: &Arc) -> HostResponse { HostResponse::agent_statuses(rows) } +// --------------------------------------------------------------------------- +// Matrix provisioning handlers +// +// The `hivectl matrix` subcommands used to run these in-process, which forced +// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …). +// They now run daemon-side over the host socket: the daemon already holds the +// register + admin tokens and the matrix creds dir. Each op returns the +// operator-facing lines hivectl used to `println!` in `HostResponse::messages` +// for the client to print verbatim. +// --------------------------------------------------------------------------- + +/// Shared reqwest client for the matrix admin HTTP calls (30s timeout, +/// mirroring the old in-CLI client). +fn matrix_http_client() -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("build reqwest client") +} + +/// True when `name` has a state dir under the agents root, i.e. it's a +/// managed agent rather than a bare (operator/human) matrix account. +fn agent_exists(name: &str) -> Result { + crate::paths::agent_state_dir(name) + .try_exists() + .with_context(|| format!("check agent state dir for {name}")) +} + +/// Guard: matrix provisioning needs the homeserver container running. +async fn require_matrix_present() -> Result<()> { + if crate::matrix::is_present().await { + return Ok(()); + } + anyhow::bail!( + "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users" + ) +} + +async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result { + require_matrix_present().await?; + let register_token = + crate::matrix::ensure_register_token().context("read matrix register token")?; + let client = matrix_http_client()?; + let mut out = Vec::new(); + if agent_exists(name)? { + if password.is_some() { + // Agents auth by access_token, never by password — the + // boot-sweep provisioning path doesn't accept one. Refuse + // rather than silently dropping it. + anyhow::bail!( + "matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" + ); + } + crate::matrix::ensure_user_for(&client, name, ®ister_token) + .await + .with_context(|| format!("matrix create-user {name}"))?; + let path = Coordinator::agent_notes_dir(name).join("matrix-token"); + out.push(format!("matrix: provisioned agent user '{name}'")); + out.push(format!("token persisted at: {}", path.display())); + } else { + 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, + ®ister_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_matrix_sync_admin() -> Result { + require_matrix_present().await?; + let register_token = + crate::matrix::ensure_register_token().context("read matrix register token")?; + let client = matrix_http_client()?; + crate::matrix::ensure_admin_user(&client, ®ister_token) + .await + .context("matrix sync-admin")?; + let path = crate::matrix::admin_token_path(); + Ok(HostResponse::messages(vec![ + format!( + "matrix: hive admin user '@{}' provisioned", + crate::matrix::HIVE_ADMIN_LOCALPART + ), + format!("token persisted at: {}", path.display()), + ])) +} + +async fn handle_matrix_promote_user(name: &str) -> Result { + require_matrix_present().await?; + let admin_token = crate::matrix::read_admin_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, &admin_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 { + require_matrix_present().await?; + let admin_token = crate::matrix::read_admin_token()?; + let client = matrix_http_client()?; + let server_name = crate::matrix::discover_server_name(&client) + .await + .context("discover matrix server_name")?; + let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name) + .await + .with_context(|| format!("matrix invite {user}"))?; + let target = if user.starts_with('@') { + user.to_owned() + } else { + format!("@{user}:{server_name}") + }; + Ok(HostResponse::messages(vec![format!( + "matrix: invited {target} to {room_id}" + )])) +} + +async fn handle_matrix_reset_password(name: &str) -> Result { + require_matrix_present().await?; + let admin_token = crate::matrix::read_admin_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, &admin_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()), + format!("next: hivectl matrix create-user {name} # mints a fresh access token"), + ])) +} + /// 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 diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index ab294f76..9d7ed40c 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -94,6 +94,37 @@ pub enum HostRequest { #[serde(default)] scope: LifecycleScope, }, + /// Create or refresh a matrix account + access token for `name`. + /// The daemon runs the provisioning (it holds the register + admin + /// tokens and the matrix creds dir) and returns the operator-facing + /// results (persisted-token path for agents, or the freshly-minted + /// token + password for non-agent accounts) in + /// [`HostResponse::messages`]. `password` is resolved by the client + /// (inline flag or stdin) and `None` requests a random throwaway. + MatrixCreateUser { + name: String, + #[serde(default)] + password: Option, + }, + /// Provision (or re-provision) the hive system admin matrix account. + /// Daemon-side equivalent of `hivectl matrix sync-admin`. + MatrixSyncAdmin, + /// Promote a matrix user to homeserver admin via the admin API. + /// Uses the daemon's system admin token; `server_name` is discovered + /// from the running homeserver. + MatrixPromoteUser { name: String }, + /// Reset a matrix user's password via the admin API and persist the + /// new password to the matrix creds dir so a later token mint can + /// re-login. Returns the outcome in [`HostResponse::messages`]. + MatrixResetPassword { name: String }, + /// 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, + }, } /// Selects which container classes a hive-wide [`HostRequest::Stop`] / @@ -189,6 +220,13 @@ pub struct HostResponse { /// been evicted from the queue's history tail. #[serde(default, skip_serializing_if = "Option::is_none")] pub dags: Option>, + /// Free-form operator-facing output lines the client prints verbatim + /// (one per line). Carries results a request produced daemon-side that + /// have no structured home — e.g. a freshly-minted matrix token, a + /// reset password, or an invited room id from the `Matrix*` requests. + /// Empty for requests that produce no such output. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub messages: Vec, } impl HostResponse { @@ -267,4 +305,15 @@ impl HostResponse { ..Self::default() } } + + /// A success carrying operator-facing output lines the client prints + /// verbatim — the result shape for the `Matrix*` provisioning requests. + #[must_use] + pub fn messages(messages: Vec) -> Self { + Self { + ok: true, + messages, + ..Self::default() + } + } }