{ hyperhivePackage, hyperhiveFrontend, hyperhiveAssets, hyperhiveFlake, hyperhiveNixpkgsUnstable, agentBaseToplevel, managerToplevel, }: { pkgs, lib, config, ... }: let cfg = config.services.hyperhive.c0re; # Privsep splits ownership across users, so git/libgit2's dubious- # ownership guard trips on legitimate cross-user reads: hive-priv (root) # fetches the hive-core-owned meta/applied repos via nix, and hive-c0re # (hive-core) fetches the agent-owned proposed-config repos. Both # processes are trusted and can already read the files; this gitconfig # 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. safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" '' [safe] directory = * ''; # The `hive-c0re serve` config, written to the store as JSON and passed # via a single `--config` flag so the systemd ExecStart line stays short # instead of carrying every host-level setting as its own flag (the # context-window + model-price maps alone were escaped JSON blobs on the # command line). Keys are snake_case to match the `ServeConfig` serde # shape the daemon deserialises (the container-injected HiveEnv fields, # flattened, plus the hive-c0re-local model_prices table); per-flag # overrides still work for ad-hoc invocations. serveConfig = pkgs.writeText "hive-c0re-serve.json" ( builtins.toJSON { hyperhive_flake = cfg.hyperhiveFlake; nixpkgs_flake = cfg.nixpkgsFlake; nixpkgs_unstable_flake = cfg.nixpkgsUnstableFlake; dashboard_port = cfg.dashboardPort; operator_pronouns = cfg.operatorPronouns; context_window_tokens = cfg.contextWindowTokens; agent_cpu_quota = cfg.agentCpuQuota; agent_memory_max = cfg.agentMemoryMax; model_prices = cfg.modelPrices; } ); # Stylix theme integration (zero-op auto-detect). When the operator's # host config has stylix enabled, generate a base16 `colors.css` from # its palette and overlay it onto the bundled frontend dist so the # dashboard re-themes with no operator action and no npm/esbuild rebuild # (a pure file-copy over the prebuilt dist). `colors.css` is the entire # swap contract — `theme.css` derives every semantic var from the 16 # base16 slots (see docs/web-ui/css-vars.md). The guarded access makes # this a clean no-op when stylix isn't imported into the host config. stylixThemeColors = if (config.stylix.enable or false) && ((config.lib.stylix or { }) ? colors) then config.lib.stylix.colors.withHashtag else null; themedColorsCss = c: pkgs.writeText "hyperhive-colors.css" '' :root { --base00: ${c.base00}; --base01: ${c.base01}; --base02: ${c.base02}; --base03: ${c.base03}; --base04: ${c.base04}; --base05: ${c.base05}; --base06: ${c.base06}; --base07: ${c.base07}; --base08: ${c.base08}; --base09: ${c.base09}; --base0A: ${c.base0A}; --base0B: ${c.base0B}; --base0C: ${c.base0C}; --base0D: ${c.base0D}; --base0E: ${c.base0E}; --base0F: ${c.base0F}; } ''; # Overlay the generated colors.css onto both dist subtrees. Both the # dashboard (served by hive-c0re via HIVE_STATIC_DIR) and the agent UIs # (served by the gateway from HIVE_AGENT_FRONTEND_DIR — static files # straight from the store) read their colors.css from this host-side # tree, so swapping both re-themes both surfaces. # # Not covered here: an agent reached directly on its own harness web # server (no gateway) serves from its per-agent `mergedDist`, built in # the agent's own nixosSystem with no access to the host's stylix # colours — theming that path needs the base16 palette forwarded # host→agent, tracked separately. themedFrontend = c: pkgs.runCommand "hyperhive-frontend-themed" { } '' cp -r ${cfg.frontend} $out chmod -R u+w $out install -m644 ${themedColorsCss c} $out/dashboard/static/colors.css install -m644 ${themedColorsCss c} $out/agent/static/colors.css ''; servedFrontend = if stylixThemeColors != null then themedFrontend stylixThemeColors else cfg.frontend; in { # The forge is mandatory — hive-c0re mirrors every agent's applied # config repo into it and it's the canonical store for the meta flake # + `internal/*` repos, so there's no enable toggle; it deploys with # hyperhive itself. hive-matrix is opt-in (off by default). All # subsystems rely on `services.hyperhive.domain`, which is required # (asserted in hive-network.nix) whenever hyperhive is enabled. imports = [ ./hive-ci.nix ./hive-forge.nix ./hive-gateway.nix ./hive-matrix.nix ./hive-network.nix ./hive-tls.nix ]; # Top-level hyperhive enable flag. When true, automatically enables # hive-c0re and the on-by-default hyperhive subsystems. options.services.hyperhive.enable = lib.mkEnableOption "hyperhive — the agent swarm coordinator"; # Canonical hive DNS domain shared by every subsystem that needs a # stable hostname. Typed nullOr (default null) so the option always # exists, but it's REQUIRED whenever hyperhive is enabled — an # assertion in hive-network.nix fails eval when it's unset, since # matrix bakes it in on first boot and the gateway/forge/agent URLs all # derive from it (no safe default). Full identity-surface # context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var # chain → identity.rs → claude prompt): docs/conventions.md:: # Hive identity (label + domain + display names). options.services.hyperhive.domain = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "darkest.space"; description = '' Canonical host domain for hyperhive subsystems that need a stable name (currently: `services.hyperhive.matrix.serverName` derives from this, defaulting to `matrix.''${services.hyperhive.domain}` when `serverName` is null). **Required** when `services.hyperhive.enable` — eval fails with a helpful message if it's unset (it's baked into matrix on first boot and drives the gateway/forge/agent URLs, with no safe default; changing it later is destructive). Exposed to agents as `HYPERHIVE_HIVE_DOMAIN`; consumed by `hive-ag3nt::identity::hive_domain()` for `@` qualified labels. ''; }; # Human display names for hive + swarm. Distinct from the DNS # domain above (machine-readable) — see # docs/conventions.md::Hive identity for the # domain-vs-name-vs-swarm distinction + the env-var # propagation chain. options.services.hyperhive.hiveName = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "pr1ma"; description = '' Human-readable name of this single-host hive instance. Distinct from `services.hyperhive.domain` (the machine- addressable DNS name): the domain may carry the hive name as its leftmost label by convention, but this option is the canonical readable identity. Exposed to agents as `HYPERHIVE_HIVE_NAME`; surfaced in the dashboard chrome and per-agent system prompt when set. Null falls back to the default behaviour (chrome shows the domain, prompt doesn't mention a hive name). ''; }; options.services.hyperhive.swarmName = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "constellat1on"; description = '' Human-readable name of the wider swarm this hive belongs to. Hives at different DNS domains can share a swarm name when they federate together. Exposed to agents as `HYPERHIVE_SWARM_NAME`; surfaced in the dashboard chrome and per-agent system prompt when set. ''; }; # Whether this hive runs "ruthless" — with no root/manager agent at all. # When true, hive-c0re skips the root-agent auto-management sweep (create # if missing, restart if present-but-stopped). Some hives don't want a # root agent at all — see issue tracker "scope concept: special agents". options.services.hyperhive.ruthless = lib.mkOption { type = lib.types.bool; default = false; example = true; description = '' Run this hive "ruthless" — with no root (manager) agent at all (no ruth). When `true`, hive-c0re skips the root-agent auto-management sweep entirely (it otherwise creates the root agent's container when missing and restarts it when present but stopped). Defaults to `false` (the historical behaviour — the root agent is auto-managed as required infrastructure). Exposed to hive-c0re as `HYPERHIVE_RUTHLESS`. ''; }; # Hive-wide OTEL stats export. Set ONCE here at host level; the # meta-flake renderer (`hive-c0re/src/meta.rs::otel_config`) reads the # HYPERHIVE_OTEL_* env exported below off hive-c0re's unit and injects # the matching `hyperhive.otel.*` build-time config into EVERY agent # (mirroring the CA-cert injection), so each agent's harness exports # its own Claude Code stats directly to the collector. There is no # per-agent opt-in — this is the single switch for the whole hive. options.services.hyperhive.otel = { enable = lib.mkEnableOption '' hive-wide export of every agent's Claude Code stats (token usage, cost, tool calls) to an OTLP endpoint via Claude Code's built-in OpenTelemetry. One switch for all agents; each harness exports directly to the collector, so it keeps working even when hive-c0re is down ''; endpoint = lib.mkOption { type = lib.types.str; default = ""; example = "https://collector.example.com/otel"; description = '' OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT` for every agent. Required when `enable` is true. ''; }; protocol = lib.mkOption { type = lib.types.enum [ "http/protobuf" "http/json" "grpc" ]; default = "http/protobuf"; description = '' OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. ''; }; headersCredential = lib.mkOption { # `str`, not `path`: a `path`-typed relative literal is hash-copied # into the world-readable nix store at eval time, defeating the # point. Keep it a string + require an absolute runtime path so the # secret is only ever read from disk by systemd at start. type = lib.types.nullOr lib.types.str; default = null; example = "/run/secrets/otel-headers"; description = '' Absolute path to an operator-provided secret file whose contents become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. `Authorization=Bearer `). hive-c0re forwards this host file into each agent container's credential store via systemd-nspawn `--load-credential=otel-headers:`; the inner harness unit inherits it by name (`LoadCredential`), so the token is never copied into the nix store, the generated config, a bind mount, or argv. Must be absolute. Leave null if the endpoint needs no auth header. A configured-but-missing file is skipped with a log warning (OTEL still exports, without the auth header). ''; }; extraResourceAttributes = lib.mkOption { type = lib.types.str; default = ""; example = "deployment.environment=prod"; description = '' Extra comma-separated entries appended to `OTEL_RESOURCE_ATTRIBUTES` after the built-in `service.name` / `agent` / `hive` / `swarm` labels. ''; }; metricIntervalMs = lib.mkOption { type = lib.types.nullOr lib.types.ints.positive; default = null; example = 10000; description = '' Metric export interval in milliseconds, set as `OTEL_METRIC_EXPORT_INTERVAL` for every agent. Claude Code's default is 60000 (60s). Leave `null` to use that default. Each agent runs claude as a short-lived per-turn process; claude force-flushes metrics on shutdown, so this is not required for metrics to be exported, but a lower value gives more frequent intermediate flushes within long turns. Cosmetic, not a correctness knob. ''; }; }; # Peer hives in the same swarm. Each entry declares a remote hive # reachable from this host. Serialised to JSON and injected as # `HYPERHIVE_PEERS` into the hive-c0re service and forwarded to agent # containers via `meta.rs::FORWARDED_VARS`. Consumed by # `identity.rs::peers()` + the dashboard's `peer_hives` state field # (feeds the P33RS dashboard tab). options.services.hyperhive.swarm.peers = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { certFingerprint = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12"; description = '' Expected TLS certificate fingerprint for this peer's HTTPS endpoint. Null = trust the system CA bundle (for Let's Encrypt peers). Set to pin a self-signed cert. Format: the literal `sha256:` followed by exactly 64 hex digits (case-insensitive, no colon separators) — the SHA-256 digest of the peer's DER-encoded leaf certificate. Generate with `openssl x509 -noout -fingerprint -sha256`, then strip the colons and prepend `sha256:`. A malformed value is ignored with a warning rather than weakening trust. See docs/swarm.md for the full recipe. Scopes only to hive-c0re's own peer HTTPS checks — it does NOT help Matrix federation (tuwunel validates against its container trust bundle). For a self-signed peer whose root CA you want trusted hive-wide (every agent + Matrix federation), set `caCert` below. ''; }; caCert = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; example = "./peers/edge-ca.pem"; description = '' Path to this peer hive's root CA certificate (PEM). When set, the CA is embedded (at build time, into the nix store — no runtime file on the host) and trusted **everywhere the hive's own internal CA is**: it rides alongside `hive-ca.pem` in each agent's `security.pki.certificateFiles` (via the meta-flake renderer), and is added to the Matrix homeserver container's trust bundle so tuwunel validates *federation* TLS from a self-signed peer hive whose cert chains to it. This is the CA-trust path that `certFingerprint` (leaf-pinning, c0re-only) can't cover, and is what unblocks Matrix federation with a self-signed peer hive. Trust stays inside the hive (agents + the Matrix container), never the host system trust store. Mutually complementary with `certFingerprint`; set `caCert` for the federation case. See docs/swarm.md. ''; }; wireguardPublicKey = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "base64pubkey="; description = '' WireGuard public key for this peer host. Required when `services.hyperhive.swarm.wireguard.enable = true` and you want this peer reachable over the mesh. Null = TLS- only peering (public internet, no mesh tunnel). ''; }; wireguardEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "203.0.113.1:51820"; description = '' WireGuard endpoint for this peer in `host:port` form. Required when the peer host is behind a firewall and this host needs to initiate the tunnel. Null = this host waits for the peer to connect (peer-initiates; peer must have an endpoint pointing back at this host). ''; }; wireguardAddress = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "10.100.0.2/32"; description = '' IP address (with prefix) of the peer host on the WireGuard mesh. Used as the `allowedIPs` for the peer's WireGuard config entry and injected into `HYPERHIVE_PEERS` so hive-c0re can route intra-swarm traffic to the mesh address rather than the public domain. Required to include the peer in the WireGuard mesh (peers missing this field are silently excluded from `wg-hive`). ''; }; }; } ); default = { }; example = { "lab.example.com" = { certFingerprint = "sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12"; }; "edge.corp" = { }; }; description = '' Peer hives in the same swarm. The attrset key is the peer's DNS domain — used for dashboard links and Matrix federation discovery. Null `certFingerprint` trusts the system CA bundle; set it to pin a self-signed TLS cert. Add `wireguardPublicKey` + `wireguardAddress` (and optionally `wireguardEndpoint`) to include the peer in the WireGuard mesh when `swarm.wireguard.enable = true`. ''; }; # WireGuard mesh config for the local host. # When enabled, hive-c0re configures a `wg-hive` interface on the host # connecting to all peers that have `wireguardPublicKey` declared. # Peers reachable over the mesh are preferred for inter-hive traffic # (no public TLS round-trip needed); peers without a public key still # work via normal HTTPS. options.services.hyperhive.swarm.wireguard = { enable = lib.mkOption { type = lib.types.bool; default = false; description = '' Enable the WireGuard inter-hive mesh. When true, a `wg-hive` interface is brought up connecting to all swarm peers that declare a `wireguardPublicKey`. Requires `privateKeyFile` to be set. ''; }; privateKeyFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; example = "/etc/wireguard/hive.key"; description = '' Path to the host's WireGuard private key file. The file must be readable by root and should have mode 0400. Generate with `wg genkey > /etc/wireguard/hive.key`. Required when `swarm.wireguard.enable = true`. ''; }; address = lib.mkOption { type = lib.types.str; default = ""; example = "10.100.0.1/24"; description = '' IP address (with prefix) of this host on the WireGuard mesh. Use a /24 (or broader) prefix so the routing table covers all peer /32 routes. Example: `"10.100.0.1/24"` for a 256-host mesh. ''; }; listenPort = lib.mkOption { type = lib.types.port; default = 51820; description = '' UDP port the local WireGuard interface listens on. Must be reachable from peer hosts when they initiate the tunnel. Default: 51820 (standard WireGuard port). ''; }; persistentKeepalive = lib.mkOption { type = lib.types.nullOr lib.types.int; default = 25; example = 25; description = '' Seconds between keepalive packets sent to each peer. Useful when this host (or a peer) is behind NAT — keeps the UDP hole open. Set to null to disable. Default: 25 seconds. ''; }; }; options.services.hyperhive.c0re = { enable = lib.mkOption { type = lib.types.bool; default = config.services.hyperhive.enable; defaultText = lib.literalExpression "config.services.hyperhive.enable"; description = "Enable hive-c0re coordinator daemon (auto-enabled by services.hyperhive.enable)."; }; package = lib.mkOption { type = lib.types.package; default = hyperhivePackage pkgs.stdenv.hostPlatform.system; defaultText = lib.literalExpression "hyperhive.packages.\${system}.default"; description = '' hyperhive workspace package. Provides `/bin/hive-c0re` (coordinator daemon + admin-socket CLI) and `/bin/hivectl` (operator-facing host CLI for ad-hoc administration). ''; }; frontend = lib.mkOption { type = lib.types.package; default = hyperhiveFrontend pkgs.stdenv.hostPlatform.system; defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend"; description = '' Bundled frontend dist (see `./nix/frontend.nix`). Output has `dashboard/` and `agent/` subdirectories — hive-c0re serves `dashboard/` via `tower_http::ServeDir` from the path passed in `HIVE_STATIC_DIR`. Override to ship a custom dashboard SPA; the JSON contract (`/api/state`, the SSE streams, the action endpoints) is the source of truth for any replacement. ''; }; servedFrontend = lib.mkOption { type = lib.types.package; internal = true; readOnly = true; default = servedFrontend; defaultText = lib.literalExpression ""; description = '' Internal, read-only: `frontend` re-themed with the active stylix palette (or `frontend` verbatim when unthemed); has `dashboard/` and `agent/`. Exposed so `hive-gateway.nix` can static-serve `dashboard/` as an nginx root instead of proxying to hive-c0re. ''; }; assets = lib.mkOption { type = lib.types.package; default = hyperhiveAssets pkgs.stdenv.hostPlatform.system; defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets"; description = '' Bundled static runtime assets (see `./nix/assets.nix`): the project's branding family + the claude system-prompt template + claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`; passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR` (`hive_sh4re::assets::*` resolve paths underneath). Override to ship customised branding or prompts without rebuilding the rust derivation. ''; }; hyperhiveFlake = lib.mkOption { type = lib.types.str; default = hyperhiveFlake; defaultText = lib.literalMD "the flake's own store path"; description = '' URL of the hyperhive flake (no fragment). Inlined into each per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent flake then pulls `hyperhive.nixosConfigurations.agent-base` to build the container. Defaults to this flake's own store path — only override if you want agents tracking a different ref. ''; }; nixpkgsFlake = lib.mkOption { type = lib.types.str; default = "path:${pkgs.path}"; defaultText = lib.literalMD "`\"path:\${pkgs.path}\"`"; description = '' Store-path URL for the `nixpkgs` input in the generated meta flake. The meta flake declares this as a top-level input and wires `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` so every agent container evaluates with this exact nixpkgs. Defaults to `"path:''${pkgs.path}"` — the store path of the nixpkgs the host NixOS module was evaluated with. When the operator sets `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` in their host flake, `pkgs.path` resolves to the host's own nixpkgs, so agents transparently track the same channel as the host. Override to pin agents to a specific nixpkgs version regardless of the host's channel. ''; }; nixpkgsUnstableFlake = lib.mkOption { type = lib.types.str; default = hyperhiveNixpkgsUnstable; defaultText = lib.literalMD "hyperhive's own `nixpkgs-unstable` store path"; description = '' Store-path URL for the `nixpkgs-unstable` input in the generated meta flake. The meta flake declares this as a top-level input and wires `inputs.hyperhive.inputs.nixpkgs-unstable.follows = "nixpkgs-unstable"` so agents use this exact unstable nixpkgs. Defaults to the store path of the `nixpkgs-unstable` input hyperhive's own `flake.nix` was evaluated with (the channel that carries `claude-code`). Override when you want to track a newer unstable snapshot or a custom `claude-code` package. ''; }; dashboardPort = lib.mkOption { type = lib.types.port; default = 7000; description = "TCP port the hive-c0re dashboard listens on."; }; operatorPronouns = lib.mkOption { type = lib.types.str; default = "she/her"; example = "they/them"; description = '' Operator pronouns, free text. Threaded into every agent container as the `HIVE_OPERATOR_PRONOUNS` env var; the harness substitutes it into the agent / manager system prompt at boot so claude refers to the operator naturally in third person ("ask her", "tell them", etc.). Changes propagate to running agents on the next `↻ R3BU1LD` — forwards as a meta flake env-var bump, no per-agent approval needed. ''; }; preBuildAgentTemplates = lib.mkOption { type = lib.types.bool; default = false; example = true; description = '' Pre-fetch the per-container system closures (agent-base + manager toplevels) into the host's /nix/store as part of this host's NixOS build, instead of letting the first agent spawn do all the work. Enabling this adds roughly the full nixpkgs runtime closure + claude-code + the harness binary to your system closure size (low single-digit GB), but the first `nixos-container start` for any agent then completes in seconds instead of minutes because nothing's left to fetch. Off by default because the toplevels are pinned to `x86_64-linux` (nixos-containers run native arch). Enabling on an aarch64 host would force nix to build the x86 closure via cross or a remote builder, which is rarely what you want. Flip to `true` on an x86_64 host when you care more about first-spawn latency than host store size — or just `nix build ${hyperhiveFlake}#agent-base-toplevel` once manually to warm the store. ''; }; contextWindowTokens = lib.mkOption { type = lib.types.attrsOf lib.types.int; default = { haiku = 200000; sonnet = 1000000; opus = 1000000; }; example = { haiku = 150000; sonnet = 900000; }; description = '' Per-model context-window sizes in tokens. Each key is a model-family short name matched case-insensitively as a substring of the active model name at runtime (e.g. `"sonnet"` matches `"claude-sonnet-4-5"`). The defaults cover the known Anthropic families; add entries for new models or override existing ones here to change the window for all agents at once. Passed to `hive-c0re serve` as JSON and injected into every container's harness service environment as `HIVE_CONTEXT_WINDOW_TOKENS_`. Changes propagate on the next `↻ R3BU1LD` — no per-agent approval needed. ''; }; modelPrices = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { input = lib.mkOption { type = lib.types.numbers.nonnegative; description = "USD per million input tokens."; }; output = lib.mkOption { type = lib.types.numbers.nonnegative; description = "USD per million output tokens."; }; cache_read = lib.mkOption { type = lib.types.numbers.nonnegative; description = "USD per million cache-read tokens."; }; cache_write = lib.mkOption { type = lib.types.numbers.nonnegative; description = "USD per million cache-creation (write) tokens."; }; }; } ); # Current Anthropic list prices for the Claude 4.x family (Opus # 4.x, Sonnet 4.x, Haiku 4.5); cache_write is the 1-hour cache-TTL # price (the default through the Claude subscription the agents run # on). Keep in sync with `builtin_prices` in # hive-c0re/src/hive_stats.rs. default = { opus = { input = 5.0; output = 25.0; cache_read = 0.5; cache_write = 10.0; }; sonnet = { input = 3.0; output = 15.0; cache_read = 0.3; cache_write = 6.0; }; haiku = { input = 1.0; output = 5.0; cache_read = 0.1; cache_write = 2.0; }; }; example = { sonnet = { input = 3.0; output = 15.0; cache_read = 0.3; cache_write = 6.0; }; }; description = '' Per-model USD prices (per **million** tokens) used for the hive-wide cost *estimate* on the dashboard's ST4TS tab. Each key is a model-family short name matched case-insensitively as a substring of the active model id at runtime (e.g. `"sonnet"` matches `"claude-sonnet-4-5"`); the longest matching key wins, so a specific entry beats a generic family name. Any model not covered by this table falls back to hive-c0re's built-in estimate. The defaults track Anthropic list pricing at the time of writing — override them here to keep the estimate current without a code change. Passed to `hive-c0re serve` as JSON via `--model-prices`; read only by hive-c0re itself (not injected into containers). Changes apply on the next host rebuild. ''; }; agentCpuQuota = lib.mkOption { type = lib.types.str; default = "200%"; example = "400%"; description = '' systemd `CPUQuota=` applied to every agent container via a `container@h-.service.d/` drop-in written on each spawn/rebuild. Expressed as a percentage of one CPU core — `"200%"` allows each agent to use up to 2 cores. The old hard-coded value was `"50%"`; bump this if agents are hitting CPU limits during builds or heavy tool use. For a hive-wide cap across all containers, set `systemd.slices.machine.serviceConfig.CPUQuota` in your NixOS config (all nspawn containers live in `machine.slice`). ''; }; agentMemoryMax = lib.mkOption { type = lib.types.str; default = "4G"; example = "8G"; description = '' systemd `MemoryMax=` applied to every agent container via the same drop-in as `agentCpuQuota`. The old hard-coded value was `"2G"`. ''; }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ cfg.package pkgs.git ]; # Pull the per-container toplevels into the host system closure. # `system.extraDependencies` adds paths to the system build # without referencing them at runtime — nixos-rebuild fetches / # builds them, they end up in /nix/store, and the first # nixos-container update + start for an agent has nothing left to # do. Gated because the closure is sizeable and pinned to x86_64. system.extraDependencies = lib.optionals cfg.preBuildAgentTemplates [ agentBaseToplevel managerToplevel ]; # Unprivileged coordinator user. hive-c0re runs as this user # (privsep phase 2); privileged operations are delegated to # hive-priv which runs as root, socket-activated at # /run/hive/priv.sock. users.users.hive-core = { isSystemUser = true; group = "hive-core"; description = "hive-c0re coordinator daemon user"; }; users.groups.hive-core = { }; # The gateway nginx is always the sole external entry point (it runs # alongside hyperhive), so the per-agent web-port range stays closed on # the host firewall. See `docs/gateway.md::Firewall posture (host-level)`. # WireGuard inter-hive mesh. Enabled when # `services.hyperhive.swarm.wireguard.enable = true`. Brings up a # `wg-hive` interface and connects to each peer that has # `wireguardPublicKey` set. Firewall opens the UDP listen port on # the host (not inside containers — this is host-level networking). networking.wireguard.interfaces = lib.mkIf config.services.hyperhive.swarm.wireguard.enable ( let wgCfg = config.services.hyperhive.swarm.wireguard; meshPeers = lib.filterAttrs ( _: p: p.wireguardPublicKey != null && p.wireguardAddress != null ) config.services.hyperhive.swarm.peers; in { wg-hive = { ips = [ wgCfg.address ]; listenPort = wgCfg.listenPort; privateKeyFile = wgCfg.privateKeyFile; peers = lib.mapAttrsToList ( _domain: p: { publicKey = p.wireguardPublicKey; allowedIPs = [ p.wireguardAddress ]; } // lib.optionalAttrs (p.wireguardEndpoint != null) { endpoint = p.wireguardEndpoint; } // lib.optionalAttrs (wgCfg.persistentKeepalive != null) { persistentKeepalive = wgCfg.persistentKeepalive; } ) meshPeers; }; } ); # Open the WireGuard UDP port on the host firewall when the mesh is on. networking.firewall.allowedUDPPorts = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [ config.services.hyperhive.swarm.wireguard.listenPort ]; # NB: `services.hyperhive.domain` is required when hyperhive is # enabled — the canonical assertion lives in `hive-network.nix` (the # hive resolver is authoritative for `` and agents reach the # forge/matrix through the gateway by it). So everything below can # treat `config.services.hyperhive.domain` as non-null. assertions = lib.optionals config.services.hyperhive.swarm.wireguard.enable [ { assertion = config.services.hyperhive.swarm.wireguard.privateKeyFile != null; message = '' services.hyperhive.swarm.wireguard.enable requires services.hyperhive.swarm.wireguard.privateKeyFile to be set. Generate a key: wg genkey > /etc/wireguard/hive.key ''; } { assertion = config.services.hyperhive.swarm.wireguard.address != ""; message = '' services.hyperhive.swarm.wireguard.enable requires services.hyperhive.swarm.wireguard.address to be set (e.g. "10.100.0.1/24"). ''; } ] ++ lib.optionals config.services.hyperhive.otel.enable [ { assertion = config.services.hyperhive.otel.endpoint != ""; message = "services.hyperhive.otel.enable is true but services.hyperhive.otel.endpoint is empty."; } ]; systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; wantedBy = [ "multi-user.target" ]; # Socket unit must start before the service so hive-c0re receives the # pre-bound fd via LISTEN_FDS (socket activation). Without this # dependency, nixos-rebuild switch activates hive-c0re.socket while # hive-c0re.service is already running (started by multi-user.target), # and systemd refuses with "Socket service already active". Adding # requires+after causes systemd to stop the service, start the socket, # then restart the service -- clean transition on every config apply. requires = [ "hive-c0re.socket" ]; after = [ "hive-c0re.socket" ]; path = [ pkgs.git "/run/current-system/sw" ]; environment = { # nix (the prebuild `nix build`, flake-check, and meta eval) writes # its fetcher/eval cache under $HOME/.cache. As a system user # hive-core has no home, so HOME defaults to the unwritable # /var/empty and Lix fails to initialise its cache. Point HOME at # the writable StateDirectory. HOME = "/var/lib/hyperhive"; HYPERHIVE_GIT = "${pkgs.git}/bin/git"; # No HIVE_STATIC_DIR: the gateway static-serves the dashboard dist # now (see hive-gateway.nix); this router is API-only. # Path to the base agent frontend dist. hive-c0re's # gateway_nginx.rs uses this to generate split location # blocks in agents.conf — static HTML/CSS/JS served from the # nix store directly; dynamic API paths still proxied to the # agent daemon. The nix store is shared across nspawn # containers, so this path is reachable from inside the # gateway container's nginx. HIVE_AGENT_FRONTEND_DIR = "${servedFrontend}/agent"; # Path to the static runtime asset tree (branding + claude # prompts). `hive_sh4re::assets::*` reads paths underneath. # `forge.rs` reads the avatar PNGs from here on startup. HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive"; # Whether this hive runs ruthless — no root/manager agent at all # (`auto_update::ensure_root_agent`). Default false = historical # behaviour (root auto-managed); true makes the sweep a no-op. HYPERHIVE_RUTHLESS = lib.boolToString config.services.hyperhive.ruthless; } // { # Identity env vars threaded into c0re's own service env and # forwarded by meta.rs into every sub-agent's harness env — # full chain in docs/conventions.md::Hive identity. `domain` is # required (asserted in hive-network.nix), so it's always set. HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain; } // lib.optionalAttrs (config.services.hyperhive.hiveName != null) { HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName; } // lib.optionalAttrs (config.services.hyperhive.swarmName != null) { HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName; } // lib.optionalAttrs config.services.hyperhive.otel.enable ( # Hive-wide OTEL config -> read by meta.rs::otel_config and # injected as build-time `hyperhive.otel.*` into every agent. # Endpoint presence is the enable signal on the meta side; the # optional fields are only emitted when set so absent values # don't render no-op env lines. let otel = config.services.hyperhive.otel; in { HYPERHIVE_OTEL_ENDPOINT = otel.endpoint; HYPERHIVE_OTEL_PROTOCOL = otel.protocol; } // lib.optionalAttrs (otel.extraResourceAttributes != "") { HYPERHIVE_OTEL_EXTRA_RESOURCE_ATTRIBUTES = otel.extraResourceAttributes; } // lib.optionalAttrs (otel.headersCredential != null) { HYPERHIVE_OTEL_HEADERS_CREDENTIAL = otel.headersCredential; } // lib.optionalAttrs (otel.metricIntervalMs != null) { HYPERHIVE_OTEL_METRIC_INTERVAL_MS = toString otel.metricIntervalMs; } ) // { # In-cluster forge URL — the gateway vhost (`forge.`), which # nginx proxies to forgejo. The forge is mandatory, so this is # unconditional (the whole env block is already gated on hyperhive # being enabled). See `docs/gateway.md::HIVE_FORGE_URL`. HIVE_FORGE_URL = "http://${config.services.hyperhive.forge.domain}"; } // lib.optionalAttrs config.services.hyperhive.matrix.enable { # In-cluster matrix homeserver URL for each agent's # hive-matrix-daemon — the gateway vhost (`matrix.`). The # gatewayHost null-guard falls back to loopback so a domain-less # config still evals. Forwarded to agents by meta.rs alongside # HIVE_FORGE_URL; shares the same env-forwarding ordering caveat # (value baked at config-generation time). HIVE_MATRIX_URL = if config.services.hyperhive.matrix.gatewayHost != null then "http://${config.services.hyperhive.matrix.gatewayHost}" else "http://127.0.0.1:${toString config.services.hyperhive.matrix.httpPort}"; } // lib.optionalAttrs config.services.hyperhive.matrix.gui.enable { # Availability flags read by the dashboard's `/api/state`. # Matrix GUI lives entirely on the gateway nginx (matrix tab # only shows when both flags are on). Gateway routing detail: # docs/gateway.md::Vhost map. HIVE_MATRIX_GUI_ENABLED = "1"; } // { # The gateway always runs, so the dashboard always builds # same-origin `/agent//` links (never the direct # `:` TCP fallback). Kept as an env flag so the # dashboard doesn't need to learn the gateway is unconditional. HIVE_GATEWAY_ENABLED = "1"; } // lib.optionalAttrs config.services.hyperhive.forge.behindGateway { # Public URL of the forge vhost served by hive-gateway. The # dashboard uses this to build browser-facing forge links # instead of hardcoding `:3000`, which breaks when # the operator accesses the dashboard through the gateway # (forge sub-domain has no port; direct port URL would be # wrong). Absent when `behindGateway = false` — dashboard # falls back to `:3000`. HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}"; } // lib.optionalAttrs ( config.services.hyperhive.matrix.gui.enable && config.services.hyperhive.matrix.gatewayHost != null ) { # Browser-facing matrix GUI (fluffychat) URL — the gateway # vhost (`matrix.`). Surfaced via the daemon's `Urls` # request for `hivectl open matrix`. Absent when the GUI is off # or no gatewayHost is set (no browser-reachable matrix vhost). HIVE_MATRIX_PUBLIC_URL = "https://${config.services.hyperhive.matrix.gatewayHost}/"; } // lib.optionalAttrs (config.services.hyperhive.swarm.peers != { }) { # Peer hives serialised as a JSON array of {domain, cert_fingerprint, # wireguard_address?} objects. Consumed by hive-ag3nt::identity::peers() # + the dashboard's peer_hives StateSnapshot field (P33RS tab). Domain # is the attrset key; cert_fingerprint is null for CA-trusted peers; # wireguard_address is omitted when not part of the mesh. HYPERHIVE_PEERS = builtins.toJSON ( lib.mapAttrsToList ( domain: p: { inherit domain; cert_fingerprint = p.certFingerprint; } // lib.optionalAttrs (p.wireguardAddress != null) { wireguard_address = p.wireguardAddress; } ) config.services.hyperhive.swarm.peers ); } // lib.optionalAttrs (lib.any (p: p.caCert != null) (lib.attrValues config.services.hyperhive.swarm.peers)) { # Peer-hive root CA file paths (colon-joined), one per peer that # declares `swarm.peers..caCert`. hive-c0re's meta-flake # renderer (meta.rs) embeds each next to every agent's flake and # adds it to `security.pki.certificateFiles`, so a peer CA is # trusted everywhere the hive's own internal CA (`hive-ca.pem`) # is — i.e. by every agent. The matrix container trusts the same # CAs separately for federation TLS. The `caCert` files are # copied into the nix store at build, so these are store paths — # nothing mutable lives on the host. HIVE_PEER_CA_PATHS = lib.concatStringsSep ":" ( lib.filter (c: c != null) ( lib.mapAttrsToList (_domain: p: p.caCert) config.services.hyperhive.swarm.peers ) ); }; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config ${serveConfig}"; SyslogIdentifier = "hive-c0re"; # Migrate hive-c0re's *own* state to the service user after an # upgrade from a root-run install (systemd's StateDirectory only # chowns the top-level dir, not pre-existing files inside it). The # `+` prefix runs as root despite User = hive-core; `-` tolerates # failure. coreutils ships `chown` but no `sh`, so invoke the # binaries directly rather than through a shell. # # CRITICAL: exclude the per-agent `agents/` subtree. Its contents # (each agent's `claude/` OAuth creds, `state/`, `harness/`, # `config/`) are owned by the per-agent / manager users, and each # container's `hive-agent-user-migrate` activation script chowns # them back to that user on boot. Blanket-chowning them to hive-core # makes every agent's `~/.claude` unreadable — logging them all out # with no way to log back in. So chown everything *except* agents/, # plus the `agents/` dir node itself (not its contents) so c0re can # still create new per-agent subdirs. ExecStartPre = [ # Install the safe.directory gitconfig at $HOME/.gitconfig # (HOME = /var/lib/hyperhive) so c0re's `git fetch`/`rev-parse` # against the agent-owned proposed repos pass the ownership guard. # Placed before the chown below so it's chowned to hive-core too. "+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hyperhive/.gitconfig" "+-${pkgs.findutils}/bin/find /var/lib/hyperhive -mindepth 1 -maxdepth 1 -not -name agents -exec ${pkgs.coreutils}/bin/chown -R hive-core:hive-core {} +" "+-${pkgs.coreutils}/bin/chown hive-core:hive-core /var/lib/hyperhive/agents" ]; Restart = "on-failure"; RestartSec = 2; User = "hive-core"; Group = "hive-core"; SupplementaryGroups = [ "systemd-journal" ]; RuntimeDirectory = "hyperhive"; RuntimeDirectoryMode = "0750"; RuntimeDirectoryPreserve = "yes"; StateDirectory = "hyperhive"; StateDirectoryMode = "0750"; }; }; # Socket unit for the hive-c0re admin socket. systemd creates and holds # `/run/hyperhive/host.sock` before hive-c0re starts, then passes the fd # via LISTEN_FDS (socket activation). Benefits: `hivectl` can connect # the moment the socket unit is active — no racy retry window — and a # hive-c0re restart never drops the socket inode, so queued commands # drain cleanly. # # `hive-c0re serve` reads LISTEN_FDS via the `listenfd` crate and # accepts the fd in preference to its own `bind()` path. When invoked # directly (dev, CI, without the socket unit) LISTEN_FDS is absent and # the traditional bind path runs unchanged — no regression. systemd.sockets.hive-c0re = { description = "hive-c0re admin socket"; wantedBy = [ "sockets.target" ]; socketConfig = { # Must match the `--socket` arg passed to `hive-c0re serve`. ListenStream = "/run/hyperhive/host.sock"; # 0660 root:root — `hivectl` is a host-only tool run as root. SocketMode = "0660"; # Parent dir inherits the RuntimeDirectory mode (0750) set on the # service unit; DirectoryMode is only consulted when the dir is # absent at socket-unit activation. DirectoryMode = "0750"; }; }; # Socket unit for hive-priv — the narrow root helper that executes # privileged operations on behalf of hive-c0re. Systemd creates and # holds `/run/hive/priv.sock` before the first connection arrives. # # Mode 0660 hive-core:hive-core: only the hive-c0re service user can # connect. hive-priv (server) runs as root and validates every request # against a strict allowlist before executing any privileged op. systemd.sockets.hive-priv = { description = "hive-priv privileged helper socket"; wantedBy = [ "sockets.target" ]; socketConfig = { ListenStream = "/run/hive/priv.sock"; SocketMode = "0660"; SocketGroup = "hive-core"; # Create /run/hive/ if absent; 0755 so the hive-core user can # traverse into it to reach the socket. DirectoryMode = "0755"; }; }; # Service unit for hive-priv. Runs as root — it genuinely needs root to # invoke `nixos-container`, write `/etc/nixos-containers/`, write # systemd drop-ins in `/run/systemd/system/`, and call `chown(2)`. # Every request is validated against a strict container-name allowlist # inside the binary; the attack surface is narrow by design. # # Socket-activated: systemd starts hive-priv on the first connection # (no earlier). LISTEN_FDS + LISTEN_PID are set by systemd; hive-priv # reads them to accept the pre-bound socket fd instead of binding its # own. systemd.services.hive-priv = { description = "hive-priv privileged helper"; # No wantedBy — socket-activated exclusively. The socket unit is the # entry point; systemd starts this service on first connect. after = [ "hive-priv.socket" ]; requires = [ "hive-priv.socket" ]; # `nixos-container` is a perl script that shells out by bare name to # nix / nix-env / nix-instantiate (create + update), machinectl + # systemctl (start/stop), and find / rm / umount / chattr (destroy); # only nsenter + su are hardcoded. Give the helper exactly those — # not the whole system profile — on top of the systemd/coreutils/ # findutils already in the default unit PATH. Without `nixos-container` # on PATH every container op fails ENOENT, which `build_all` silently # swallows into an empty list ("no managed containers"). # # `nix` itself shells out by bare name too: `git` whenever it has to # fetch/re-resolve a git-source flake input (an agent.nix with a # `git+https://…` input, or a stale flake.lock whose node URL no longer # matches the flake's declared input → nix re-resolves at eval), and # `ssh` to dispatch to remote builders (`nix.buildMachines` / # `ssh-ng://`). Without these on PATH `nixos-container update` dies with # `executing "git": No such file or directory` / `Could not find # executable 'ssh'` — the agent build fails before it starts. path = [ pkgs.nixos-container pkgs.nix # nix, nix-env, nix-instantiate — create + update pkgs.gitMinimal # git — nix fetches/re-resolves git-source flake inputs pkgs.openssh # ssh — nix dispatches builds to remote builders pkgs.util-linux # umount (nsenter is hardcoded in the script) pkgs.e2fsprogs # chattr pkgs.btrfs-progs # btrfs subvolume create/delete — Ensure/DeleteAgentSubvolume ]; environment = { # `nixos-container update/create` runs `nix`, which writes its # fetcher/eval cache under $HOME/.cache. With ProtectHome and no # explicit HOME this lands on the unwritable /var/empty and Lix # errors out. Point HOME at the StateDirectory below (persistent, # so the cache survives across rebuilds). HOME = "/var/lib/hive-priv"; }; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-priv"; SyslogIdentifier = "hive-priv"; Type = "simple"; User = "root"; PrivateTmp = true; ProtectHome = true; # Harden the file system view: strict makes the entire hierarchy # read-only by default; ReadWritePaths carves out exactly the # paths hive-priv must write to at runtime. # # Why each entry is needed: # /etc/nixos-containers — writes .conf (bind mounts, # network isolation, nspawn flags) # /run/hive-agent — chown/chmod per-agent socket directories # /run/systemd — container@ unit drop-ins (resource limits) # + machinectl / systemd-machined state # /run/lock — `nixos-container` opens a lock file at # /run/lock/nixos-container to serialise # create/destroy. Under ProtectSystem=strict # /run is read-only, so without this the very # first `nixos-container create` (ruth, on a # fresh host) dies with "Read-only file # system" before any container exists. # /var/lib/nixos-containers — container rootfs written by nixos-container # /var/lib/hyperhive — agent state files written by WriteAgentForgeToken # / WriteAgentMatrixToken (tokens under agents//state/) # /nix — nix store + profile updates during # container create/update ProtectSystem = "strict"; ReadWritePaths = [ "/etc/nixos-containers" "/run/hive-agent" "/run/systemd" "/run/lock" "/var/lib/nixos-containers" "/var/lib/hyperhive" "/nix" ]; # Writable HOME for nix's caches (see environment.HOME above). StateDirectory = "hive-priv"; # With ProtectSystem=strict the root filesystem is read-only inside # hive-priv. When `nixos-container create/update` invokes nix, nix # creates a temporary result symlink in its working directory. Without # an explicit WorkingDirectory the cwd is / (inherited from systemd), # which is read-only under strict, causing: # error: creating symlink "/.tmp.tmp-..." -> ...: Read-only file system # Point the working directory at the writable StateDirectory so nix # drops its temp symlink there instead. WorkingDirectory = "/var/lib/hive-priv"; # nix (run here as root for `nixos-container update --flake # /var/lib/hyperhive/meta#`) fetches the hive-core-owned # meta/applied repos; libgit2 refuses them without safe.directory. # See safeDirGitconfig above. ExecStartPre = "+-${pkgs.coreutils}/bin/cp ${safeDirGitconfig} /var/lib/hive-priv/.gitconfig"; }; }; }; }