Compare commits

...
3 changed files with 226 additions and 50 deletions

View file

@ -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 `<name>`.
/// 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/<name>/state/forge-token`.
///
/// When `<name>` matches an existing agent (i.e. it has a state
/// dir under `/var/lib/hyperhive/agents/`), persists the token to
/// `<state>/forge-token` (idempotent: re-mints + rewrites every
/// call so the on-disk scope matches the current
/// `forge::TOKEN_SCOPES`).
///
/// When `<name>` 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 `<name>` in `h-<name>`; manager
/// agent uses the literal `manager`).
/// Forgejo username. For agents: the container/agent name
/// (`<n>` in `h-<n>`; manager uses the literal `manager`).
/// For humans: any forgejo username — `mara`, `damocles`, etc.
name: String,
},
}
@ -72,12 +81,39 @@ enum ForgeCmd {
#[derive(Subcommand)]
enum MatrixCmd {
/// Create or refresh the matrix account + access token for `<name>`.
/// Idempotent: skips registration entirely when the token file is
/// already populated. To force re-registration, delete the token
/// file at `/var/lib/hyperhive/agents/<name>/state/matrix-token`.
///
/// When `<name>` matches an existing agent (i.e. it has a state
/// dir under `/var/lib/hyperhive/agents/`), persists the token to
/// `<state>/matrix-token`. Skips registration when the file is
/// already populated; delete it to force re-registration.
///
/// When `<name>` 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).
CreateUser {
/// Container/agent name.
/// 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<String>,
/// 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,
},
}
@ -95,25 +131,78 @@ 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,
},
}
}
/// 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(())
}
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 <PW>` 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<Option<String>> {
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"
@ -125,9 +214,41 @@ 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, &register_token)
.await
.with_context(|| format!("matrix create-user {name}"))?;
println!("matrix: provisioned user '{name}' (idempotent)");
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, &register_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, &register_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(())
}

View file

@ -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
/// `<state>/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<String> {
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/<name>/` directories for users that
/// aren't agents (#662).
pub async fn provision_user_token(name: &str) -> Result<String> {
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");
}

View file

@ -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<String> {
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<String> {
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,6 +278,26 @@ 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/<name>/` 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<String> {
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.