hyperhive/hive-c0re/src/forge/repos.rs
atlas 44572d1e1a fix(#2911): keep the forge token out of argv
`forge_git_url` spliced `core:<token>@` between scheme and authority, and
that URL is a process argument. `/proc/<pid>/cmdline` is mode 0444 —
world-readable — so the core admin token, which provisions every agent's
forge account, was published to any local user for the lifetime of each
git child. Seven call sites built such a URL.

The credential now travels in the environment instead:
`git_command_authed` sets `http.extraHeader` via `GIT_CONFIG_*`, which
git reads exactly like a config file, and `/proc/<pid>/environ` is 0400 —
owner-only. Same credential, materially smaller audience. The remote is a
plain `http://forge/<org>/<repo>.git`, and `forge_git_url` no longer takes
a token, so the old shape cannot be rebuilt by accident.

`knowledge`'s clone was the one place a credentialed URL was stored as a
named remote — git persists the clone URL into `.git/config`, so the
token sat on disk and every later `pull` authenticated from there. That
is the case `forge::repos::push_config` documents as forbidden ("the
tokenised URL ... deliberately never stored as a named remote"). `pull`
now rewrites `origin` to the plain URL first, which also scrubs the
persisted token from existing deployments, and authenticates from the
environment when a token is available. The repo is public, so the pull
still works without one.

Three call sites also stopped spawning `Command::new("git")` directly,
so they honour the `HYPERHIVE_GIT` path the NixOS module bakes in and
the `kill_on_drop` every other git spawn gets.

The two URL-shape tests now assert the *absence* of a credential, and a
new one decodes the header back to `core:<token>` — without that, a
malformed header would leave every forge operation silently anonymous
with the other assertions still green.
2026-08-02 13:21:42 +02:00

1054 lines
44 KiB
Rust

//! 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. The typed API-client
//! constructor + org-name constants live in the module root (`super`).
use std::path::Path;
use std::sync::Mutex;
use anyhow::{Context, Result};
use forgejo_api::structs::{
AddCollaboratorOption, AddCollaboratorOptionPermission, CreateBranchProtectionOption,
CreateOrgOption, CreateRepoOption, CreateTeamOption, CreateTeamOptionPermission,
EditBranchProtectionOption, EditRepoOption, EditTeamOption, EditTeamOptionPermission,
MigrateRepoOptions, MigrateRepoOptionsService, Repository,
};
use forgejo_api::{ApiErrorKind, ForgejoError};
use reqwest::StatusCode;
use crate::coordinator::Coordinator;
use super::{
AGENTS_ORG, CONFIG_ORG, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO, SHARED_ORG, api,
core_auth_header, core_token, forge_git_url, forge_http_base, is_present,
};
/// Creation options for an empty repo defaulting to `main`.
fn repo_option(name: &str, private: bool) -> CreateRepoOption {
CreateRepoOption {
auto_init: Some(false),
default_branch: Some("main".to_owned()),
description: None,
gitignores: None,
issue_labels: None,
license: None,
name: name.to_owned(),
object_format_name: None,
private: Some(private),
readme: None,
template: None,
trust_model: None,
}
}
/// `EditRepoOption` with every field unset — repo edits only ever
/// change the one field the caller sets on top (Forgejo leaves `None`
/// fields untouched).
fn sparse_edit_repo_option() -> EditRepoOption {
EditRepoOption {
allow_fast_forward_only_merge: None,
allow_manual_merge: None,
allow_merge_commits: None,
allow_rebase: None,
allow_rebase_explicit: None,
allow_rebase_update: None,
allow_squash_merge: None,
archived: None,
autodetect_manual_merge: None,
default_allow_maintainer_edit: None,
default_branch: None,
default_delete_branch_after_merge: None,
default_merge_style: None,
default_update_style: None,
description: None,
enable_prune: None,
external_tracker: None,
external_wiki: None,
globally_editable_wiki: None,
has_actions: None,
has_issues: None,
has_packages: None,
has_projects: None,
has_pull_requests: None,
has_releases: None,
has_wiki: None,
ignore_whitespace_conflicts: None,
internal_tracker: None,
mirror_interval: None,
name: None,
private: None,
template: None,
website: None,
wiki_branch: None,
}
}
/// Whether a create-style call failed because the object already
/// exists. Forgejo signals this as HTTP 409 (conflict) or 422
/// (validation). The typed client surfaces those as
/// `ApiErrorKind::Other(409)` / `ValidationFailed` when the endpoint
/// spec lists the status, or as a bare `UnexpectedStatusCode`
/// otherwise — match all shapes defensively.
fn is_already_exists(e: &ForgejoError) -> bool {
match e {
ForgejoError::ApiError(api) => match api.error_kind() {
ApiErrorKind::ValidationFailed => true,
ApiErrorKind::Other(s) => *s == StatusCode::CONFLICT,
_ => false,
},
ForgejoError::UnexpectedStatusCode(s) => {
*s == StatusCode::CONFLICT || *s == StatusCode::UNPROCESSABLE_ENTITY
}
_ => false,
}
}
/// Whether an error is specifically an HTTP 409 conflict (and NOT a
/// 422): the migrate endpoint's 422 is a validation error (bad
/// `clone_addr` / service) and must surface, so it can't share
/// [`is_already_exists`]'s 422 tolerance.
fn is_conflict(e: &ForgejoError) -> bool {
match e {
ForgejoError::ApiError(api) => {
matches!(api.error_kind(), ApiErrorKind::Other(s) if *s == StatusCode::CONFLICT)
}
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::CONFLICT,
_ => false,
}
}
/// Whether a rendered error message names the "already exists" case. The
/// discriminator that tells a *benign* already-exists 422 apart from a
/// *real* validation 422 (invalid units, etc.). Case-insensitive.
fn message_says_already_exists(rendered: &str) -> bool {
rendered.to_lowercase().contains("already exists")
}
/// Whether `e` is Forgejo saying the resource already exists — matching
/// the 409 conflict shape *and* the 422 shape this Forgejo build actually
/// returns for a duplicate team: `validation failed: team already exists`.
/// A 409-only [`is_conflict`] check misses that 422, so the caller fires
/// a spurious "provisioning failed" warning every boot and skips its
/// settings reconcile. This still surfaces *other* 422s (bad request
/// body) as real failures — only a 422 whose message names the
/// already-exists case is folded in.
fn is_already_exists_lenient(e: &ForgejoError) -> bool {
if is_conflict(e) {
return true;
}
let is_unprocessable = match e {
ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::ValidationFailed),
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::UNPROCESSABLE_ENTITY,
_ => false,
};
is_unprocessable && message_says_already_exists(&e.to_string())
}
/// Whether an error is Forgejo saying 404 — the resource is absent,
/// as opposed to a transport / auth / server failure.
fn is_not_found(e: &ForgejoError) -> bool {
match e {
ForgejoError::ApiError(api) => {
matches!(api.error_kind(), ApiErrorKind::NotFound { .. })
}
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND,
_ => false,
}
}
/// Fold a repo-creation result's "already exists" (409 / 422) into
/// success. `label` is `<owner>/<name>` — purely for log + error
/// context.
fn created_or_exists(res: Result<Repository, ForgejoError>, label: &str) -> Result<()> {
match res {
Ok(_) => {
tracing::info!(%label, "forge: created repo");
Ok(())
}
Err(e) if is_already_exists(&e) => {
tracing::debug!(%label, "forge: repo already exists");
Ok(())
}
Err(e) => Err(e).with_context(|| format!("create repo {label}")),
}
}
/// 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 mut edit = sparse_edit_repo_option();
edit.private = Some(false);
api(token)?
.repo_edit(owner, repo, edit)
.await
.with_context(|| format!("edit {owner}/{repo} (set public)"))?;
tracing::debug!(%owner, %repo, "forge: repo set to public");
Ok(())
}
/// Create `name` inside org `org` as a public repo. Idempotent.
async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> {
let res = api(token)?
.create_org_repo(org, repo_option(name, false))
.await;
created_or_exists(res, &format!("{org}/{name}"))
}
/// 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<()> {
let res = api(token)?
.create_current_user_repo(repo_option(name, true))
.await;
created_or_exists(res, &format!("core/{name}"))
}
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
/// Idempotent.
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
let res = api(token)?
.create_org_repo(org, repo_option(name, true))
.await;
created_or_exists(res, &format!("{org}/{name}"))
}
/// Dashboard-warning guard for the meta non-fast-forward condition.
/// Held while `core/meta` remote is ahead of our local mirror; cleared
/// automatically when the next push succeeds.
static META_NON_FF_GUARD: Mutex<Option<crate::warnings::WarningGuard>> = Mutex::new(None);
/// 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:<token>` or just
// any-username:<token>; using `core` matches the owner so the
// remote name is self-describing.
//
// No --force: `meta.rs` uses regular `git commit` (append-only),
// so the push is always fast-forward in normal operation. Per
// operator directive, hive-c0re must not force-push anywhere. If
// the remote is somehow ahead (e.g. split-brain or manual push) we
// raise a dashboard warning and leave the remote intact rather than
// silently erasing its history — consistent with `push_config`'s
// non-fast-forward handling.
let url = forge_git_url("core/meta");
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
.current_dir(dir)
.args(["push", &url, "HEAD:main"])
.output()
.await
.context("invoke git push core/meta")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("non-fast-forward") || stderr.contains("fetch first") {
let msg = "forge/core/meta push rejected (non-fast-forward): \
remote is ahead of local; leaving remote history intact";
tracing::warn!("{msg}");
// Raise a persistent dashboard banner; cleared on next successful push.
if let Ok(mut g) = META_NON_FF_GUARD.lock() {
*g = Some(crate::warnings::set_warning(
"forge-meta-non-ff",
"warn",
msg,
));
}
return Ok(());
}
anyhow::bail!(
"git push core/meta failed ({}): {}",
out.status,
stderr.trim()
);
}
// Successful push: clear any outstanding non-ff warning.
if let Ok(mut g) = META_NON_FF_GUARD.lock() {
*g = None;
}
tracing::info!("forge: pushed meta to core/meta");
Ok(())
}
/// Ensure the `agent-configs/<name>` 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,
AddCollaboratorOptionPermission::Write,
&token,
)
.await?;
// Protect `main` core-only, fast-forward-only (no auto force-push). A
// failure here is security-relevant (an unprotected config repo lets an
// operator-merged config PR bypass the deploy pipeline), so it's tracked
// on the dashboard banner in addition to the journal warning callers
// already log — see `record_branch_protection_result`.
let result = apply_config_repo_branch_protection(name, &token).await;
record_branch_protection_result(name, result.is_ok());
result
}
/// Dashboard-banner tracker for [`apply_config_repo_branch_protection`]
/// failures. The registry ([`crate::warnings::set_warning`]) only takes
/// `&'static str` kinds, so a dynamic per-agent key isn't possible — instead
/// this keeps one static `crit` warning (`"branch_protection_missing"`) whose
/// message lists every agent currently failing to protect, and clears it
/// once the set is empty. Called on every `ensure_config_repo` pass (startup
/// sweep + per-rebuild), so a fixed agent drops out of the message on its
/// next successful sweep without requiring a restart.
fn record_branch_protection_result(name: &str, ok: bool) {
use std::collections::BTreeSet;
use std::sync::{OnceLock, PoisonError};
use crate::warnings::{WarningGuard, set_warning};
static FAILING: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
static GUARD: OnceLock<Mutex<Option<WarningGuard>>> = OnceLock::new();
let mut failing = FAILING
.get_or_init(|| Mutex::new(BTreeSet::new()))
.lock()
.unwrap_or_else(PoisonError::into_inner);
if ok {
failing.remove(name);
} else {
failing.insert(name.to_owned());
}
let mut guard = GUARD
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(PoisonError::into_inner);
if failing.is_empty() {
*guard = None;
return;
}
let names = failing.iter().cloned().collect::<Vec<_>>().join(", ");
let message = format!(
"config-repo branch protection not applied for: {names}\
operator-merged config PRs for these agents could bypass the deploy pipeline"
);
match guard.as_ref() {
Some(g) => g.update("crit", message),
None => *guard = Some(set_warning("branch_protection_missing", "crit", message)),
}
}
/// 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: re-adding an existing collaborator succeeds (Forgejo
/// answers 204 either way). 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<()> {
add_collaborator(
SHARED_ORG,
SHARED_DOCS_REPO,
name,
AddCollaboratorOptionPermission::Read,
core_token,
)
.await?;
tracing::info!(%name, "forge: granted shared-docs read access");
Ok(())
}
/// 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:
/// re-adding an existing collaborator succeeds (Forgejo answers 204
/// either way).
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
add_collaborator(
"core",
"meta",
name,
AddCollaboratorOptionPermission::Read,
core_token,
)
.await?;
tracing::info!(%name, "forge: granted meta read access");
Ok(())
}
/// Add the forge `core/meta.git` URL 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(());
}
// A malformed name has no proposed config repo (repos are only created
// under a validated Ident), so there's nothing to wire — no-op.
let Ok(agent) = hive_types::Ident::parse(name) else {
return Ok(());
};
let proposed_dir = Coordinator::agent_proposed_dir(&agent);
if !proposed_dir.join(".git").exists() {
return Ok(());
}
let want = format!("{}/core/meta.git", forge_http_base());
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 — every status tag
/// (`proposal` / `approved` / `building` / `deployed` / `failed` /
/// `denied`) plus a best-effort `main` — to `agent-configs/<name>` 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.
///
/// Call this after every hive-c0re mutation of an applied repo's refs
/// so the forge copy always reflects what core actually did.
///
/// **Two separate pushes, not one.** The status tags are id-suffixed
/// (`deployed/<id>`, …) and add-only, so they must always land. `main`,
/// by contrast, is core-only branch-protected and authoritatively
/// advanced by `pr_merge`'s ff-only merge API — so a mirror push of an
/// already-established `main` is routinely rejected (protected-branch,
/// or non-fast-forward after a rolled-back deploy). Git's pre-receive
/// hook is all-or-nothing: bundling both refspecs in one push means that
/// `main` reject declines the whole push, dropping the tags too.
/// So we push the tags on their own first, then attempt `main`
/// separately and swallow the expected reject. The `main` push still
/// matters for the initial seed of a fresh (empty) config repo, where it
/// creates `main`. Operator-driven divergence fix: the `hivectl forge
/// reconcile-config` command.
///
/// 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 = crate::paths::applied_dir(name);
if !dir.join(".git").exists() {
return Ok(());
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
let auth = core_auth_header(&token);
// Tags first, in their own push, so they land regardless of main's fate.
let out = run_config_push(&dir, &url, &auth, "refs/tags/*:refs/tags/*").await?;
if !out.status.success() {
anyhow::bail!(
"git push tags {CONFIG_ORG}/{name} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
// Then main on its own — a protected-branch / non-ff reject of an
// established main is expected (pr_merge owns main); only the initial
// empty-repo seed actually advances it here.
let out = run_config_push(&dir, &url, &auth, "refs/heads/main:refs/heads/main").await?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("non-fast-forward")
|| stderr.contains("protected branch")
|| stderr.contains("pre-receive hook declined")
{
tracing::info!(
%name,
"forge: mirror push of main skipped — forge main is protected / \
owned by the config-PR ff-merge (expected); tags mirrored"
);
return Ok(());
}
anyhow::bail!(
"git push main {CONFIG_ORG}/{name} failed ({}): {}",
out.status,
stderr.trim()
);
}
tracing::info!(%name, "forge: mirrored applied config to agent-configs");
Ok(())
}
/// Run a single `git push <url> <refspec>` in the applied repo `dir` and
/// return the raw output for the caller to classify. Split out so
/// [`push_config`] can push tags and `main` as independent pushes.
async fn run_config_push(
dir: &std::path::Path,
url: &str,
auth: &str,
refspec: &str,
) -> Result<std::process::Output> {
crate::lifecycle::git_command_authed(auth)
.current_dir(dir)
.args(["push", url, refspec])
.output()
.await
.context("invoke git push agent-configs")
}
/// Create an org named `name` (`org_create`). Idempotent: HTTP 422
/// ("user already exists") / 409 is treated as success.
pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
let org = CreateOrgOption {
description: None,
email: None,
full_name: None,
location: None,
repo_admin_change_team_access: None,
username: name.to_owned(),
visibility: None,
website: None,
};
match api(admin_token)?.org_create(org).await {
Ok(_) => {
tracing::info!(%name, "forge: created org");
Ok(())
}
Err(e) if is_already_exists(&e) => {
tracing::debug!(%name, "forge: org already exists");
Ok(())
}
Err(e) => Err(e).with_context(|| format!("create org {name}")),
}
}
/// 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 `<owner>/<repo>` 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<Mirror> = 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 <owner>/<repo>; 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
/// call (a race between the existence check and the migrate) is also
/// success.
async fn ensure_mirror_repo(
upstream: &str,
owner: &str,
repo: &str,
admin_token: &str,
) -> Result<()> {
let client = api(admin_token)?;
match client.repo_get(owner, repo).await {
Ok(_) => {
// Mirror already present. Patch interval so mirrors seeded before
// this field was introduced (or with a different value) converge.
let mut edit = sparse_edit_repo_option();
edit.mirror_interval = Some(MIRROR_INTERVAL.to_owned());
match client.repo_edit(owner, repo, edit).await {
Ok(_) => {
tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated");
}
Err(e) => {
tracing::warn!(
%owner, %repo, error = %e,
"forge: failed to set mirror_interval on existing pull-mirror"
);
}
}
return Ok(());
}
// Absent — fall through to migrate.
Err(e) if is_not_found(&e) => {}
// Anything else (transport, auth, 5xx) leaves the repo's existence
// unknown: migrating anyway would fold a 409 into success and skip
// the interval patch this pass. Surface it instead.
Err(e) => {
return Err(e).with_context(|| format!("get pull-mirror {owner}/{repo}"));
}
}
let opts = MigrateRepoOptions {
auth_password: None,
auth_token: None,
auth_username: None,
clone_addr: upstream.to_owned(),
description: None,
issues: None,
labels: None,
lfs: None,
lfs_endpoint: None,
milestones: None,
mirror: Some(true),
// Periodic refresh instead of on-access sync — keeps CI isolated
// from external DNS failures at clone time.
mirror_interval: Some(MIRROR_INTERVAL.to_owned()),
private: Some(false),
pull_requests: None,
releases: None,
repo_name: repo.to_owned(),
repo_owner: Some(owner.to_owned()),
service: Some(MigrateRepoOptionsService::Git),
uid: None,
wiki: None,
};
match client.repo_migrate(opts).await {
Ok(_) => {
tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror");
Ok(())
}
// 409 = a race created it between our existence check and here (the
// check 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 error arm, not be swallowed as "already
// exists" — hence `is_conflict`, not `is_already_exists`.
Err(e) if is_conflict(&e) => {
tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)");
Ok(())
}
Err(e) => Err(e).with_context(|| format!("migrate pull-mirror {owner}/{repo}")),
}
}
/// 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 (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.
///
/// Uses `is_conflict` (409 only) — NOT `is_already_exists` (which also
/// folds 422 into "already exists"). A 422 from `org_create_team` is a
/// real validation error (bad request shape, missing units, etc.) that
/// must surface so it can be fixed; the previous 422-swallowing hid the
/// true cause and left the team silently uncreated every boot.
/// Repo-unit access flags for the `operators` team.
/// Explicit list so Forgejo doesn't reject a null/absent `units` field;
/// a `write`-permission team needs at least `repo.code` + `repo.pulls`
/// to review and merge PRs.
const OPERATORS_TEAM_UNITS: &[&str] = &[
"repo.code",
"repo.issues",
"repo.pulls",
"repo.releases",
"repo.wiki",
"repo.projects",
"repo.packages",
];
pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
let units_vec: Vec<String> = OPERATORS_TEAM_UNITS.iter().map(|&s| s.to_owned()).collect();
let team = CreateTeamOption {
can_create_org_repo: Some(false),
description: Some("hyperhive operators — merge gate for agent repos".to_owned()),
includes_all_repositories: Some(true),
name: OPERATORS_TEAM.to_owned(),
permission: Some(CreateTeamOptionPermission::Write),
units: Some(units_vec.clone()),
units_map: None,
};
let client = api(token)?;
match client.org_create_team(org, team).await {
Ok(_) => {
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
Ok(())
}
// Team already exists — reconcile settings to desired state so a
// team created with an older/wrong shape self-heals on next boot.
// Forgejo signals the duplicate as a 409 conflict OR (this build) a
// 422 `validation failed: team already exists`; both mean the same
// thing, so fold both in via `is_already_exists_lenient` — a 409-only
// check missed the 422 and warned every boot. List teams to find the
// id (required by org_edit_team), then unconditionally PATCH to the
// desired settings. Members are a separate endpoint; untouched here.
Err(e) if is_already_exists_lenient(&e) => {
let (_headers, teams) = client
.org_list_teams(org)
.await
.with_context(|| format!("list teams for {org}"))?;
let team_id = teams
.into_iter()
.find(|t| t.name.as_deref() == Some(OPERATORS_TEAM))
.and_then(|t| t.id)
.with_context(|| {
format!("{OPERATORS_TEAM} team not found in {org} after 409 conflict")
})?;
let edit = EditTeamOption {
can_create_org_repo: Some(false),
description: Some("hyperhive operators — merge gate for agent repos".to_owned()),
includes_all_repositories: Some(true),
name: OPERATORS_TEAM.to_owned(),
permission: Some(EditTeamOptionPermission::Write),
units: Some(units_vec),
units_map: None,
};
client
.org_edit_team(team_id, edit)
.await
.with_context(|| format!("reconcile {org}/{OPERATORS_TEAM} team settings"))?;
tracing::debug!(%org, "forge: reconciled {OPERATORS_TEAM} team settings");
Ok(())
}
// Any OTHER error (a 422 that is NOT already-exists, or a transport
// / auth failure): surface it — don't mask a real failure. A
// non-already-exists 422 means the request body is invalid (e.g.
// Forgejo rejected the units list); it repeats every boot until fixed.
Err(e) => Err(e).with_context(|| format!("create team {org}/{OPERATORS_TEAM}")),
}
}
/// Add `user` as a collaborator on `owner/repo` at `permission`.
/// Idempotent: adding an existing collaborator just updates its
/// permission (Forgejo answers 204 either way; a 201 from older
/// versions is tolerated defensively).
async fn add_collaborator(
owner: &str,
repo: &str,
user: &str,
permission: AddCollaboratorOptionPermission,
token: &str,
) -> Result<()> {
let res = api(token)?
.repo_add_collaborator(
owner,
repo,
user,
AddCollaboratorOption {
permission: Some(permission),
},
)
.await;
match res {
Ok(()) => {}
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {}
Err(e) => {
return Err(e).with_context(|| format!("add collaborator {user} to {owner}/{repo}"));
}
}
tracing::debug!(%owner, %repo, %user, ?permission, "forge: collaborator set");
Ok(())
}
/// `CreateBranchProtectionOption` protecting `main` with every other
/// field unset — Forgejo treats `None` fields as their defaults, same
/// as the sparse JSON bodies the raw-HTTP predecessor sent. Callers
/// set the whitelist/approval fields they need on top.
fn main_branch_protection_option() -> CreateBranchProtectionOption {
CreateBranchProtectionOption {
apply_to_admins: None,
approvals_whitelist_teams: None,
approvals_whitelist_username: None,
block_on_official_review_requests: None,
block_on_outdated_branch: None,
block_on_rejected_reviews: None,
branch_name: Some("main".to_owned()),
dismiss_stale_approvals: None,
enable_approvals_whitelist: None,
enable_merge_whitelist: None,
enable_push: None,
enable_push_whitelist: None,
enable_status_check: None,
ignore_stale_approvals: None,
merge_whitelist_teams: None,
merge_whitelist_usernames: None,
protected_file_patterns: None,
push_whitelist_deploy_keys: None,
push_whitelist_teams: None,
push_whitelist_usernames: None,
require_signed_commits: None,
required_approvals: None,
rule_name: None,
status_check_contexts: None,
unprotected_file_patterns: None,
}
}
/// 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, but **verify-don't-trust**: a create failure is ambiguous —
/// "rule already exists" (success) OR a silent rejection that created NO rule
/// (e.g. a 422 where `OPERATORS_TEAM` doesn't exist in `AGENTS_ORG`). The old
/// code folded 200/409/422 into `Ok` and left the repo unprotected with no
/// error — a fail-open merge gate. So on any create error, GET the `main` rule
/// and only treat it as success if the rule is actually present (the exact fix
/// already applied to [`apply_config_repo_branch_protection`]).
async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> {
let client = api(token)?;
let mut rule = main_branch_protection_option();
rule.enable_merge_whitelist = Some(true);
rule.merge_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]);
rule.enable_approvals_whitelist = Some(true);
rule.approvals_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]);
rule.required_approvals = Some(1);
rule.block_on_official_review_requests = Some(true);
let Err(create_err) = client
.repo_create_branch_protection(AGENTS_ORG, repo, rule)
.await
else {
tracing::info!(%repo, "forge: applied operator branch protection");
return Ok(());
};
match client
.repo_get_branch_protection(AGENTS_ORG, repo, "main")
.await
{
Ok(_) => {
tracing::debug!(
%repo, create_error = %create_err,
"forge: operator branch protection already present"
);
Ok(())
}
Err(check_err) => anyhow::bail!(
"branch protection for {AGENTS_ORG}/{repo} not applied: create failed \
({create_err}); GET main rule failed ({check_err}), no `main` rule present"
),
}
}
/// Apply branch protection to an `agent-configs/<name>` repo's `main` so it
/// can serve as the agent-editable, PR-merge config surface:
/// - **`main` is never directly pushable** — no push is enabled on the
/// protected branch, so neither the agent (a write collaborator) nor
/// hive-c0re can `git push` it. It only advances via the config-PR merge
/// node (`actions::run_deploy_apply`), which fast-forward-*merges* the reviewed
/// head through the forge merge API (`Do=fast-forward-only`,
/// `head_commit_id` pinned to the reviewed sha).
/// - **merge is whitelisted to `core`** — only hive-c0re can merge a config PR;
/// the agent can push feature branches + open PRs but can't land them.
/// - **the operator's dashboard approval is the gate** — approval happens on
/// the `MergeConfigPr` card and hive-c0re only merges an approved PR. There's
/// deliberately no Forgejo `required_approvals` review requirement: the flow
/// never does an in-forge review, so requiring one would only dead-block the
/// `core` merge. The dashboard approval + the `core`-only merge whitelist are
/// the real gate.
/// - **fast-forward-only** — `main` only ever advances by fast-forward; a raced
/// non-ff `main` is refused by the merge API rather than force-moved.
///
/// 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 client = api(token)?;
let mut rule = main_branch_protection_option();
// Only `core` may merge (through the config-PR merge API); no push is
// enabled at all, so `main` can only advance via that merge. Push +
// approval defaults are off in `main_branch_protection_option`, so a fresh
// rule needs nothing but the merge whitelist. (`config_repo_protection_edit`
// must clear the old push/approval fields explicitly, since a PATCH leaves
// unset fields untouched.)
rule.enable_merge_whitelist = Some(true);
rule.merge_whitelist_usernames = Some(vec!["core".to_owned()]);
let Err(create_err) = client
.repo_create_branch_protection(CONFIG_ORG, repo, rule)
.await
else {
tracing::info!(%repo, "forge: applied config-repo branch protection");
return Ok(());
};
// Create failed. This is ambiguous — "rule already exists" (the common
// idempotent case) OR a silent rejection that created no rule. Either way a
// create never *updates* an existing rule, and repos protected under an
// older shape (the push-based / approval-gated rules) carry stale settings.
// So converge the existing rule with a PATCH that explicitly clears them:
// it fixes those stale repos on the next boot's `ensure_config_repo` pass,
// is a harmless no-op when the rule is already correct, and still fails
// loudly when no rule can be established (you can't edit a rule that isn't
// there) — so it can't leave a repo silently unprotected.
match client
.repo_edit_branch_protection(CONFIG_ORG, repo, "main", config_repo_protection_edit())
.await
{
Ok(_) => {
// Debug, not info: this PATCH runs on every `ensure_config_repo`
// boot pass for every existing repo (create → 409 → converge), so
// it's almost always a no-op re-assertion — info-logging it would
// be N lines of noise per boot on a many-agent hive.
tracing::debug!(
%repo, create_error = %create_err,
"forge: converged existing config-repo branch protection"
);
Ok(())
}
Err(edit_err) => anyhow::bail!(
"branch protection for {CONFIG_ORG}/{repo} not applied: create failed \
({create_err}); edit of existing `main` rule failed ({edit_err})"
),
}
}
/// The `EditBranchProtectionOption` that converges an existing
/// `agent-configs/<name>` `main` rule to the current desired shape: merge
/// whitelisted to `core`, **no direct push at all**, and **no in-forge approval
/// requirement** (the dashboard approval + `core`-only merge whitelist are the
/// gate). The push/approval fields are set to their explicit off-values, not
/// left `None`, so a repo carrying an older push-based or approval-gated rule is
/// actually *converged* rather than merely re-asserted — a PATCH leaves unset
/// fields untouched. Every field unrelated to this policy stays `None`.
fn config_repo_protection_edit() -> EditBranchProtectionOption {
EditBranchProtectionOption {
apply_to_admins: None,
approvals_whitelist_teams: None,
approvals_whitelist_username: None,
block_on_official_review_requests: Some(false),
block_on_outdated_branch: None,
block_on_rejected_reviews: None,
dismiss_stale_approvals: None,
enable_approvals_whitelist: Some(false),
enable_merge_whitelist: Some(true),
enable_push: Some(false),
enable_push_whitelist: Some(false),
enable_status_check: None,
ignore_stale_approvals: None,
merge_whitelist_teams: None,
merge_whitelist_usernames: Some(vec!["core".to_owned()]),
protected_file_patterns: None,
push_whitelist_deploy_keys: None,
push_whitelist_teams: None,
push_whitelist_usernames: Some(Vec::new()),
require_signed_commits: None,
required_approvals: Some(0),
status_check_contexts: None,
unprotected_file_patterns: None,
}
}
/// 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<String> {
ensure_org_repo(AGENTS_ORG, repo, core_token).await?;
add_collaborator(
AGENTS_ORG,
repo,
agent,
AddCollaboratorOptionPermission::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}"))
}
#[cfg(test)]
mod tests {
use super::message_says_already_exists;
#[test]
fn already_exists_message_is_recognised() {
// The exact 422 body this Forgejo build returns for a duplicate team.
assert!(message_says_already_exists(
"validation failed: team already exists [org_id: 10, name: operators]"
));
// Case-insensitive.
assert!(message_says_already_exists("Repository Already Exists"));
}
#[test]
fn real_validation_error_is_not_treated_as_already_exists() {
// A genuine bad-request 422 must still surface, not be folded in.
assert!(!message_says_already_exists(
"validation failed: units must not be empty"
));
assert!(!message_says_already_exists("not found"));
}
}