From a17015f01eff7eba4df829b54c929d84458c65d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Mon, 6 Jul 2026 22:08:22 +0200 Subject: [PATCH] refactor(hive-c0re): split forge into submodules mod.rs keeps the shared admin/http helpers + ensure_all/sync_agent; user/token provisioning, repo/org/mirror ops, and the trust-boundary PR-merge primitives move to users.rs / repos.rs / pr_merge.rs --- hive-c0re/src/forge/mod.rs | 1427 +------------------------------ hive-c0re/src/forge/pr_merge.rs | 295 +++++++ hive-c0re/src/forge/repos.rs | 621 ++++++++++++++ hive-c0re/src/forge/users.rs | 534 ++++++++++++ 4 files changed, 1468 insertions(+), 1409 deletions(-) create mode 100644 hive-c0re/src/forge/pr_merge.rs create mode 100644 hive-c0re/src/forge/repos.rs create mode 100644 hive-c0re/src/forge/users.rs diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index fe44824b..f5d70daa 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -4,32 +4,31 @@ //! 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; +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 base64::Engine; use reqwest::StatusCode; -use tokio::process::Command; -use crate::coordinator::Coordinator; +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"; -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 — @@ -79,16 +78,6 @@ 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 @@ -116,24 +105,6 @@ async fn forge_admin(args: &[&str]) -> Result { 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 @@ -164,891 +135,6 @@ async fn forge_http( 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 @@ -1059,152 +145,6 @@ pub fn is_hive_managed_namespace(ns: &str) -> bool { HIVE_MANAGED_NAMESPACES.contains(&ns) } -/// Provision the [`OPERATORS_TEAM`] inside `org` as an **empty** team. -/// Branch protection on that org's 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 repo in the org; `write` is enough to approve + merge. hive-c0re -/// never manages membership. Idempotent (422/409 = already exists). -/// -/// Must run for BOTH [`AGENTS_ORG`] and [`CONFIG_ORG`]: Gitea teams are -/// org-scoped, so a config-repo branch-protection rule referencing -/// `operators` needs the team to exist in `agent-configs` too. Missing it -/// there 422'd every `apply_config_repo_branch_protection`, leaving config -/// repos unprotected — operator-merged config PRs then bypassed the deploy -/// pipeline and silently didn't apply. -async fn ensure_operators_team(org: &str, token: &str) -> Result<()> { - let url = format!("{FORGE_HTTP}/api/v1/orgs/{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!(%org, "forge: created {OPERATORS_TEAM} team"); - Ok(()) - } - 409 | 422 => { - tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists"); - Ok(()) - } - other => { - anyhow::bail!("POST /orgs/{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 @@ -1338,334 +278,3 @@ pub async fn ensure_all() { 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" - ); - } -} diff --git a/hive-c0re/src/forge/pr_merge.rs b/hive-c0re/src/forge/pr_merge.rs new file mode 100644 index 00000000..02133085 --- /dev/null +++ b/hive-c0re/src/forge/pr_merge.rs @@ -0,0 +1,295 @@ +//! PR-based config-flow merge primitives — the forge-side mechanics +//! hive-c0re's approve-handler (`run_merge_config_pr`) orchestrates to +//! land an operator-approved config PR. Part of the operator trust +//! boundary; moved verbatim from the `forge` module root. + +use anyhow::Context; + +use crate::coordinator::Coordinator; + +use super::{CONFIG_ORG, FORGE_HTTP, core_token, forge_http}; + +// --------------------------------------------------------------------------- +// 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::{repo_agent_name, tokenised_repo_url}; + + #[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" + ); + } +} diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs new file mode 100644 index 00000000..d5fce8f5 --- /dev/null +++ b/hive-c0re/src/forge/repos.rs @@ -0,0 +1,621 @@ +//! Repo + org plumbing on the local Forgejo: org / repo creation, +//! the meta + shared-docs + knowledge repos, per-agent config-repo +//! mirroring (`push_config` / `push_meta`), collaborator grants, +//! pull-mirrors, and branch-protection rules. Shared HTTP helpers + +//! org-name constants live in the module root (`super`). + +use std::path::Path; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +use crate::coordinator::Coordinator; + +use super::{ + AGENTS_ORG, CONFIG_ORG, FORGE_HTTP, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO, + SHARED_ORG, core_token, forge_http, is_present, +}; + +/// 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 +} + +/// 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. +pub(super) 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. +pub(super) 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}") + } + } +} + +/// Provision the [`OPERATORS_TEAM`] inside `org` as an **empty** team. +/// Branch protection on that org's 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 repo in the org; `write` is enough to approve + merge. hive-c0re +/// never manages membership. Idempotent (422/409 = already exists). +/// +/// Must run for BOTH [`AGENTS_ORG`] and [`CONFIG_ORG`]: Gitea teams are +/// org-scoped, so a config-repo branch-protection rule referencing +/// `operators` needs the team to exist in `agent-configs` too. Missing it +/// there 422'd every `apply_config_repo_branch_protection`, leaving config +/// repos unprotected — operator-merged config PRs then bypassed the deploy +/// pipeline and silently didn't apply. +pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> { + let url = format!("{FORGE_HTTP}/api/v1/orgs/{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!(%org, "forge: created {OPERATORS_TEAM} team"); + Ok(()) + } + 409 | 422 => { + tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists"); + Ok(()) + } + other => { + anyhow::bail!("POST /orgs/{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}")) +} diff --git a/hive-c0re/src/forge/users.rs b/hive-c0re/src/forge/users.rs new file mode 100644 index 00000000..38bc382b --- /dev/null +++ b/hive-c0re/src/forge/users.rs @@ -0,0 +1,534 @@ +//! Per-agent Forgejo user + access-token provisioning, account +//! policy (email alignment, repo-creation lockdown), avatar uploads, +//! and the bootstrap `core` admin user + token lifecycle. Shared +//! HTTP / `forgejo admin` helpers live in the module root (`super`). + +use std::path::Path; + +use anyhow::{Context, Result}; +use base64::Engine; +use reqwest::StatusCode; + +use super::{CONFIG_ORG, FORGE_HTTP, forge_admin, forge_http, is_present}; + +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. +/// 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"; + +/// 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") +} + +/// 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). +pub(super) 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. +pub(super) 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. +pub(super) 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. +pub(super) 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. +pub(super) 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()) +} + +/// 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()) +} + +#[cfg(test)] +mod tests { + use super::{CoreTokenCheck, classify_core_token_status}; + 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" + ); + } + } +}