hivectl: print token to stdout for non-agent users (#662)

This commit is contained in:
damocles 2026-05-30 21:39:42 +02:00 committed by Mara
commit 18253bf2f5
3 changed files with 121 additions and 40 deletions

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");
}