//! 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. //! //! It also mirrors each agent's hive-c0re-owned *applied* config repo //! into the private `agent-configs` org (`push_config`), so every //! deploy / approval tag core plants is visible on the forge. Each //! agent is a read-only collaborator on `core/meta` (the meta flake) //! so they can fetch their deployment context; the `agent-configs` //! repos remain core-only. //! //! 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 base64::Engine; use reqwest::StatusCode; 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"; /// Marker that records whether `ensure_core_avatar` has successfully /// uploaded the hyperhive logo as `core`'s avatar (issue #320). One-shot: /// the upload runs once, the marker is written, subsequent startups skip /// the call. Delete to force re-upload. const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set"; /// Hyperhive logo bytes, baked into the daemon. Uploaded once via the /// admin avatar API so the `core` Forgejo user shows the project mark /// next to commits in `agent-configs/*`, `core/meta`, etc. instead of /// the default hash identicon. const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png"); /// Forgejo org grouping every agent's applied config repo. Core is a /// site admin and reads + writes every repo here; agents are NOT /// members and the repos are private, so no agent — not even the one /// a repo describes — can reach a config repo through the forge. The /// applied repos stay hive-c0re-owned on disk; this org is just a /// mirror target core pushes to. const CONFIG_ORG: &str = "agent-configs"; /// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at /// `core/meta` (the `core` user's own namespace — no org needed). const SEEDED_ORGS: &[&str] = &[CONFIG_ORG]; /// 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. /// - `read:notification` — required by `forge_notify` to poll /// `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"; /// 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) } /// Canonical email address for a hive agent's Forgejo account. /// Must match the `user.email` set by `meta::render_flake` so commits /// by the agent link back to their Forgejo profile page. fn agent_email(name: &str) -> String { format!("{name}@hyperhive") } /// Thin Forgejo REST helper. Sends `method` to `url` with a JSON body /// and `Authorization: token `, returns the HTTP status code. /// All Forgejo API calls that don't shell out to `forgejo admin` go /// through here — one place for auth header, content-type, error /// propagation, and the shared reqwest Client. async fn forge_http( method: reqwest::Method, url: &str, token: &str, body: &str, ) -> Result { let client = reqwest::Client::new(); let resp = client .request(method, url) .header("Authorization", format!("token {token}")) .header("Content-Type", "application/json") .body(body.to_owned()) .send() .await .with_context(|| format!("forge HTTP request to {url}"))?; Ok(resp.status()) } /// 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 = agent_email(name); 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(()) } } } } /// Idempotently align the Forgejo account email to `agent_email(name)`. /// Existing agents were created with `{name}@hive.local`; this corrects /// that so git commits (which use `{name}@hyperhive`) link to profiles. /// Best-effort: failures are warned, not propagated. async fn ensure_user_email(name: &str) { let email = agent_email(name); match forge_admin(&["user", "edit", "--username", name, "--email", &email]).await { Ok(_) => tracing::debug!(%name, %email, "forge: user email aligned"), Err(e) => tracing::warn!(%name, error = %e, "forge: could not align user email"), } } /// 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. Always re-mints the /// token so the on-disk file always reflects the current `TOKEN_SCOPES`. /// 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(()); } ensure_user_exists(name, false).await?; ensure_user_email(name).await; mint_and_persist_token(name, &token_path(name)).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 /// runs fine with the default hash identicon. async fn ensure_core_avatar(token: &str) -> Result<()> { let marker = std::path::Path::new(CORE_AVATAR_MARKER); if marker.exists() { return Ok(()); } let body = format!( r#"{{"image":"{}"}}"#, base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG), ); let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar"); let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; if !status.is_success() { anyhow::bail!("set core avatar: HTTP {status}"); } if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(marker, "").ok(); tracing::info!("forge: set core user avatar to hyperhive logo"); Ok(()) } /// 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()) } /// JSON body for a private, empty repo defaulting to `main`. fn repo_body(name: &str) -> String { format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#) } /// POST a repo-creation request to `url` and fold "already exists" /// (HTTP 409 / 422) into success. `label` is `/` — purely /// for log + error context. async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> { let status = forge_http(reqwest::Method::POST, url, token, body).await?; match status.as_u16() { 201 => { tracing::info!(%label, "forge: created repo"); Ok(()) } 409 | 422 => { tracing::debug!(%label, "forge: repo already exists"); Ok(()) } other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"), } } /// Create a repo in the token-owner's own namespace. `token` belongs /// to the user we want the repo owned by (we use `core`'s token for /// `core/meta`). Idempotent. pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { create_repo( &format!("{FORGE_HTTP}/api/v1/user/repos"), &repo_body(name), token, &format!("core/{name}"), ) .await } /// Create `name` inside org `org` (used for `agent-configs/`). /// Idempotent. async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> { create_repo( &format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"), &repo_body(name), token, &format!("{org}/{name}"), ) .await } /// 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(()) } /// Ensure the `agent-configs/` repo exists so the first /// `push_config` doesn't 404. No-op when the forge isn't running or /// the core token isn't minted yet. Safe to call on every spawn and /// on every startup. pub async fn ensure_config_repo(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } let Some(token) = core_token() else { return Ok(()); }; ensure_org_repo(CONFIG_ORG, name, &token).await } /// Grant agent `name` read-only collaborator access to `core/meta` on /// the forge so the agent can clone/fetch the meta flake. Idempotent: /// HTTP 204 (already a collaborator) is treated as success. pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> { let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}"); let body = r#"{"permission":"read"}"#; let out = Command::new("curl") .args([ "-sS", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT", "-H", "Content-Type: application/json", "-H", &format!("Authorization: token {core_token}"), "-d", body, &url, ]) .output() .await .context("invoke curl PUT core/meta/collaborators")?; let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); match code.as_str() { "204" => { tracing::info!(%name, "forge: granted meta read access"); Ok(()) } other => anyhow::bail!( "PUT core/meta/collaborators/{name} returned HTTP {other}" ), } } /// Add `http://localhost:3000/core/meta.git` as the `meta` remote in /// the agent's proposed config repo so the agent (and the manager) can /// fetch the meta flake from the forge. Idempotent: no-op when the /// remote already points at the right URL, or when the proposed repo /// does not exist yet. No-op when the forge is not running. pub async fn ensure_meta_remote(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } let proposed_dir = Coordinator::agent_proposed_dir(name); if !proposed_dir.join(".git").exists() { return Ok(()); } let want = format!("{FORGE_HTTP}/core/meta.git"); let existing = crate::lifecycle::git_command() .current_dir(&proposed_dir) .args(["remote", "get-url", "meta"]) .output() .await .context("git remote get-url meta")?; if existing.status.success() { let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned(); if current == want { return Ok(()); } crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await } else { crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).await } } /// Mirror agent `name`'s applied config repo — `main` plus every tag /// (`proposal` / `approved` / `building` / `deployed` / `failed` / /// `denied`) — to `agent-configs/` on the local forge. /// Best-effort: returns Err which callers log + ignore. No-op when the /// forge isn't seeded or the applied repo doesn't exist yet. /// /// Call this after every hive-c0re mutation of an applied repo's refs /// so the forge copy always reflects what core actually did. `--force` /// because a failed build rolls `main` backwards to the last-good sha. /// /// The tokenised URL is passed straight to `git push` and deliberately /// never stored as a named remote: the applied repo is bind-mounted /// READ-ONLY into the manager container (`/applied`), so a token in /// `.git/config` would leak core's admin credential to an agent. pub async fn push_config(name: &str) -> Result<()> { let Some(token) = core_token() else { return Ok(()); }; let dir = Coordinator::agent_applied_dir(name); if !dir.join(".git").exists() { return Ok(()); } let url = format!("http://core:{token}@localhost:3000/{CONFIG_ORG}/{name}.git"); let out = crate::lifecycle::git_command() .current_dir(&dir) .args([ "push", "--force", &url, "refs/heads/main:refs/heads/main", "refs/tags/*:refs/tags/*", ]) .output() .await .context("invoke git push agent-configs")?; if !out.status.success() { anyhow::bail!( "git push {CONFIG_ORG}/{name} failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } tracing::info!(%name, "forge: mirrored applied config to agent-configs"); 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 status = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?; match status.as_u16() { 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}"), } } /// Per-agent forge sync: ensure the agent has a forgejo user + token, /// a mirrored config repo, read access to `core/meta`, and the `meta` /// remote in its proposed repo. All operations are idempotent; failures /// are logged as warnings but don't abort the caller. /// /// `core_token` is `core_token()` — passed in so callers that already /// fetched it don't re-read the file. Pass `None` to skip the /// `meta_read_access` step (safe: the access grant is best-effort). /// /// Called by both `ensure_all()` (startup sweep) and `rebuild_agent` /// (per-rebuild) so the two paths stay equivalent. pub async fn sync_agent(name: &str, core_token: Option<&str>) { if let Err(e) = ensure_user_for(name).await { tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); } // Align email to match the git user.email set by meta::render_flake // so commits link to the agent's Forgejo profile. Best-effort; // also patches up agents created before this fix (old @hive.local). ensure_user_email(name).await; // Mirror the agent's applied config repo into agent-configs. // ensure_config_repo is idempotent; push_config catches any // drift since the last run — e.g. the startup migration just // relocated `deployed/0`, or a deploy landed while the forge // was down. if let Err(e) = ensure_config_repo(name).await { tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed"); } if let Err(e) = push_config(name).await { tracing::warn!(%name, error = ?e, "forge: push_config failed"); } // 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"); } if let Err(e) = ensure_meta_remote(name).await { tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed"); } } /// Sweep every existing container (manager + sub-agents) and ensure /// each has a forgejo user + token, plus an `agent-configs/` /// repo mirroring its applied config. Also seeds the `core` admin /// user (hive-c0re's own identity for pushing the meta repo + driving /// the API), the `agent-configs` org, and the `core/meta` repo. /// 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"); } if let Err(e) = ensure_core_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_core_avatar 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; }; sync_agent(&name, core_token.as_deref()).await; } }