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.