diff --git a/nix/modules/default.nix b/nix/modules/default.nix index 940c300d..c795abe2 100644 --- a/nix/modules/default.nix +++ b/nix/modules/default.nix @@ -18,6 +18,7 @@ ./hive-gateway ./hive-matrix.nix ./hive-network.nix + ./hive-priv.nix ./hive-tls.nix ./otel.nix ./swarm.nix diff --git a/nix/modules/hive-c0re/default.nix b/nix/modules/hive-c0re/default.nix index 99088d89..d658dd00 100644 --- a/nix/modules/hive-c0re/default.nix +++ b/nix/modules/hive-c0re/default.nix @@ -1,11 +1,9 @@ -# The hive-c0re coordinator daemon (unprivileged `hive-core` user) and -# its narrow root helper hive-priv, both socket-activated. Options -# under `services.hyperhive.c0re.*`. The package/source options -# (`package`, `frontend`, `assets`, `xdgIcons`, `hyperhiveFlake`, -# `hyperhiveDocs`, `agentBaseToplevel`, `managerToplevel`) have no -# in-module defaults — the flake's `nixosModules.default` wires them -# to this flake's own package outputs via `lib.mkDefault`, so -# operator overrides still win and no overlay is involved. +# The hive-c0re coordinator daemon (runs as the unprivileged +# `hive-core` user), socket-activated at /run/hyperhive/host.sock. +# Layout: ./options.nix (option declarations), ./theme.nix (stylix +# frontend theming → `servedFrontend`), ./environment.nix (the daemon +# unit's env attrset). The root privileged helper it delegates to is +# its own module (../hive-priv.nix). { pkgs, lib, @@ -53,383 +51,12 @@ let model_prices = cfg.modelPrices; build_slots = cfg.buildSlots; }; - - # 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 { - 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; - 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). Wired to - this flake's `packages..default` by - `nixosModules.default` (via `lib.mkDefault`, so setting it here - wins). - ''; - }; - frontend = lib.mkOption { - type = lib.types.package; - defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend"; - description = '' - Bundled frontend dist (see `nix/packages/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 the hive-gateway module can static-serve - `dashboard/` as an nginx root instead of proxying to hive-c0re. - ''; - }; - assets = lib.mkOption { - type = lib.types.package; - defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets"; - description = '' - Bundled static runtime assets (see `nix/packages/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. - ''; - }; - xdgIcons = lib.mkOption { - type = lib.types.package; - defaultText = lib.literalExpression "hyperhive.packages.\${system}.xdg-icons"; - description = '' - XDG icon set + .desktop entries for hyperhive processes (see - `nix/packages/hive-xdg-icons.nix`), installed into the host - system packages so desktop environments can match hyperhive - processes to their icon. - ''; - }; - hyperhiveFlake = lib.mkOption { - type = lib.types.str; - defaultText = lib.literalMD "the hyperhive flake's own filtered source 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. Wired by `nixosModules.default` to this - flake's own filtered source — only override if you want agents - tracking a different ref. - ''; - }; - hyperhiveDocs = lib.mkOption { - type = lib.types.str; - defaultText = lib.literalMD "the docs/ tree's own store path"; - description = '' - URL of the narrow `docs/` source (no fragment). Inlined into the - generated meta `flake.nix` at `inputs.hyperhive-docs.url` and - threaded to each agent as `hyperhive.docs.source`, from which the - harness resolves `$HIVE_DOCS_DIR`. Its own store path — separate - from `hyperhiveFlake` — so a doc edit only re-locks this input - instead of rebuilding every agent container. - ''; - }; - agentBaseToplevel = lib.mkOption { - type = lib.types.package; - defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.agent-base-toplevel"; - description = '' - Pre-built agent-base container system closure, pulled into the - host system closure when `preBuildAgentTemplates` is on. Wired - by `nixosModules.default`; only evaluated when that option is - enabled. - ''; - }; - managerToplevel = lib.mkOption { - type = lib.types.package; - defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.ruth-toplevel"; - description = '' - Pre-built manager (ruth) container system closure — see - `agentBaseToplevel`. - ''; - }; - 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. - ''; - }; - 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 .#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. 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`. - ''; - }; - - buildSlots = lib.mkOption { - type = lib.types.ints.positive; - default = 1; - example = 2; - description = '' - Number of nix-heavy job-queue nodes (container prebuilds, - profile swaps, first-spawn creates, meta lock bumps) hive-c0re - runs concurrently. The default of 1 serializes all heavy nix - work; raise it on hosts with the cores/RAM to build several - agent toplevels at once. Per-agent correctness is independent - of this count — each agent's container-affecting operations are - serialized by its lifecycle lease regardless. - ''; - }; - }; + imports = [ + ./options.nix + ./theme.nix + ]; config = lib.mkIf cfg.enable { environment.systemPackages = [ @@ -458,7 +85,7 @@ in # Unprivileged coordinator user. hive-c0re runs as this user; # privileged operations are delegated to hive-priv which runs as - # root, socket-activated at /run/hive/priv.sock. + # root, socket-activated at /run/hive/priv.sock (./hive-priv.nix). users.users.hive-core = { isSystemUser = true; group = "hive-core"; @@ -473,8 +100,8 @@ in # 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. + # forge/matrix through the gateway by it). So the daemon environment + # (./environment.nix) can treat it as non-null. systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; wantedBy = [ "multi-user.target" ]; @@ -491,174 +118,7 @@ in 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 - # (see the hive-gateway module); 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 = 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.github.enable) { - # GitHub integration is on by default; only signal the OFF override to - # meta.rs, which then injects `hyperhive.github.enable = false` into - # every agent. See services.hyperhive.github.enable. - HYPERHIVE_GITHUB_DISABLED = "1"; - } - // 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; - } - // lib.optionalAttrs otel.debug { - HYPERHIVE_OTEL_DEBUG = "1"; - } - ) - // { - # In-cluster forge URL — the gateway vhost (`forge.`), which - # nginx proxies to forgejo. Used both for internal API calls in - # hive-c0re (forge/mod.rs `forge_http_base()`) and forwarded to - # agents via meta.rs for their forge-notify client. 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 - ) - ); - }; + environment = import ./environment.nix { inherit lib config pkgs; }; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config /etc/hyperhive/serve.json"; SyslogIdentifier = "hive-c0re"; @@ -754,139 +214,5 @@ in 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"; - # hive-priv runs as root. Root nix defaults to store=auto which - # resolves to the LOCAL store — bypassing the host daemon, its - # remote builders, and prebuilt derivation outputs. Force daemon - # routing so nixos-container update and the nix prebuild see the - # same store and substituters as every other build context. - NIX_REMOTE = "daemon"; - }; - 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"; - }; - }; }; } diff --git a/nix/modules/hive-c0re/environment.nix b/nix/modules/hive-c0re/environment.nix new file mode 100644 index 00000000..f7342f14 --- /dev/null +++ b/nix/modules/hive-c0re/environment.nix @@ -0,0 +1,180 @@ +# Environment of the hive-c0re daemon unit — a plain function file +# (not a module) returning the env attrset, imported by ./default.nix. +# Everything meta.rs forwards into agent containers or reads for the +# meta-flake render is assembled here. +{ + lib, + config, + pkgs, +}: +let + cfg = config.services.hyperhive.c0re; +in +{ + # 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 + # (see the hive-gateway module); 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 = "${cfg.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 = 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.github.enable) { + # GitHub integration is on by default; only signal the OFF override to + # meta.rs, which then injects `hyperhive.github.enable = false` into + # every agent. See services.hyperhive.github.enable. + HYPERHIVE_GITHUB_DISABLED = "1"; +} +// 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; + } + // lib.optionalAttrs otel.debug { + HYPERHIVE_OTEL_DEBUG = "1"; + } +) +// { + # In-cluster forge URL — the gateway vhost (`forge.`), which + # nginx proxies to forgejo. Used both for internal API calls in + # hive-c0re (forge/mod.rs `forge_http_base()`) and forwarded to + # agents via meta.rs for their forge-notify client. 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 + ) + ); + } diff --git a/nix/modules/hive-c0re/options.nix b/nix/modules/hive-c0re/options.nix new file mode 100644 index 00000000..30080d20 --- /dev/null +++ b/nix/modules/hive-c0re/options.nix @@ -0,0 +1,319 @@ +# Option declarations for `services.hyperhive.c0re.*` — the c0re +# daemon's knobs plus the package/source options the flake's +# `nixosModules.default` wires to its own outputs (they carry no +# in-module defaults; see ../../../flake.nix). The read-only +# `servedFrontend` option lives in ./theme.nix with the stylix wiring +# that computes it. +{ + pkgs, + lib, + config, + ... +}: +{ + 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; + 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). Wired to + this flake's `packages..default` by + `nixosModules.default` (via `lib.mkDefault`, so setting it here + wins). + ''; + }; + frontend = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend"; + description = '' + Bundled frontend dist (see `nix/packages/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. + ''; + }; + assets = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets"; + description = '' + Bundled static runtime assets (see `nix/packages/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. + ''; + }; + xdgIcons = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.xdg-icons"; + description = '' + XDG icon set + .desktop entries for hyperhive processes (see + `nix/packages/hive-xdg-icons.nix`), installed into the host + system packages so desktop environments can match hyperhive + processes to their icon. + ''; + }; + hyperhiveFlake = lib.mkOption { + type = lib.types.str; + defaultText = lib.literalMD "the hyperhive flake's own filtered source 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. Wired by `nixosModules.default` to this + flake's own filtered source — only override if you want agents + tracking a different ref. + ''; + }; + hyperhiveDocs = lib.mkOption { + type = lib.types.str; + defaultText = lib.literalMD "the docs/ tree's own store path"; + description = '' + URL of the narrow `docs/` source (no fragment). Inlined into the + generated meta `flake.nix` at `inputs.hyperhive-docs.url` and + threaded to each agent as `hyperhive.docs.source`, from which the + harness resolves `$HIVE_DOCS_DIR`. Its own store path — separate + from `hyperhiveFlake` — so a doc edit only re-locks this input + instead of rebuilding every agent container. + ''; + }; + agentBaseToplevel = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.agent-base-toplevel"; + description = '' + Pre-built agent-base container system closure, pulled into the + host system closure when `preBuildAgentTemplates` is on. Wired + by `nixosModules.default`; only evaluated when that option is + enabled. + ''; + }; + managerToplevel = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.x86_64-linux.ruth-toplevel"; + description = '' + Pre-built manager (ruth) container system closure — see + `agentBaseToplevel`. + ''; + }; + 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. + ''; + }; + 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 .#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. 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`. + ''; + }; + + buildSlots = lib.mkOption { + type = lib.types.ints.positive; + default = 1; + example = 2; + description = '' + Number of nix-heavy job-queue nodes (container prebuilds, + profile swaps, first-spawn creates, meta lock bumps) hive-c0re + runs concurrently. The default of 1 serializes all heavy nix + work; raise it on hosts with the cores/RAM to build several + agent toplevels at once. Per-agent correctness is independent + of this count — each agent's container-affecting operations are + serialized by its lifecycle lease regardless. + ''; + }; + }; +} diff --git a/nix/modules/hive-c0re/theme.nix b/nix/modules/hive-c0re/theme.nix new file mode 100644 index 00000000..7a3c7023 --- /dev/null +++ b/nix/modules/hive-c0re/theme.nix @@ -0,0 +1,79 @@ +# 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. Exposed as the read-only `c0re.servedFrontend` option. +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.c0re; + 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 + ''; +in +{ + options.services.hyperhive.c0re.servedFrontend = lib.mkOption { + type = lib.types.package; + internal = true; + readOnly = true; + default = if stylixThemeColors != null then themedFrontend stylixThemeColors else cfg.frontend; + 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 the hive-gateway module can static-serve + `dashboard/` as an nginx root instead of proxying to hive-c0re. + ''; + }; +} diff --git a/nix/modules/hive-priv.nix b/nix/modules/hive-priv.nix new file mode 100644 index 00000000..cf5ca4e2 --- /dev/null +++ b/nix/modules/hive-priv.nix @@ -0,0 +1,157 @@ +# hive-priv — the narrow root privileged helper hive-c0re delegates +# to, socket-activated at /run/hive/priv.sock. See docs/boundary.md +# for the operator/agent trust-boundary design. +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.c0re; + + # Same safe.directory gitconfig as the c0re unit (see ./hive-c0re) + # — hive-priv (root) runs nix, which fetches the hive-core-owned + # meta/applied repos; libgit2 refuses cross-user reads without it. + safeDirGitconfig = pkgs.writeText "hyperhive-safe-gitconfig" '' + [safe] + directory = * + ''; +in +{ + config = lib.mkIf cfg.enable { + # 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"; + # hive-priv runs as root. Root nix defaults to store=auto which + # resolves to the LOCAL store — bypassing the host daemon, its + # remote builders, and prebuilt derivation outputs. Force daemon + # routing so nixos-container update and the nix prebuild see the + # same store and substituters as every other build context. + NIX_REMOTE = "daemon"; + }; + 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"; + }; + }; + }; +}