//! 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 pr_merge; mod repos; mod users; pub use pr_merge::{ ForgeMergeError, config_repo, fetch_pr_head_into_applied, ff_push_to_main, mark_pr_merged, pr_head_sha, }; 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 anyhow::{Context, Result}; use reqwest::StatusCode; 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"; pub(crate) const FORGE_HTTP: &str = "http://localhost:3000"; /// 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. 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}/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"; /// Hive-managed Forgejo namespaces that agent-initiated repo creation must /// never target. `internal` is operator-curated shared content; /// `agent-configs` + `core` are hive-c0re-internal mirror/meta namespaces. /// (`hyperhive` is NOT managed — it's just a repo that happens to be built /// by this hive.) hive-c0re's create path forces [`AGENTS_ORG`], so this is /// a defensive guard against any future caller passing an explicit owner. const HIVE_MANAGED_NAMESPACES: &[&str] = &[SHARED_ORG, CONFIG_ORG, "core"]; /// 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]; /// 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) } /// 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!("forgejo admin {} (via hive-priv)", args.join(" ")))?; Ok(stdout) } /// 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. /// Returns the response status **and body**. The body lets callers log /// *why* Forgejo rejected a request (e.g. the validation message on a /// 422); status-only callers just bind `(status, _)`. Body read is /// best-effort — a read error yields an empty string rather than /// failing the whole call. async fn forge_http( method: reqwest::Method, url: &str, token: &str, body: &str, ) -> Result<(StatusCode, String)> { 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}"))?; let status = resp.status(); let text = resp.text().await.unwrap_or_default(); Ok((status, text)) } /// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated /// repo creation must never target — `internal` (operator-curated /// shared content) + `agent-configs` / `core` (hive-c0re-internal). The /// create path forces [`AGENTS_ORG`], so this guards a future surface that /// might accept an explicit owner. #[must_use] pub fn is_hive_managed_namespace(ns: &str) -> bool { HIVE_MANAGED_NAMESPACES.contains(&ns) } /// 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; // 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"); } 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"); } // 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"); } // internal/knowledge is public — no per-agent collaborator grant needed. } /// 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"); } } // 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"); } } // 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"); } // 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"); } // Seed the hive-wide knowledge repo. if let Err(e) = ensure_knowledge_repo(token).await { tracing::warn!(error = ?e, "forge: ensure_knowledge_repo failed"); } // 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"); } if let Err(e) = ensure_core_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_core_avatar failed"); } if let Err(e) = ensure_config_org_avatar(token).await { tracing::warn!(error = ?e, "forge: ensure_config_org_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 Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; sync_agent(name, core_token.as_deref()).await; } }