//! 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 FORGE_HTTP: &str = "http://localhost:3000"; const TOKEN_NAME_PREFIX: &str = "hyperhive"; /// Where the host-side `core` admin token lives. Used by hive-c0re /// itself to push the meta repo + drive admin API calls (org /// creation, future webhook setup, etc.). Root-only. const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; /// Forgejo orgs hive-c0re ensures on startup. The meta repo lives /// at `core/meta` (the `core` user's own namespace — no org needed); /// `agents` holds per-agent applied config repos so they're /// visible/grouped together and access can be granted org-wide /// (RO membership for the future shared docs/skills repo). const SEEDED_ORGS: &[&str] = &["agents"]; /// 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. /// `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 email = format!("{name}@hive.local"); args.push(&email); args.extend(["--random-password", "--must-change-password=false"]); if admin { args.push("--admin"); } let result = forge_admin(&args).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<()> { use std::os::unix::fs::PermissionsExt; 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()))?; 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, false).await?; mint_and_persist_token(name, &path).await } /// Ensure the bootstrap `core` admin user + a token at /// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo /// API calls (org creation now, meta-repo push later). Returns the /// token. Idempotent: skips creation when user exists, skips token /// when the file is present. async fn ensure_core_user_and_token() -> Result { let path = std::path::Path::new(CORE_TOKEN_PATH); if let Ok(existing) = std::fs::read_to_string(path) { let trimmed = existing.trim().to_owned(); if !trimmed.is_empty() { return Ok(trimmed); } } ensure_user_exists("core", true).await?; mint_and_persist_token("core", path).await?; let raw = std::fs::read_to_string(path) .with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?; Ok(raw.trim().to_owned()) } /// POST `/api/v1/user/repos` to create a repo in the authenticated /// user's own namespace. `token` belongs to the user we want the /// repo owned by (we use `core`'s token for `core/meta`). Idempotent: /// HTTP 409 ("repository already exists") is treated as success. pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { let body = format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#); let url = format!("{FORGE_HTTP}/api/v1/user/repos"); let out = Command::new("curl") .args([ "-sS", "-o", "/dev/null", "-w", "%{http_code}", "-X", "POST", "-H", "Content-Type: application/json", "-H", &format!("Authorization: token {token}"), "-d", &body, &url, ]) .output() .await .context("invoke curl POST /api/v1/user/repos")?; let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); match code.as_str() { "201" => { tracing::info!(%name, "forge: created repo"); Ok(()) } "409" | "422" => { tracing::debug!(%name, "forge: repo already exists"); Ok(()) } other => anyhow::bail!( "POST /api/v1/user/repos name={name} returned HTTP {other}" ), } } /// Read the persisted core token, or None when the forge isn't /// seeded yet. Cheap — just a file read. pub fn core_token() -> Option { std::fs::read_to_string(CORE_TOKEN_PATH) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) } /// Push `dir` (the meta repo) to `core/meta` on the local forge. /// Best-effort: returns Err which callers log + ignore. No-op when /// the core token isn't present (forge not enabled). pub async fn push_meta(dir: &Path) -> Result<()> { let Some(token) = core_token() else { return Ok(()); }; // Token-in-URL push. Forgejo accepts `oauth2:` or just // any-username:; using `core` matches the owner so the // remote name is self-describing. let url = format!("http://core:{token}@localhost:3000/core/meta.git"); let out = Command::new("git") .current_dir(dir) .args(["push", "--force", &url, "HEAD:main"]) .output() .await .context("invoke git push core/meta")?; if !out.status.success() { anyhow::bail!( "git push core/meta failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } tracing::info!("forge: pushed meta to core/meta"); Ok(()) } /// POST `/api/v1/orgs` to create an org named `name`. Idempotent: /// HTTP 422 ("user already exists") is treated as success. async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { let body = format!(r#"{{"username":"{name}"}}"#); let url = format!("{FORGE_HTTP}/api/v1/orgs"); let out = Command::new("curl") .args([ "-sS", "-o", "/dev/null", "-w", "%{http_code}", "-X", "POST", "-H", "Content-Type: application/json", "-H", &format!("Authorization: token {admin_token}"), "-d", &body, &url, ]) .output() .await .context("invoke curl POST /api/v1/orgs")?; let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); match code.as_str() { "201" => { tracing::info!(%name, "forge: created org"); Ok(()) } "422" | "409" => { tracing::debug!(%name, "forge: org already exists"); Ok(()) } other => anyhow::bail!( "POST /api/v1/orgs name={name} returned HTTP {other}: {}", String::from_utf8_lossy(&out.stderr).trim() ), } } /// Sweep every existing container (manager + sub-agents) and ensure /// each has a forgejo user + token. Also seeds the `core` admin /// user (hive-c0re's own identity for pushing the meta repo + driving /// the API) and the `core` / `agents` orgs the system pushes into. /// Called once at hive-c0re startup. Per-step 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 core_token = match ensure_core_user_and_token().await { Ok(t) => Some(t), Err(e) => { tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed"); None } }; if let Some(token) = core_token.as_deref() { for org in SEEDED_ORGS { if let Err(e) = ensure_org(org, token).await { tracing::warn!(%org, error = ?e, "forge: ensure_org failed"); } } // Meta repo lives at core/meta — pushed from git_commit in // meta.rs on every deploy/lock-update. Make sure it exists // before the first push hits a 404. if let Err(e) = ensure_repo("meta", token).await { tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed"); } } 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"); } } }