//! Optional Forgejo wiring — per-agent user + token provisioning, //! config-repo mirroring, meta read-access grants. Also seeds //! `internal/docs` — a private repo every agent gets read-only //! collaborator access to for operator-curated shared content. //! No-op when `hive-forge` isn't running. Full design: `docs/forge.md`. mod ci_runner; pub mod config_pr_poll; mod pr_merge; mod reconcile; mod repos; mod users; pub use pr_merge::{ ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff, post_pr_comment, pr_head_sha, pr_is_open, }; pub use reconcile::{reconcile_config_apply, reconcile_config_status}; pub use repos::{ create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, meta_read_access, push_config, push_meta, shared_docs_access, }; pub use users::{core_token, ensure_user_for, provision_user_token}; use std::sync::OnceLock; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use forgejo_api::{Auth, Forgejo}; use url::Url; use repos::{ensure_mirrors, ensure_operators_team, ensure_org}; use users::{ ensure_config_org_avatar, ensure_core_avatar, ensure_core_user_and_token, ensure_repo_creation_disabled, ensure_user_email, }; const FORGE_CONTAINER: &str = "hive-forge"; /// Base HTTP URL for the local Forgejo instance, from `HIVE_FORGE_URL` /// (set unconditionally by `hive-c0re.nix` to `http://`). /// /// # Panics /// /// When `HIVE_FORGE_URL` is unset. That is deliberate: this daemon only /// runs under the NixOS module, which always sets it, so an unset var /// means the deployment is broken. There is no loopback fallback, /// because a guess is wrong in exactly the cases that matter — the /// forge may live on a different host from the daemon, and a fallback /// turns "misconfigured" into "silently talking to the wrong machine" /// or, worse, "connection refused" surfacing far from its cause. pub(crate) fn forge_http_base() -> &'static str { static BASE: OnceLock = OnceLock::new(); BASE.get_or_init(|| { std::env::var("HIVE_FORGE_URL").expect( "HIVE_FORGE_URL is unset — hive-c0re.nix sets it unconditionally, \ so this process was started outside the NixOS module", ) }) } /// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**. /// /// The token does not go here. A URL is a process argument, and `argv` is /// world-readable through `/proc//cmdline` for as long as the git child /// lives, so a credentialed remote publishes the core admin token to every /// local user on the host. Credentials travel in the environment instead, via /// [`core_auth_header`] and [`crate::lifecycle::git_command_authed`] — /// `/proc//environ` is owner-only. pub(crate) fn forge_git_url(repo: &str) -> String { git_url_with_base(forge_http_base(), repo) } /// The pure half of [`forge_git_url`], split out so it can be tested without a /// process-wide env var (which would race every other test in this binary). fn git_url_with_base(base: &str, repo: &str) -> String { format!("{base}/{repo}.git") } /// The `http.extraHeader` value authenticating as the forge core user. /// /// Basic auth over a header rather than userinfo in the URL, so the secret /// reaches git through the environment (see [`forge_git_url`]). Pair with /// [`crate::lifecycle::git_command_authed`], which is the only thing that /// should ever hold the result. pub(crate) fn core_auth_header(token: &str) -> String { use base64::Engine as _; let basic = base64::engine::general_purpose::STANDARD.encode(format!("core:{token}")); format!("Authorization: Basic {basic}") } /// Forgejo org grouping every agent's config repo. Core is a site admin /// and reads + writes every repo here. As of the agent-config-PR flow each /// agent is a **write collaborator on its own** `agent-configs/` repo — /// the editable PR surface it pushes config-change branches to — but `main` is /// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge /// handler lands on it (operator approval required; the agent can't push /// `main` or self-merge). The repos remain private, so an agent still can't /// reach *another* agent's config. `main` is fast-forward-only — hive-c0re /// never force-pushes; the `push_config` mirror runs best-effort until the /// PR-merge flow retires it. pub(crate) const CONFIG_ORG: &str = "agent-configs"; /// Forgejo org hosting the operator-curated shared docs/skills repo /// that every agent gets read-only access to. Agents use it as a /// common reference without the operator having to bake content into /// the system prompt or rely on `/shared`. Only the manager + operator /// (i.e. `core` user) can push. const SHARED_ORG: &str = "internal"; /// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent /// at `{forge_http_base()}/internal/docs.git`. const SHARED_DOCS_REPO: &str = "docs"; /// The hive-wide knowledge repo inside `SHARED_ORG`. Public — agents /// can fork it and open PRs without explicit collaborator grants. /// Bind-mounted read-only into every container at `/knowledge`. /// See `hive-c0re/src/knowledge.rs`. const KNOWLEDGE_REPO: &str = crate::knowledge::REPO; /// Forgejo org that owns agent-created repos. Agents can't create /// repos with their own token (`max_repo_creation = 0`); instead hive-c0re /// creates them here and adds the requesting agent as a **write** member /// (not owner/admin). Because the org — not the agent — owns the repo, /// perms stay c0re-managed and branch protection (referencing /// [`OPERATORS_TEAM`]) can block the author from merging their own PR. This /// is the "agents namespace" repos land in by default. const AGENTS_ORG: &str = "agents"; /// Operator merge-gate team inside [`AGENTS_ORG`]. Provisioned **empty** by /// hive-c0re (so perms can be set before anyone joins); the operator adds /// herself via the forge UI / hivectl. Branch protection on agents-org repos /// references this team by name for the merge/approval whitelist, so the /// rule never hardcodes a specific reviewer agent (which may not exist). const OPERATORS_TEAM: &str = "operators"; /// 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, SHARED_ORG, AGENTS_ORG]; /// Leak `s` to get a `&'static str` warning `kind` for the small, bounded /// set of per-org boot warnings in [`ensure_all`] (one per seeded org, at /// most a handful per process). [`crate::warnings::set_boot_warning`] /// requires a `'static` kind so distinct orgs/repos don't clobber each /// other's banner entry; leaking a few short strings once per boot is /// cheap and bounded, unlike a per-request or per-loop-iteration leak. fn static_kind(s: String) -> &'static str { Box::leak(s.into_boxed_str()) } /// Probe whether `hive-forge` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Routed /// through hive-priv: `nixos-container` needs root, and hive-c0re runs /// unprivileged (privsep). pub async fn is_present() -> bool { let Ok(stdout) = crate::priv_client::list_containers().await else { return false; }; stdout.lines().any(|l| l.trim() == FORGE_CONTAINER) } /// Name a `forgejo admin` invocation for an error message without /// reproducing its arguments: keep the leading verb path, stop at the /// first flag. `["user", "create", "--username", "iris", "--password", /// "…"]` becomes `forgejo admin user create`. /// /// The verbs are an **allowlist**, and that is the whole point. Some /// callers pass a live secret as an argument value (`--password`), so a /// message built from the raw vector puts it in the log. Listing the /// flags to *hide* instead would repeat the bug this guards against: a /// newly added secret-bearing flag would leak until someone remembered /// to extend the list. Verbs are a closed set this crate chooses /// itself; argument values never are. fn describe_forge_admin(args: &[&str]) -> String { let verbs: Vec<&str> = args .iter() .take_while(|a| !a.starts_with('-')) .copied() .collect(); if verbs.is_empty() { "forgejo admin".to_owned() } else { format!("forgejo admin {}", verbs.join(" ")) } } /// 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 { // Route through hive-priv (root helper) because `nixos-container run` // uses nsenter to enter the container's namespaces, which requires root. // hive-c0re runs as the unprivileged `hive-core` user and cannot call // nsenter directly — doing so produces: // nsenter: stat of /proc//ns/user failed: Permission denied let (stdout, _stderr) = crate::priv_client::run_forge_admin(args) .await .with_context(|| format!("{} (via hive-priv)", describe_forge_admin(args)))?; Ok(stdout) } /// Typed Forgejo API client for the local forge ([`forge_http_base()`]), /// authenticated as `token`. All Forgejo API calls that don't shell /// out to `forgejo admin` go through clients built here — one place /// for the base URL and auth. Tokens differ per call site (core admin /// token vs per-agent tokens), so the token is passed per call; the /// base URL is parsed once. Failures surface as /// `forgejo_api::ForgejoError`, whose Display carries the HTTP status /// and the API's error message (e.g. the validation reason on a 422). pub(crate) fn api(token: &str) -> Result { static URL: OnceLock = OnceLock::new(); let url = URL .get_or_init(|| Url::parse(forge_http_base()).expect("forge_http_base() is a valid URL")) .clone(); Forgejo::new(Auth::Token(token), url).context("build forgejo api client") } /// 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. /// Returns `true` if all steps succeeded, `false` if any step failed. The /// caller can use the return value to aggregate per-agent failures into a /// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and /// relies on the journal `warn!` lines alone (a rebuild is its own retry). pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool { let mut ok = true; if let Err(e) = ensure_user_for(name).await { tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); ok = false; } // 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; // Block direct agent-initiated repo creation: agents create // repos through hive-c0re, never with their own token. Idempotent + // marker-guarded; also covers agents provisioned before this landed. ensure_repo_creation_disabled(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"); ok = false; } if let Err(e) = push_config(name).await { tracing::warn!(%name, error = ?e, "forge: push_config failed"); ok = false; } // 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"); ok = false; } if let Err(e) = ensure_meta_remote(name).await { tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed"); ok = false; } // Grant read-only access to internal/docs so the agent can clone // the operator-curated shared skills/runbook repo. Best-effort. if let Some(token) = core_token && let Err(e) = shared_docs_access(name, token).await { tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed"); ok = false; } // internal/knowledge is public — no per-agent collaborator grant needed. ok } /// The `core_token.is_some()` half of [`ensure_all`]: orgs, teams, the meta /// repo, shared/knowledge repos, avatars, and CI runner registration — every /// step that needs an authenticated forge client. Split out purely to keep /// `ensure_all` under clippy's function-length limit; not meant to be called /// from anywhere else. async fn ensure_all_orgs_and_repos(token: &str) { for org in SEEDED_ORGS { if let Err(e) = ensure_org(org, token).await { tracing::warn!(%org, error = ?e, "forge: ensure_org failed"); crate::warnings::set_boot_warning( static_kind(format!("forge_ensure_org_{org}")), "crit", format!("forge: org {org} provisioning failed: {e}"), ); } } // Seed the operator-declared pull-mirrors (nix `forge.mirrors` + // the CI-auto `actions/checkout`, forwarded via the // `HYPERHIVE_FORGE_MIRRORS` env). Each ensures its own dest org, so // this is independent of the SEEDED_ORGS loop above. ensure_mirrors(token).await; // Provision the operator merge-gate team (empty) inside BOTH the // agents org and the agent-configs org so branch protection in each // can reference it before anyone joins. Gitea teams are org-scoped — // missing the agent-configs copy 422'd every config-repo protection // apply, leaving those repos unprotected and letting operator-merged // config PRs bypass the deploy pipeline. The operator adds herself as // a member out-of-band. for org in [AGENTS_ORG, CONFIG_ORG] { if let Err(e) = ensure_operators_team(org, token).await { tracing::warn!(%org, error = ?e, "forge: ensure_operators_team failed"); crate::warnings::set_boot_warning( static_kind(format!("forge_ensure_operators_team_{org}")), "crit", format!("forge: operators team in {org} provisioning failed: {e}"), ); } } // 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"); crate::warnings::set_boot_warning( "forge_ensure_meta_repo", "crit", format!("forge: core/meta repo provisioning failed: {e}"), ); } // Seed the shared docs repo. internal is already in // SEEDED_ORGS above so the org exists; ensure the repo itself. if let Err(e) = ensure_shared_docs_repo(token).await { tracing::warn!(error = ?e, "forge: ensure_shared_docs_repo failed"); crate::warnings::set_boot_warning( "forge_ensure_shared_docs_repo", "warn", format!("forge: shared docs repo provisioning failed: {e}"), ); } // Seed the hive-wide knowledge repo. if let Err(e) = ensure_knowledge_repo(token).await { tracing::warn!(error = ?e, "forge: ensure_knowledge_repo failed"); crate::warnings::set_boot_warning( "forge_ensure_knowledge_repo", "crit", format!("forge: knowledge repo provisioning failed: {e}"), ); } // Clone knowledge repo locally so it can be bind-mounted into agents. if let Err(e) = crate::knowledge::ensure_local_clone(token).await { tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed"); crate::warnings::set_boot_warning( "forge_knowledge_local_clone", "crit", format!("knowledge: local clone failed: {e}"), ); } if let Err(e) = ensure_core_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_core_avatar failed"); crate::warnings::set_boot_warning( "forge_ensure_core_avatar", "warn", format!("forge: core avatar upload failed: {e}"), ); } if let Err(e) = ensure_config_org_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed"); crate::warnings::set_boot_warning( "forge_ensure_config_org_avatar", "warn", format!("forge: agent-configs org avatar upload failed: {e}"), ); } // Register the hive-ci Actions runner (off the container's boot path; // no-op when CI is disabled or the runner already holds valid creds). ci_runner::ensure_ci_runner_registered(token).await; } /// Poll the local Forgejo API until it answers, with a timeout of 1 minute. /// The whole boot provisioning below hits the API, and [`is_present`] only /// confirms the container exists — not that Forgejo is *listening*. A /// `nixos-rebuild` that restarts hive-forge and hive-c0re together races: /// without this gate every `ensure_*` fires at a refused socket and leaves a /// stale "provisioning failed" banner that never clears (the pass is one-shot). /// Returns whether the endpoint became ready before the deadline; on timeout /// the caller proceeds anyway so a genuinely-down forge still surfaces its real /// errors. Uses the unauthenticated `get_version` endpoint (no token yet). async fn wait_until_ready() -> bool { const READY_TIMEOUT: Duration = Duration::from_mins(1); const POLL_INTERVAL: Duration = Duration::from_millis(500); let Ok(url) = Url::parse(forge_http_base()) else { tracing::warn!("forge: HIVE_FORGE_URL is not a valid URL; skipping readiness wait"); return false; }; let Ok(client) = Forgejo::new(Auth::None, url) else { tracing::warn!("forge: could not build readiness client; skipping readiness wait"); return false; }; let deadline = Instant::now() + READY_TIMEOUT; let mut waited = false; loop { if client.get_version().await.is_ok() { if waited { tracing::info!("forge: endpoint ready, proceeding with provisioning"); } return true; } if Instant::now() >= deadline { tracing::warn!( "forge: endpoint not ready after {READY_TIMEOUT:?}; provisioning anyway" ); return false; } waited = true; tokio::time::sleep(POLL_INTERVAL).await; } } /// 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; } // The container exists, but its HTTP may not be up yet (a rebuild restarts // hive-forge + hive-c0re together). Wait for the API to answer before the // provisioning pass so a boot race doesn't leave stale failure banners. wait_until_ready().await; 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"); crate::warnings::set_boot_warning( "forge_ensure_core_user", "crit", format!("forge: core user/token provisioning failed: {e}"), ); None } }; if let Some(token) = core_token.as_deref() { ensure_all_orgs_and_repos(token).await; } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep"); crate::warnings::set_boot_warning( "forge_container_list", "crit", "forge: nixos-container list failed; per-agent forge sync skipped this boot", ); return; }; let mut sync_failed: Vec = Vec::new(); for c in containers { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; if !sync_agent(name, core_token.as_deref()).await { sync_failed.push(name.to_owned()); } } if !sync_failed.is_empty() { // Use static_kind to mint a `&'static str` key from the failed-agent // list (bounded leak: one per hive-c0re boot, not per request). let key = static_kind(format!("forge_sync_agent_{}", sync_failed.join("_"))); crate::warnings::set_boot_warning( key, "warn", format!( "forge: per-agent sync failed for: {} (see journal for per-step detail)", sync_failed.join(", ") ), ); } } /// Ensure a Forgejo `pull_request` org-webhook for `agent-configs` exists and /// points at hive-c0re's `/webhook/config-pr` endpoint. Idempotent — lists /// existing hooks first and skips creation when one is already targeting the /// correct URL. /// /// `hive_domain` is the public domain name of the hive (e.g. /// `pr1ma.darkest.space`); the webhook URL is /// `https:///webhook/config-pr` (routed through the gateway, /// avoiding the Forgejo SSRF guard that blocks loopback delivery). /// /// `webhook_secret` is the HMAC secret Forgejo will attach as /// `X-Hub-Signature-256` on each delivery; hive-c0re verifies this header /// in the dashboard webhook handler (`post_webhook_config_pr`). /// /// An org-level hook covers every repo in `agent-configs` automatically, /// so no per-repo setup is needed as new agents are provisioned. /// /// Called at startup alongside `knowledge::ensure_webhook`. No-op when the /// core token is absent (forge not yet provisioned). /// /// # Errors /// /// Returns an error if: /// - `hive_domain` produces a URL that `url::Url::parse` rejects. /// - The Forgejo `org_create_hook` API call fails (transport error, auth /// failure, or the `agent-configs` org does not exist). /// - The HTTP call times out (10 s limit). /// /// Listing failures are treated as best-effort: they fall through to the /// create attempt rather than surfacing an error. pub async fn ensure_config_pr_webhook( core_token: &str, hive_domain: &str, webhook_secret: &str, ) -> Result<()> { use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType}; use std::collections::BTreeMap; const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); let target_url = format!("https://{hive_domain}/webhook/config-pr"); let client = api(core_token)?; // List existing org hooks — skip creation if ours is already there. // Best-effort: a listing failure falls through to the create attempt. let listed = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).send()) .await .map_err(anyhow::Error::from) .and_then(|r| r.map_err(anyhow::Error::from)); match listed { Ok(hooks) => { let already_exists = hooks.iter().any(|h| { h.config .as_ref() .and_then(|c| c.get("url")) .map(String::as_str) == Some(target_url.as_str()) }); if already_exists { tracing::debug!(%target_url, "forge: config-pr webhook already configured"); return Ok(()); } // Delete stale hooks that point at our path but a different base // (e.g. old loopback hooks from before the SSRF-bypass migration). for h in &hooks { let hook_url = h .config .as_ref() .and_then(|c| c.get("url")) .map_or("", String::as_str); if hook_url.ends_with("/webhook/config-pr") && hook_url != target_url && let Some(id) = h.id { tracing::info!( hook_url, org = CONFIG_ORG, "forge: deleting stale config-pr webhook (wrong base)" ); let _ = tokio::time::timeout( HTTP_TIMEOUT, client.org_delete_hook(CONFIG_ORG, id).send(), ) .await; } } } Err(e) => { tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create"); } } let mut additional = BTreeMap::new(); additional.insert("secret".to_owned(), webhook_secret.to_owned()); let hook = CreateHookOption { active: Some(true), authorization_header: None, branch_filter: None, config: CreateHookOptionConfig { content_type: "json".to_owned(), url: Url::parse(&target_url).context("parse config-pr webhook target url")?, additional, }, events: Some(vec!["pull_request".to_owned()]), r#type: CreateHookOptionType::Forgejo, }; tokio::time::timeout(HTTP_TIMEOUT, client.org_create_hook(CONFIG_ORG, hook)) .await .map_err(anyhow::Error::from) .and_then(|r| r.map_err(anyhow::Error::from)) .with_context(|| format!("create config-pr webhook on org {CONFIG_ORG}"))?; tracing::info!(%target_url, "forge: config-pr webhook created on org {CONFIG_ORG}"); Ok(()) } #[cfg(test)] mod tests { use super::describe_forge_admin; #[test] fn describe_keeps_the_verb_path_and_drops_every_value() { assert_eq!( describe_forge_admin(&[ "user", "create", "--username", "iris", "--email", "iris@hyperhive.local", "--random-password", ]), "forgejo admin user create" ); } #[test] fn describe_does_not_reproduce_a_password_argument() { let described = describe_forge_admin(&[ "user", "change-password", "--username", "iris", "--password", "correct-horse-battery-staple", ]); assert_eq!(described, "forgejo admin user change-password"); assert!(!described.contains("correct-horse-battery-staple")); } #[test] fn describe_survives_a_leading_flag_and_an_empty_vector() { assert_eq!(describe_forge_admin(&["--help"]), "forgejo admin"); assert_eq!(describe_forge_admin(&[]), "forgejo admin"); } }