From fe5a41288d9bab8cd08b46f5b610baf5aee8f695 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 1 Jun 2026 22:23:33 +0200 Subject: [PATCH] feat(#1003): inject pkgs.path into meta flake as explicit nixpkgs.url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meta flake was using `nixpkgs.follows = "hyperhive/nixpkgs"` but `hyperhive` is a store-path input, so nix resolves hyperhive's own pinned lock rather than the host's follows-substituted version. When an operator sets `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` in their host flake, the meta flake was silently ignoring it and using hyperhive's pinned nixpkgs instead. Fix: hive-c0re.nix injects `--nixpkgs-flake path:${pkgs.path}` into the daemon's ExecStart. `pkgs` IS the host's nixpkgs when follows is set; otherwise it's hyperhive's own pin — so the meta flake gets the right nixpkgs in both cases. render_flake emits `nixpkgs.url = "..."` (explicit) when nixpkgs_flake is non-empty, falling back to the old `follows` form when empty for backward compat. --- hive-c0re/src/actions.rs | 3 ++ hive-c0re/src/auto_update.rs | 2 + hive-c0re/src/coordinator.rs | 10 ++++ hive-c0re/src/lifecycle.rs | 4 ++ hive-c0re/src/main.rs | 13 +++++ hive-c0re/src/meta.rs | 101 ++++++++++++++++++++++------------- hive-c0re/src/migrate.rs | 1 + hive-c0re/src/server.rs | 2 + nix/modules/hive-c0re.nix | 2 +- 9 files changed, 99 insertions(+), 39 deletions(-) diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 1905ac0b..18568c80 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -224,6 +224,7 @@ pub async fn run_approval_spawn( let result = lifecycle::spawn( &approval.agent, &coord.hyperhive_flake, + &coord.nixpkgs_flake, &agent_dir, &proposed_dir, &applied_dir, @@ -543,6 +544,7 @@ async fn run_apply_commit( }; if let Err(e) = crate::meta::sync_agents( &coord.hyperhive_flake, + &coord.nixpkgs_flake, coord.dashboard_port, &coord.operator_pronouns, &coord.context_window_tokens, @@ -713,6 +715,7 @@ async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> { let agents = lifecycle::agents_for_meta_listing().await?; crate::meta::sync_agents( &coord.hyperhive_flake, + &coord.nixpkgs_flake, coord.dashboard_port, &coord.operator_pronouns, &coord.context_window_tokens, diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 856b1caf..3015e178 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -88,6 +88,7 @@ pub async fn rebuild_agent( let result = lifecycle::rebuild( name, &coord.hyperhive_flake, + &coord.nixpkgs_flake, &agent_dir, &applied_dir, &claude_dir, @@ -190,6 +191,7 @@ pub async fn ensure_manager(coord: &Arc) -> Result<()> { lifecycle::spawn( MANAGER_NAME, &coord.hyperhive_flake, + &coord.nixpkgs_flake, &runtime, &proposed, &applied, diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 7bea60bc..3eed7564 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -51,6 +51,14 @@ pub struct Coordinator { /// URL of the hyperhive flake (no fragment). Inlined into per-agent /// `flake.nix` files as `inputs.hyperhive.url`. pub hyperhive_flake: String, + /// Store-path URL of the nixpkgs to wire into the meta flake as + /// `inputs.nixpkgs.url`. Populated by `--nixpkgs-flake` (set by the + /// NixOS module to `"path:${pkgs.path}"` so the meta flake always + /// tracks the same nixpkgs the host evaluated with — which is the + /// host's nixpkgs when `inputs.hyperhive.inputs.nixpkgs.follows = + /// "nixpkgs"` is set in the host flake). Empty string = legacy + /// `follows = "hyperhive/nixpkgs"` behaviour. + pub nixpkgs_flake: String, /// TCP port the host's hive-c0re dashboard listens on. Inlined into /// each per-agent flake so the agent's web UI can build the right /// rebuild-button URL pointing back at the dashboard. @@ -211,6 +219,7 @@ impl Coordinator { pub fn open( db_path: &Path, hyperhive_flake: String, + nixpkgs_flake: String, dashboard_port: u16, operator_pronouns: String, context_window_tokens: std::collections::HashMap, @@ -242,6 +251,7 @@ impl Coordinator { scheduled_prompts: Arc::new(scheduled_prompts), build_logs, hyperhive_flake, + nixpkgs_flake, dashboard_port, operator_pronouns, context_window_tokens, diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 516ae0db..b87f58bb 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -185,6 +185,7 @@ async fn port_collision(self_name: &str) -> Option { pub async fn spawn( name: &str, hyperhive_flake: &str, + nixpkgs_flake: &str, agent_dir: &Path, proposed_dir: &Path, applied_dir: &Path, @@ -211,6 +212,7 @@ pub async fn spawn( let agents = agents_after_spawn(name).await?; crate::meta::sync_agents( hyperhive_flake, + nixpkgs_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -349,6 +351,7 @@ pub async fn destroy(name: &str) -> Result<()> { pub async fn rebuild( name: &str, hyperhive_flake: &str, + nixpkgs_flake: &str, agent_dir: &Path, applied_dir: &Path, claude_dir: &Path, @@ -366,6 +369,7 @@ pub async fn rebuild( let agents = agents_for_meta(None).await?; crate::meta::sync_agents( hyperhive_flake, + nixpkgs_flake, dashboard_port, operator_pronouns, context_window_tokens, diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 6d149abf..8bc01d94 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -36,6 +36,15 @@ enum Cmd { /// `flake.nix` as the `hyperhive` input. #[arg(long, default_value = "/etc/hyperhive")] hyperhive_flake: String, + /// Store-path URL of the nixpkgs to wire into the meta flake as + /// `inputs.nixpkgs.url`. Set by the NixOS module to + /// `"path:${pkgs.path}"` so the meta flake tracks exactly the + /// nixpkgs the host was evaluated with (the host's own nixpkgs + /// when `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` is + /// set, otherwise hyperhive's pin). Empty = legacy + /// `follows = "hyperhive/nixpkgs"` fallback. + #[arg(long, default_value = "")] + nixpkgs_flake: String, /// Path to the sqlite message store. #[arg(long, default_value = "/var/lib/hyperhive/broker.sqlite")] db: PathBuf, @@ -118,6 +127,7 @@ async fn main() -> Result<()> { match cli.cmd { Cmd::Serve { hyperhive_flake, + nixpkgs_flake, db, dashboard_port, operator_pronouns, @@ -125,6 +135,7 @@ async fn main() -> Result<()> { } => { cmd_serve( hyperhive_flake, + nixpkgs_flake, db, dashboard_port, operator_pronouns, @@ -172,6 +183,7 @@ async fn main() -> Result<()> { /// dashboard), then serve the admin socket until a signal arrives. async fn cmd_serve( hyperhive_flake: String, + nixpkgs_flake: String, db: std::path::PathBuf, dashboard_port: u16, operator_pronouns: String, @@ -183,6 +195,7 @@ async fn cmd_serve( let coord = Arc::new(Coordinator::open( &db, hyperhive_flake, + nixpkgs_flake, dashboard_port, operator_pronouns, cwt, diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 8bd21f32..8b7cc8d8 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -52,6 +52,7 @@ pub fn meta_dir() -> PathBuf { #[allow(dead_code, clippy::implicit_hasher)] // first caller lands in a later commit pub async fn sync_agents( hyperhive_flake: &str, + nixpkgs_flake: &str, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -63,6 +64,7 @@ pub async fn sync_agents( let new_flake = render_flake( hyperhive_flake, + nixpkgs_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -300,6 +302,7 @@ pub async fn lock_update_hyperhive() -> Result<()> { fn render_flake( hyperhive_flake: &str, + nixpkgs_flake: &str, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -307,6 +310,7 @@ fn render_flake( ) -> String { render_flake_with_lookup( hyperhive_flake, + nixpkgs_flake, dashboard_port, operator_pronouns, context_window_tokens, @@ -392,6 +396,7 @@ fn agent_canonical_inputs(name: &str) -> Vec<&'static str> { )] fn render_flake_with_lookup( hyperhive_flake: &str, + nixpkgs_flake: &str, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -404,27 +409,30 @@ where use std::fmt::Write as _; let mut out = String::new(); out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n"); - // hyperhive's own flake.nix is the single channel-pin authority. - // meta declares `nixpkgs` + `nixpkgs-unstable` as aliases for - // hyperhive's sub-inputs via `follows`, so every agent-level - // `inputs..inputs.nixpkgs.follows = "nixpkgs"` directive - // resolves transitively to hyperhive's pin. One channel decision - // in the whole tree, no second source to drift. + // `hyperhive` is the single channel-pin authority. `nixpkgs` is wired + // to the exact nixpkgs store path hive-c0re was evaluated with — which + // is the host's nixpkgs when the operator sets + // `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` in their host + // flake, or hyperhive's own pin otherwise. Using an explicit `path:` + // URL instead of `follows = "hyperhive/nixpkgs"` is essential here: + // meta points to hyperhive's store path, so nix would otherwise + // resolve hyperhive's own pinned lock rather than the host-substituted + // version that `follows` produced. // - // All nixpkgs follow the one hyperhive was deployed with — - // anything else would drift. + // `nixpkgs-unstable` still follows hyperhive (claude-code lives there; + // no same-channel requirement from the host side). // - // Operators who want to slide the whole swarm onto a different - // channel do it at the host level via - // `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"`, which - // makes hyperhive's nixpkgs = the host's nixpkgs and cascades - // through to every agent. - // - // `nixpkgs` is still a single canonical name in the meta tree, - // it just resolves through hyperhive instead of being its own - // root input. + // `nixpkgs` is the single canonical name in the meta tree — every + // agent that declares it in its own `flake.nix` gets a + // `agent-.inputs.nixpkgs.follows = "nixpkgs"` directive that + // collapses all per-agent nixpkgs nodes into one. let _ = writeln!(out, " hyperhive.url = \"{hyperhive_flake}\";"); - out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n"); + if nixpkgs_flake.is_empty() { + // Fallback: legacy behaviour when nixpkgs_flake not injected. + out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n"); + } else { + let _ = writeln!(out, " nixpkgs.url = \"{nixpkgs_flake}\";"); + } out.push_str(" nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\";\n"); for spec in agents { let _ = writeln!( @@ -687,40 +695,55 @@ mod tests { } #[test] - fn render_flake_aliases_nixpkgs_to_hyperhive() { + fn render_flake_uses_explicit_nixpkgs_url_when_provided() { let out = render_flake( "github:example/hyperhive", + "path:/nix/store/aaaa-nixpkgs-source", 8000, "she/her", &std::collections::HashMap::new(), &[sample_spec("alice", false, 9001)], ); - // Meta's `nixpkgs` + `nixpkgs-unstable` are aliases for - // hyperhive's sub-inputs. Single channel-pin authority: - // hyperhive's own flake.nix. All nixpkgs follow the one - // hyperhive was deployed with. + // Explicit nixpkgs_flake → meta uses `nixpkgs.url`, NOT follows. + // This is the path taken when hive-c0re.nix injects `pkgs.path`: + // the URL is the exact nixpkgs evaluated with the host's nixpkgs + // (which IS the host's version when `follows` is set). assert!( - out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""), - "missing nixpkgs follows alias:\n{out}" + out.contains("nixpkgs.url = \"path:/nix/store/aaaa-nixpkgs-source\""), + "expected explicit nixpkgs.url:\n{out}" ); + assert!( + !out.contains("nixpkgs.follows"), + "follows must not appear when nixpkgs_flake is set:\n{out}" + ); + // nixpkgs-unstable still follows hyperhive (claude-code lives there). assert!( out.contains("nixpkgs-unstable.follows = \"hyperhive/nixpkgs-unstable\""), - "missing nixpkgs-unstable follows alias:\n{out}" - ); - // And conversely: no literal channel ref baked into meta. If - // this fails, someone reintroduced a hardcoded ref — would - // drift away from hyperhive's pin. - assert!( - !out.contains("nixpkgs.url ="), - "no literal `nixpkgs.url` should be emitted (hyperhive owns the pin):\n{out}" + "missing nixpkgs-unstable follows:\n{out}" ); } - // `render_flake_collapses_hyperhive_nixpkgs_via_follows` dropped: - // with meta's `nixpkgs.follows = "hyperhive/nixpkgs"`, there's no - // separate meta-level nixpkgs to collapse hyperhive's into. The - // redirect goes the other way now (the alias test above covers - // the new invariant). + #[test] + fn render_flake_falls_back_to_follows_when_nixpkgs_flake_empty() { + // Empty nixpkgs_flake → legacy follows behaviour (backward compat + // for any code path that can't inject pkgs.path). + let out = render_flake( + "github:example/hyperhive", + "", + 8000, + "she/her", + &std::collections::HashMap::new(), + &[sample_spec("alice", false, 9001)], + ); + assert!( + out.contains("nixpkgs.follows = \"hyperhive/nixpkgs\""), + "expected fallback follows:\n{out}" + ); + assert!( + !out.contains("nixpkgs.url ="), + "no explicit url should be emitted in fallback mode:\n{out}" + ); + } #[test] fn render_flake_emits_follows_for_agents_declaring_nixpkgs() { @@ -735,6 +758,7 @@ mod tests { }; let out = render_flake_with_lookup( "github:example/hyperhive", + "path:/nix/store/aaaa-nixpkgs-source", 8000, "she/her", &std::collections::HashMap::new(), @@ -768,6 +792,7 @@ mod tests { fn render_flake_skips_canonical_follows_when_lookup_returns_empty() { let out = render_flake_with_lookup( "github:example/hyperhive", + "path:/nix/store/aaaa-nixpkgs-source", 8000, "she/her", &std::collections::HashMap::new(), diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 8b37643d..2a026c50 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -78,6 +78,7 @@ pub async fn run(coord: &Arc) -> Result<()> { .unwrap_or_default(); if let Err(e) = meta::sync_agents( &coord.hyperhive_flake, + &coord.nixpkgs_flake, coord.dashboard_port, &coord.operator_pronouns, &coord.context_window_tokens, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 62d7410f..43087a58 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -87,6 +87,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { match lifecycle::spawn( name, &coord.hyperhive_flake, + &coord.nixpkgs_flake, &agent_dir, &proposed_dir, &applied_dir, @@ -151,6 +152,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let result = lifecycle::rebuild( name, &coord.hyperhive_flake, + &coord.nixpkgs_flake, &agent_dir, &applied_dir, &claude_dir, diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 38a7f158..90cd8e6e 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -373,7 +373,7 @@ in ); }; serviceConfig = { - ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}"; + ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake path:${pkgs.path} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}"; Restart = "on-failure"; RestartSec = 2; RuntimeDirectory = "hyperhive";