forge: mirror applied config repos to a private agent-configs org

on startup (and after every applied-repo ref mutation) core pushes
each agent's hive-c0re-owned applied repo — main plus every
proposal/approved/building/deployed/failed/denied tag — to
agent-configs/<name> on the local forge. the org is private and
agents are not members, so core is the only principal that can read
it.

the tokenised push url is passed inline, never stored as a named
remote: the applied repo is bind-mounted read-only into the manager,
so a token in .git/config would leak the core admin credential to an
agent.

push_config is best-effort at every site (ensure_all, spawn,
approve, deny, submit) — a missing or down forge never blocks a
deploy.
This commit is contained in:
müde 2026-05-20 10:24:50 +02:00
parent 1529c2d777
commit 5aad2d67e1
4 changed files with 185 additions and 27 deletions

View file

@ -183,6 +183,34 @@ read them à la carte.
In-flight or recent context that hasn't earned a section yet. In-flight or recent context that hasn't earned a section yet.
Prune freely. Prune freely.
- **Just landed:** applied config repos mirrored to the
forge. New private `agent-configs` Forgejo org (renamed
from the unused `agents` org in `SEEDED_ORGS`); core is the
only principal with access (site admin + private repos +
agents not members). `forge::push_config(name)` mirrors an
agent's hive-c0re-owned applied repo — `main` + every tag
(proposal/approved/building/deployed/failed/denied) — to
`agent-configs/<name>.git` via `git push --force`. The
tokenised URL is passed inline per push, never stored as a
named remote: the applied repo is RO-bind-mounted into the
manager at `/applied`, so a token in `.git/config` would
leak core's admin credential to an agent. Call sites:
`forge::ensure_all` (startup, per agent — catches migrate +
offline-forge drift), the spawn task in `actions::approve`
(+ `ensure_config_repo`), `actions::approve` ApplyCommit
branch, `actions::deny` ApplyCommit branch, and
`manager_server::submit_apply_commit`. All best-effort
(warn + continue). `ensure_repo` refactored to share a
`create_repo` helper with the new `ensure_org_repo`.
- **Just landed:** answer questions inline from the per-agent
web page. Question rows in the loose-ends section grew a
textarea + send button; the operator answers as operator by
POSTing cross-origin to the core dashboard's
`/answer-question/{id}` (CORS shim `with_cors` on that
route), never the per-agent socket — keeps the
operator-authority path off the agent's own socket. See
`TODO-ops.md` for the boundary rationale + the deployment/
gateway/privsep cluster.
- **Just landed:** sub-agents get a read-only view of their own - **Just landed:** sub-agents get a read-only view of their own
config repo. `set_nspawn_flags` now adds config repo. `set_nspawn_flags` now adds
`--bind-ro={proposed_dir}:/agents/<name>/config` for every `--bind-ro={proposed_dir}:/agents/<name>/config` for every

View file

@ -50,6 +50,11 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
&notes_dir, &notes_dir,
) )
.await; .await;
// Mirror the applied repo's new tag/branch state (approved/
// building/deployed-or-failed + main) to the forge.
if let Err(e) = crate::forge::push_config(&approval.agent).await {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after apply failed");
}
finish_approval(&coord, &approval, result, terminal_tag) finish_approval(&coord, &approval, result, terminal_tag)
} }
ApprovalKind::Spawn => { ApprovalKind::Spawn => {
@ -77,10 +82,19 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
) )
.await; .await;
drop(guard); drop(guard);
if result.is_ok() if result.is_ok() {
&& let Err(e) = crate::forge::ensure_user_for(&agent_bg).await if let Err(e) = crate::forge::ensure_user_for(&agent_bg).await {
{ tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_user after spawn failed");
tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_user after spawn failed"); }
// Create the agent-configs mirror repo and seed it
// with the freshly-initialised applied repo (main +
// deployed/0).
if let Err(e) = crate::forge::ensure_config_repo(&agent_bg).await {
tracing::warn!(agent = %agent_bg, error = ?e, "forge: ensure_config_repo after spawn failed");
}
if let Err(e) = crate::forge::push_config(&agent_bg).await {
tracing::warn!(agent = %agent_bg, error = ?e, "forge: push_config after spawn failed");
}
} }
if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) { if let Err(e) = finish_approval(&coord_bg, &approval_bg, result, None) {
tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed");
@ -417,6 +431,10 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()
tag = Some(tag_name); tag = Some(tag_name);
} }
} }
// Mirror the denied/<id> tag to the forge.
if let Err(e) = crate::forge::push_config(&a.agent).await {
tracing::warn!(%id, agent = %a.agent, error = ?e, "forge: push_config after deny failed");
}
} }
let approval_kind = match a.kind { let approval_kind = match a.kind {
ApprovalKind::Spawn => "spawn", ApprovalKind::Spawn => "spawn",

View file

@ -6,6 +6,13 @@
//! when the user already exists, skips token issuance when the file //! when the user already exists, skips token issuance when the file
//! is already there. //! is already there.
//! //!
//! It also mirrors each agent's hive-c0re-owned *applied* config repo
//! into the private `agent-configs` org (`push_config`), so every
//! deploy / approval tag core plants is visible on the forge. Core is
//! the only principal with access — agents are not org members and
//! the repos are private, so an agent can't reach a config repo
//! (not even its own) through the forge.
//!
//! No-op when `hive-forge` isn't enabled (detected via //! No-op when `hive-forge` isn't enabled (detected via
//! `nixos-container list`), so operators who don't run the bundled //! `nixos-container list`), so operators who don't run the bundled
//! forge pay nothing. //! forge pay nothing.
@ -24,12 +31,16 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive";
/// itself to push the meta repo + drive admin API calls (org /// itself to push the meta repo + drive admin API calls (org
/// creation, future webhook setup, etc.). Root-only. /// creation, future webhook setup, etc.). Root-only.
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives /// Forgejo org grouping every agent's applied config repo. Core is a
/// at `core/meta` (the `core` user's own namespace — no org needed); /// site admin and reads + writes every repo here; agents are NOT
/// `agents` holds per-agent applied config repos so they're /// members and the repos are private, so no agent — not even the one
/// visible/grouped together and access can be granted org-wide /// a repo describes — can reach a config repo through the forge. The
/// (RO membership for the future shared docs/skills repo). /// applied repos stay hive-c0re-owned on disk; this org is just a
const SEEDED_ORGS: &[&str] = &["agents"]; /// mirror target core pushes to.
const CONFIG_ORG: &str = "agent-configs";
/// 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];
/// Forgejo scopes the agent's token gets. Broad-but-not-admin: every /// Forgejo scopes the agent's token gets. Broad-but-not-admin: every
/// repo / PR / issue thing an agent needs day-to-day, no admin /// repo / PR / issue thing an agent needs day-to-day, no admin
/// surface. /// surface.
@ -231,13 +242,15 @@ async fn ensure_core_user_and_token() -> Result<String> {
Ok(raw.trim().to_owned()) Ok(raw.trim().to_owned())
} }
/// POST `/api/v1/user/repos` to create a repo in the authenticated /// JSON body for a private, empty repo defaulting to `main`.
/// user's own namespace. `token` belongs to the user we want the fn repo_body(name: &str) -> String {
/// repo owned by (we use `core`'s token for `core/meta`). Idempotent: format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#)
/// HTTP 409 ("repository already exists") is treated as success. }
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
let body = format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#); /// POST a repo-creation request to `url` and fold "already exists"
let url = format!("{FORGE_HTTP}/api/v1/user/repos"); /// (HTTP 409 / 422) into success. `label` is `<owner>/<name>` — purely
/// for log + error context.
async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> {
let out = Command::new("curl") let out = Command::new("curl")
.args([ .args([
"-sS", "-sS",
@ -252,28 +265,51 @@ pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
"-H", "-H",
&format!("Authorization: token {token}"), &format!("Authorization: token {token}"),
"-d", "-d",
&body, body,
&url, url,
]) ])
.output() .output()
.await .await
.context("invoke curl POST /api/v1/user/repos")?; .with_context(|| format!("invoke curl POST {url}"))?;
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
match code.as_str() { match code.as_str() {
"201" => { "201" => {
tracing::info!(%name, "forge: created repo"); tracing::info!(%label, "forge: created repo");
Ok(()) Ok(())
} }
"409" | "422" => { "409" | "422" => {
tracing::debug!(%name, "forge: repo already exists"); tracing::debug!(%label, "forge: repo already exists");
Ok(()) Ok(())
} }
other => anyhow::bail!( other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"),
"POST /api/v1/user/repos name={name} 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/<agent>`).
/// 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 /// Read the persisted core token, or None when the forge isn't
/// seeded yet. Cheap — just a file read. /// seeded yet. Cheap — just a file read.
pub fn core_token() -> Option<String> { pub fn core_token() -> Option<String> {
@ -311,6 +347,66 @@ pub async fn push_meta(dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Ensure the `agent-configs/<name>` 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.
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
}
/// Mirror agent `name`'s applied config repo — `main` plus every tag
/// (`proposal` / `approved` / `building` / `deployed` / `failed` /
/// `denied`) — 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 yet.
///
/// Call this after every hive-c0re mutation of an applied repo's refs
/// so the forge copy always reflects what core actually did. `--force`
/// because a failed build rolls `main` backwards to the last-good sha.
///
/// 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",
"--force",
&url,
"refs/heads/main:refs/heads/main",
"refs/tags/*:refs/tags/*",
])
.output()
.await
.context("invoke git push agent-configs")?;
if !out.status.success() {
anyhow::bail!(
"git push {CONFIG_ORG}/{name} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!(%name, "forge: mirrored applied config to agent-configs");
Ok(())
}
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent: /// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
/// HTTP 422 ("user already exists") is treated as success. /// HTTP 422 ("user already exists") is treated as success.
async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
@ -354,9 +450,10 @@ async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
} }
/// Sweep every existing container (manager + sub-agents) and ensure /// Sweep every existing container (manager + sub-agents) and ensure
/// each has a forgejo user + token. Also seeds the `core` admin /// each has a forgejo user + token, plus an `agent-configs/<name>`
/// repo mirroring its applied config. Also seeds the `core` admin
/// user (hive-c0re's own identity for pushing the meta repo + driving /// user (hive-c0re's own identity for pushing the meta repo + driving
/// the API) and the `core` / `agents` orgs the system pushes into. /// the API), the `agent-configs` org, and the `core/meta` repo.
/// Called once at hive-c0re startup. Per-step failures are logged /// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep. /// but don't abort the sweep.
pub async fn ensure_all() { pub async fn ensure_all() {
@ -399,5 +496,16 @@ pub async fn ensure_all() {
if let Err(e) = ensure_user_for(&name).await { if let Err(e) = ensure_user_for(&name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
} }
// Mirror the agent's applied config repo into agent-configs.
// ensure_config_repo is idempotent; push_config catches any
// drift since the last run — e.g. the startup migration just
// relocated `deployed/0`, or a deploy landed while the forge
// was down.
if let Err(e) = ensure_config_repo(&name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
}
if let Err(e) = push_config(&name).await {
tracing::warn!(%name, error = ?e, "forge: push_config failed");
}
} }
} }

View file

@ -481,6 +481,10 @@ async fn submit_apply_commit(
.approvals .approvals
.set_fetched_sha(id, &sha) .set_fetched_sha(id, &sha)
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?; .map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
// Mirror the freshly-planted proposal/<id> tag to the forge.
if let Err(e) = crate::forge::push_config(agent).await {
tracing::warn!(%agent, %id, error = ?e, "forge: push_config after submit failed");
}
// Phase 5b: surface the new pending approval on the dashboard // Phase 5b: surface the new pending approval on the dashboard
// event channel. Compute the diff once here so live subscribers // event channel. Compute the diff once here so live subscribers
// get a fully-formed row without a snapshot refetch. // get a fully-formed row without a snapshot refetch.