From 2fe79aaef5e88df83c50b5c5d6b876a8a134f04e Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 23 Jun 2026 15:11:22 +0200 Subject: [PATCH 1/4] feat(#1787): make agent-configs/ the agent-editable, PR-merge config surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the per-agent config repo as the editable PR surface the #1838 merge handler (run_merge_config_pr) consumes, without yet retiring the push_config force-mirror (that waits for #1838 P2 — the agent-opens-PR MCP surface — so config changes keep working through the transition). ensure_config_repo now, after creating agent-configs/: - adds the agent as a WRITE collaborator (can push config-change branches + open PRs); - branch-protects main core-only via apply_config_repo_branch_protection: push + merge whitelists are core-only (only hive-c0re lands on main, via its verify-and-ff-push handler), operator-team approval required, the agent can't push main directly or self-merge. The protection sets enable_force_push=true as a TRANSITIONAL allowance so push_config's applied->main force-mirror keeps working until P2 retires it (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. At P2, flip it to false and keep core in the push-whitelist so ff_push_to_main still lands. Agent code repos (AGENTS_ORG) are intentionally untouched — their merge flow has no auto-merge handler yet, so switching them to core-only-merge would strand those PRs. All steps idempotent (runs on every spawn + startup sweep). Updated the CONFIG_ORG doc comment + docs/forge.md to drop the "mirror-only" framing. --- docs/forge.md | 14 ++++++--- hive-c0re/src/forge.rs | 71 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 14 deletions(-) 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 From 558552ec5469d98b908bdd6e7eb4186aa4052482 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 23 Jun 2026 15:17:12 +0200 Subject: [PATCH 2/4] docs(forge): s/mirror/config repo/ in dashboard anchor bullet for consistency Address iris review nit on #1948: the bullet above now reframes the agent-configs entry as a config repo, so the dashboard-anchor bullet should match. Pure wording, no behavior change. --- docs/forge.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/forge.md b/docs/forge.md index 2b10ce59..e247e747 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -62,7 +62,7 @@ Two things live in the `agent-configs` Forgejo organization: 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 + config repo, so operators can click straight from the SW4RM tab into the rendered repo without an extra `git` step. The `hive-forge` CLI (separate workspace crate, see From 60da6c14b47e639a8d00bfaaa991d5760bee19c4 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 23 Jun 2026 15:29:20 +0200 Subject: [PATCH 3/4] fix(forge): reword tracker-tag references in doc comments to prose Address argus review on the config-repo PR surface: 6 issue-number tags appeared in Rust doc comments in forge.rs; the tracker-tag lint matches ${'#'}NNN in code comments (markdown docs are exempt, doc comments are not). Reword to prose, no behavior change. --- hive-c0re/src/forge.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index fe330c9b..e7acf4fa 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -31,15 +31,14 @@ const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; // 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 #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 +/// and reads + writes every repo here. As of the agent-config-PR flow each +/// agent is a **write collaborator on its own** `agent-configs/` repo — +/// the editable PR surface it pushes config-change branches to — but `main` is /// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge /// handler lands on it (operator approval required; the agent can't push /// `main` or self-merge). The repos remain private, so an agent still can't /// reach *another* agent's config. During the transition `push_config` also -/// force-mirrors `applied → main` here until the PR flow (#1838 P2) replaces -/// it. +/// force-mirrors `applied → main` here until the PR-merge flow 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 @@ -692,8 +691,8 @@ pub async fn push_meta(dir: &Path) -> Result<()> { } /// Ensure the `agent-configs/` repo exists so the first -/// `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 + +/// `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 @@ -991,7 +990,7 @@ 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): +/// 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 @@ -999,12 +998,12 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> /// - **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 +/// force-mirrors `applied → main` until the agent-opened PR-merge flow +/// 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, 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. From 5caec9c1a972118eeb4fbfcaa1d328bf89c4be67 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 23 Jun 2026 22:24:23 +0200 Subject: [PATCH 4/4] fix(forge): config-repo main is fast-forward-only (no auto force-push) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per operator directive: a silent automatic force-push is a bug. The config-repo merge path already never force-pushes (run_merge_config_pr lands via ff_push_to_main, a non-force git push). So set the branch protection's enable_force_push to false — main only ever advances by fast-forward. The legacy push_config mirror does force-push (it re-points the status tags and rewinds main on a failed-build rollback); the protection now rejects those non-ff updates, so the mirror runs best-effort until the agent-opened PR-merge flow retires it. Docs + comments updated to match. --- docs/forge.md | 9 +++++---- hive-c0re/src/forge.rs | 25 +++++++++++++------------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/forge.md b/docs/forge.md index e247e747..4d4cbf62 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -56,10 +56,11 @@ Two things live in the `agent-configs` Forgejo organization: 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 + the agent can neither push `main` directly nor self-merge. `main` is + fast-forward-only — no auto force-push (the merge handler's ff push lands + fine; the legacy `push_config` mirror, which force-pushes to re-point status + tags and rewind on rollback, runs best-effort until the PR flow retires it). + 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 config repo, so operators can click straight from the SW4RM tab into diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index e7acf4fa..e2bb8756 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -37,8 +37,9 @@ const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; /// 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-merge flow replaces it. +/// reach *another* agent's config. `main` is fast-forward-only (no auto +/// force-push); the legacy `push_config` mirror runs best-effort until the +/// PR-merge flow retires it. const CONFIG_ORG: &str = "agent-configs"; /// Forgejo org hosting the operator-curated shared docs/skills repo /// that every agent gets read-only access to. Agents use it as a @@ -708,7 +709,7 @@ pub async fn ensure_config_repo(name: &str) -> Result<()> { // 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). + // Protect `main` core-only, fast-forward-only (no auto force-push). apply_config_repo_branch_protection(name, &token).await } @@ -997,20 +998,20 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> /// (`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 agent-opened PR-merge flow -/// 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, flip this to `false`; keep `core` in the push-whitelist so -/// `ff_push_to_main` can still land fast-forwards. +/// - **`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":true}}"# + 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 = forge_http(reqwest::Method::POST, &url, token, &body).await?; match status.as_u16() {