diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 9647b06c..92d4e396 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -70,11 +70,32 @@ enum ForgeCmd { /// 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). + /// + /// Without `--password` / `--password-stdin` a random throwaway is + /// used (fine for agents — they auth by token via tea / hive-forge). + /// Set a password to log into the forge web UI afterwards (#663). + /// `--password` is idempotent: re-running with the same value sets + /// the same password (covers password resets on already-created + /// accounts since `forgejo admin user create` silently no-ops once + /// the user exists). CreateUser { /// 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, + /// Set the account password to this string instead of a random + /// throwaway. Use this for operator accounts that need to log + /// into the forge web UI. 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, }, } @@ -128,7 +149,11 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { Cmd::Forge { cmd } => match cmd { - ForgeCmd::CreateUser { name } => forge_create_user(&name).await, + ForgeCmd::CreateUser { + name, + password, + password_stdin, + } => forge_create_user(&name, password.as_deref(), password_stdin).await, }, Cmd::Matrix { cmd } => match cmd { MatrixCmd::CreateUser { @@ -149,13 +174,23 @@ fn is_agent(name: &str) -> bool { Coordinator::agent_state_root(name).exists() } -async fn forge_create_user(name: &str) -> Result<()> { +async fn forge_create_user( + name: &str, + password: Option<&str>, + password_stdin: bool, +) -> 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" ); } + let user_password = resolve_password(password, password_stdin)?; if is_agent(name) { + if user_password.is_some() { + bail!( + "forge create-user: --password / --password-stdin is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token" + ); + } hive_c0re::forge::ensure_user_for(name) .await .with_context(|| format!("forge create-user {name}"))?; @@ -163,11 +198,18 @@ async fn forge_create_user(name: &str) -> Result<()> { println!("forge: provisioned agent user '{name}'"); println!("token persisted at: {}", path.display()); } else { - let token = hive_c0re::forge::provision_user_token(name) + let token = hive_c0re::forge::provision_user_token(name, user_password.as_deref()) .await .with_context(|| format!("forge create-user {name}"))?; println!("forge: 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 the forge web UI"); + } else { + println!( + "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)" + ); + } } Ok(()) } @@ -221,7 +263,7 @@ async fn matrix_create_user( // 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" + "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) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 9959fcb8..4fe27d5d 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -195,12 +195,19 @@ async fn forge_http( /// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// returns a "user already exists" error which we treat as success. /// `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"]; +/// `core` user that drives the API. `password` picks the initial +/// account password: `None` uses `--random-password` (the existing +/// agent provisioning shape — the password is never read, agents auth +/// by token); `Some(pw)` uses `--password ` so the operator path +/// in `hivectl` can set a real password for matrix-style web-UI login +/// (#663). +async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> { let email = agent_email(name); - args.push(&email); - args.extend(["--random-password", "--must-change-password=false"]); + let mut args = vec!["user", "create", "--username", name, "--email", &email]; + match password { + Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]), + None => args.extend(["--random-password", "--must-change-password=false"]), + } if admin { args.push("--admin"); } @@ -226,6 +233,23 @@ async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> { } } +/// Set the forgejo password for an existing user. Used by the operator +/// path in `hivectl forge create-user --password` so re-running on an +/// already-created account still updates the password (covers the +/// "I forgot the password I set last week" case + the "argus retried +/// the verb to verify the fix" case — `forgejo admin user create` +/// silently skips a password change once the account exists). Idempotent +/// from the operator's point of view: same password input → same final +/// account state. +async fn change_user_password(name: &str, password: &str) -> Result<()> { + let args = ["user", "change-password", "--username", name, "--password", password]; + forge_admin(&args) + .await + .with_context(|| format!("forgejo admin user change-password {name}"))?; + tracing::info!(%name, "forge: changed user password"); + Ok(()) +} + /// Idempotently align the Forgejo account email to `agent_email(name)`. /// Existing agents were created with `{name}@hive.local`; this corrects /// that so git commits (which use `{name}@hyperhive`) link to profiles. @@ -322,7 +346,7 @@ pub async fn ensure_user_for(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } - ensure_user_exists(name, false).await?; + ensure_user_exists(name, false, None).await?; ensure_user_email(name).await; mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await } @@ -333,13 +357,28 @@ pub async fn ensure_user_for(name: &str) -> Result<()> { /// 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 { +/// +/// `password` picks the account password. `None` keeps the existing +/// random-throwaway shape (caller doesn't need web UI access — token +/// alone is enough). `Some(pw)` sets `pw` as the password, including +/// running `forgejo admin user change-password` if the account already +/// exists, so the operator can log into the forge web UI afterwards +/// (#663). Idempotent: re-running with the same `Some(pw)` lands on +/// the same final state. +pub async fn provision_user_token(name: &str, password: Option<&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_exists(name, false, password).await?; + if let Some(pw) = password { + // `user create` silently no-ops on an existing account, so + // we run change-password unconditionally when the caller + // asked for a specific password — keeps the verb idempotent + // for "set or reset" use. + change_user_password(name, pw).await?; + } ensure_user_email(name).await; mint_token(name, TOKEN_SCOPES).await } @@ -421,7 +460,7 @@ async fn ensure_core_user_and_token() -> Result { return Ok(trimmed); } } - ensure_user_exists("core", true).await?; + ensure_user_exists("core", true, None).await?; mint_and_persist_token("core", path, CORE_TOKEN_SCOPES).await?; let raw = std::fs::read_to_string(path) .with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?; diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 0c51c123..c695e17c 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -289,6 +289,12 @@ pub async fn ensure_user_for( /// web clients afterwards (#663); for headless agent re-provisioning /// the caller can pass [`random_password`] to keep the existing /// throwaway behaviour. +/// +/// **Not idempotent** (unlike [`forge::provision_user_token`]): the +/// matrix UIAA `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) +/// on second call for the same localpart. 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,