diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 9647b06c..9d2f64e4 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -17,7 +17,6 @@ use anyhow::{Context as _, Result, bail}; use clap::{Parser, Subcommand}; -use hive_c0re::coordinator::Coordinator; #[derive(Parser)] #[command( @@ -59,21 +58,13 @@ enum Cmd { #[derive(Subcommand)] enum ForgeCmd { /// Create or refresh the Forgejo account + token for ``. - /// - /// 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). + /// 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`. CreateUser { - /// Forgejo username. For agents: the container/agent name - /// (`` in `h-`; manager uses the literal `manager`). - /// For humans: any forgejo username — `mara`, `damocles`, etc. + /// Container/agent name (the `` in `h-`; manager + /// agent uses the literal `manager`). name: String, }, } @@ -81,39 +72,12 @@ enum ForgeCmd { #[derive(Subcommand)] enum MatrixCmd { /// Create or refresh the matrix account + access token for ``. - /// - /// 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). - /// - /// 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). + /// 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`. CreateUser { - /// Matrix localpart. For agents: the container/agent name. - /// For humans: any matrix localpart — `mara`, `damocles`, etc. + /// Container/agent name. 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, }, } @@ -131,78 +95,25 @@ async fn main() -> Result<()> { ForgeCmd::CreateUser { name } => forge_create_user(&name).await, }, Cmd::Matrix { cmd } => match cmd { - MatrixCmd::CreateUser { - name, - password, - password_stdin, - } => matrix_create_user(&name, password.as_deref(), password_stdin).await, + MatrixCmd::CreateUser { name } => matrix_create_user(&name).await, }, } } -/// 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" ); } - 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}"); - } + hive_c0re::forge::ensure_user_for(name) + .await + .with_context(|| format!("forge create-user {name}"))?; + println!("forge: provisioned user '{name}' (idempotent)"); Ok(()) } -/// 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<()> { +async fn matrix_create_user(name: &str) -> 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" @@ -214,41 +125,9 @@ async fn matrix_create_user( .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}"))?; - 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)" - ); - } - } + hive_c0re::matrix::ensure_user_for(&client, name, ®ister_token) + .await + .with_context(|| format!("matrix create-user {name}"))?; + println!("matrix: provisioned user '{name}' (idempotent)"); Ok(()) } diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 9959fcb8..0e63c2ca 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -75,7 +75,8 @@ 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 @@ -197,7 +198,13 @@ 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"]); @@ -268,13 +275,15 @@ async fn ensure_user_email(name: &str) { } } -/// Mint a fresh access token for `name`. Token name is suffixed with -/// a monotonic clock so re-issuing doesn't collide with an existing +/// 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 /// 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_token(name: &str, scopes: &str) -> Result { +async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", std::time::SystemTime::now() @@ -295,23 +304,13 @@ async fn mint_token(name: &str, 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(), "forge: persisted access token"); + tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token"); Ok(()) } @@ -327,23 +326,6 @@ 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 @@ -401,10 +383,7 @@ 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(()) } @@ -559,7 +538,9 @@ 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}" + ), } } @@ -692,10 +673,9 @@ 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 0c51c123..497c00a7 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -169,33 +169,19 @@ async fn register_post( Ok((status, json)) } -/// 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). +/// 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. 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, @@ -266,8 +252,7 @@ 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 = register_user(client, name, register_token).await?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } @@ -278,26 +263,6 @@ pub async fn ensure_user_for( Ok(()) } -/// 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, password).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.