diff --git a/docs/approvals.md b/docs/approvals.md index c1713fb5..ca84dc77 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -213,13 +213,31 @@ transient. The hive-c0re-owned repo at `/var/lib/hyperhive/meta/` declares one flake input per agent (`agent-.url = -"git+file:///var/lib/hyperhive/applied/"`) and one +"git+http:///agent-configs/.git"`) and one `nixosConfigurations.` output per agent. Each output wraps `inputs.agent-.nixosModules.default` with the identity + `HIVE_PORT` / `HIVE_LABEL` / `HIVE_DASHBOARD_PORT` injection module that `setup_applied` used to generate inline. Containers run against `--flake /var/lib/hyperhive/meta#`. +The declared input url is the agent's **forge config repo** (the +same `agent-configs/` the config-PR flow lands approved changes +on), so the meta flake references a reviewable, reproducible source +rather than a local checkout. hive-c0re authenticates that +`git+http` fetch via a git credential helper that reads the live +forge-core token — no token in the url or the lock. The deploy and +manual-rebuild paths, however, do **not** re-lock from the forge: +they `--override-input agent- +git+file:///var/lib/hyperhive/applied/`, locking the exact config +that `verify_commit` gated and `applied//main` was +fast-forwarded to. That keeps a deploy/rebuild reproducible and +independent of forge reachability — rebuilds fire on crash-restart +and meta bumps, not just config PRs — while the declared url stays +the forge. `sync_agents` re-renders + re-locks the persistent input; +a plain `nix flake lock` leaves an existing applied override in +place (it only re-locks when the declared url itself changes), so +the forge-declared / applied-deployed split is stable. + Per-deploy lock flow (two-phase, owned by `actions::run_merge_config_pr` → `deploy_applied_target` → `meta::{prepare,finalize,abort}_deploy`): diff --git a/docs/forge.md b/docs/forge.md index b657191b..5895d659 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -64,6 +64,11 @@ Two things live in the `agent-configs` Forgejo organization: approved history, the `failed/` tag records the divergence). 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 + flake input** (`agent-.url = git+http:///agent-configs/.git`; + see [approvals.md § Meta flake](approvals.md)), fetching it as the `core` + user via a git credential helper that reads the live forge-core token — + so the config lives on the forge, not a hand-synced local checkout. - The dashboard links each container's "config" anchor to this config repo, so operators can click straight from the SW4RM tab into the rendered repo without an extra `git` step. diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 66f61023..7d824d38 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -74,6 +74,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { &hive.operator_pronouns, &hive.context_window_tokens, agents, + crate::forge::forge_http_base(), ); let flake_path = dir.join("flake.nix"); let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default(); @@ -238,7 +239,29 @@ pub async fn prepare_deploy(name: &str) -> Result<()> { let _guard = META_LOCK.lock().await; let dir = crate::paths::meta_root(); let input = format!("agent-{name}"); - nix_logged(&dir, &["flake", "update", &input], name, "prepare-deploy").await?; + // Re-lock the agent input against the LOCAL applied mirror, not the + // persistent forge URL declared in the meta flake (see the `## Meta flake` + // note in docs/approvals.md): the deploy must build the exact reviewed + // config that `verify_commit` gated and `applied//main` was + // fast-forwarded to, and it must keep working when the forge is + // unreachable (rebuilds fire on crash-restart / meta bumps too, not just + // config PRs). `--override-input` writes the applied rev into the lock; + // the forge URL stays the declared, reviewable source of truth. + let applied = applied_override_url(&crate::paths::applied_dir(name)); + nix_logged( + &dir, + &[ + "flake", + "update", + &input, + "--override-input", + &input, + &applied, + ], + name, + "prepare-deploy", + ) + .await?; // Stage the new lock — git+file://'s dirty-tree fetcher reads // index entries, so the upcoming nixos-container update sees the // bumped rev without a commit yet. @@ -281,7 +304,22 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> { let _guard = META_LOCK.lock().await; let dir = crate::paths::meta_root(); let input = format!("agent-{name}"); - nix(&dir, &["flake", "update", &input]).await?; + // Re-lock from the local applied mirror, not the persistent forge URL — + // same rationale as `prepare_deploy`: build exactly `applied//main` and + // stay reproducible when the forge is unreachable. + let applied = applied_override_url(&crate::paths::applied_dir(name)); + nix( + &dir, + &[ + "flake", + "update", + &input, + "--override-input", + &input, + &applied, + ], + ) + .await?; if !paths_dirty(&dir, &["flake.lock"]).await? { return Ok(()); } @@ -301,6 +339,17 @@ fn agent_input_override(applied_dir: &Path, sha: &str) -> String { format!("git+file://{}?rev={sha}", applied_dir.display()) } +/// `--override-input` URL re-locking an agent's config input against its +/// LOCAL applied mirror (`git+file://`, current `main` head) +/// instead of the persistent forge URL declared in the meta flake. The +/// deploy + rebuild paths re-lock from here: the applied tree was already +/// fast-forwarded to the reviewed head, so this builds exactly that config +/// and stays reproducible when the forge is unreachable. No `?rev` — `main` +/// head is the reviewed head at deploy time. Pure so it's unit-testable. +fn applied_override_url(applied_dir: &Path) -> String { + format!("git+file://{}", applied_dir.display()) +} + /// Non-mutating "would this commit apply?" verify for the PR-based config /// flow. Evaluates the agent's nixos configuration with its meta /// input overridden to the exact `sha`, WITHOUT moving `applied/main` or @@ -586,6 +635,11 @@ pub async fn bulk_commit_topology( Ok(changed) } +#[allow( + clippy::too_many_arguments, + reason = "many genuine flake inputs (source flakes, port, pronouns, tokens, \ + agents, forge base); a params struct would just move the same fields" +)] fn render_flake( hyperhive_flake: &str, docs_flake: &str, @@ -594,6 +648,7 @@ fn render_flake( operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, agents: &[AgentSpec], + forge_base: &str, ) -> String { render_flake_with_lookup( hyperhive_flake, @@ -603,6 +658,7 @@ fn render_flake( operator_pronouns, context_window_tokens, agents, + forge_base, agent_canonical_inputs, ) } @@ -879,6 +935,7 @@ fn render_flake_with_lookup( operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, agents: &[AgentSpec], + forge_base: &str, lookup: F, ) -> String where @@ -923,12 +980,19 @@ where let _ = writeln!(out, " hyperhive-docs.url = \"{docs_flake}\";"); out.push_str(" hyperhive-docs.flake = false;\n"); } + // Each agent's *persistent* config input is its canonical repo on the + // forge (`git+{forge_base}/agent-configs/.git`, `forge_base` supplied + // by the caller from `HIVE_FORGE_URL`), authenticated by hive-core's git + // credential helper (which reads the live `forge-core-token` — no token in + // the URL or lock). The deploy re-lock + `verify_commit` eval keep pinning + // the local `applied/` override (`agent_input_override`), so a deploy + // never does a network fetch — only the persistent input tracks the forge. for spec in agents { let _ = writeln!( out, - " agent-{}.url = \"git+file://{}\";", - spec.name, - crate::paths::applied_dir(&spec.name).display(), + " agent-{name}.url = \"git+{forge_base}/{org}/{name}.git\";", + name = spec.name, + org = crate::forge::CONFIG_ORG, ); // For each canonical input the agent declares in its own // `flake.nix` (detected by reading its applied `flake.lock`), @@ -1450,6 +1514,20 @@ mod tests { ); } + #[test] + fn applied_override_url_targets_local_mirror_main_head() { + // Deploy + rebuild re-lock against the local applied mirror's `main` + // head (no `?rev`), never the persistent forge URL — so a rebuild + // survives forge unreachability and builds the fast-forwarded config. + let p = Path::new("/var/lib/hyperhive/agents/iris/applied"); + let url = applied_override_url(p); + assert_eq!(url, "git+file:///var/lib/hyperhive/agents/iris/applied"); + assert!( + !url.contains("?rev="), + "must lock main head, not a pinned rev: {url}" + ); + } + #[test] fn render_flake_uses_explicit_nixpkgs_url_when_provided() { let out = render_flake( @@ -1460,6 +1538,7 @@ mod tests { "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], + "http://forge.test", ); // nixpkgs is a top-level input with an explicit URL; hyperhive // follows it. @@ -1503,6 +1582,7 @@ mod tests { "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], + "http://forge.test", ); assert!( !out.contains("hyperhive-docs"), @@ -1522,6 +1602,7 @@ mod tests { "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], + "http://forge.test", ); assert!( out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""), @@ -1555,6 +1636,7 @@ mod tests { sample_spec("bitburner", false, 9002), sample_spec("dmatrix", false, 9003), ], + "http://forge.test", lookup, ); // bitburner declares nixpkgs → follows emitted. @@ -1583,6 +1665,7 @@ mod tests { "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], + "http://forge.test", |_| Vec::new(), ); // No agent-side follows when the lookup reports nothing @@ -1617,6 +1700,7 @@ mod tests { "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], + "http://forge.test", ); unsafe { std::env::remove_var("HIVE_FORGE_URL"); @@ -1639,6 +1723,36 @@ mod tests { ); } + #[test] + fn render_flake_agent_input_points_at_forge_config_repo() { + // The persistent agent config input must reference the canonical + // repo on the forge (git+http, org `agent-configs`), NOT the local + // `applied/` checkout — that's what lets the config live on the + // forge instead of a hand-synced local copy. Auth is out-of-band via + // hive-core's git credential helper, so no creds appear in the URL. + // `forge_base` is an explicit param now, so no env mutation is needed. + let out = render_flake( + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", + 8000, + "she/her", + &std::collections::HashMap::new(), + &[sample_spec("alice", false, 9001)], + "http://forge.example.test", + ); + assert!( + out.contains( + "agent-alice.url = \"git+http://forge.example.test/agent-configs/alice.git\"" + ), + "expected the agent input to point at the forge config repo:\n{out}" + ); + assert!( + !out.contains("agent-alice.url = \"git+file://"), + "the local applied/ path must no longer be the persistent input:\n{out}" + ); + } + #[test] fn render_flake_embeds_hive_ca_when_signalled() { // When hive-tls.nix signals a self-signed hive CA via @@ -1665,6 +1779,7 @@ mod tests { "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], + "http://forge.test", ) }; @@ -1747,6 +1862,7 @@ mod tests { "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], + "http://forge.test", ) }; unsafe { @@ -1831,6 +1947,7 @@ mod tests { "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], + "http://forge.test", ) }; unsafe { diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index ae2736be..ce89b2fe 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -21,9 +21,33 @@ let # only satisfies the ownership guard. libgit2 honours the literal `*` # (mid-path globs aren't supported, so per-agent repos can't be listed); # in practice these processes only ever touch hyperhive's own repos. + # + # The agent config inputs now live on the forge + # (`git+http://${forge.domain}/agent-configs/.git`, see meta.rs render), + # so hive-core's `nix flake update` of those inputs is an authenticated + # `git+http` fetch. The `[credential]` stanza points git at the `hive-forge` + # helper below (scoped to the forge host) so the fetch authenticates as the + # forge `core` user with no token in any URL or lock. safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" '' [safe] directory = * + [credential "http://${config.services.hyperhive.forge.domain}"] + helper = hive-forge + username = core + ''; + + # git credential helper for hive-core's authenticated fetches of the + # agent-config repos on the forge. Reads the live forge-core admin token + # (`/var/lib/hyperhive/forge-core-token` — paths.rs `FORGE_CORE_TOKEN`) on + # every invocation, so it never holds a stale copy and survives token + # rotation. Scoped to the forge host by the `[credential]` stanza above; + # implements the git credential-helper protocol (only `get` answers). + forgeCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" '' + [ "''${1:-}" = "get" ] || exit 0 + if [ -r /var/lib/hyperhive/forge-core-token ]; then + printf 'username=core\n' + printf 'password=%s\n' "$(cat /var/lib/hyperhive/forge-core-token)" + fi ''; # The `hive-c0re serve` config JSON. Keys are snake_case to match the @@ -116,6 +140,9 @@ in after = [ "hive-c0re.socket" ]; path = [ pkgs.git + # `git-credential-hive-forge` on PATH so git finds it when nix fetches + # the forge-hosted agent-config inputs (helper = hive-forge). + forgeCredHelper "/run/current-system/sw" ]; environment = import ./environment.nix { inherit lib config pkgs; };