diff --git a/docs/forge.md b/docs/forge.md index 527ca5ac..2b10ce59 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -51,10 +51,16 @@ read it without touching c0re's host-side credential store. Two things live in the `agent-configs` Forgejo organization: -- A mirror repo per agent (`agent-configs/`) — c0re pushes the - agent's applied config repo on each `↻ R3BU1LD`. Agents are - read-only collaborators on `core/meta` (the hive-c0re-owned meta - flake) so they can fetch but never push. +- A config repo per agent (`agent-configs/`). As of #1787 the + agent is a **write collaborator on its own** repo — it can push + config-change branches and (once #1838 P2 lands) open config PRs — but + `main` is branch-protected core-only: only hive-c0re's verify-and-ff-push + merge handler lands on `main`, an operator-team approval is required, and + the agent can neither push `main` directly nor self-merge. During the + transition c0re still force-mirrors the agent's applied config repo here + on each `↻ R3BU1LD` (the `enable_force_push` allowance), until the PR + flow replaces that. Repos stay private, so an agent can't read another + agent's config. (Agents remain read-only collaborators on `core/meta`.) - The dashboard links each container's "config" anchor to this mirror, so operators can click straight from the SW4RM tab into the rendered repo without an extra `git` step. diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index e2d953c1..fe330c9b 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -30,12 +30,16 @@ const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; // 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 applied config repo. Core is a -/// site admin and reads + writes every repo here; agents are NOT -/// members and the repos are private, so no agent — not even the one -/// a repo describes — can reach a config repo through the forge. The -/// applied repos stay hive-c0re-owned on disk; this org is just a -/// mirror target core pushes to. +/// Forgejo org grouping every agent's config repo. Core is a site admin +/// and reads + writes every repo here. As of #1787 each agent is a **write +/// collaborator on its own** `agent-configs/` repo — the editable +/// PR surface it pushes config-change branches to — but `main` is +/// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge +/// handler lands on it (operator approval required; the agent can't push +/// `main` or self-merge). The repos remain private, so an agent still can't +/// reach *another* agent's config. During the transition `push_config` also +/// force-mirrors `applied → main` here until the PR flow (#1838 P2) replaces +/// it. const CONFIG_ORG: &str = "agent-configs"; /// Forgejo org hosting the operator-curated shared docs/skills repo /// that every agent gets read-only access to. Agents use it as a @@ -688,9 +692,12 @@ pub async fn push_meta(dir: &Path) -> Result<()> { } /// Ensure the `agent-configs/` repo exists so the first -/// `push_config` doesn't 404. 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. +/// `push_config` doesn't 404, and wire it as the agent-editable PR surface +/// (#1787): 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(()); @@ -698,7 +705,12 @@ pub async fn ensure_config_repo(name: &str) -> Result<()> { let Some(token) = core_token() else { return Ok(()); }; - ensure_org_repo(CONFIG_ORG, name, &token).await + 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 (+ transitional force-push for push_config). + apply_config_repo_branch_protection(name, &token).await } /// Ensure the `internal/docs` repo exists. Called once at startup @@ -978,6 +990,45 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> } } +/// Apply branch protection to an `agent-configs/` repo's `main` so it +/// can serve as the agent-editable, PR-merge config surface (#1787 / #1838): +/// - **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 a TRANSITIONAL allowance**: `push_config` still +/// force-mirrors `applied → main` until the PR flow (#1838 P2) replaces it, +/// and a protected branch otherwise rejects force-push. Forgejo's force-push +/// allowlist is deploy-keys-only (no per-user list), so this is a plain +/// `enable_force_push` toggle — only `core` is in the push-whitelist, so +/// only `core` can force-push anyway. When `push_config` is retired (#1838 +/// P2), flip this to `false`; keep `core` in the push-whitelist so +/// `ff_push_to_main` can still land fast-forwards. +/// +/// 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":true}}"# + ); + let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; + match status.as_u16() { + 201 => { + tracing::info!(%repo, "forge: applied config-repo branch protection"); + Ok(()) + } + 200 | 409 | 422 => { + tracing::debug!(%repo, "forge: config-repo branch protection already present"); + Ok(()) + } + other => { + anyhow::bail!("POST {CONFIG_ORG}/{repo}/branch_protections returned HTTP {other}") + } + } +} + /// 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