//! 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. Broad-but-not-admin: every /// repo / PR / issue thing an agent needs day-to-day, no admin /// surface. /// - `write:repository` — create, clone, push, delete repos in the /// user's own namespace; merge PRs. /// - `write:issue` — open / comment / review issues *and* pull /// requests (forgejo namespaces PR conversation under issues). /// - `write:user` — edit own profile, create repos under own user. /// - `write:organization` — create + manage orgs (lets agents share /// a forge namespace). /// - `read:user` — token-owner endpoint clients call to introspect. /// - `write:misc` — hooks, attachments, the rest of the long tail. const TOKEN_SCOPES: &str = "read:user,write:user,write:repository,write:issue,write:organization,write:misc"; /// 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. // // `--work-path` is mandatory: without it, the admin CLI defaults // WorkPath to `dirname(executable)` (a RO nix-store path), then // looks for `/custom/conf/app.ini` which doesn't // exist, falls back to defaults, and F3 init tries to mkdir // under the nix store and fatals. The systemd unit sets // WORK_PATH for the daemon; we mirror it here for the CLI. cmd.args([ "run", FORGE_CONTAINER, "--", "runuser", "-u", "forgejo", "--", "forgejo", "--work-path", "/var/lib/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"); } } }