diff --git a/AGENTS.md b/AGENTS.md index 80dc74c3..a80537af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,8 @@ Per-hive (host + in-container): both (under a unit, stdout already is the journal). Do not grow it into a utility crate. - **`hive-metric/`** — one-shot CLI pushing a labeled metric over OTLP. +- **`hive-meta-flake/`** — the meta flake's `flake.nix` text, a pure + render. hive-c0re and swarm-controller both call it. Wire types + socket plumbing: diff --git a/Cargo.lock b/Cargo.lock index d18e0546..edb39e32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1781,6 +1781,7 @@ dependencies = [ "hive-jobq", "hive-jobq-wire", "hive-log", + "hive-meta-flake", "hive-priv-sock", "hive-sh4re", "hive-sock-client", @@ -1959,6 +1960,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hive-meta-flake" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde_json", + "swarm-wire", +] + [[package]] name = "hive-metric" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 30ab2a15..cbea8d23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "hive-jobq-metrics", "hive-jobq-wire", "hive-log", + "hive-meta-flake", "hive-matrix-mcp", "hive-metric", "hive-priv", @@ -107,6 +108,7 @@ hive-jobq = { path = "hive-jobq" } hive-jobq-metrics = { path = "hive-jobq-metrics" } hive-jobq-wire = { path = "hive-jobq-wire" } hive-log = { path = "hive-log" } +hive-meta-flake = { path = "hive-meta-flake" } hive-core-agent-sock = { path = "hive-core-agent-sock" } hive-claude = "0.1.4" hive-host-sock = { path = "hive-host-sock" } diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 6bbb7cf2..4678d516 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -39,6 +39,7 @@ hive-host-sock.workspace = true hive-jobq.workspace = true hive-jobq-wire.workspace = true hive-log.workspace = true +hive-meta-flake.workspace = true hive-priv-sock.workspace = true hive-sock-client.workspace = true hive-types.workspace = true diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index a14451d0..3c1a3b4a 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -367,7 +367,7 @@ fn read_active_model(name: &hive_types::Ident) -> Option { /// The agent-side /// `hive-agent::identity::{hive_name, swarm_name}` accessors read the /// same env vars after they're forwarded into each sub-agent's -/// harness service environment by `meta::render_flake`; surfacing +/// harness service environment by `hive_meta_flake::render_flake`; surfacing /// them here from c0re's own env keeps the manager + agent /// `GetAgentMeta` paths consistent without a round-trip to the /// target container. diff --git a/hive-c0re/src/forge/users.rs b/hive-c0re/src/forge/users.rs index 22e854bc..979ba4e3 100644 --- a/hive-c0re/src/forge/users.rs +++ b/hive-c0re/src/forge/users.rs @@ -55,7 +55,7 @@ fn extract_token(output: &str) -> Option { } /// Canonical email address for a hive agent's Forgejo account. -/// Must match the `user.email` set by `meta::render_flake` so commits +/// Must match the `user.email` set by `hive_meta_flake::render_flake` so commits /// by the agent link back to their Forgejo profile page. fn agent_email(name: &str) -> String { format!("{name}@hyperhive.local") diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 48891760..ee864626 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -13,6 +13,11 @@ use tokio::process::Command; use tokio::sync::{Mutex, MutexGuard}; use tokio::time::Instant; +pub use hive_meta_flake::{AgentPin, AgentSpec}; +#[cfg(test)] +use hive_meta_flake::{FORWARDED_VAR_OPTIONS, push_forwarded_var_options}; +use hive_meta_flake::{FORWARDED_VARS, canonical_inputs_in_lock, pinned_git_url}; + use crate::coordinator::HiveEnv; use crate::lifecycle; @@ -112,30 +117,6 @@ async fn lock_notifying<'a>( /// Where the manager sees this directory inside its container (RO bind). pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta"; -#[derive(Debug, Clone)] -pub struct AgentSpec { - pub name: String, - pub port: u16, - /// The archetype the agent is built from (`crate::archetypes`), `None` - /// for a custom agent built from its own config repo. - pub archetype: Option, - /// The pin of a custom agent's config input. `None` until [`sync_agents`] - /// resolves it; an archetype agent has no config input and keeps `None`. - pub pin: Option, -} - -/// What the rendered flake needs to know about one agent's config input, all -/// of it read from the agent's config repo at one commit. The rendered text -/// is a function of this and nothing on the host. -#[derive(Debug, Clone)] -pub struct AgentPin { - /// `git+/agent-configs/.git?rev=`, `` being the head of - /// `main` on the forge when the flake was rendered. - pub url: String, - /// The `CANONICAL_INPUTS` the agent's `flake.lock` declares at that commit. - pub canonical_inputs: Vec<&'static str>, -} - /// Stage topology.json if it exists: it is regenerated by `reconcile` /// whenever the agent set changed. Without staging, an /// existing-but-untracked file shows up as untracked in the meta repo, @@ -422,15 +403,6 @@ fn hyperhive_input_url(hive: &HiveEnv) -> Result { ) } -/// The pure half of [`hyperhive_input_url`], testable without `HIVE_FORGE_URL`. -fn pinned_git_url(forge_git_url: &str, rev: Option<&str>) -> Result { - let rev = rev.filter(|r| !r.is_empty()).context( - "hyperhive rev unknown: the host was built from a dirty tree \ - (`self.rev` unset) and `services.hyperhive.c0re.hyperhiveRev` is not set", - )?; - Ok(format!("git+{forge_git_url}?rev={rev}")) -} - /// [`render_flake`] with the hyperhive pin taken from `hive`, as /// [`sync_agents`] renders it. Nothing else in `hive` reaches the flake: the /// values that differ per host go to the container at start @@ -439,113 +411,15 @@ fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result { render_flake(&hyperhive_input_url(hive)?, agents) } -/// Canonical inputs meta knows how to dedup. An agent that declares one -/// of these as a top-level input in its own `flake.nix` will get a -/// `follows = ""` line emitted in meta — collapsing the -/// otherwise-separate-but-identical `nixpkgs_N` nodes into a single -/// meta-level reference. -const CANONICAL_INPUTS: &[&str] = &["nixpkgs"]; - -/// Env vars hive-c0re forwards from its own systemd unit env into every -/// sub-agent's harness service env. Each entry is `(env_var_name, -/// host_value)`. Empty / unset vars are filtered out so absent options -/// don't render no-op `FOO = ""` lines into the meta flake. -/// -/// Returns owned `String` values so the result is `'static`-friendly + -/// trivial to stub from tests (which build their own slice instead of -/// touching process-wide env). -const FORWARDED_VARS: &[&str] = &[ - // Where the swarm queue is, as an agent container reaches it. An address - // the *host* computes, never a loopback address, which inside a container - // is the agent itself. - "HIVE_AGENT_NATS_URL", - // Where the swarm secret store listens. The hive's own `BAO_ADDR` value, - // forwarded under a separate name so the container never inherits the - // hive's store environment wholesale — the address is the only part of it - // an agent may have, the certificate beside it being the hive's own. - "HIVE_AGENT_BAO_ADDR", - // The mount path of the store's services `pki` engine, whose root an agent - // fetches to trust the swarm. Forwarded with the address, under the same gate. - "HIVE_AGENT_BAO_SERVICES_PKI_MOUNT", - // Where an agent's `swarm-logs` queries, computed by the host for the - // same reason as the queue's: it is a gateway address, and a container - // cannot derive it from anything it holds. - "HIVE_AGENT_LOGS_QUERY_URL", - // The metrics store's origin, for `swarm-metrics`, for the same reason. - "HIVE_AGENT_METRICS_URL", - "HIVE_FORGE_URL", - "HIVE_FORGE_PUBLIC_URL", - "HIVE_MATRIX_URL", - "HYPERHIVE_SWARM_NAME", -]; - -/// Map of forwarded env var -> the agent option carrying the same value. -/// -/// Both exist because they're consumed at different times: the option is baked -/// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env -/// var is read at runtime. Setting only one leaves the other on its default, -/// which is how a hive ends up with two disagreeing answers for one value. -/// -/// The swarm display name is here for that reason: `otel.nix` reads the option -/// at eval time for the `swarm=` resource label, and with the env var alone -/// that label evaluates to `unknown` while the process env holds the right -/// name. Half-wiring this map is a value that evaluates fine and is silently -/// wrong. -const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[ - // Read at build time to decide whether the in-container identity check is - // generated at all — see `nix/agent-modules/bao.nix`. Both halves wired - // for this map's own reason: the unit bakes the address into its own - // environment, so an agent whose option says no store and whose env says - // otherwise would have a check that is simply not there. - ("HIVE_AGENT_BAO_ADDR", "services.hyperhive.agent.bao.addr"), - // Read at build time to bake the root-fetch URL into - // `nix/agent-modules/hive-ca.nix`'s unit. - ( - "HIVE_AGENT_BAO_SERVICES_PKI_MOUNT", - "services.hyperhive.agent.bao.servicesPkiMount", - ), - // Read at build time to decide whether `swarm-logs` is installed at all — - // see `nix/agent-modules/logs.nix`. Both halves wired for this map's own - // reason: an agent whose option says no log store and whose env says - // otherwise has a binary that is not on PATH and coordinates that claim - // it is. - ( - "HIVE_AGENT_LOGS_QUERY_URL", - "services.hyperhive.agent.logs.queryUrl", - ), - // The same for `swarm-metrics`: see `nix/agent-modules/metrics.nix`. - ( - "HIVE_AGENT_METRICS_URL", - "services.hyperhive.agent.metrics.url", - ), - ("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"), - ("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"), - ("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"), -]; - -/// Render the forwarded-var option assignments for one agent's module block. -/// -/// Split out of `render_flake` so it can be tested without touching process -/// env: the render-level tests have to `set_var`, which forces them to -/// serialise on the crate's `test_env` lock (a `#[cfg(test)]` module, so this -/// deliberately isn't an intra-doc link). A pure function over the -/// already-collected pairs has no such hazard. -/// -/// A var that isn't present emits nothing rather than a guess. For an optional -/// service that is the whole point — the agent option defaults to `null`, -/// meaning "not configured", and the units that would use it aren't generated. -/// For a service the hive cannot run without, silence would instead produce a -/// fleet of agents quietly missing an integration, so those are checked by -/// [`require_service_urls`] before this is called. -fn push_forwarded_var_options(out: &mut String, vars: &[(&'static str, String)]) { - use std::fmt::Write as _; - for (var, val) in vars { - let Some((_, option)) = FORWARDED_VAR_OPTIONS.iter().find(|(name, _)| name == var) else { - continue; - }; - let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); - let _ = writeln!(out, " {option} = \"{escaped}\";"); - } +/// [`hive_meta_flake::render_flake`] with the forwarded values and the GitHub +/// switch read from this process's environment. +fn render_flake(hyperhive_url: &str, agents: &[AgentSpec]) -> Result { + hive_meta_flake::render_flake( + hyperhive_url, + agents, + &forwarded_env_vars(), + std::env::var_os("HYPERHIVE_GITHUB_DISABLED").is_some(), + ) } /// Service URLs a running hive must always supply, checked before rendering. @@ -562,7 +436,7 @@ const REQUIRED_SERVICE_URL_VARS: &[&str] = &["HIVE_FORGE_URL"]; /// `vars`. /// /// Pure over the already-collected pairs so it can be tested without touching -/// process env (the same reason [`push_forwarded_var_options`] is split out). +/// process env (the same reason [`hive_meta_flake::push_forwarded_var_options`] is split out). /// /// Checked by `sync_agents` — the point at which the hive commits a rendered /// flake to disk — rather than inside the renderer. Rendering is a pure string @@ -693,252 +567,6 @@ where }) } -/// The subset of `CANONICAL_INPUTS` that the `flake.lock` text `raw` declares -/// as direct (root-level) inputs. Empty when the lock is unparsable — safe -/// degradation, the worst case is no dedup for that agent. -fn canonical_inputs_in_lock(raw: &str) -> Vec<&'static str> { - let Ok(json) = serde_json::from_str::(raw) else { - return Vec::new(); - }; - let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { - return Vec::new(); - }; - let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { - return Vec::new(); - }; - let Some(root_inputs) = nodes - .get(root_name) - .and_then(|n| n.get("inputs")) - .and_then(|v| v.as_object()) - else { - return Vec::new(); - }; - CANONICAL_INPUTS - .iter() - .copied() - .filter(|canon| root_inputs.contains_key(*canon)) - .collect() -} - -/// Render the meta flake. Errors on a custom agent without a [`AgentPin`]. -#[allow( - clippy::too_many_lines, - reason = "templated string-builder for the meta flake — the length is one \ - contiguous fmt block, splitting it would just hide the shape" -)] -fn render_flake(hyperhive_url: &str, agents: &[AgentSpec]) -> Result { - use std::fmt::Write as _; - let mut out = String::new(); - out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n"); - // `hyperhive` is pinned by forge URL and rev, so the flake resolves on - // any machine that can reach the forge. `nixpkgs` follows hyperhive's - // own pin, which cascades to every agent because - // `agent-.inputs.nixpkgs.follows = "nixpkgs"` resolves to this node. - let _ = writeln!(out, " hyperhive.url = \"{hyperhive_url}\";"); - out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n"); - // Each agent's config input is its forge repo at the pinned commit, so - // the flake text depends only on (agent, rev) and resolves anywhere that - // can reach the forge. - for spec in agents { - // An archetype agent has no config repo, so no input: `mkAgent` - // takes its module from the archetype. - if spec.archetype.is_some() { - continue; - } - let pin = spec.pin.as_ref().with_context(|| { - format!( - "agent {}: no pinned rev, refusing to render an unpinned config input", - spec.name - ) - })?; - let _ = writeln!( - out, - " agent-{name}.url = \"{url}\";", - name = spec.name, - url = pin.url, - ); - // For each canonical input the agent's `flake.lock` declares at the - // pinned commit, emit - // `inputs.agent-.inputs..follows = ""`. - // Collapses otherwise-separate-but-identical nixpkgs nodes - // (root + every agent's own nixpkgs) into one. Skipped - // silently for agents that don't declare the input — emitting - // follows on a non-existent input would error at - // `nix flake lock` time. - for canon in &pin.canonical_inputs { - let _ = writeln!( - out, - " agent-{}.inputs.{canon}.follows = \"{canon}\";", - spec.name, - ); - } - } - out.push_str( - " };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n mkAgent = { name, port, archetype ? null }:\n", - ); - out.push_str( - r#" let - base = hyperhive.nixosConfigurations.agent-base; - # Every flake an archetype can come from, by its source name. - archetypeSources = { inherit hyperhive; }; - agentModule = - if archetype == null then - inputs."agent-${name}".nixosModules.default - else - (archetypeSources.${archetype.source} - or (throw "agent ${name}: archetype source ${archetype.source} is not an input of this hive's meta flake") - ).hyperhiveArchetypes.${archetype.name}.module - or (throw "agent ${name}: this hive's ${archetype.source} input has no archetype ${archetype.name}; update that input"); - service = "hive-agent"; - in - base.extendModules { - modules = [ - agentModule - { -"#, - ); - // Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`): - // emit the host's real values as build-time agent config, the same - // host-state -> agent-module shape as the CA block above. - // - // These options exist already, but until now *nothing set them*, so every - // agent silently fell back to their `localhost:` defaults while the - // true value reached the container only as an env var (below). Two sources - // of truth that disagree, with the winner decided by which code path - // happens to read which one --- the option default is baked into scripts - // at build time, the env var is read at runtime. - // - // Rendering them here makes the option the single source, which is the - // precondition for removing those defaults: a loopback address is only - // ever correct when the callee shares the caller's netns, and the forge - // and homeserver are moving to swarm level, possibly on other hosts. - // - // Absent vars emit nothing rather than a guess, and the agent option - // treats "unset" as "this service is not configured" rather than - // substituting a loopback address — an absent integration instead of a - // misdirected one. - // - // That is the right default for an optional service and the wrong one for - // the forge, which a running hive always has — so `sync_agents` rejects a - // missing `HIVE_FORGE_URL` before it writes anything (`require_service_urls`). - // The check lives there rather than here because rendering is a pure string - // operation the tests exercise directly. - push_forwarded_var_options(&mut out, &forwarded_env_vars()); - // GitHub integration is on by default in every agent - // (`services.hyperhive.agent.github.enable`); the host turns it off swarm-wide via - // `services.hyperhive.swarm.github.enable = false`, surfaced here as the - // `HYPERHIVE_GITHUB_DISABLED` env on hive-c0re's unit. Only the OFF - // override is propagated — the enabled default needs no per-agent line. - if std::env::var_os("HYPERHIVE_GITHUB_DISABLED").is_some() { - out.push_str(" services.hyperhive.agent.github.enable = false;\n"); - } - out.push_str( - r#" # The harness service inside the container runs as a - # non-root unix user named after the agent (`damocles`, - # `iris`, `root`, …). UID auto-assigned by NixOS; the - # per-agent override here is what makes - # `services.hyperhive.agent.user.name` match the agent's identity - # instead of the harness default of `"agent"`. - services.hyperhive.agent.user.name = name; - # Without this an agent container is called `nixos` and ships - # every log line under that: nixpkgs sets the hostname from the - # attr name in the `containers.` submodule, and an - # agent's system is its own `nixosSystem` off this flake, which - # never evaluates that option. The machine name, not the - # logical one — it is what `container.name` on our metrics and - # nspawn itself already call this container, so logs join to - # both without a prefix transform. - networking.hostName = "h-${name}"; - programs.git.config.user = { - name = name; - email = "${name}@hyperhive.local"; - }; - # Container-wide env: every service + co-process daemon can - # resolve the agent's durable state dir without hard-coding it. - # `environment.variables` only writes /etc/environment (login - # shells); `systemd.globalEnvironment` is the analogue for - # systemd units so forge-avatar-sync / - # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR` - # without each service having to redeclare it. - environment.variables = { - HIVE_LABEL = name; - HYPERHIVE_STATE_DIR = "/agents/${name}/state"; - HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; - }; - systemd.globalEnvironment = { - HIVE_LABEL = name; - HYPERHIVE_STATE_DIR = "/agents/${name}/state"; - HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; -"#, - ); - // Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment, - // not just the harness service env below, so EVERY service + shell in - // the container inherits them — crucially the bash-task runner (where - // `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync - // and interactive shells. Scoped to the harness service alone they were - // invisible to bash tasks: harmless in shared netns (the localhost - // default works) but broken under isolation, where the in-cluster - // `forge.` URL is the only reachable path. - for (var, val) in forwarded_env_vars() { - let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); - let _ = writeln!(out, " {var} = \"{escaped}\";"); - } - out.push_str( - r" }; - systemd.services.${service}.environment = { - HIVE_PORT = toString port; - HIVE_LABEL = name; -", - ); - // Forwarded env vars — picked up from hive-c0re's own systemd unit - // env (`services.hyperhive.*` options flow through the hyperhive - // NixOS module into the host process). We copy whatever's set into - // each sub-agent's harness service env so the in-container surfaces - // (`identity.rs`, `forge_notify`) see a consistent view across the - // whole hive. Absent host-side env (option not set) → skip emission - // → in-container accessors fall back to None / defaults gracefully. - // - // - HIVE_FORGE_URL: agents poll this for Forgejo notifications. - // - HYPERHIVE_SWARM_NAME: human display name for the swarm. - for (var, val) in forwarded_env_vars() { - let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); - let _ = writeln!(out, " {var} = \"{escaped}\";"); - } - out.push_str( - r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state"; - HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; - }; - } - # Tells the harness the agent has no config repo of its own. - (if archetype == null then { } else { - systemd.services.${service}.environment.HIVE_ARCHETYPE = archetype.name; - }) - ]; - }; - in - { - nixosConfigurations = { -"#, - ); - for spec in agents { - // `source` and `name` are identifiers (`archetypes::store_read`), so - // they need no escaping. - let archetype = spec.archetype.as_ref().map_or(String::new(), |a| { - format!( - " archetype = {{ source = \"{}\"; name = \"{}\"; }};", - a.source, a.name - ) - }); - let _ = writeln!( - out, - " {} = mkAgent {{ name = \"{}\"; port = {};{archetype} }};", - spec.name, spec.name, spec.port, - ); - } - out.push_str(" };\n };\n}\n"); - Ok(out) -} - /// Return the list of file names that are currently staged (index differs /// from HEAD). On the initial commit (`HEAD` doesn't exist yet) falls back /// to `git diff --cached --name-only HEAD` failing gracefully by using diff --git a/hive-meta-flake/Cargo.toml b/hive-meta-flake/Cargo.toml new file mode 100644 index 00000000..2402defd --- /dev/null +++ b/hive-meta-flake/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "hive-meta-flake" +edition.workspace = true +version.workspace = true +readme = "README.md" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +serde_json.workspace = true +swarm-wire.workspace = true diff --git a/hive-meta-flake/README.md b/hive-meta-flake/README.md new file mode 100644 index 00000000..9e1141be --- /dev/null +++ b/hive-meta-flake/README.md @@ -0,0 +1,10 @@ +# hive-meta-flake + +The meta flake's `flake.nix` text: one `nixosConfigurations.` per agent, +each built from hyperhive's `agent-base` plus the agent's config input or +archetype. + +Pure string rendering, with no IO and no environment reads. The caller passes +the hyperhive pin, the agents with their pins, and the values every agent +container gets. hive-c0re and swarm-controller both call `render_flake`, so +there is one renderer. diff --git a/hive-meta-flake/src/lib.rs b/hive-meta-flake/src/lib.rs new file mode 100644 index 00000000..cf7f0861 --- /dev/null +++ b/hive-meta-flake/src/lib.rs @@ -0,0 +1,399 @@ +//! The meta flake's text: one `nixosConfigurations.` per agent, built +//! from hyperhive's `agent-base` and the agent's config input or archetype. +//! +//! Pure: the output is a function of the arguments only. hive-c0re and +//! swarm-controller both render through [`render_flake`], so the flake a hive +//! builds has one renderer. + +use anyhow::{Context, Result}; + +/// One agent of the meta flake. +#[derive(Debug, Clone)] +pub struct AgentSpec { + pub name: String, + pub port: u16, + /// The archetype the agent is built from, `None` + /// for a custom agent built from its own config repo. + pub archetype: Option, + /// The pin of a custom agent's config input. `None` until the caller + /// resolves it; an archetype agent has no config input and keeps `None`. + pub pin: Option, +} + +/// What the rendered flake needs to know about one agent's config input, all +/// of it read from the agent's config repo at one commit. The rendered text +/// is a function of this and nothing on the host. +#[derive(Debug, Clone)] +pub struct AgentPin { + /// `git+/agent-configs/.git?rev=`, `` being the head of + /// `main` on the forge when the flake was rendered. + pub url: String, + /// The `CANONICAL_INPUTS` the agent's `flake.lock` declares at that commit. + pub canonical_inputs: Vec<&'static str>, +} + +/// `git+?rev=`. Errors on a missing or empty rev rather +/// than rendering an unpinned URL. +pub fn pinned_git_url(forge_git_url: &str, rev: Option<&str>) -> Result { + let rev = rev.filter(|r| !r.is_empty()).context( + "hyperhive rev unknown: the host was built from a dirty tree \ + (`self.rev` unset) and `services.hyperhive.c0re.hyperhiveRev` is not set", + )?; + Ok(format!("git+{forge_git_url}?rev={rev}")) +} + +/// Canonical inputs meta knows how to dedup. An agent that declares one +/// of these as a top-level input in its own `flake.nix` will get a +/// `follows = ""` line emitted in meta — collapsing the +/// otherwise-separate-but-identical `nixpkgs_N` nodes into a single +/// meta-level reference. +pub const CANONICAL_INPUTS: &[&str] = &["nixpkgs"]; + +/// Env vars the meta flake sets in every agent container, from the +/// `(name, value)` pairs [`render_flake`] is given. A caller passes only the +/// names it has a non-empty value for, so absent options render no no-op +/// `FOO = ""` lines. +pub const FORWARDED_VARS: &[&str] = &[ + // Where the swarm queue is, as an agent container reaches it. An address + // the *host* computes, never a loopback address, which inside a container + // is the agent itself. + "HIVE_AGENT_NATS_URL", + // Where the swarm secret store listens. The hive's own `BAO_ADDR` value, + // forwarded under a separate name so the container never inherits the + // hive's store environment wholesale — the address is the only part of it + // an agent may have, the certificate beside it being the hive's own. + "HIVE_AGENT_BAO_ADDR", + // The mount path of the store's services `pki` engine, whose root an agent + // fetches to trust the swarm. Forwarded with the address, under the same gate. + "HIVE_AGENT_BAO_SERVICES_PKI_MOUNT", + // Where an agent's `swarm-logs` queries, computed by the host for the + // same reason as the queue's: it is a gateway address, and a container + // cannot derive it from anything it holds. + "HIVE_AGENT_LOGS_QUERY_URL", + // The metrics store's origin, for `swarm-metrics`, for the same reason. + "HIVE_AGENT_METRICS_URL", + "HIVE_FORGE_URL", + "HIVE_FORGE_PUBLIC_URL", + "HIVE_MATRIX_URL", + "HYPERHIVE_SWARM_NAME", +]; + +/// Map of forwarded env var -> the agent option carrying the same value. +/// +/// Both exist because they're consumed at different times: the option is baked +/// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env +/// var is read at runtime. Setting only one leaves the other on its default, +/// which is how a hive ends up with two disagreeing answers for one value. +/// +/// The swarm display name is here for that reason: `otel.nix` reads the option +/// at eval time for the `swarm=` resource label, and with the env var alone +/// that label evaluates to `unknown` while the process env holds the right +/// name. Half-wiring this map is a value that evaluates fine and is silently +/// wrong. +pub const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[ + // Read at build time to decide whether the in-container identity check is + // generated at all — see `nix/agent-modules/bao.nix`. Both halves wired + // for this map's own reason: the unit bakes the address into its own + // environment, so an agent whose option says no store and whose env says + // otherwise would have a check that is simply not there. + ("HIVE_AGENT_BAO_ADDR", "services.hyperhive.agent.bao.addr"), + // Read at build time to bake the root-fetch URL into + // `nix/agent-modules/hive-ca.nix`'s unit. + ( + "HIVE_AGENT_BAO_SERVICES_PKI_MOUNT", + "services.hyperhive.agent.bao.servicesPkiMount", + ), + // Read at build time to decide whether `swarm-logs` is installed at all — + // see `nix/agent-modules/logs.nix`. Both halves wired for this map's own + // reason: an agent whose option says no log store and whose env says + // otherwise has a binary that is not on PATH and coordinates that claim + // it is. + ( + "HIVE_AGENT_LOGS_QUERY_URL", + "services.hyperhive.agent.logs.queryUrl", + ), + // The same for `swarm-metrics`: see `nix/agent-modules/metrics.nix`. + ( + "HIVE_AGENT_METRICS_URL", + "services.hyperhive.agent.metrics.url", + ), + ("HIVE_FORGE_URL", "services.hyperhive.agent.forge.url"), + ("HIVE_MATRIX_URL", "services.hyperhive.agent.matrix.url"), + ("HYPERHIVE_SWARM_NAME", "services.hyperhive.agent.swarmName"), +]; + +/// Render the forwarded-var option assignments for one agent's module block. +/// +/// Split out of `render_flake` so it can be tested without touching process +/// env: the render-level tests have to `set_var`, which forces them to +/// serialise on the crate's `test_env` lock (a `#[cfg(test)]` module, so this +/// deliberately isn't an intra-doc link). A pure function over the +/// already-collected pairs has no such hazard. +/// +/// A var that isn't present emits nothing rather than a guess. For an optional +/// service that is the whole point — the agent option defaults to `null`, +/// meaning "not configured", and the units that would use it aren't generated. +/// For a service the hive cannot run without, silence would instead produce a +/// fleet of agents quietly missing an integration, so the caller checks +/// those before rendering. +pub fn push_forwarded_var_options(out: &mut String, vars: &[(&'static str, String)]) { + use std::fmt::Write as _; + for (var, val) in vars { + let Some((_, option)) = FORWARDED_VAR_OPTIONS.iter().find(|(name, _)| name == var) else { + continue; + }; + let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); + let _ = writeln!(out, " {option} = \"{escaped}\";"); + } +} + +/// The subset of `CANONICAL_INPUTS` that the `flake.lock` text `raw` declares +/// as direct (root-level) inputs. Empty when the lock is unparsable — safe +/// degradation, the worst case is no dedup for that agent. +#[must_use] +pub fn canonical_inputs_in_lock(raw: &str) -> Vec<&'static str> { + let Ok(json) = serde_json::from_str::(raw) else { + return Vec::new(); + }; + let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else { + return Vec::new(); + }; + let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { + return Vec::new(); + }; + let Some(root_inputs) = nodes + .get(root_name) + .and_then(|n| n.get("inputs")) + .and_then(|v| v.as_object()) + else { + return Vec::new(); + }; + CANONICAL_INPUTS + .iter() + .copied() + .filter(|canon| root_inputs.contains_key(*canon)) + .collect() +} + +/// Render the meta flake. `vars` are the [`FORWARDED_VARS`] pairs with a +/// value; `github_disabled` turns the agents' GitHub integration off. Errors +/// on a custom agent without an [`AgentPin`]. +#[allow( + clippy::too_many_lines, + reason = "templated string-builder for the meta flake — the length is one \ + contiguous fmt block, splitting it would just hide the shape" +)] +pub fn render_flake( + hyperhive_url: &str, + agents: &[AgentSpec], + vars: &[(&'static str, String)], + github_disabled: bool, +) -> Result { + use std::fmt::Write as _; + let mut out = String::new(); + out.push_str("{\n description = \"hyperhive deployed agents\";\n inputs = {\n"); + // `hyperhive` is pinned by forge URL and rev, so the flake resolves on + // any machine that can reach the forge. `nixpkgs` follows hyperhive's + // own pin, which cascades to every agent because + // `agent-.inputs.nixpkgs.follows = "nixpkgs"` resolves to this node. + let _ = writeln!(out, " hyperhive.url = \"{hyperhive_url}\";"); + out.push_str(" nixpkgs.follows = \"hyperhive/nixpkgs\";\n"); + // Each agent's config input is its forge repo at the pinned commit, so + // the flake text depends only on (agent, rev) and resolves anywhere that + // can reach the forge. + for spec in agents { + // An archetype agent has no config repo, so no input: `mkAgent` + // takes its module from the archetype. + if spec.archetype.is_some() { + continue; + } + let pin = spec.pin.as_ref().with_context(|| { + format!( + "agent {}: no pinned rev, refusing to render an unpinned config input", + spec.name + ) + })?; + let _ = writeln!( + out, + " agent-{name}.url = \"{url}\";", + name = spec.name, + url = pin.url, + ); + // For each canonical input the agent's `flake.lock` declares at the + // pinned commit, emit + // `inputs.agent-.inputs..follows = ""`. + // Collapses otherwise-separate-but-identical nixpkgs nodes + // (root + every agent's own nixpkgs) into one. Skipped + // silently for agents that don't declare the input — emitting + // follows on a non-existent input would error at + // `nix flake lock` time. + for canon in &pin.canonical_inputs { + let _ = writeln!( + out, + " agent-{}.inputs.{canon}.follows = \"{canon}\";", + spec.name, + ); + } + } + out.push_str( + " };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n mkAgent = { name, port, archetype ? null }:\n", + ); + out.push_str( + r#" let + base = hyperhive.nixosConfigurations.agent-base; + # Every flake an archetype can come from, by its source name. + archetypeSources = { inherit hyperhive; }; + agentModule = + if archetype == null then + inputs."agent-${name}".nixosModules.default + else + (archetypeSources.${archetype.source} + or (throw "agent ${name}: archetype source ${archetype.source} is not an input of this hive's meta flake") + ).hyperhiveArchetypes.${archetype.name}.module + or (throw "agent ${name}: this hive's ${archetype.source} input has no archetype ${archetype.name}; update that input"); + service = "hive-agent"; + in + base.extendModules { + modules = [ + agentModule + { +"#, + ); + // Agent-facing service URLs (`services.hyperhive.agent.forge.url`, `services.hyperhive.agent.matrix.url`): + // emit the host's real values as build-time agent config, the same + // host-state -> agent-module shape as the CA block above. + // + // These options exist already, but until now *nothing set them*, so every + // agent silently fell back to their `localhost:` defaults while the + // true value reached the container only as an env var (below). Two sources + // of truth that disagree, with the winner decided by which code path + // happens to read which one --- the option default is baked into scripts + // at build time, the env var is read at runtime. + // + // Rendering them here makes the option the single source, which is the + // precondition for removing those defaults: a loopback address is only + // ever correct when the callee shares the caller's netns, and the forge + // and homeserver are moving to swarm level, possibly on other hosts. + // + // Absent vars emit nothing rather than a guess, and the agent option + // treats "unset" as "this service is not configured" rather than + // substituting a loopback address — an absent integration instead of a + // misdirected one. + // + // That is the right default for an optional service and the wrong one for + // the forge, which a running hive always has, so the caller rejects a + // missing `HIVE_FORGE_URL` before it renders. The check is not here because + // rendering is a pure string operation the tests exercise directly. + push_forwarded_var_options(&mut out, vars); + // GitHub integration is on by default in every agent + // (`services.hyperhive.agent.github.enable`); the host turns it off swarm-wide via + // `services.hyperhive.swarm.github.enable = false`, passed here as + // `github_disabled`. Only the OFF + // override is propagated — the enabled default needs no per-agent line. + if github_disabled { + out.push_str(" services.hyperhive.agent.github.enable = false;\n"); + } + out.push_str( + r#" # The harness service inside the container runs as a + # non-root unix user named after the agent (`damocles`, + # `iris`, `root`, …). UID auto-assigned by NixOS; the + # per-agent override here is what makes + # `services.hyperhive.agent.user.name` match the agent's identity + # instead of the harness default of `"agent"`. + services.hyperhive.agent.user.name = name; + # Without this an agent container is called `nixos` and ships + # every log line under that: nixpkgs sets the hostname from the + # attr name in the `containers.` submodule, and an + # agent's system is its own `nixosSystem` off this flake, which + # never evaluates that option. The machine name, not the + # logical one — it is what `container.name` on our metrics and + # nspawn itself already call this container, so logs join to + # both without a prefix transform. + networking.hostName = "h-${name}"; + programs.git.config.user = { + name = name; + email = "${name}@hyperhive.local"; + }; + # Container-wide env: every service + co-process daemon can + # resolve the agent's durable state dir without hard-coding it. + # `environment.variables` only writes /etc/environment (login + # shells); `systemd.globalEnvironment` is the analogue for + # systemd units so forge-avatar-sync / + # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR` + # without each service having to redeclare it. + environment.variables = { + HIVE_LABEL = name; + HYPERHIVE_STATE_DIR = "/agents/${name}/state"; + HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; + }; + systemd.globalEnvironment = { + HIVE_LABEL = name; + HYPERHIVE_STATE_DIR = "/agents/${name}/state"; + HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; +"#, + ); + // Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment, + // not just the harness service env below, so EVERY service + shell in + // the container inherits them — crucially the bash-task runner (where + // `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync + // and interactive shells. Scoped to the harness service alone they were + // invisible to bash tasks: harmless in shared netns (the localhost + // default works) but broken under isolation, where the in-cluster + // `forge.` URL is the only reachable path. + for (var, val) in vars { + let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); + let _ = writeln!(out, " {var} = \"{escaped}\";"); + } + out.push_str( + r" }; + systemd.services.${service}.environment = { + HIVE_PORT = toString port; + HIVE_LABEL = name; +", + ); + // Forwarded env vars, as the caller passed them. We copy whatever's set into + // each sub-agent's harness service env so the in-container surfaces + // (`identity.rs`, `forge_notify`) see a consistent view across the + // whole hive. Absent host-side env (option not set) → skip emission + // → in-container accessors fall back to None / defaults gracefully. + // + // - HIVE_FORGE_URL: agents poll this for Forgejo notifications. + // - HYPERHIVE_SWARM_NAME: human display name for the swarm. + for (var, val) in vars { + let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); + let _ = writeln!(out, " {var} = \"{escaped}\";"); + } + out.push_str( + r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state"; + HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; + }; + } + # Tells the harness the agent has no config repo of its own. + (if archetype == null then { } else { + systemd.services.${service}.environment.HIVE_ARCHETYPE = archetype.name; + }) + ]; + }; + in + { + nixosConfigurations = { +"#, + ); + for spec in agents { + // `source` and `name` are identifiers (`archetypes::store_read`), so + // they need no escaping. + let archetype = spec.archetype.as_ref().map_or(String::new(), |a| { + format!( + " archetype = {{ source = \"{}\"; name = \"{}\"; }};", + a.source, a.name + ) + }); + let _ = writeln!( + out, + " {} = mkAgent {{ name = \"{}\"; port = {};{archetype} }};", + spec.name, spec.name, spec.port, + ); + } + out.push_str(" };\n };\n}\n"); + Ok(out) +} diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index a5e38a2f..4278c8e9 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -72,7 +72,7 @@ in internal = true; description = '' Human-readable swarm name, rendered per-agent by - `meta.rs::render_flake` from the host's + `hive_meta_flake::render_flake` from the host's `services.hyperhive.swarm.name`. Read during evaluation for the OTEL `swarm` resource label; `HYPERHIVE_SWARM_NAME` carries the same value at runtime. diff --git a/nix/agent-modules/matrix.nix b/nix/agent-modules/matrix.nix index 6cc1da06..d9fabf64 100644 --- a/nix/agent-modules/matrix.nix +++ b/nix/agent-modules/matrix.nix @@ -275,7 +275,7 @@ in # homeserver is an account the daemon can never log in as, so declaring one # unconditionally would enable matrix everywhere and inject a tool surface # backed by a permanently no-opping daemon. On a real hive hive-c0re renders - # this URL per agent (meta.rs's `FORWARDED_VAR_OPTIONS`), so the common case + # this URL per agent (hive-meta-flake's `FORWARDED_VAR_OPTIONS`), so the common case # is still "every agent has `main`". services.hyperhive.agent.matrixAccounts = lib.mkIf (config.services.hyperhive.agent.matrix.url != null) diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix index 37b63d28..5ea7e110 100644 --- a/nix/host-modules/hive-c0re/environment.nix +++ b/nix/host-modules/hive-c0re/environment.nix @@ -256,7 +256,7 @@ in lib.optionalAttrs haveBaoClientIdentity { BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; # The same address again under an agent-facing name, forwarded into every - # container's module block by `meta::render_flake`. Deliberately not the + # container's module block by `hive_meta_flake::render_flake`. Deliberately not the # `BAO_ADDR` line itself: a container that inherited this hive's store # environment wholesale would inherit the hive's certificate paths with it, # and those name a credential no agent may present.