feat(#1003): inject pkgs.path into meta flake as explicit nixpkgs.url

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.
This commit is contained in:
atlas 2026-06-01 22:23:33 +02:00
commit fe5a41288d
9 changed files with 97 additions and 37 deletions

View file

@ -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,

View file

@ -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<Coordinator>) -> Result<()> {
lifecycle::spawn(
MANAGER_NAME,
&coord.hyperhive_flake,
&coord.nixpkgs_flake,
&runtime,
&proposed,
&applied,

View file

@ -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<String, u64>,
@ -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,

View file

@ -185,6 +185,7 @@ async fn port_collision(self_name: &str) -> Option<String> {
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,

View file

@ -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,

View file

@ -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<String, u64>,
@ -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<String, u64>,
@ -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<F>(
hyperhive_flake: &str,
nixpkgs_flake: &str,
dashboard_port: u16,
operator_pronouns: &str,
context_window_tokens: &std::collections::HashMap<String, u64>,
@ -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.<X>.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-<n>.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(),

View file

@ -78,6 +78,7 @@ pub async fn run(coord: &Arc<Coordinator>) -> 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,

View file

@ -87,6 +87,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> 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<Coordinator>) -> HostResponse {
let result = lifecycle::rebuild(
name,
&coord.hyperhive_flake,
&coord.nixpkgs_flake,
&agent_dir,
&applied_dir,
&claude_dir,

View file

@ -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";