diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index 6d43e64a..4f7551a7 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -207,14 +207,13 @@ the submitter pushes again (or closes it) to retry. ### Forge mirror The bundled `hive-forge` container runs on the swarm's forge host -(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)), -and hive-c0re mirrors every agent's applied repo into a -private `agent-configs` Forgejo org. `forge::push_config()` pushes -every tag, then `applied/main`, to `agent-configs/` on every -startup sweep and every rebuild. Forge `main` is branch-protected, so -the forge routinely refuses a push of an established `main`, which -hive-c0re expects. Pushes are best-effort — a missing or stopped forge never -blocks a deploy. +(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)). +Every agent has a repo in the private `agent-configs` Forgejo org, created +and seeded by swarm-controller. hive-c0re pushes nothing to it: the +applied repo's tags (`deployed/0`) exist only locally, and forge `main` +advances only by an operator's merge. hive-c0re +reads from it — reseeding a purged `applied`, catching `applied` up, and +seeding `proposed` at first spawn. Each agent is a **write collaborator on its own** `agent-configs/` repo — so it can push a branch and open a config PR — but not a member @@ -222,8 +221,8 @@ of any other agent's, so it can't reach another agent's config through the forge. Branch protection keeps the agent off the `main` push allowlist and allowlists merging to the `operators` team only, so an agent can't fast-forward its own config or self-merge its PR (see -[Config changes](#config-changes) above). hive-c0re passes the tokenised -push URL inline to `git push`, never writing it into +[Config changes](#config-changes) above). hive-c0re passes the credential to +each `git` call inline, never writing it into `applied//.git/config`; that repo is RO-bind-mounted into the root agent, and a stored token would leak core's admin credential to an agent. diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 9cf22b40..38586440 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -70,9 +70,7 @@ Two things live in the `agent-configs` Forgejo organization: `main` directly nor self-merge. An operator's merge in the Forgejo UI deploys the merged commit (see [config-changes.md § Config changes](../agent-lifecycle/config-changes.md#config-changes)). - hive-c0re never force-pushes: the `push_config` mirror pushes the - add-only status tags and `main` without force, and treats a refused - `main` push as expected. + hive-c0re pushes nothing to this org; it only reads from it. Repos stay private, so an agent can't read another agent's config. (Agents remain read-only collaborators on `core/meta`.) hive-c0re also references this repo as the agent's **persistent meta diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 50e90e7e..ab181ce9 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -13,7 +13,7 @@ pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment}; pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status}; pub use repos::{ clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main, - fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access, + fetch_config_main_into_applied, meta_read_access, push_meta, shared_docs_access, }; pub use users::core_token; @@ -89,8 +89,7 @@ pub(crate) fn core_auth_header(token: &str) -> String { /// swarm-controller's: merge and approval allowlisted to the `operators` team, /// so the agent can't push `main` or self-merge, and an operator's merge /// deploys through swarm-controller. The repos remain private, so an agent -/// still can't reach *another* agent's config. hive-c0re never force-pushes; -/// the `push_config` mirror runs best-effort. +/// still can't reach *another* agent's config. hive-c0re only reads from it. pub(crate) 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 @@ -180,9 +179,9 @@ pub(crate) fn api(token: &str) -> Result { Forgejo::new(Auth::Token(token), url).context("build forgejo api client") } -/// Per-agent forge sync: mirror the agent's config repo, grant read access -/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures -/// are logged as warnings but don't abort the caller. +/// Per-agent forge sync: grant read access to `core/meta`, and wire the +/// `meta` remote in its proposed repo. All operations are idempotent; +/// failures are logged as warnings but don't abort the caller. /// /// `core_token` is `core_token()` — passed in so callers that already /// fetched it don't re-read the file. Pass `None` to skip the @@ -196,17 +195,9 @@ pub(crate) fn api(token: &str) -> Result { /// relies on the journal `warn!` lines alone (a rebuild is its own retry). pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool { // The agent's forge account, token and `agent-configs/` repo are - // swarm-controller's; this hive creates none of them and only pushes + // swarm-controller's; this hive creates none of them and pushes nothing // to the repo. let mut ok = true; - // Mirror the agent's applied config repo into agent-configs. - // 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) = push_config(name).await { - tracing::warn!(%name, error = ?e, "forge: push_config failed"); - ok = false; - } // Grant read-only access to core/meta and wire the `meta` remote // into the proposed repo so agents can fetch their deployment context. if let Some(token) = core_token diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index 8f26c16d..1b172f1e 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -1,6 +1,5 @@ //! Repo + org plumbing on the local Forgejo: the meta + shared-docs + -//! knowledge repos, per-agent config-repo -//! mirroring (`push_config` / `push_meta`), collaborator grants, +//! knowledge repos, meta mirroring (`push_meta`), collaborator grants, //! pull-mirrors, and branch-protection rules. The typed API-client //! constructor + org-name constants live in the module root (`super`). @@ -107,8 +106,7 @@ pub async fn push_meta(dir: &Path) -> Result<()> { // 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. + // silently erasing its history. let url = forge_git_url("core/meta"); let out = crate::lifecycle::git_command_authed(&core_auth_header(&token)) .current_dir(dir) @@ -216,91 +214,19 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> { } } -/// Mirror agent `name`'s applied config repo — every status tag -/// (`proposal` / `approved` / `building` / `deployed` / `failed` / -/// `denied`) plus a best-effort `main` — to `agent-configs/` 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/`, …) and add-only, so they must always land. `main`, -/// by contrast, is branch-protected and advances through an operator's -/// merge on the forge — so a mirror push of an already-established `main` -/// is routinely rejected (protected-branch, or non-fast-forward). 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 (an operator's forge merge advances it); - // 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 \ - and advances by an operator 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(()) -} - /// Reseed a revived agent's `applied` repo — missing its `.git` entirely, /// e.g. after `destroy --purge` — by fetching `agent-configs/`'s -/// `main` from the forge, [`push_config`]'s mirror target. Unlike -/// `proposed` (seeded once at first spawn and never touched again — see -/// `lifecycle::setup_proposed`'s own doc comment), the forge mirror is -/// kept current after every deploy, so this reflects the agent's actual -/// last-deployed state rather than a stale creation-time snapshot. +/// `main` from the forge. Unlike `proposed` (seeded once at first spawn and +/// never touched again — see `lifecycle::setup_proposed`'s own doc +/// comment), forge `main` advances with every operator-merged config PR, so +/// this reflects the last merged config rather than a stale creation-time +/// snapshot. A config deployed through hive-c0re without a forge merge never +/// reaches it. /// -/// Best-effort, mirroring [`push_config`]'s own shape: returns `false` -/// (not an error) rather than bailing when the forge is absent, the core -/// token isn't minted yet, `applied` already has a `.git` (nothing to -/// do), or any git step fails — callers fall back to a less-current -/// source or the original hard error. Only `true` once `applied` is +/// Best-effort: returns `false` (not an error) rather than bailing when the +/// forge is absent, the core token isn't minted yet, `applied` already has a +/// `.git` (nothing to do), or any git step fails — callers fall back to a +/// less-current source or the original hard error. Only `true` once `applied` is /// actually seeded and tagged `deployed/0`, mirroring first-spawn's own /// `setup_applied` seed shape exactly. pub async fn fetch_config_main_into_applied(name: &str) -> bool { @@ -486,23 +412,6 @@ pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool { } } -/// Run a single `git push ` 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 { - crate::lifecycle::git_command_authed(auth) - .current_dir(dir) - .args(["push", url, refspec]) - .output() - .await - .context("invoke git push agent-configs") -} - /// 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 diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index a5ae2fac..e0ea9409 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -274,9 +274,9 @@ pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> /// (`job_queue::exec::run_purge_state`) — a later revive of the *same* /// agent name is still a rebuild, not a first-spawn, so it lands here, not /// in `provision_container`. Recover from `agent-configs/` on the -/// forge when that's the case (kept current after every deploy by -/// `forge::push_config` — see `forge::fetch_config_main_into_applied`'s own -/// doc comment) rather than bailing outright. Deliberately **no fallback to +/// forge when that's the case (forge `main` is the last operator-merged +/// config — see `forge::fetch_config_main_into_applied`'s own doc comment) +/// rather than bailing outright. Deliberately **no fallback to /// `proposed`**: that repo is seeded once at first spawn and never touched /// again (`setup_proposed`'s own doc comment), so it can be arbitrarily /// stale for a long-lived agent — restoring from it would silently revive diff --git a/hive-c0re/src/workers/knowledge.rs b/hive-c0re/src/workers/knowledge.rs index bd6cb705..fa35d21c 100644 --- a/hive-c0re/src/workers/knowledge.rs +++ b/hive-c0re/src/workers/knowledge.rs @@ -98,9 +98,8 @@ pub async fn pull(coord: &Coordinator) -> Result<()> { } // Rewrite any credentialed `origin` left by an older clone, which spliced // `core:@` into the URL and persisted it in `.git/config` — the one - // place this repo stored a token in a named remote, against the rule - // `forge::repos::push_config` states. Harmless to repeat once the URL is - // already clean. + // place this repo stored a token in a named remote. Harmless to repeat + // once the URL is already clean. let plain = crate::forge::forge_git_url(&format!("{ORG}/{REPO}")); let _ = crate::lifecycle::git_command() .args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain])