From 18253bf2f5cf8bfde574240db68fdcf600cb183b Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 30 May 2026 21:39:42 +0200 Subject: [PATCH 1/2] hivectl: print token to stdout for non-agent users (#662) --- hive-c0re/src/bin/hivectl.rs | 83 ++++++++++++++++++++++++++++-------- hive-c0re/src/forge.rs | 64 +++++++++++++++++---------- hive-c0re/src/matrix.rs | 14 ++++++ 3 files changed, 121 insertions(+), 40 deletions(-) diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 9d2f64e4..ddb462f1 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -17,6 +17,7 @@ use anyhow::{Context as _, Result, bail}; use clap::{Parser, Subcommand}; +use hive_c0re::coordinator::Coordinator; #[derive(Parser)] #[command( @@ -58,13 +59,21 @@ enum Cmd { #[derive(Subcommand)] enum ForgeCmd { /// Create or refresh the Forgejo account + token for ``. - /// Idempotent: skips user creation when the account exists, - /// skips token mint when the token file is already populated. - /// To force re-minting, delete the token file at - /// `/var/lib/hyperhive/agents//state/forge-token`. + /// + /// When `` matches an existing agent (i.e. it has a state + /// dir under `/var/lib/hyperhive/agents/`), persists the token to + /// `/forge-token` (idempotent: re-mints + rewrites every + /// call so the on-disk scope matches the current + /// `forge::TOKEN_SCOPES`). + /// + /// When `` is **not** an agent (a human or any other + /// non-container account), creates the forgejo user and prints the + /// freshly-minted token to stdout — no `/var/lib/hyperhive/agents/` + /// directory is created for the user (#662). CreateUser { - /// Container/agent name (the `` in `h-`; manager - /// agent uses the literal `manager`). + /// Forgejo username. For agents: the container/agent name + /// (`` in `h-`; manager uses the literal `manager`). + /// For humans: any forgejo username — `mara`, `damocles`, etc. name: String, }, } @@ -72,11 +81,20 @@ enum ForgeCmd { #[derive(Subcommand)] enum MatrixCmd { /// Create or refresh the matrix account + access token for ``. - /// Idempotent: skips registration entirely when the token file is - /// already populated. To force re-registration, delete the token - /// file at `/var/lib/hyperhive/agents//state/matrix-token`. + /// + /// When `` matches an existing agent (i.e. it has a state + /// dir under `/var/lib/hyperhive/agents/`), persists the token to + /// `/matrix-token`. Skips registration when the file is + /// already populated; delete it to force re-registration. + /// + /// When `` is **not** an agent (a human or any other + /// non-container account), registers the matrix user and prints + /// the freshly-minted access token to stdout — no + /// `/var/lib/hyperhive/agents/` directory is created for the user + /// (#662). CreateUser { - /// Container/agent name. + /// Matrix localpart. For agents: the container/agent name. + /// For humans: any matrix localpart — `mara`, `damocles`, etc. name: String, }, } @@ -100,16 +118,35 @@ async fn main() -> Result<()> { } } +/// True when `name` matches an existing hyperhive agent — i.e. it has a +/// persistent state dir under `/var/lib/hyperhive/agents/`. We use the +/// state dir (not the live container list) so kept-state tombstones +/// still resolve as agents — re-provisioning a destroyed-but-kept agent +/// should still drop its token in the existing state tree. +fn is_agent(name: &str) -> bool { + Coordinator::agent_state_root(name).exists() +} + async fn forge_create_user(name: &str) -> Result<()> { if !hive_c0re::forge::is_present().await { bail!( "hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users" ); } - hive_c0re::forge::ensure_user_for(name) - .await - .with_context(|| format!("forge create-user {name}"))?; - println!("forge: provisioned user '{name}' (idempotent)"); + if is_agent(name) { + hive_c0re::forge::ensure_user_for(name) + .await + .with_context(|| format!("forge create-user {name}"))?; + let path = Coordinator::agent_notes_dir(name).join("forge-token"); + println!("forge: provisioned agent user '{name}'"); + println!("token persisted at: {}", path.display()); + } else { + let token = hive_c0re::forge::provision_user_token(name) + .await + .with_context(|| format!("forge create-user {name}"))?; + println!("forge: provisioned user '{name}' (not an agent — token not persisted)"); + println!("token: {token}"); + } Ok(()) } @@ -125,9 +162,19 @@ async fn matrix_create_user(name: &str) -> Result<()> { .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; - hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token) - .await - .with_context(|| format!("matrix create-user {name}"))?; - println!("matrix: provisioned user '{name}' (idempotent)"); + if is_agent(name) { + 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 token = hive_c0re::matrix::provision_user_token(&client, name, ®ister_token) + .await + .with_context(|| format!("matrix create-user {name}"))?; + println!("matrix: provisioned user '{name}' (not an agent — token not persisted)"); + println!("token: {token}"); + } Ok(()) } diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 0e63c2ca..9959fcb8 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -75,8 +75,7 @@ const SEEDED_ORGS: &[&str] = &[CONFIG_ORG]; /// `GET /notifications` for unread PR/review events. /// - `write:notification` — required by `forge_notify` to mark /// notifications as read via `PATCH /notifications/threads/{id}`. -const TOKEN_SCOPES: &str = - "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; +const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; /// Scopes for the bootstrap `core` token used by hive-c0re itself. /// Adds `read:admin,write:admin` on top of `TOKEN_SCOPES` so the host @@ -198,13 +197,7 @@ async fn forge_http( /// `admin` adds `--admin` (site admin) — used for the bootstrap /// `core` user that drives the API. async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> { - let mut args = vec![ - "user", - "create", - "--username", - name, - "--email", - ]; + let mut args = vec!["user", "create", "--username", name, "--email"]; let email = agent_email(name); args.push(&email); args.extend(["--random-password", "--must-change-password=false"]); @@ -275,15 +268,13 @@ async fn ensure_user_email(name: &str) { } } -/// Mint a fresh access token for `name` and persist it to -/// `/forge-token` (0600). Token name is suffixed with a -/// monotonic clock so re-issuing doesn't collide with an existing +/// Mint a fresh access token for `name`. Token name is suffixed with +/// a monotonic clock so re-issuing doesn't collide with an existing /// token of the same name in the DB. `scopes` is the scope string /// passed to `forgejo admin user generate-access-token --scopes`; /// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the /// bootstrap `core` user. -async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> { - use std::os::unix::fs::PermissionsExt; +async fn mint_token(name: &str, scopes: &str) -> Result { let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", std::time::SystemTime::now() @@ -304,13 +295,23 @@ async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result .await?; let token = extract_token(&stdout) .with_context(|| format!("parse token from forgejo output: {stdout:?}"))?; + tracing::debug!(%name, %token_name, "forge: minted access token"); + Ok(token) +} + +/// Mint a fresh access token for `name` and persist it to `path` +/// (0600). Wraps [`mint_token`] for callers that want the token on +/// disk under an agent state dir. +async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let token = mint_token(name, scopes).await?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(path, format!("{token}\n")) .with_context(|| format!("write token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token"); + tracing::info!(%name, path = %path.display(), "forge: persisted access token"); Ok(()) } @@ -326,6 +327,23 @@ pub async fn ensure_user_for(name: &str) -> Result<()> { mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await } +/// Provision a forgejo user for `name` and return the freshly-minted +/// token. Unlike [`ensure_user_for`], the token is **not** persisted to +/// disk — the caller is responsible for storing it. Used by `hivectl +/// forge create-user` for human (non-agent) accounts so we don't create +/// stray `/var/lib/hyperhive/agents//` directories for users that +/// aren't agents (#662). +pub async fn provision_user_token(name: &str) -> Result { + if !is_present().await { + anyhow::bail!( + "hive-forge container not running — start it (services.hyperhive.forge.enable = true) before provisioning forge users" + ); + } + ensure_user_exists(name, false).await?; + ensure_user_email(name).await; + mint_token(name, TOKEN_SCOPES).await +} + /// Set `core`'s Forgejo avatar to the hyperhive logo once, then /// remember it so subsequent startups don't re-upload (issue #320). /// Best-effort — any non-2xx is logged at the caller; the project @@ -383,7 +401,10 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> { std::fs::create_dir_all(parent).ok(); } std::fs::write(marker, "").ok(); - tracing::info!(org = CONFIG_ORG, "forge: set org avatar to configs-stack logo"); + tracing::info!( + org = CONFIG_ORG, + "forge: set org avatar to configs-stack logo" + ); Ok(()) } @@ -538,9 +559,7 @@ pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> { tracing::info!(%name, "forge: granted meta read access"); Ok(()) } - other => anyhow::bail!( - "PUT core/meta/collaborators/{name} returned HTTP {other}" - ), + other => anyhow::bail!("PUT core/meta/collaborators/{name} returned HTTP {other}"), } } @@ -673,9 +692,10 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) { // Grant read-only access to core/meta and wire the `meta` remote // into the proposed repo so agents can fetch their deployment context. if let Some(token) = core_token - && let Err(e) = meta_read_access(name, token).await { - tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed"); - } + && let Err(e) = meta_read_access(name, token).await + { + tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed"); + } if let Err(e) = ensure_meta_remote(name).await { tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed"); } diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 497c00a7..8e01dc54 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -263,6 +263,20 @@ pub async fn ensure_user_for( Ok(()) } +/// Register a matrix account for `name` and return the freshly-minted +/// access token. Unlike [`ensure_user_for`], 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 so +/// we don't create stray `/var/lib/hyperhive/agents//` directories +/// for users that aren't agents (#662). +pub async fn provision_user_token( + client: &reqwest::Client, + name: &str, + register_token: &str, +) -> Result { + register_user(client, name, register_token).await +} + /// Per-agent matrix sync: ensure the agent has a matrix account + token. /// All operations are idempotent; failures are logged as warnings but /// don't abort the caller. From 5d1909bb5cbfe1fe4ad8f8eae808fb0987aaeea1 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 30 May 2026 21:49:04 +0200 Subject: [PATCH 2/2] hivectl matrix create-user: --password / --password-stdin for operator accounts (#663) --- hive-c0re/src/bin/hivectl.rs | 84 +++++++++++++++++++++++++++++++++--- hive-c0re/src/matrix.rs | 51 +++++++++++++++------- 2 files changed, 115 insertions(+), 20 deletions(-) diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index ddb462f1..9647b06c 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -92,10 +92,28 @@ enum MatrixCmd { /// the freshly-minted access token to stdout — no /// `/var/lib/hyperhive/agents/` directory is created for the user /// (#662). + /// + /// Without `--password` / `--password-stdin` a random throwaway is + /// used (fine for agents — they auth by `access_token`, never by + /// password). Set a password to log into a matrix web client + /// afterwards (#663). CreateUser { /// Matrix localpart. For agents: the container/agent name. /// For humans: any matrix localpart — `mara`, `damocles`, etc. name: String, + /// Set the account password to this string instead of a random + /// throwaway. Use this for operator accounts that need to log + /// into matrix web clients via `m.login.password` (#663). + /// Mutually exclusive with `--password-stdin`. WARNING: the + /// password is visible in shell history + process listings; + /// prefer `--password-stdin` for anything sensitive. + #[arg(long)] + password: Option, + /// Read the password from stdin (single line, trailing newline + /// stripped) instead of an inline flag. Mutually exclusive with + /// `--password`. + #[arg(long, conflicts_with = "password")] + password_stdin: bool, }, } @@ -113,7 +131,11 @@ async fn main() -> Result<()> { ForgeCmd::CreateUser { name } => forge_create_user(&name).await, }, Cmd::Matrix { cmd } => match cmd { - MatrixCmd::CreateUser { name } => matrix_create_user(&name).await, + MatrixCmd::CreateUser { + name, + password, + password_stdin, + } => matrix_create_user(&name, password.as_deref(), password_stdin).await, }, } } @@ -150,7 +172,37 @@ async fn forge_create_user(name: &str) -> Result<()> { Ok(()) } -async fn matrix_create_user(name: &str) -> Result<()> { +/// Resolve the password the caller asked for, or return `None` to fall +/// back to a random throwaway. `--password ` wins outright; +/// `--password-stdin` reads one line off stdin (trailing newline +/// stripped). Empty stdin is treated as an error so the caller doesn't +/// silently provision an empty-password account. +fn resolve_password(password: Option<&str>, password_stdin: bool) -> Result> { + if let Some(p) = password { + return Ok(Some(p.to_owned())); + } + if password_stdin { + use std::io::BufRead as _; + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin + .lock() + .read_line(&mut line) + .context("read password from stdin")?; + let trimmed = line.trim_end_matches(['\r', '\n']).to_owned(); + if trimmed.is_empty() { + bail!("--password-stdin: empty input"); + } + return Ok(Some(trimmed)); + } + Ok(None) +} + +async fn matrix_create_user( + 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" @@ -162,7 +214,16 @@ async fn matrix_create_user(name: &str) -> Result<()> { .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; + let user_password = resolve_password(password, password_stdin)?; if is_agent(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 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}"))?; @@ -170,11 +231,24 @@ async fn matrix_create_user(name: &str) -> Result<()> { println!("matrix: provisioned agent user '{name}'"); println!("token persisted at: {}", path.display()); } else { - let token = hive_c0re::matrix::provision_user_token(&client, name, ®ister_token) - .await - .with_context(|| format!("matrix create-user {name}"))?; + 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(()) } diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 8e01dc54..0c51c123 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -169,19 +169,33 @@ async fn register_post( Ok((status, json)) } -/// Run the matrix-spec UIAA flow to register `agent` and return the -/// resulting access token. Two round-trips: first POST elicits the -/// 401 + session id, second POST supplies the registration token in -/// the `auth` block. If the homeserver returns 200 on the first POST -/// (no flow stages required — `allow_registration` with no token), we -/// take the access token directly. +/// 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` (#663) lets the caller +/// supply a real password instead so they can log into a matrix web +/// client (`m.login.password`). +pub fn random_password() -> Result { + random_hex(PASSWORD_BYTES) +} + +/// Run the matrix-spec UIAA flow to register `agent` with the given +/// `password` and return the resulting access token. Two round-trips: +/// first POST elicits the 401 + session id, second POST supplies the +/// registration token in the `auth` block. If the homeserver returns +/// 200 on the first POST (no flow stages required — `allow_registration` +/// with no token), we take the access token directly. +/// +/// 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 (#663). async fn register_user( client: &reqwest::Client, agent: &str, register_token: &str, + password: &str, ) -> Result { let localpart = user_localpart(agent); - let password = random_hex(PASSWORD_BYTES)?; let initial = serde_json::json!({ "username": localpart, "password": password, @@ -252,7 +266,8 @@ pub async fn ensure_user_for( tracing::debug!(%name, "matrix: token already present"); return Ok(()); } - let access_token = register_user(client, name, register_token).await?; + let password = random_password()?; + let access_token = register_user(client, name, register_token, &password).await?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } @@ -263,18 +278,24 @@ pub async fn ensure_user_for( Ok(()) } -/// Register a matrix account for `name` and return the freshly-minted -/// access token. Unlike [`ensure_user_for`], 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 so -/// we don't create stray `/var/lib/hyperhive/agents//` directories -/// for users that aren't agents (#662). +/// Register a matrix account for `name` with the supplied `password` +/// and return the freshly-minted access token. Unlike [`ensure_user_for`], +/// 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 so we don't create stray +/// `/var/lib/hyperhive/agents//` directories for users that +/// aren't agents (#662). For operator accounts the caller passes a +/// real password so the operator can `m.login.password` into matrix +/// web clients afterwards (#663); for headless agent re-provisioning +/// the caller can pass [`random_password`] to keep the existing +/// throwaway behaviour. pub async fn provision_user_token( client: &reqwest::Client, name: &str, register_token: &str, + password: &str, ) -> Result { - register_user(client, name, register_token).await + register_user(client, name, register_token, password).await } /// Per-agent matrix sync: ensure the agent has a matrix account + token.