//! Optional Forgejo wiring. When the `hive-forge` nixos-container is //! present and running, hive-c0re ensures every agent (and the //! manager) has a corresponding forgejo user with an API token //! written to `/forge-token` — visible inside the //! container as `/state/forge-token`. Idempotent: skips creation //! when the user already exists, skips token issuance when the file //! is already there. //! //! No-op when `hive-forge` isn't enabled (detected via //! `nixos-container list`), so operators who don't run the bundled //! forge pay nothing. use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use tokio::process::Command; use crate::coordinator::Coordinator; const FORGE_CONTAINER: &str = "hive-forge"; const TOKEN_NAME_PREFIX: &str = "hyperhive"; /// Forgejo scopes the agent's token gets. `write:repository` covers /// clone/push/repo-create on the user's own repos; `write:issue` is /// what PRs and comments ride under; `read:user` is mandatory for /// the token-owner endpoint clients use to introspect. const TOKEN_SCOPES: &str = "read:user,write:repository,write:issue"; /// Token file inside the agent's bind-mounted state dir (visible as /// `/state/forge-token` from inside the container). fn token_path(name: &str) -> PathBuf { Coordinator::agent_notes_dir(name).join("forge-token") } /// Probe whether `hive-forge` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. pub async fn is_present() -> bool { let Ok(out) = Command::new("nixos-container") .arg("list") .output() .await else { return false; }; if !out.status.success() { return false; } String::from_utf8_lossy(&out.stdout) .lines() .any(|l| l.trim() == FORGE_CONTAINER) } /// Run `forgejo admin ` inside the hive-forge container as the /// forgejo user (the only uid with write access to the state dir). /// Returns stdout on success; bails with stderr context on failure. async fn forge_admin(args: &[&str]) -> Result { let mut cmd = Command::new("nixos-container"); // `runuser` (util-linux, always present in a NixOS container) // beats `sudo` here — sudo isn't installed unless `security.sudo` // is enabled, and we don't want to depend on that. cmd.args(["run", FORGE_CONTAINER, "--", "runuser", "-u", "forgejo", "--", "forgejo", "admin"]); cmd.args(args); let out = cmd .output() .await .context("invoke nixos-container run hive-forge -- forgejo admin")?; if !out.status.success() { anyhow::bail!( "forgejo admin {} failed ({}): {}", args.join(" "), out.status, String::from_utf8_lossy(&out.stderr).trim(), ); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// Pull the access token out of forgejo's success message. Format /// has shifted across versions (table form vs. "Access token was /// successfully created: "), so just hunt the output for the /// first long hex-looking word. fn extract_token(output: &str) -> Option { output .split(|c: char| c.is_whitespace() || c == ',' || c == ':') .find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit())) .map(str::to_owned) } /// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// returns a "user already exists" error which we treat as success. async fn ensure_user_exists(name: &str) -> Result<()> { let result = forge_admin(&[ "user", "create", "--username", name, "--email", &format!("{name}@hive.local"), "--random-password", "--must-change-password=false", ]) .await; match result { Ok(_) => { tracing::info!(%name, "forge: created user"); Ok(()) } Err(e) => { // Forgejo's "already exists" error wording varies; just // try the next step and let token issuance surface a // real failure if the user truly isn't there. let msg = format!("{e:#}"); if msg.contains("already exists") || msg.contains("user already") { tracing::debug!(%name, "forge: user already exists"); Ok(()) } else { tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway"); Ok(()) } } } } /// 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. async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> { let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) ); let stdout = forge_admin(&[ "user", "generate-access-token", "--username", name, "--token-name", &token_name, "--scopes", TOKEN_SCOPES, ]) .await?; let token = extract_token(&stdout) .with_context(|| format!("parse token from forgejo output: {stdout:?}"))?; 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()))?; use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token"); Ok(()) } /// Ensure `name` has a forgejo user + token file. No-op when the /// token file is already present. Safe to call on every spawn and /// on every hive-c0re startup. pub async fn ensure_user_for(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } let path = token_path(name); if path.exists() { return Ok(()); } ensure_user_exists(name).await?; mint_and_persist_token(name, &path).await } /// Sweep every existing container (manager + sub-agents) and ensure /// each has a forgejo user + token. Called once at hive-c0re /// startup. Per-agent failures are logged but don't abort the sweep. pub async fn ensure_all() { if !is_present().await { tracing::debug!("forge: hive-forge container absent, skipping user sweep"); return; } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep"); return; }; for c in containers { let name = if c == crate::lifecycle::MANAGER_NAME { c } else if let Some(n) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) { n.to_owned() } else { continue; }; if let Err(e) = ensure_user_for(&name).await { tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); } } }