feat(#1787): make agent-configs/<n> the agent-editable, PR-merge config surface

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/<name>:
- 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.
This commit is contained in:
atlas 2026-06-23 15:11:22 +02:00 committed by mara
commit 2fe79aaef5
2 changed files with 71 additions and 14 deletions

View file

@ -51,10 +51,16 @@ read it without touching c0re's host-side credential store.
Two things live in the `agent-configs` Forgejo organization: Two things live in the `agent-configs` Forgejo organization:
- A mirror repo per agent (`agent-configs/<name>`) — c0re pushes the - A config repo per agent (`agent-configs/<name>`). As of #1787 the
agent's applied config repo on each `↻ R3BU1LD`. Agents are agent is a **write collaborator on its own** repo — it can push
read-only collaborators on `core/meta` (the hive-c0re-owned meta config-change branches and (once #1838 P2 lands) open config PRs — but
flake) so they can fetch but never push. `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 - The dashboard links each container's "config" anchor to this
mirror, so operators can click straight from the SW4RM tab into mirror, so operators can click straight from the SW4RM tab into
the rendered repo without an extra `git` step. the rendered repo without an extra `git` step.

View file

@ -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 // helpers in `hive_sh4re::assets`. The `agent-configs.png` is
// rendered from its SVG during the `hyperhive-assets` derivation's // rendered from its SVG during the `hyperhive-assets` derivation's
// build. // build.
/// Forgejo org grouping every agent's applied config repo. Core is a /// Forgejo org grouping every agent's config repo. Core is a site admin
/// site admin and reads + writes every repo here; agents are NOT /// and reads + writes every repo here. As of #1787 each agent is a **write
/// members and the repos are private, so no agent — not even the one /// collaborator on its own** `agent-configs/<name>` repo — the editable
/// a repo describes — can reach a config repo through the forge. The /// PR surface it pushes config-change branches to — but `main` is
/// applied repos stay hive-c0re-owned on disk; this org is just a /// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge
/// mirror target core pushes to. /// 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"; const CONFIG_ORG: &str = "agent-configs";
/// Forgejo org hosting the operator-curated shared docs/skills repo /// Forgejo org hosting the operator-curated shared docs/skills repo
/// that every agent gets read-only access to. Agents use it as a /// 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/<name>` repo exists so the first /// Ensure the `agent-configs/<name>` repo exists so the first
/// `push_config` doesn't 404. No-op when the forge isn't running or /// `push_config` doesn't 404, and wire it as the agent-editable PR surface
/// the core token isn't minted yet. Safe to call on every spawn and /// (#1787): the agent is a **write** collaborator (can push feature branches +
/// on every startup. /// 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<()> { pub async fn ensure_config_repo(name: &str) -> Result<()> {
if !is_present().await { if !is_present().await {
return Ok(()); return Ok(());
@ -698,7 +705,12 @@ pub async fn ensure_config_repo(name: &str) -> Result<()> {
let Some(token) = core_token() else { let Some(token) = core_token() else {
return Ok(()); 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 /// 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/<name>` 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 /// 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 /// 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 /// as a **write** collaborator (not owner — can push + open PRs but can't