//! 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`. use std::path::Path; use anyhow::{Context, Result}; use base64::Engine; use reqwest::StatusCode; use tokio::process::Command; use crate::coordinator::Coordinator; const FORGE_CONTAINER: &str = "hive-forge"; pub(crate) 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"; // Forge provisioning markers (`forge/core-avatar-set`, // `forge/agent-configs-avatar-set`, `forge/email-aligned-`) live // in `crate::paths` — one-shot guards: the upload/align runs once, the // marker is written, subsequent startups skip. Delete one to force its // step to re-run. // Avatar PNGs are loaded at runtime from // `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the // helpers in `hive_sh4re::assets`. The `agent-configs.png` is // rendered from its SVG during the `hyperhive-assets` derivation's // build. /// 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]; /// Per-agent token scopes (broad-but-not-admin). See /// `docs/forge.md::Token scopes` for the per-scope rationale. const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; /// Bootstrap `core` token scopes — adds `read:admin,write:admin` on /// top of `TOKEN_SCOPES` so the host daemon can drive /// `/api/v1/admin/*`. Site-admin membership alone isn't enough: the /// token's own scope gate runs before the user-permission check. /// See `docs/forge.md::Token scopes`. const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; /// 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) } /// 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.local") } /// 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)) } /// 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. `password` picks the initial /// account password: `None` uses `--random-password` (the existing /// agent provisioning shape — the password is never read, agents auth /// by token); `Some(pw)` uses `--password ` so the operator path /// in `hivectl` can set a real password for matrix-style web-UI login. async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> { let email = agent_email(name); let mut args = vec!["user", "create", "--username", name, "--email", &email]; match password { Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]), None => 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(()) } } } } /// Set the forgejo password for an existing user. Used by the operator /// path in `hivectl forge create-user --password` so re-running on an /// already-created account still updates the password (covers the /// "I forgot the password I set last week" case + the "argus retried /// the verb to verify the fix" case — `forgejo admin user create` /// silently skips a password change once the account exists). Idempotent /// from the operator's point of view: same password input → same final /// account state. async fn change_user_password(name: &str, password: &str) -> Result<()> { let args = [ "user", "change-password", "--username", name, "--password", password, ]; forge_admin(&args) .await .with_context(|| format!("forgejo admin user change-password {name}"))?; tracing::info!(%name, "forge: changed user password"); 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. /// /// Marker-guarded: writes `EMAIL_ALIGNED_MARKER_PREFIX{name}` on first /// success and skips the PATCH on all subsequent calls. This prevents /// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar` /// on every `sync_agent` tick. Delete the marker to force re-alignment. /// /// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather /// than `forgejo admin user edit` because the CLI dropped the `edit` /// subcommand somewhere between forgejo 8 and current. Body includes /// `login_name` (required by Forgejo's `EditUserOption` validator) and /// `source_id = 0` (local auth, the default for users hive-c0re creates). async fn ensure_user_email(name: &str) { let marker = crate::paths::forge_email_aligned_marker(name); if marker.exists() { return; } let Some(token) = core_token() else { tracing::debug!(%name, "forge: skipping ensure_user_email — no core token yet"); return; }; let email = agent_email(name); // `login_name` is required by Forgejo's EditUserOption validator. // Omitting it caused Forgejo to reset use_custom_avatar on each call. let body = format!(r#"{{"email":"{email}","login_name":"{name}","source_id":0}}"#); let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}"); match forge_http(reqwest::Method::PATCH, &url, &token, &body).await { Ok((status, _)) if status.is_success() => { if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&marker, "").ok(); tracing::info!(%name, %email, "forge: user email aligned"); } Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => { // Core token missing admin scope — see // `docs/forge.md::Token scopes` migration note. tracing::warn!( %name, %email, %status, "forge: PATCH user email forbidden — core token likely missing admin scope. \ Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes." ); } Ok((status, _)) => { tracing::warn!(%name, %email, %status, "forge: PATCH user email returned non-success"); } Err(e) => tracing::warn!(%name, error = %e, "forge: PATCH user email transport error"), } } /// Disable direct repo creation for agent `name` by setting /// `max_repo_creation = 0` on its Forgejo account. Agents must /// create repos *through hive-c0re* (which owns the perms), never with /// their own token — a write-scoped token can otherwise create + own /// repos and self-merge, bypassing the operator-only-merge policy. /// /// `max_repo_creation = 0` means `CanCreateRepo()` is false for any /// count (Forgejo: `MaxRepoCreation >= 0 && NumRepos >= MaxRepoCreation`), /// so creation is refused while push / PR / clone stay intact. **Existing /// repos are untouched** — this only blocks *new* direct creation. /// /// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per /// agent (delete the marker to re-apply). Body carries `login_name` + /// `source_id` for the same reason `ensure_user_email` does — omitting /// `login_name` makes Forgejo's `EditUserOption` validator reset /// `use_custom_avatar`. Best-effort: failures warn, don't propagate. async fn ensure_repo_creation_disabled(name: &str) { let marker = crate::paths::forge_repo_creation_disabled_marker(name); if marker.exists() { return; } let Some(token) = core_token() else { tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet"); return; }; let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#); let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}"); match forge_http(reqwest::Method::PATCH, &url, &token, &body).await { Ok((status, _)) if status.is_success() => { if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&marker, "").ok(); tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)"); } Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => { tracing::warn!( %name, %status, "forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \ Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes." ); } Ok((status, _)) => { tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success"); } Err(e) => { tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error"); } } } /// Mint a fresh access token for `name`. 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. `scopes` is the scope string /// passed to `forgejo admin user generate-access-token --scopes`; /// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the /// bootstrap `core` user. async fn mint_token(name: &str, scopes: &str) -> Result { let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_secs()) ); let stdout = forge_admin(&[ "user", "generate-access-token", "--username", name, "--token-name", &token_name, "--scopes", scopes, ]) .await?; let token = extract_token(&stdout) .with_context(|| format!("parse token from forgejo output: {stdout:?}"))?; tracing::debug!(%name, %token_name, "forge: minted access token"); Ok(token) } /// Mint a fresh Forgejo access token for an agent and write it to the /// agent's state dir via hive-priv. hive-c0re runs unprivileged and /// cannot write to agent-owned (0755) state directories directly. async fn mint_and_persist_agent_token(name: &str) -> Result<()> { let token = mint_token(name, TOKEN_SCOPES).await?; crate::priv_client::write_agent_forge_token(name, &token) .await .with_context(|| format!("write forge-token for {name} via hive-priv")) } /// Mint a fresh Forgejo access token for the `core` admin user and /// write it directly to `path`. Unlike agent tokens this path is owned /// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct /// write is both correct and necessary (no priv round-trip). async fn mint_and_persist_core_token(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; let token = mint_token("core", CORE_TOKEN_SCOPES).await?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(path, format!("{token}\n")) .with_context(|| format!("write core token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); tracing::info!(path = %path.display(), "forge: persisted core 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, None).await?; ensure_user_email(name).await; mint_and_persist_agent_token(name).await } /// Provision a forgejo user for `name` and return the freshly-minted /// token. Unlike [`ensure_user_for`], the token is **not** persisted to /// disk — the caller is responsible for storing it. Used by `hivectl /// forge create-user` for human (non-agent) accounts so we don't create /// stray `/var/lib/hyperhive/agents//` directories for users that /// aren't agents. /// /// `password` picks the account password. `None` keeps the existing /// random-throwaway shape (caller doesn't need web UI access — token /// alone is enough). `Some(pw)` sets `pw` as the password, including /// running `forgejo admin user change-password` if the account already /// exists, so the operator can log into the forge web UI afterwards. /// Idempotent: re-running with the same `Some(pw)` lands on the same /// final state. pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result { if !is_present().await { anyhow::bail!( "hive-forge container not running — wait for hive-c0re to start it before provisioning forge users" ); } ensure_user_exists(name, false, password).await?; if let Some(pw) = password { // `user create` silently no-ops on an existing account, so // we run change-password unconditionally when the caller // asked for a specific password — keeps the verb idempotent // for "set or reset" use. change_user_password(name, pw).await?; } ensure_user_email(name).await; mint_token(name, TOKEN_SCOPES).await } /// Set `core`'s Forgejo avatar to the hyperhive logo once, then /// remember it so subsequent startups don't re-upload. 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 = crate::paths::forge_core_avatar_marker(); if marker.exists() { return Ok(()); } let png_path = hive_sh4re::assets::core_avatar_png(); let png_bytes = tokio::fs::read(&png_path) .await .with_context(|| format!("read core avatar PNG from {}", png_path.display()))?; let body = format!( r#"{{"image":"{}"}}"#, base64::engine::general_purpose::STANDARD.encode(&png_bytes), ); 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(()) } /// Set the `agent-configs` org's Forgejo avatar to the /// configs-stack glyph once. Sibling to `ensure_core_avatar`: /// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar /// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG /// JSON body — same shape as the admin user endpoint above. async fn ensure_config_org_avatar(token: &str) -> Result<()> { let marker = crate::paths::forge_config_org_avatar_marker(); if marker.exists() { return Ok(()); } let png_path = hive_sh4re::assets::config_org_avatar_png(); let png_bytes = tokio::fs::read(&png_path) .await .with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?; let body = format!( r#"{{"image":"{}"}}"#, base64::engine::general_purpose::STANDARD.encode(&png_bytes), ); let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar"); let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; if !status.is_success() { anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}"); } if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(marker, "").ok(); tracing::info!( org = CONFIG_ORG, "forge: set org avatar to configs-stack logo" ); Ok(()) } /// Outcome of probing whether the persisted core token still works /// against the *current* forge. Existence on disk is not validity: a /// token minted before a forge rebuild / re-provision is unknown to the /// new forge's DB and 401s on every call — which silently breaks the /// hive-ci runner-registration prefetch (it reads this same token to /// fetch a runner registration token). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CoreTokenCheck { /// Token authenticated successfully — keep using it. Valid, /// Forge explicitly rejected the token (401/403) — re-mint. Invalid, /// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a /// transient: keep the existing token and let a later ensure pass /// re-check once the forge is responsive. Re-minting here would both /// fail (mint needs the forge too) and churn tokens needlessly. Indeterminate, } /// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`]. /// Pure so the decision logic is unit-testable without a live forge. fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck { if status.is_success() { CoreTokenCheck::Valid } else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { CoreTokenCheck::Invalid } else { CoreTokenCheck::Indeterminate } } /// Probe whether `token` is still accepted by the current forge with a /// cheap authenticated `GET /api/v1/user` (covered by the core token's /// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is /// interpreted. async fn check_core_token(token: &str) -> CoreTokenCheck { let url = format!("{FORGE_HTTP}/api/v1/user"); match forge_http(reqwest::Method::GET, &url, token, "").await { Ok((status, _)) => classify_core_token_status(status), Err(e) => { tracing::debug!( error = %e, "forge: core-token probe could not reach forge; treating as indeterminate" ); CoreTokenCheck::Indeterminate } } } /// 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, meta-repo push, and the hive-ci /// runner-registration prefetch). Returns the token. /// /// Idempotent, but validity-aware: when a token file is already present /// it is **probed against the current forge** before being trusted. A /// token persisted before a forge rebuild / re-provision is stale (the /// new forge DB doesn't know it) and would 401 every caller — so on a /// definitive rejection the token is re-minted. A merely-unreachable /// forge leaves the existing token in place (a later ensure pass /// re-checks) rather than churning tokens on a transient. 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() { match check_core_token(&trimmed).await { CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed), CoreTokenCheck::Invalid => { tracing::warn!( path = %path.display(), "forge: persisted core token rejected by forge (stale after rebuild?); \ re-minting" ); } } } } ensure_user_exists("core", true, None).await?; mint_and_persist_core_token(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"}}"#) } /// JSON body for a public, empty repo defaulting to `main`. fn repo_body_public(name: &str) -> String { format!(r#"{{"name":"{name}","auto_init":false,"private":false,"default_branch":"main"}}"#) } /// Set an existing repo to public visibility. No-op if the repo is /// already public. Used for `internal/knowledge` which may have been /// created as private on an older deployment. async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> { let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}"); let (status, _) = forge_http(reqwest::Method::PATCH, &url, token, r#"{"private":false}"#).await?; match status.as_u16() { 200 => { tracing::debug!(%owner, %repo, "forge: repo set to public"); Ok(()) } other => anyhow::bail!("PATCH {owner}/{repo} (set public) returned HTTP {other}"), } } /// Create `name` inside org `org` as a public repo. Idempotent. async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> { create_repo( &format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"), &repo_body_public(name), token, &format!("{org}/{name}"), ) .await } /// 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 yet (forge container not provisioned). 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, and wire it as the agent-editable PR surface: /// the agent is a **write** collaborator (can push feature branches + /// open config PRs) and `main` is branch-protected core-only (only hive-c0re's /// merge handler lands on it; operator approval required). 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 (all steps idempotent). 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?; // Agent = write collaborator: it can push config-PR branches + open PRs, // but the branch protection below keeps it off `main` directly. add_collaborator(CONFIG_ORG, name, name, "write", &token).await?; // Protect `main` core-only, fast-forward-only (no auto force-push). apply_config_repo_branch_protection(name, &token).await } /// Ensure the `internal/docs` repo exists. Called once at startup /// after `ensure_org(SHARED_ORG)`. Idempotent — `ensure_org_repo` /// treats 409 as success. pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> { ensure_org_repo(SHARED_ORG, SHARED_DOCS_REPO, core_token).await } /// Grant agent `name` read-only collaborator access to `internal/docs`. /// Idempotent: HTTP 204 (already a collaborator) is treated as success. /// Mirrors `meta_read_access` so agents can clone the shared docs repo /// without authentication hassle. pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> { let url = format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/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 internal/docs/collaborators")?; let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); match code.as_str() { "204" => { tracing::info!(%name, "forge: granted shared-docs read access"); Ok(()) } other => anyhow::bail!( "PUT {SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name} returned HTTP {other}" ), } } /// Ensure the `internal/knowledge` repo exists and is public. /// Called once at startup after `ensure_org(SHARED_ORG)`. Idempotent. /// /// The repo is created as public so any agent with a forge account can /// fork it and open PRs to contribute. Existing deployments that ended /// up with a private repo are patched to public on the next hive-c0re /// startup via `set_repo_public`. pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> { ensure_org_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_token).await?; // Ensure public even if the repo already existed as private (older deployment). set_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_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. /// /// Never force-pushes. The status tags are id-suffixed /// (`proposal/`, `deployed/`, …) and therefore add-only, and /// `main` is published history — after a failed deploy rolls the LOCAL /// applied `main` back to last-good, the forge `main` may legitimately /// be ahead (e.g. an operator-merged config PR whose rebuild failed). /// Rewinding it would erase that merged commit from the forge, which /// is exactly the incident this guards against: the local repo tracks /// "what last built", the forge tracks "what was approved", and the /// `failed/` tag records the divergence. A non-fast-forward /// rejection of `main` is therefore expected + logged at info; the /// tags in the same push still land (git pushes refspecs /// independently). Any other failure is a real error. /// /// 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", &url, "refs/heads/main:refs/heads/main", "refs/tags/*:refs/tags/*", ]) .output() .await .context("invoke git push agent-configs")?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); if stderr.contains("non-fast-forward") { tracing::info!( %name, "forge: mirror push of main rejected (non-fast-forward) — forge main is \ ahead of local applied main (rolled-back deploy); leaving forge history intact" ); return Ok(()); } anyhow::bail!( "git push {CONFIG_ORG}/{name} failed ({}): {}", out.status, 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}"), } } /// One operator-declared pull-mirror, forwarded from the nix /// `services.hyperhive.forge.mirrors` option as JSON in /// `HYPERHIVE_FORGE_MIRRORS`. #[derive(serde::Deserialize)] struct Mirror { /// Upstream clone URL to mirror from (e.g. `https://github.com/actions/checkout`). upstream: String, /// Local `/` the mirror is created at. dest: String, } /// Ensure each `HYPERHIVE_FORGE_MIRRORS` entry exists as a real Forgejo /// pull-mirror. The env carries the JSON-encoded nix `forge.mirrors` list /// (plus the CI-auto `actions/checkout` entry). Absent/empty env = no-op. /// Per-mirror failures warn and continue — never abort the startup sweep. async fn ensure_mirrors(admin_token: &str) { let raw = match std::env::var("HYPERHIVE_FORGE_MIRRORS") { Ok(s) if !s.trim().is_empty() => s, _ => return, }; let mirrors: Vec = match serde_json::from_str(&raw) { Ok(m) => m, Err(e) => { tracing::warn!(error = ?e, "forge: HYPERHIVE_FORGE_MIRRORS is not valid JSON; skipping mirror seed"); return; } }; for m in mirrors { let Some((owner, repo)) = m.dest.split_once('/') else { tracing::warn!(dest = %m.dest, "forge: mirror dest is not /; skipping"); continue; }; // Create the dest org first (idempotent); the mirror can't land // without its owner existing. if let Err(e) = ensure_org(owner, admin_token).await { tracing::warn!(%owner, error = ?e, "forge: ensure_org for mirror failed"); continue; } if let Err(e) = ensure_mirror_repo(&m.upstream, owner, repo, admin_token).await { tracing::warn!(dest = %m.dest, error = ?e, "forge: ensure_mirror_repo failed"); } } } /// Periodic sync interval for pull-mirrors. Forgejo syncs mirrors /// on-access by default, which re-introduces external DNS latency on /// every `git clone` (the hive-ci runner shares the host netns and is /// therefore affected by host resolver blips). A fixed periodic interval /// isolates CI from transient DNS failures — a stale mirror is /// acceptable; a broken clone because of a momentary DNS blip is not. const MIRROR_INTERVAL: &str = "8h0m0s"; /// Create `owner/repo` as a pull-mirror of `upstream` via the migrate API. /// Idempotent: if the repo already exists this function patches its /// `mirror_interval` to ensure it matches (covers mirrors that were /// created before the interval was introduced). A 409 on the migrate /// POST (a race between the GET check and the POST) is also success. async fn ensure_mirror_repo( upstream: &str, owner: &str, repo: &str, admin_token: &str, ) -> Result<()> { let repo_url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}"); let (status, _) = forge_http(reqwest::Method::GET, &repo_url, admin_token, "").await?; if status.is_success() { // Mirror already present. Patch interval so mirrors seeded before // this field was introduced (or with a different value) converge. let patch_body = serde_json::json!({ "mirror_interval": MIRROR_INTERVAL }).to_string(); let (patch_status, patch_text) = forge_http(reqwest::Method::PATCH, &repo_url, admin_token, &patch_body).await?; if patch_status.is_success() { tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated"); } else { tracing::warn!( %owner, %repo, status = %patch_status, body = %patch_text, "forge: failed to set mirror_interval on existing pull-mirror" ); } return Ok(()); } // serde_json::json! → the upstream URL is escaped safely (no string // interpolation into the JSON body). let body = serde_json::json!({ "clone_addr": upstream, "repo_owner": owner, "repo_name": repo, "mirror": true, // Periodic refresh instead of on-access sync — keeps CI isolated // from external DNS failures at clone time. "interval": MIRROR_INTERVAL, "service": "git", "private": false, }) .to_string(); let url = format!("{FORGE_HTTP}/api/v1/repos/migrate"); let (status, text) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?; match status.as_u16() { 201 => { tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror"); Ok(()) } // 409 = a race created it between our GET check and here (the GET // is the real idempotency guard). NOT 422: for the migrate endpoint // 422 is a validation error (bad clone_addr / service), so it must // surface via the bail arm, not be swallowed as "already exists". 409 => { tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)"); Ok(()) } other => { anyhow::bail!("POST /api/v1/repos/migrate {owner}/{repo} returned HTTP {other}: {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) } /// Provision the [`OPERATORS_TEAM`] inside [`AGENTS_ORG`] as an **empty** /// team. Branch protection on agents-org repos references it as the /// merge/approval whitelist; the operator adds herself as a member via the /// forge UI / hivectl. `includes_all_repositories` so the gate applies to /// every agent repo; `write` is enough to approve + merge. hive-c0re never /// manages membership. Idempotent (422/409 = already exists). async fn ensure_operators_team(token: &str) -> Result<()> { let url = format!("{FORGE_HTTP}/api/v1/orgs/{AGENTS_ORG}/teams"); let body = format!( r#"{{"name":"{OPERATORS_TEAM}","description":"hyperhive operators — merge gate for agent repos","permission":"write","includes_all_repositories":true,"can_create_org_repo":false}}"# ); let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; match status.as_u16() { 201 => { tracing::info!("forge: created {OPERATORS_TEAM} team in {AGENTS_ORG}"); Ok(()) } 409 | 422 => { tracing::debug!("forge: {OPERATORS_TEAM} team already exists"); Ok(()) } other => { anyhow::bail!("POST /orgs/{AGENTS_ORG}/teams ({OPERATORS_TEAM}) returned HTTP {other}") } } } /// Add `user` as a collaborator on `owner/repo` at `permission` /// (`read` / `write` / `admin`). Idempotent: 201 (added) and 204 (already a /// collaborator / permission updated) both count as success. async fn add_collaborator( owner: &str, repo: &str, user: &str, permission: &str, token: &str, ) -> Result<()> { let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}/collaborators/{user}"); let body = format!(r#"{{"permission":"{permission}"}}"#); let (status, _) = forge_http(reqwest::Method::PUT, &url, token, &body).await?; match status.as_u16() { 201 | 204 => { tracing::debug!(%owner, %repo, %user, %permission, "forge: collaborator set"); Ok(()) } other => { anyhow::bail!("PUT {owner}/{repo}/collaborators/{user} returned HTTP {other}") } } } /// Apply the operator merge-gate branch protection to `repo`'s default /// branch: only [`OPERATORS_TEAM`] members can merge, and an /// approving review from that team is required — so the author (a write-level /// agent, not in the team) cannot merge its own PR. Idempotent: an existing /// rule for the branch (200/409/422) is treated as success. async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> { let url = format!("{FORGE_HTTP}/api/v1/repos/{AGENTS_ORG}/{repo}/branch_protections"); let body = format!( r#"{{"branch_name":"main","enable_merge_whitelist":true,"merge_whitelist_teams":["{OPERATORS_TEAM}"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true}}"# ); let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; match status.as_u16() { 201 => { tracing::info!(%repo, "forge: applied operator branch protection"); Ok(()) } 200 | 409 | 422 => { tracing::debug!(%repo, "forge: branch protection already present"); Ok(()) } other => anyhow::bail!("POST {AGENTS_ORG}/{repo}/branch_protections returned HTTP {other}"), } } /// Apply branch protection to an `agent-configs/` repo's `main` so it /// can serve as the agent-editable, PR-merge config surface: /// - **push + merge whitelists are `core`-only** — the agent (a write /// collaborator) can push feature branches and open config PRs, but only /// hive-c0re lands on `main`, via its verify-and-ff-push merge handler /// (`run_merge_config_pr`). The agent can never push `main` directly. /// - **operator-team approval is required** to merge, and the author (not in /// the team) cannot self-approve. /// - **`enable_force_push` is `false`** — `main` only ever advances by /// fast-forward. The merge handler's `ff_push_to_main` is already a /// non-force push, so it lands fine. The legacy `push_config` mirror DOES /// force-push (it re-points status tags and rewinds `main` on a failed-build /// rollback), so the protection now rejects those non-ff updates — that /// mirror runs best-effort until the agent-opened PR-merge flow retires it. /// (Auto force-push is intentionally not allowed: per operator directive a /// silent force-push is a bug, not a feature.) /// /// Idempotent: an existing rule for the branch (200/409/422) is success. async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> { let url = format!("{FORGE_HTTP}/api/v1/repos/{CONFIG_ORG}/{repo}/branch_protections"); let body = format!( r#"{{"branch_name":"main","enable_push_whitelist":true,"push_whitelist_usernames":["core"],"enable_merge_whitelist":true,"merge_whitelist_usernames":["core"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true,"allow_manual_merge":true,"enable_force_push":false}}"# ); let (status, resp_body) = forge_http(reqwest::Method::POST, &url, token, &body).await?; if status.as_u16() == 201 { tracing::info!(%repo, "forge: applied config-repo branch protection"); return Ok(()); } // Non-201 is ambiguous: it can mean "rule already exists" (idempotent // success) OR a silent rejection — e.g. a 422 where Forgejo refused // the request and created NO rule. The old code treated 200/409/422 // all as success, so a rejected POST left the repo unprotected with // no error (the reported case: a new agent's config repo had no // `main` rule and nothing was logged). Don't trust the status code: // verify the `main` rule actually exists, and on failure surface the // POST's response body so the real reason is in the journal. let main_url = format!("{url}/main"); let (check, _) = forge_http(reqwest::Method::GET, &main_url, token, "").await?; if check.as_u16() == 200 { tracing::debug!(%repo, %status, "forge: config-repo branch protection already present"); Ok(()) } else { anyhow::bail!( "branch protection for {CONFIG_ORG}/{repo} not applied: POST -> HTTP {status} \ (body: {body}); GET main -> HTTP {check}, no `main` rule present", body = resp_body.trim(), ) } } /// Create a repo for `agent` in the c0re-owned [`AGENTS_ORG`] and wire the /// perms: the org owns it (perms stay c0re-managed), the agent is added /// as a **write** collaborator (not owner — can push + open PRs but can't /// bypass branch protection), and the default branch gets the operator /// merge gate. This is the sanctioned create path now that agents can't /// create repos directly (`max_repo_creation = 0`). Idempotent. pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result { ensure_org_repo(AGENTS_ORG, repo, core_token).await?; add_collaborator(AGENTS_ORG, repo, agent, "write", core_token).await?; apply_operator_branch_protection(repo, core_token).await?; tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate"); Ok(format!("{AGENTS_ORG}/{repo}")) } /// 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 the agents // org so branch protection can reference it before anyone joins //. The operator adds herself as a member out-of-band. if let Err(e) = ensure_operators_team(token).await { tracing::warn!(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; } } // --------------------------------------------------------------------------- // PR-based config-flow merge primitives (part of the // dashboard-approve-driven config-change flow). // // The dashboard-approve-driven flow has hive-c0re verify an operator-approved // config PR, then land it: ff-push the verified sha to the protected default // branch (= the merge) and mark the PR merged manually. These three fns are // the forge-side mechanics the c0re approve-handler (`run_merge_config_pr`) // orchestrates; the orchestration fetches the verified sha into the agent's // applied repo before calling `ff_push_to_main`. The core token is sourced // internally (`core_token`), never passed in. `repo` is the agent's editable // forge config repo in `owner/name` form (e.g. `agent-configs/`). // --------------------------------------------------------------------------- /// Typed failure for the merge primitives so the c0re approve-handler can /// `match` recoverable drift (refresh the request sha + re-verify) against a /// hard failure (fail the approval). The two drift variants carry the observed /// sha so the handler can re-pin to it; `Other` is everything else (transport, /// API, unexpected) and is not auto-retried. #[derive(Debug)] pub enum ForgeMergeError { /// The PR head moved off `expected` (now at `actual`), seen at /// mark-merged time. The handler's pre-merge `pr_head_sha` re-read is the /// primary drift gate; this is the belt-and-suspenders race catch. HeadDrift { expected: String, actual: String }, /// `main` (`actual_head`) is not a descendant of `expected_ancestor`, so /// pushing the verified sha would not be a fast-forward — `main` raced. NotFastForward { expected_ancestor: String, actual_head: String, }, /// Transport / API / unexpected failure — hard-fail, no auto-retry. Other(anyhow::Error), } impl std::fmt::Display for ForgeMergeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::HeadDrift { expected, actual } => { write!(f, "PR head drifted: expected {expected}, found {actual}") } Self::NotFastForward { expected_ancestor, actual_head, } => write!( f, "not a fast-forward: main {actual_head} is not a descendant of {expected_ancestor}" ), Self::Other(e) => write!(f, "{e}"), } } } impl std::error::Error for ForgeMergeError {} impl From for ForgeMergeError { fn from(e: anyhow::Error) -> Self { Self::Other(e) } } /// Agent name from an `owner/name` forge repo string (the trailing segment). fn repo_agent_name(repo: &str) -> &str { repo.rsplit('/').next().unwrap_or(repo) } /// Token-in-URL clone/push URL for a forge repo (`owner/name`). Mirrors /// `push_config`'s pattern; the token is passed straight to git and never /// stored as a named remote. fn tokenised_repo_url(repo: &str, token: &str) -> String { format!("http://core:{token}@localhost:3000/{repo}.git") } /// Resolve a PR's head sha via `git ls-remote refs/pull//head` /// (Forgejo exposes PR heads there). Pure read, no mutation — the handler's /// primary drift gate (compare against the approved sha), and `mark_pr_merged` /// uses it to detect head-drift on failure. /// /// # Errors /// `Other` on transport failure or an empty/missing ref. pub async fn pr_head_sha(repo: &str, pr: u64) -> Result { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; let url = tokenised_repo_url(repo, &token); let refspec = format!("refs/pull/{pr}/head"); let out = crate::lifecycle::git_command() .args(["ls-remote", &url, &refspec]) .output() .await .context("git ls-remote PR head")?; if !out.status.success() { return Err(ForgeMergeError::Other(anyhow::anyhow!( "git ls-remote {repo} {refspec} failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ))); } let stdout = String::from_utf8_lossy(&out.stdout); let sha = stdout .split_whitespace() .next() .filter(|s| !s.is_empty()) .ok_or_else(|| { ForgeMergeError::Other(anyhow::anyhow!( "no head ref for PR #{pr} in {repo} (ls-remote empty)" )) })?; Ok(sha.to_string()) } /// Full `owner/name` path of an agent's config repo on the forge — the /// `agent-configs` org mirror that the PR-merge flow reads + fast-forwards. pub fn config_repo(agent: &str) -> String { format!("{CONFIG_ORG}/{agent}") } /// Fetch PR #`pr`'s head into the agent's applied repo via /// `refs/pull//head` (which Forgejo always serves — a bare-sha fetch can /// be refused by uploadpack policy). This makes the reviewed head an object /// in the applied repo so the ancestor check in [`ff_push_to_main`], the /// `git_update_ref(main, …)` in the deploy tail, and the eval-verify all /// resolve it locally before the irreversible push. /// /// # Errors /// `Other` on transport failure or a non-zero git exit. pub async fn fetch_pr_head_into_applied(repo: &str, pr: u64) -> Result<(), ForgeMergeError> { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; let url = tokenised_repo_url(repo, &token); let applied = Coordinator::agent_applied_dir(repo_agent_name(repo)); let refspec = format!("refs/pull/{pr}/head"); let out = crate::lifecycle::git_command() .current_dir(&applied) .args(["fetch", "--no-tags", &url, &refspec]) .output() .await .context("git fetch PR head into applied")?; if !out.status.success() { return Err(ForgeMergeError::Other(anyhow::anyhow!( "git fetch {repo} {refspec} into applied failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ))); } Ok(()) } /// Fast-forward the forge repo's `main` to `sha` — THE merge in the PR flow. /// Reads `main`'s current sha (`git ls-remote … refs/heads/main`), verifies it /// is a strict ancestor of `sha` (`git merge-base --is-ancestor`, run in the /// agent's applied repo where the orchestration has already fetched `sha`), /// then does a **non-force** `git push :refs/heads/main`. The ancestor /// pre-check and the non-force push are two independent guards: either catches /// a raced `main` (→ `NotFastForward`) rather than clobbering reviewed history. /// /// # Errors /// `NotFastForward` if `main` raced ahead of `sha`; `Other` on transport/other. pub async fn ff_push_to_main(repo: &str, sha: &str) -> Result<(), ForgeMergeError> { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; let url = tokenised_repo_url(repo, &token); let applied = Coordinator::agent_applied_dir(repo_agent_name(repo)); // Current `main` on the forge repo. let ls = crate::lifecycle::git_command() .args(["ls-remote", &url, "refs/heads/main"]) .output() .await .context("git ls-remote main")?; if !ls.status.success() { return Err(ForgeMergeError::Other(anyhow::anyhow!( "git ls-remote {repo} refs/heads/main failed ({}): {}", ls.status, String::from_utf8_lossy(&ls.stderr).trim() ))); } let ls_out = String::from_utf8_lossy(&ls.stdout); let main_sha = ls_out.split_whitespace().next().unwrap_or("").to_string(); // Strict-ancestor check: `main` must be an ancestor of `sha` for a true // fast-forward. Skip when `main` is unborn (empty) — the push creates it. if !main_sha.is_empty() { let anc = crate::lifecycle::git_command() .current_dir(&applied) .args(["merge-base", "--is-ancestor", &main_sha, sha]) .output() .await .context("git merge-base --is-ancestor")?; match anc.status.code() { Some(0) => {} // ancestor → fast-forward safe Some(1) => { return Err(ForgeMergeError::NotFastForward { expected_ancestor: main_sha, actual_head: sha.to_string(), }); } _ => { return Err(ForgeMergeError::Other(anyhow::anyhow!( "git merge-base --is-ancestor errored ({}): {}", anc.status, String::from_utf8_lossy(&anc.stderr).trim() ))); } } } // Non-force push `sha` → `main`. Without `--force`, git rejects a // non-fast-forward (a race between the check above and now), surfaced as // `NotFastForward` rather than clobbering the remote. let push = crate::lifecycle::git_command() .current_dir(&applied) .args(["push", &url, &format!("{sha}:refs/heads/main")]) .output() .await .context("git push sha:main")?; if !push.status.success() { let stderr = String::from_utf8_lossy(&push.stderr); if stderr.contains("non-fast-forward") || stderr.contains("fetch first") { return Err(ForgeMergeError::NotFastForward { expected_ancestor: main_sha, actual_head: sha.to_string(), }); } return Err(ForgeMergeError::Other(anyhow::anyhow!( "git push {repo} {sha}:main failed ({}): {}", push.status, stderr.trim() ))); } Ok(()) } /// Mark PR `pr` as **manually merged** at `sha` (Forgejo /// `POST …/pulls/{pr}/merge` with `Do=manually-merged`, `MergeCommitID=sha`). /// `ff_push_to_main` must have already set `main` to `sha` (Forgejo requires /// the branch already be at the merge commit). On a non-2xx, re-reads the PR /// head to distinguish drift (`HeadDrift`) from a generic failure (`Other`) — /// best-effort, since the handler's pre-merge head re-read is the real gate. /// /// # Errors /// `HeadDrift` if the PR head no longer matches `sha`; `Other` otherwise. pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; let url = format!("{FORGE_HTTP}/api/v1/repos/{repo}/pulls/{pr}/merge"); let body = format!(r#"{{"Do":"manually-merged","MergeCommitID":"{sha}"}}"#); let (status, _) = forge_http(reqwest::Method::POST, &url, &token, &body) .await .context("POST pulls//merge (manually-merged)")?; if status.is_success() { return Ok(()); } // Best-effort drift detection: if the live head no longer matches `sha`, // that's a head-drift race; otherwise surface as a hard failure. match pr_head_sha(repo, pr).await { Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift { expected: sha.to_string(), actual, }), _ => Err(ForgeMergeError::Other(anyhow::anyhow!( "mark PR #{pr} in {repo} manually-merged at {sha} failed: HTTP {status}" ))), } } #[cfg(test)] mod tests { use super::{CoreTokenCheck, classify_core_token_status, repo_agent_name, tokenised_repo_url}; use reqwest::StatusCode; #[test] fn success_statuses_are_valid() { assert_eq!( classify_core_token_status(StatusCode::OK), CoreTokenCheck::Valid ); assert_eq!( classify_core_token_status(StatusCode::NO_CONTENT), CoreTokenCheck::Valid ); } #[test] fn auth_rejection_statuses_are_invalid() { // The whole point: a stale token (forge rebuilt out from under it) // 401s, and 401/403 are the only outcomes that trigger a re-mint. assert_eq!( classify_core_token_status(StatusCode::UNAUTHORIZED), CoreTokenCheck::Invalid ); assert_eq!( classify_core_token_status(StatusCode::FORBIDDEN), CoreTokenCheck::Invalid ); } #[test] fn transient_and_unexpected_statuses_are_indeterminate() { // Never re-mint on a transient — minting needs the forge too, and // churning tokens on a blip is worse than keeping the existing one. for s in [ StatusCode::INTERNAL_SERVER_ERROR, StatusCode::BAD_GATEWAY, StatusCode::SERVICE_UNAVAILABLE, StatusCode::GATEWAY_TIMEOUT, StatusCode::NOT_FOUND, ] { assert_eq!( classify_core_token_status(s), CoreTokenCheck::Indeterminate, "status {s} should be indeterminate" ); } } #[test] fn repo_agent_name_takes_trailing_segment() { assert_eq!(repo_agent_name("agent-configs/atlas"), "atlas"); assert_eq!(repo_agent_name("atlas"), "atlas"); assert_eq!(repo_agent_name("a/b/c"), "c"); } #[test] fn tokenised_repo_url_shape() { assert_eq!( tokenised_repo_url("agent-configs/iris", "tok"), "http://core:tok@localhost:3000/agent-configs/iris.git" ); } }