diff --git a/CLAUDE.md b/CLAUDE.md index aab72fdc..33c4a4c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,12 +63,16 @@ hand-maintained per-file tree drifts out of sync with the code. ### Other top-level dirs - **`frontend/`** — npm workspaces → static dashboard + per-agent UI - dist, built hermetically by `nix/frontend.nix`. Packages: `shared` - (terminal pane + Catppuccin palette), `dashboard` (the operator SPA), - `agent` (the default per-container UI). -- **`nix/`** — NixOS modules (`modules/hive-{c0re,forge,gateway,matrix, - ci,network}.nix`) + per-container templates (`templates/{harness-base, - agent-base,manager,weston-vnc}.nix`) + the options-doc derivation. + dist, built hermetically by `nix/packages/frontend.nix`. Packages: + `shared` (terminal pane + Catppuccin palette), `dashboard` (the + operator SPA), `agent` (the default per-container UI). +- **`nix/`** — `host-modules/` (the host stack: hyperhive core options, + `hive-{c0re,priv,forge,gateway,matrix,network,tls,ci}`, otel, swarm), + `agent-modules/` (the per-agent harness feature modules), + `templates/{agent,ruth}.nix` (container entry points), `packages/` + (flake package outputs), `docs/` (the options-doc derivation), plus + `sources.nix` / `rust.nix` / `checks.nix` / `devshell.nix` / + `treefmt.nix` behind the thin `flake.nix`. - **`docs/`** — subsystem reference docs (see *Reading paths* below). - **`branding/`**, **`scripts/`** — static assets + helper scripts. diff --git a/README.md b/README.md index 0a9e8e7d..f68f1ad6 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ account), it prints the token to stdout and writes nothing. nix develop -c cargo check nix flake check # rust + nix + toml fmt + clippy -# deploy from a host config that imports hyperhive.nixosModules.hive-c0re +# deploy from a host config that imports hyperhive.nixosModules.default nix flake update --update-input hyperhive sudo nixos-rebuild switch --flake .# ``` diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index 038a52c3..18ad370f 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -166,7 +166,7 @@ Tree-shape version: `HYPERHIVE_STATE_DIR` is now injected uniformly via `systemd.globalEnvironment` in `meta.rs` for every container (manager included), so all token/state paths resolve through -`$HYPERHIVE_STATE_DIR`. The harness-base shell scripts +`$HYPERHIVE_STATE_DIR`. The agent-module shell scripts (tea-login, forge-avatar-sync) simplified from glob+for loops to a direct `$HYPERHIVE_STATE_DIR/` read. @@ -215,15 +215,15 @@ nspawn agent. Open questions, not yet wired: ## Harness systemd unit shape One harness serve binary (`hive-agent`, with its `hive-agent-mcp` / -`hive-agent-wake` siblings), one `harness-base.nix` template, one +`hive-agent-wake` siblings), one shared `nix/agent-modules/` tree, one service unit (`systemd.services.hive-ag3nt`) for all agents. There is no longer a separate manager service name or role distinction in the harness — privilege differences live server-side in the broker socket (which tool groups and manager-surface calls each agent receives). -`agent-base.nix` and `manager.nix` both import `harness-base.nix`. -`manager.nix` additionally sets forge defaults to suppress the +`agent.nix` and `ruth.nix` both import the shared `nix/agent-modules/`. +`ruth.nix` additionally sets forge defaults to suppress the subscription/participation firehose so ruth's inbox stays focused on direct mentions, reviews, and assignments. diff --git a/docs/ci.md b/docs/ci.md index be9189dc..5ea5a8d6 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -181,6 +181,6 @@ GC on the coordinator host won't reclaim space on the builder. ## References -- `nix/modules/hive-ci.nix`: runner configuration, auto-registration script, container setup. +- `nix/host-modules/hive-ci.nix`: runner configuration, auto-registration script, container setup. - `.forgejo/workflows/ci.yml`: workflow definition. - `docs/gotchas.md`: nix sandboxing limitations in containers. diff --git a/docs/gateway.md b/docs/gateway.md index 29b3cd09..ae795d62 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -89,8 +89,7 @@ now set unconditionally for every agent. The mechanism: 1. **Agent side**. `HIVE_WEB_SOCKET=/run/hive-agent//web.sock` is set on every harness service env; `web_ui::serve` binds a - `UnixListener` at that path. The deprecated - `hyperhive.web.useUnixSocket` option is now a no-op. + `UnixListener` at that path. 2. **Host side**. `hive-c0re` bind-mounts the per-agent subdir (`/run/hive-agent//`) into the agent's container. Dir bind, not file bind — file bind-mounts don't survive the diff --git a/docs/gotchas.md b/docs/gotchas.md index 153df0fa..119bc38e 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -77,7 +77,7 @@ socket without needing a clean reinstall. ## `claude-code` is unfree `claude-code` comes from the flake's main `nixpkgs` (nixos-26.05). -It's unfree, so `harness-base.nix` sets `config.allowUnfreePredicate` +It's unfree, so the agent modules set `config.allowUnfreePredicate` at the container level to whitelist `claude-code` specifically — scoped, only this one package. This is needed because each per-agent `nixosConfiguration` evaluates its own nixpkgs instance and the @@ -204,7 +204,7 @@ containers don't get user-namespaces by default, so `nix build` invocations _inside_ the container can't set up the build sandbox and fail outright if the host daemon's `nix.settings.sandbox-fallback` is `false` (nixpkgs default). -`nix/templates/harness-base.nix` does `lib.mkForce true` so builds +`nix/agent-modules/default.nix` does `lib.mkForce true` so builds fall back to unsandboxed local builds rather than failing. Security implications: `docs/security.md`. @@ -232,7 +232,7 @@ CI on drift). ## Split asset derivations away from the rust workspace -`nix/assets.nix` builds the branding SVG/PNG family + claude +`nix/packages/assets.nix` builds the branding SVG/PNG family + claude system-prompt template + claude-settings JSON as its own derivation, separate from the hive-ag3nt / hive-c0re crates. Reason: when the rust build's `src` was the whole repo tree, any tweak to @@ -246,7 +246,7 @@ derivation's `nativeBuildInputs`. ## Weston VNC compositor (per-agent `hyperhive.gui.enable`) -`nix/templates/weston-vnc.nix` adds an optional Weston Wayland +`nix/agent-modules/weston-vnc.nix` adds an optional Weston Wayland compositor with the VNC backend, surfaced as `hyperhive.gui.enable = true` per-agent. The harness's `/screen/ws` WebSocket relay (`docs/web-ui/agent.md::Per-agent endpoints`) @@ -284,7 +284,7 @@ connects to the compositor at `127.0.0.1:`. inherit it. - **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is launched with `--socket=wayland-0` so the socket path is - deterministic. `harness-base.nix` exports `WAYLAND_DISPLAY=wayland-0` + deterministic. `nix/agent-modules/weston-vnc.nix` exports `WAYLAND_DISPLAY=wayland-0` and `XDG_RUNTIME_DIR=/run/gui` as global system environment variables (gated on `hyperhive.gui.enable`) so every systemd service in the container inherits them. Without this, services starting @@ -311,7 +311,7 @@ connects to the compositor at `127.0.0.1:`. must never block on weston signalling readiness. A misconfigured weston degrades to a `Restart=on-failure` loop visible in `journalctl`, it does not abort the `nixos-container update`. - Same reasoning as the `tea-login` unit in `harness-base.nix`. + Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`. - **`[core] idle-time=0`**: disables weston's 300-second idle timeout. Without it the VNC desktop fades to black and desktop-shell shows its click-to-unlock screen — useless for an @@ -323,9 +323,9 @@ connects to the compositor at `127.0.0.1:`. ## Nix options reference (`nix/docs/default.nix`) `pkgs.nixosOptionsDoc` over two evaluated module trees: -`hostEval` (a stub NixOS system loading `hive-c0re.nix` with every +`hostEval` (a stub NixOS system loading the `nix/host-modules/` aggregator with every hyperhive subsystem `mkForce false` so heavy build inputs stay out of -the eval) and `agentEval` (evaluates `agent-base.nix` fresh for the +the eval) and `agentEval` (evaluates `agent.nix` fresh for the per-agent options tree). Three output trees consumed by `flake.nix`, all **markdown**: @@ -333,7 +333,7 @@ Three output trees consumed by `flake.nix`, all **markdown**: - `docs-host` — operator-facing host module options (`services.hyperhive.*`) - `docs-agent` — per-agent harness options (`hyperhive.*` - declared in `nix/templates/harness-base.nix`) + declared in `nix/agent-modules/`) - `docs` — bundle of `index.md` + `host.md` + `agent.md` Pipeline: @@ -356,7 +356,7 @@ options tree picks up everything under that root — picking against stray roots produces an empty tree and renders the host page as template chrome with no `

` headers. -### Docs drv stability: `nixSrc` + stub overlay +### Docs drv stability: `nixSrc` Naively, the docs evaluation depends on `self` (the flake's store path), so every commit — even Rust-only or frontend-only changes — produces new @@ -371,11 +371,11 @@ The fix (`nix/docs/default.nix`): context. The resulting store path is content-addressed from the nix/ file contents only. Docs drvs only change when a `.nix` file changes. -2. **`docsStubOverlay`** — replaces `self.overlays.default` with stub - packages (`pkgs.emptyFile` / `pkgs.emptyDirectory`) for the docs eval. - `nixosOptionsDoc` renders `defaultText` for all package options anyway; - the stubs prevent attribute-missing eval errors without pulling in the - Rust or frontend build closure. +2. The package options the modules consume (`hyperhive.packages.*`, + `services.hyperhive.c0re.*`) carry no in-module defaults and every + default that references them has a `defaultText`, so the doc walk + never forces a package — no stubs needed, and the Rust/frontend + build closure stays out of the eval. 3. Both `hostEval` and `agentEval` are evaluated from `nixSrc` paths (not `self`), so the docs drv dependency chain ends at `nixSrc`. diff --git a/docs/matrix.md b/docs/matrix.md index 65c89276..04671824 100644 --- a/docs/matrix.md +++ b/docs/matrix.md @@ -214,7 +214,7 @@ which has two upstream gaps for fluffychat's web target: - `native_imaging`'s C source isn't built — emscripten isn't a flutter-builder native build input. -Both fixed in `nix/modules/hive-matrix.nix` via two derivations: +Both fixed in `nix/host-modules/hive-matrix.nix` via two derivations: - **`fluffychat-web-imaging`** builds `Imaging.{js,wasm}` from the `native_imaging` C source via `pkgs.emscripten`. Source comes diff --git a/docs/network.md b/docs/network.md index 6cb56c07..b3c2a7ad 100644 --- a/docs/network.md +++ b/docs/network.md @@ -4,11 +4,11 @@ Host-side bridge + per-agent private-netns isolation — always on whenever hyperhive is enabled. Configured via `services.hyperhive.network.*`. -> **Historical note:** the bridge and private-netns isolation landed in -> two separate phases. `services.hyperhive.network.enable` and -> `services.hyperhive.network.isolateContainers` are retained as -> deprecated no-op options so existing configs eval without change; both -> are ignored — isolation is the only mode. +> Isolation is the only mode — there is no shared-netns fallback. The +> former `services.hyperhive.network.enable`, +> `services.hyperhive.network.isolateContainers` and +> `services.hyperhive.network.upstreamDns` options were removed; a +> config that still sets one fails eval with a removal message. ## Container shape (where dnsmasq lives) @@ -26,7 +26,6 @@ listener on `bridgeIp` is on the host's bridge interface. enable = true; domain = "darkest.space"; # network.bridgeIp = "10.42.0.1"; # default - # network.upstreamDns = [ "1.1.1.1" "9.9.9.9" ]; # default }; } ``` @@ -48,9 +47,12 @@ schemes pick their own. dnsmasq is **authoritative** for the hive's own zones — answers ``, `forge.`, `matrix.` queries with the bridge IP (where nginx is reachable). Everything -else gets forwarded to `upstreamDns`. Containers don't need to know -the upstream — they query the bridge IP and dnsmasq does the right -thing per-name. +else is forwarded to the host's own resolvers: dnsmasq reads the +gateway container's `/etc/resolv.conf`, the host copy nixos-container +makes at each container start — a host resolver change is picked up +on the next gateway restart. Containers don't need to know the +upstream — they query the bridge IP and dnsmasq does the right thing +per-name. `bind-interfaces` + `interface = [ bridgeName "lo" ]` means the listener only accepts queries from the bridge interface (plus lo for @@ -129,7 +131,7 @@ address arithmetic. and `HOST_BRIDGE=` via `lifecycle::set_nspawn_flags` when creating or updating containers. `LOCAL_ADDRESS` is left empty so the container's dhcpcd acquires an address from the bridge dnsmasq pool -(`networking.useDHCP = true` in `harness-base.nix`). This applies uniformly +(`networking.useDHCP = true` in `nix/agent-modules/network.nix`). This applies uniformly to all containers — agents and service containers alike. `HOST_ADDRESS` is the bridge gateway IP (the address part of @@ -154,7 +156,7 @@ wiring is runtime: - `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the gateway IP) into each container's `/etc`. -- the `hyperhive-isolated-dns` oneshot (harness-base.nix), gated on that +- the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that marker, rewrites `/etc/resolv.conf` to `nameserver ` at boot. It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and `tea-login` so the resolver is correct before the first DNS lookup. diff --git a/docs/persistence.md b/docs/persistence.md index c9aa1506..3aee55f3 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -369,7 +369,7 @@ state dirs need a one-time data shuffle so they don't lose their claude session. `system.activationScripts.hive-agent-user-migrate` (in -`nix/templates/harness-base.nix`) runs on every activation, +`nix/agent-modules/user.nix`) runs on every activation, marker-guarded so the substantive moves only happen once per container lifetime: diff --git a/docs/security.md b/docs/security.md index f0fb0efa..0baec3f0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -146,7 +146,7 @@ component can't traverse. Agent containers bind-mount the host's `nix-daemon` socket. The host daemon may have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build` inside nspawn containers to fail — containers lack kernel user namespaces, so nix -cannot set up its build sandbox. `harness-base.nix` sets `sandbox-fallback = true` +cannot set up its build sandbox. the agent modules set `sandbox-fallback = true` so that builds fall back to unsandboxed execution rather than failing outright. ### Threat model diff --git a/docs/tools/bash.md b/docs/tools/bash.md index c28dc5b0..cac557af 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -2,7 +2,7 @@ Background shell execution via `hive-bash-mcp`. Tools land as `mcp__bash__` (the MCP server name is `bash`, not `hyperhive`). -Available on every agent unconditionally — `harness-base.nix` always +Available on every agent unconditionally — `nix/agent-modules/mcp.nix` always injects bash into `hyperhive.extraMcpServers` (with `allowedTools = ["*"]`), so `mcp__bash__*` is in `--allowedTools` for every claude invocation regardless of tool groups. @@ -96,7 +96,7 @@ The bash tooling follows the same daemon + stdio-bridge pattern as the matrix MCP: - **`hive-bash-daemon`** — long-running process (one per agent container, - systemd service in `harness-base.nix`). Owns subprocess management, + systemd service in `nix/agent-modules/mcp.nix`). Owns subprocess management, output file writing, `mcp-loose-ends/` state, and wake signal delivery. Listens on `/run/hive-bash/socket` inside the container. diff --git a/docs/tools/forge.md b/docs/tools/forge.md index 1245239a..a7ce6138 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -1,7 +1,7 @@ # hive-forge CLI `hive-forge` is the Forgejo API wrapper available in every agent -container (installed via `harness-base.nix`; lives in `/hive-forge` +container (installed via `nix/agent-modules/forge.nix`; lives in `/hive-forge` as a proper Rust binary). Use it instead of ad-hoc curl pipelines. ## Credentials and repo defaults diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index 9c91f041..9d93619e 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -23,7 +23,7 @@ parsed from a turn (usage, cost, context window, resolved model) as Hive-enforced settings ship at `/etc/claude-code/managed-settings.json` (claude-code's canonical managed-settings path — precedence #1, -read-only, un-overridable), wired in `nix/templates/harness-base.nix` +read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix` from the `prompts/claude-settings.json` asset. `effortLevel` is deliberately not in that file — effort is controlled live via the `--effort` flag (`HIVE_DEFAULT_EFFORT` / the per-agent UI slider), which diff --git a/docs/turn-loop/config.md b/docs/turn-loop/config.md index ad74b988..116da2a1 100644 --- a/docs/turn-loop/config.md +++ b/docs/turn-loop/config.md @@ -17,7 +17,7 @@ sentence into the agent's system prompt so it knows the docs exist and where to find them. The tree is served by `claude --add-dir` so the full markdown is readable during every turn. -Enabled by default only for the root/manager agent (`manager.nix`). Any +Enabled by default only for the root/manager agent (`nix/templates/ruth.nix`). Any agent can opt in by adding the line above to its `agent.nix`. The `docs/` source is a narrow flake input (`hyperhive-docs`) tracked diff --git a/flake.nix b/flake.nix index 317a965b..d9eb713c 100644 --- a/flake.nix +++ b/flake.nix @@ -3,9 +3,9 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; - # Crane (replaces the former naersk-based build). Stateless — no nixpkgs input to - # follow; `crane.mkLib pkgs` returns the lib at whatever pkgs we - # pass it (we use the project's pinned nixpkgs). + # Crane is stateless — no nixpkgs input to follow; `crane.mkLib + # pkgs` returns the lib at whatever pkgs we pass it (we use the + # project's pinned nixpkgs). crane.url = "github:ipetkov/crane"; treefmt-nix = { url = "github:numtide/treefmt-nix"; @@ -13,6 +13,14 @@ }; }; + # Thin entry point — the real logic lives under nix/: + # nix/sources.nix filtered source views (meta-flake + docs inputs) + # nix/rust.nix shared crane wiring (cleanSrc, cargoArtifacts) + # nix/packages/ every package output + # nix/checks.nix flake checks + # nix/devshell.nix dev shell + # nix/treefmt.nix formatter config + # nix/host-modules/, nix/agent-modules/, nix/templates/ the NixOS module trees outputs = inputs@{ self, @@ -26,61 +34,7 @@ "aarch64-linux" "x86_64-linux" ]; - # Explicit per-consumer source filter for the hyperhive flake path - # handed to hive-c0re (`hyperhiveFlake`, below). A filtered view of - # this repo that drops files no nix or cargo derivation reads — - # shell helper scripts and root-level markdown — so editing them - # does NOT change the store path and therefore does NOT force a - # rebuild of every agent container. - # - # Companion to the rust `cleanSrc` (crane's cargo-source filter): - # each real source is a named, explicitly-filtered derivation used - # as `src`, rather than an inline filter at the use site. - # - # Dropped: scripts/, docs/, root-level *.md (README/CLAUDE/TODO/…). - # Kept (build needs them): .nix, .rs, Cargo.*, branding/, - # frontend/, prompts/, flake.lock. docs/ is deliberately dropped: - # it is shipped to agent containers as its OWN narrow - # meta-flake input (`hyperhiveDocsSource`, below), threaded through - # hive-c0re → the meta flake → `hyperhive.docs.source`. Keeping docs/ - # out of THIS source means a doc edit only re-hashes the docs input - # (a cheap re-link), not the whole flake source (which would rebuild - # every agent container). The old `nix/reference-docs.nix` build from - # `../docs` stays only for standalone `nix build .#reference-docs` - # from a full checkout — the meta path never evaluates it. - hyperhiveFlakeSource = lib.cleanSourceWith { - name = "hyperhive-flake-source"; - src = ./.; - filter = - path: type: - let - # Repo-relative path (strip the absolute source-dir prefix). - rel = lib.removePrefix (toString ./. + "/") (toString path); - in - !(lib.hasPrefix "scripts/" rel || rel == "scripts") - && !(lib.hasPrefix "docs/" rel || rel == "docs") - && !(type == "regular" && !lib.hasInfix "/" rel && lib.hasSuffix ".md" rel); - }; - # The repo `docs/` tree as a standalone narrow source. Its - # store path moves ONLY on doc edits, decoupled from - # `hyperhiveFlakeSource`. hive-c0re threads this to the meta flake as - # the `hyperhive-docs` input (same pattern as `hyperhiveFlake`); the - # harness resolves `$HIVE_DOCS_DIR` from it via `hyperhive.docs.source`. - # Evaluated here on the host where docs/ exists — it cannot be derived - # from inside the docs-stripped `hyperhiveFlakeSource`. - hyperhiveDocsSource = lib.cleanSourceWith { - name = "hyperhive-docs-source"; - src = ./docs; - }; - treefmt-config = { - projectRootFile = "flake.nix"; - programs = { - keep-sorted.enable = true; - nixfmt.enable = true; - rustfmt.enable = true; - taplo.enable = true; - }; - }; + sources = import ./nix/sources.nix { inherit lib; }; forAllSystems = f: lib.genAttrs systems ( @@ -88,70 +42,9 @@ f rec { inherit system; pkgs = nixpkgs.legacyPackages.${system}; - treefmt-eval = treefmt-nix.lib.evalModule pkgs treefmt-config; + treefmt-eval = treefmt-nix.lib.evalModule pkgs (import ./nix/treefmt.nix); craneLib = crane.mkLib pkgs; - # Narrowed source tree the rust derivations consume. - # `cleanCargoSource` is crane's standard "everything cargo - # cares about" filter (Cargo.toml/Cargo.lock + *.rs). All - # non-rust runtime assets — branding + the claude system - # prompt template + claude-settings.json — live in the - # separate `hyperhive-assets` derivation and are - # loaded by the binaries at runtime from `$HIVE_ASSETS_DIR`. - # The unit tests in `hive-ag3nt::prompt` read the same - # `prompts/system.md` directly from the workspace tree at - # *test* runtime (via `env!("CARGO_MANIFEST_DIR")` — a - # compile-time string, no file open at compile), so the - # prompt template doesn't have to be in this fileset to - # keep `cargo test` honest. Net effect: tweaks to any - # non-`*.rs` / non-`Cargo.*` file (README, branding, - # nix modules, frontend tree, OR `hive-ag3nt/prompts/*`) - # do NOT bust this src hash, so the rust derivations - # stay fully cached. - cleanSrc = craneLib.cleanCargoSource ./.; - # Build the workspace's dependency tree once, cached as - # its own derivation. `buildPackage` and `cargoClippy` - # both reuse this via `inherit cargoArtifacts;` so a - # workspace-only edit doesn't rebuild deps. All three - # derivations consume the same `cleanSrc` so the input - # hash stays consistent across the chain. - cargoArtifacts = craneLib.buildDepsOnly { - src = cleanSrc; - # Workspace Cargo.toml is virtual (no `[package].name`), - # so crane can't auto-derive a name. Spell it out - # explicitly here and below — keeps the derivation name - # stable across crane bumps + silences the placeholder - # warning. Same `pname` everywhere so dep + workspace + - # clippy share a clean naming family. - pname = "hyperhive-workspace"; - version = "0.1.0"; - inherit nativeBuildInputs; - }; - # Shared between buildDepsOnly + buildPackage + cargoClippy - # so the three derivations see the same toolchain shape. - # git: naersk used to auto-include it; crane is more - # minimal, so we add it explicitly so hive-c0re's - # `lifecycle::tests::setup_proposed_*` (which shell out to - # `git init` + commit) pass under `cargo test` in the - # sandbox. - # `librsvg` used to live here for `hive-c0re/build.rs`'s - # rsvg-convert call — that whole codepath moved into the - # `hyperhive-assets` derivation, so the rust - # derivation no longer needs the dependency. - # `sqlite` required by matrix-sdk's `sqlite` feature - # (`hive-matrix-mcp` workspace member) — the - # matrix-sdk-sqlite + rusqlite stack links against system - # libsqlite3 by default. - # `cmake` builds `aws-lc-sys` (BoringSSL) from source — pulled in by - # the `rustls` (aws-lc-rs) crypto provider under the OTLP/reqwest - # stack in `hive-metric`. Without it the crane deps build fails on - # `cargo-package-reqwest-0.13.4`. Kept in the shared inputs since - # more metric/crypto deps are expected to land in the workspace. - nativeBuildInputs = [ - pkgs.git - pkgs.sqlite - pkgs.pkg-config - pkgs.cmake - ]; + rust = import ./nix/rust.nix { inherit pkgs craneLib; }; } ); in @@ -160,272 +53,98 @@ { pkgs, craneLib, - cleanSrc, - cargoArtifacts, - nativeBuildInputs, + rust, ... }: - let - docsAttrs = import ./nix/docs { - inherit pkgs self; - inherit (nixpkgs) lib; - inherit (nixpkgs.lib) nixosSystem; - }; - # One package per daemon/harness/MCP-server binary — matches the - # `hivectl` / `hive-forge` split below rather than grouping them - # into a single derivation. Consumers (agent containers, host - # module, `nix profile install`) depend on exactly the binaries - # they need instead of an all-or-nothing bundle. All share - # `cargoArtifacts` (built once via `buildDepsOnly` above), so - # splitting doesn't cost extra rustc invocations — each call - # below just asks crane to build+link its one requested `--bin`. - # - # Tests are kept in the separate `checks.cargo-test` derivation - # (carries the hyperhive-assets build input for the prompt-template - # assertions in hive-ag3nt::prompt::tests). Keeping them out of the - # binary derivations means a prompt edit doesn't bust the cargo cache. - mkDaemonBin = - bin: description: - craneLib.buildPackage { - src = cleanSrc; - inherit cargoArtifacts nativeBuildInputs; - cargoExtraArgs = "--bin ${bin}"; - pname = bin; - version = "0.1.0"; - meta.description = description; - doCheck = false; - }; - hiveC0rePkg = mkDaemonBin "hive-c0re" "hyperhive host coordinator daemon"; - hivePrivPkg = mkDaemonBin "hive-priv" "hyperhive privileged root helper"; - hiveAgentPkg = mkDaemonBin "hive-agent" "hyperhive in-container agent harness serve loop"; - hiveAgentMcpPkg = mkDaemonBin "hive-agent-mcp" "hyperhive agent-surface MCP server"; - hiveAgentWakePkg = mkDaemonBin "hive-agent-wake" "hyperhive external wake CLI — push a message into an agent's own inbox"; - hiveBashDaemonPkg = mkDaemonBin "hive-bash-daemon" "hyperhive per-agent bash-task runner daemon"; - hiveBashMcpPkg = mkDaemonBin "hive-bash-mcp" "hyperhive bash-task MCP bridge"; - hiveMatrixDaemonPkg = mkDaemonBin "hive-matrix-daemon" "hyperhive per-agent matrix-sdk daemon"; - hiveMatrixMcpPkg = mkDaemonBin "hive-matrix-mcp" "hyperhive matrix MCP bridge"; - hiveMetricPkg = mkDaemonBin "hive-metric" "hyperhive agent-emitted custom metrics CLI"; - # Operator CLI — ships `hivectl` (with shell completions and the - # `wg` wrapper) without the daemon binaries. Suitable for - # `nix profile install .#hivectl` / `environment.systemPackages - # = [ inputs.hyperhive.packages.${system}.hivectl ]` when the - # operator only wants the admin CLI. Shares `cargoArtifacts` with - # `daemonBins` so there is no double-rustc cost. - hivectlPkg = craneLib.buildPackage { - src = cleanSrc; - inherit cargoArtifacts; - cargoExtraArgs = "--bin hivectl"; - pname = "hivectl"; - version = "0.1.0"; - meta.description = "hyperhive operator CLI"; - doCheck = false; - # `installShellFiles` + `makeWrapper` scoped to this derivation - # only — daemon bins don't need them. - nativeBuildInputs = nativeBuildInputs ++ [ - pkgs.installShellFiles - pkgs.makeWrapper - ]; - # Ship shell completions (the binary's own `completions ` - # verb is the single source of truth, so they never drift from - # the actual verbs). Wrap with wireguard-tools so `hivectl wg` - # subcommands work before `swarm.wireguard.enable` is set (wg - # init is the very first setup step). Completion generation runs - # before wrapProgram since wrapProgram renames the real binary. - postInstall = '' - installShellCompletion --cmd hivectl \ - --bash <("$out/bin/hivectl" completions bash) \ - --zsh <("$out/bin/hivectl" completions zsh) \ - --fish <("$out/bin/hivectl" completions fish) - wrapProgram "$out/bin/hivectl" \ - --prefix PATH : ${pkgs.wireguard-tools}/bin - ''; - }; - # Forgejo CLI — ships `hive-forge` without the rest of the - # workspace. Useful for operator workstations / CI environments - # that only need forge access. Shares `cargoArtifacts` with - # `daemonBins`. - hiveForgePkg = craneLib.buildPackage { - src = cleanSrc; - inherit cargoArtifacts nativeBuildInputs; - cargoExtraArgs = "--bin hive-forge"; - pname = "hive-forge"; - version = "0.1.0"; - meta.description = "hyperhive Forgejo CLI"; - doCheck = false; - }; - in - { - # All workspace binaries in one derivation via symlinkJoin. - # Each binary is compiled exactly once (one rustc per bin, all - # sharing `cargoArtifacts`); symlinkJoin assembles the outputs - # without any additional compilation. The NixOS module's - # `pkgs.hyperhive` (= this) and `nix build .#` both land here. - default = pkgs.symlinkJoin { - name = "hyperhive"; - paths = [ - hiveC0rePkg - hivePrivPkg - hiveAgentPkg - hiveAgentMcpPkg - hiveAgentWakePkg - hiveBashDaemonPkg - hiveBashMcpPkg - hiveMatrixDaemonPkg - hiveMatrixMcpPkg - hiveMetricPkg - hivectlPkg - hiveForgePkg - ]; - }; - # Per-bin split packages. Agent containers depend on the - # individual bins they actually exec/PATH-need (see - # `harness-base.nix`) instead of the `default` bundle — that - # keeps `hivectl` (dials the *host* admin socket, unreachable - # from inside a container, drags in `wireguard-tools`) and a - # redundant `hive-forge` copy (already on agent PATH via - # `hive-forge-tools.nix`) out of every agent's closure. - hivectl = hivectlPkg; - hive-forge = hiveForgePkg; - hive-c0re = hiveC0rePkg; - hive-priv = hivePrivPkg; - hive-agent = hiveAgentPkg; - hive-agent-mcp = hiveAgentMcpPkg; - hive-agent-wake = hiveAgentWakePkg; - hive-bash-daemon = hiveBashDaemonPkg; - hive-bash-mcp = hiveBashMcpPkg; - hive-matrix-daemon = hiveMatrixDaemonPkg; - hive-matrix-mcp = hiveMatrixMcpPkg; - hive-metric = hiveMetricPkg; - # Bundled browser assets — see ./nix/frontend.nix. Output is - # $out/{dashboard,agent}/ which the Rust binaries serve via - # tower_http::ServeDir. - frontend = pkgs.callPackage ./nix/frontend.nix { - branding-svg = ./branding/hyperhive.svg; - }; - # Static runtime assets the rust binaries read via - # `hive_sh4re::assets::*`: branding/* + prompts/*, - # plus the rendered agent-configs.png. Split out of the - # rust derivation so a tweak to e.g. system.md doesn't bust - # the cargo cache. Build input of the `cargo-test` check but - # NOT of `packages.default`, so the binary derivation stays - # cached when a prompt edit ripples through. - assets = pkgs.callPackage ./nix/assets.nix { }; - # The repo docs/ markdown tree as a standalone derivation — - # agents read it in-container (added as a claude additional - # directory) and the website repo reuses it as a flake input, - # neither of which needs the branding/prompt assets. See - # nix/reference-docs.nix. (`docs` above is the auto-generated - # nix-options reference, a different artifact.) - reference-docs = pkgs.callPackage ./nix/reference-docs.nix { }; - # XDG icon set + .desktop entries for hyperhive processes. - # Narrow input: only the branding SVG, so unrelated source changes - # don't bust this derivation's cache. - xdg-icons = pkgs.callPackage ./nix/packages/hive-xdg-icons.nix { - hyperhiveSvg = ./branding/hyperhive.svg; - }; - # Pre-built per-container system closures. Exposed as packages - # so operators can `nix build .#agent-base-toplevel` (or wire - # them into their host system closure via the - # `preBuildAgentTemplates` option on the hive-c0re module — - # see nix/modules/hive-c0re.nix). Speeds up the first agent - # spawn dramatically because the heavy lifting (nixpkgs + - # claude-code + hive-ag3nt binary) is already in the store - # when the meta evaluator goes to build the container. - # - # nixosConfigurations are pinned to x86_64-linux (nixos- - # containers only run native arch), so these toplevels are - # only useful on an x86_64-linux host — flake check across - # systems still tolerates evaluating them on aarch64 because - # they're plain derivations, but `nix build` from a non-x86 - # host would only succeed via a remote x86 builder. - agent-base-toplevel = self.nixosConfigurations.agent-base.config.system.build.toplevel; - ruth-toplevel = self.nixosConfigurations.ruth.config.system.build.toplevel; - - # Auto-generated nix options reference for hyperhive. - # `docs` bundles host + agent pages into one tree; the split - # outputs are useful when consumers only want one surface. - # All three are pure markdown — no rust or frontend deps in - # the closure, so `nix build .#docs` is cheap. - docs = docsAttrs.bundle; - docs-host = docsAttrs.host; - docs-agent = docsAttrs.agent; + import ./nix/packages { + inherit + pkgs + craneLib + rust + self + nixpkgs + ; } ); - overlays = { - default = final: prev: { - hyperhive = self.packages.${prev.stdenv.hostPlatform.system}.default; - # Per-binary daemon/harness/MCP-server packages, exposed via the - # overlay so container nix evaluations can depend on exactly the - # bin(s) they need instead of the full `hyperhive` bundle — see - # `packages..hive-*` above and their use in - # `harness-base.nix`. - hive-c0re = self.packages.${prev.stdenv.hostPlatform.system}.hive-c0re; - hive-priv = self.packages.${prev.stdenv.hostPlatform.system}.hive-priv; - hive-agent = self.packages.${prev.stdenv.hostPlatform.system}.hive-agent; - hive-agent-mcp = self.packages.${prev.stdenv.hostPlatform.system}.hive-agent-mcp; - hive-agent-wake = self.packages.${prev.stdenv.hostPlatform.system}.hive-agent-wake; - hive-bash-daemon = self.packages.${prev.stdenv.hostPlatform.system}.hive-bash-daemon; - hive-bash-mcp = self.packages.${prev.stdenv.hostPlatform.system}.hive-bash-mcp; - hive-matrix-daemon = self.packages.${prev.stdenv.hostPlatform.system}.hive-matrix-daemon; - hive-matrix-mcp = self.packages.${prev.stdenv.hostPlatform.system}.hive-matrix-mcp; - hive-metric = self.packages.${prev.stdenv.hostPlatform.system}.hive-metric; - # Bundled frontend dist (see ./nix/frontend.nix). Output is - # $out/{dashboard,agent}/; consumers pick the surface they - # need. Exposed via the overlay so containers' nix evaluations - # can reach it as `pkgs.hyperhive-frontend` once the overlay - # is applied (manager + agent containers both apply it via - # `mkContainer` further down). - hyperhive-frontend = self.packages.${prev.stdenv.hostPlatform.system}.frontend; - # Static runtime assets. Exposed alongside the binary - # so the harness module can wire $HIVE_ASSETS_DIR straight - # to `${pkgs.hyperhive-assets}/share/hyperhive`. - hyperhive-assets = self.packages.${prev.stdenv.hostPlatform.system}.assets; - # Standalone docs/ tree (see nix/reference-docs.nix). Exposed - # via the overlay so the harness module can build the - # in-container agent docs dir from it. - hyperhive-docs = self.packages.${prev.stdenv.hostPlatform.system}.reference-docs; + nixosModules = + let + # Package wiring for agent containers — the harness modules + # consume hyperhive's own packages via the `hyperhive.packages` + # option (see nix/agent-modules/packages.nix); no overlay. + # The `mkDefault` is applied PER KEY (`mapAttrs`), not to the + # whole attrset: definition-level priority filtering runs + # before `attrsOf`'s per-key merge, so a whole-set `mkDefault` + # would be discarded entirely the moment an agent.nix + # overrides a single key. Per-key priorities make an + # individual override win while every other key keeps the + # flake default. + agentPackages = + { lib, pkgs, ... }: + { + hyperhive.packages = lib.mapAttrs (_: lib.mkDefault) { + inherit (self.packages.${pkgs.stdenv.hostPlatform.system}) + hive-agent + hive-agent-mcp + hive-agent-wake + hive-bash-daemon + hive-bash-mcp + hive-forge + hive-matrix-daemon + hive-matrix-mcp + hive-metric + assets + frontend + reference-docs + ; + }; + }; + in + { + agent-base.imports = [ + ./nix/templates/agent.nix + agentPackages + ]; + ruth.imports = [ + ./nix/templates/ruth.nix + agentPackages + ]; + # The full host stack (nix/host-modules/default.nix aggregator) plus + # the package/source wiring from this flake. The wiring is a + # plain config module setting the `services.hyperhive.c0re.*` + # package options via `lib.mkDefault` — no overlay involved, and + # an operator override still wins. Intended usage: + # + # imports = [ hyperhive.nixosModules.default ]; + # services.hyperhive.enable = true; + # + default = + { lib, pkgs, ... }: + { + imports = [ ./nix/host-modules ]; + services.hyperhive.c0re = { + package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default; + frontend = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.frontend; + assets = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.assets; + xdgIcons = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.xdg-icons; + hyperhiveFlake = lib.mkDefault "${sources.hyperhiveFlakeSource}"; + # Narrow docs/ source, threaded as its own meta-flake input + # so doc edits don't re-hash the whole flake source. + hyperhiveDocs = lib.mkDefault "${sources.hyperhiveDocsSource}"; + # Per-container toplevels — wired into + # `system.extraDependencies` when + # `services.hyperhive.c0re.preBuildAgentTemplates` is on so + # the host system closure pre-fetches the heavy build + # inputs. x86_64-linux only (nixosConfigurations are + # hardcoded to that system); the gate keeps aarch64 hosts + # from pulling them in via cross-build. + agentBaseToplevel = lib.mkDefault self.packages.x86_64-linux.agent-base-toplevel; + managerToplevel = lib.mkDefault self.packages.x86_64-linux.ruth-toplevel; + }; + }; + hive-ci = ./nix/host-modules/hive-ci.nix; + hive-forge = ./nix/host-modules/hive-forge; }; - }; - - nixosModules = { - agent-base = ./nix/templates/agent-base.nix; - ruth = ./nix/templates/manager.nix; - # The hive-c0re module wants `pkgs.hyperhive` for its default - # `services.hyperhive.c0re.package`. To avoid making operators apply an - # overlay (which would also pollute their host pkgs with our - # build), we thread the package straight from this flake's - # `packages..default` via a `hyperhivePackage` argument. - hive-c0re = import ./nix/modules/hive-c0re.nix { - hyperhivePackage = system: self.packages.${system}.default; - hyperhiveFrontend = system: self.packages.${system}.frontend; - hyperhiveAssets = system: self.packages.${system}.assets; - hyperhiveXdgIcons = system: self.packages.${system}.xdg-icons; - hyperhiveFlake = "${hyperhiveFlakeSource}"; - # Narrow docs/ source, threaded as its own meta-flake input so - # doc edits don't re-hash the whole flake source. - hyperhiveDocs = "${hyperhiveDocsSource}"; - # Per-container toplevels — wired into `system.extraDependencies` - # when `services.hyperhive.c0re.preBuildAgentTemplates` is on so the - # host system closure pre-fetches the heavy build inputs. - # Defined only for x86_64-linux because nixosConfigurations are - # hardcoded to that system; the option's default keeps the - # extra deps gated so aarch64 hosts don't accidentally pull - # them in via cross-build. - agentBaseToplevel = self.packages.x86_64-linux.agent-base-toplevel; - managerToplevel = self.packages.x86_64-linux.ruth-toplevel; - }; - hive-ci = ./nix/modules/hive-ci.nix; - hive-forge = ./nix/modules/hive-forge.nix; - # Convenience alias: one import covers the full hyperhive host - # stack (hive-c0re + hive-forge, since hive-c0re already pulls - # in hive-forge). Intended usage: - # - # imports = [ hyperhive.nixosModules.default ]; - # services.hyperhive.enable = true; - # - default = self.nixosModules.hive-c0re; - }; nixosConfigurations = let @@ -433,14 +152,7 @@ module: nixpkgs.lib.nixosSystem { system = "x86_64-linux"; - modules = [ - module - { - nixpkgs.overlays = [ - self.overlays.default - ]; - } - ]; + modules = [ module ]; }; in { @@ -448,23 +160,7 @@ ruth = mkContainer self.nixosModules.ruth; }; - devShells = forAllSystems ( - { pkgs, ... }: - { - default = pkgs.mkShell { - packages = with pkgs; [ - cargo - clippy - librsvg # rsvg-convert — hive-c0re/build.rs invokes it - pkg-config - rust-analyzer - rustc - rustfmt - sqlite - ]; - }; - } - ); + devShells = forAllSystems ({ pkgs, rust, ... }: import ./nix/devshell.nix { inherit pkgs rust; }); formatter = forAllSystems ({ treefmt-eval, ... }: treefmt-eval.config.build.wrapper); @@ -474,84 +170,18 @@ system, treefmt-eval, craneLib, - cleanSrc, - cargoArtifacts, - nativeBuildInputs, + rust, ... }: - { - formatting = treefmt-eval.config.build.check self; - # Clippy as a check via crane's first-class `cargoClippy` - # builder. Reuses the shared `cargoArtifacts` (deps already - # built) and runs `cargo clippy --workspace --all-targets` - # directly — no `overrideAttrs` hack needed, because crane - # parses `cargoClippyExtraArgs` correctly (naersk's - # `mode = "clippy"` used to mangle the `--` separator, which - # is why the old wiring went through overrideAttrs). - # - # `-D warnings` makes the default/correctness/style lints a - # hard CI gate. `-A clippy::pedantic` then drops the pedantic - # group from that gate: pedantic is the "extra, opinionated" - # group the clippy team grows freely, so denying it means - # every toolchain bump that adds a new pedantic lint breaks CI - # with zero code changes. The `pedantic = warn` - # workspace lint (Cargo.toml) keeps it as advisory signal in - # local `cargo clippy` — it just no longer blocks the build. - # (`-A` rather than `-W` here: `-W clippy::pedantic` would - # re-enable the specific pedantic lints the workspace lints - # table allows, e.g. `must_use_candidate`.) - clippy = craneLib.cargoClippy { - src = cleanSrc; - inherit cargoArtifacts nativeBuildInputs; - pname = "hyperhive-workspace"; - version = "0.1.0"; - cargoClippyExtraArgs = "--workspace --all-targets -- -D warnings"; - }; - # `cargo test --workspace` lifted out of `buildPackage` so the - # `hyperhive-assets` dep (which `hive-ag3nt::prompt::tests` - # needs via `HIVE_ASSETS_DIR` to assert against the actual - # production prompt template) is scoped to this one check - # instead of bleeding into the binary derivation's input - # hash. Net: editing `hive-ag3nt/prompts/system.md` still - # rebuilds this test check (correct — the tests assert - # against its wording), but `packages.default` and the - # per-container toplevels stay fully cached. - cargo-test = craneLib.cargoTest { - src = cleanSrc; - inherit cargoArtifacts nativeBuildInputs; - pname = "hyperhive-workspace"; - version = "0.1.0"; - cargoTestExtraArgs = "--workspace"; - HIVE_ASSETS_DIR = "${self.packages.${system}.assets}/share/hyperhive"; - }; - # Nix options docs evaluation. Cheap: pulls in - # `nixosOptionsDoc` + the host module's stub eval, no rust or - # frontend deps. CI fails fast if a module change breaks - # option declarations or the doc rendering. Reuses the - # `packages..docs` derivation so the per-system eval - # of `nix/docs/default.nix` happens once. - inherit (self.packages.${system}) docs; - # `hivectl` CLI reference freshness check. The committed - # markdown at `docs/tools/hivectl-cli.md` is the rendered - # output of the hidden `hivectl markdown-docs` subcommand - # (clap-markdown walks the binary's own command tree). This - # check regenerates it from the built binary and fails if the - # committed copy drifted — so a verb / flag / help-string edit - # that forgets to refresh the doc is caught in CI. Reuses the - # already-built `packages..default` (no extra compile). - # Regenerate locally with: - # nix build .#default - # ./result/bin/hivectl markdown-docs > docs/tools/hivectl-cli.md - hivectl-docs = pkgs.runCommand "hivectl-docs-fresh" { nativeBuildInputs = [ pkgs.diffutils ]; } '' - ${self.packages.${system}.default}/bin/hivectl markdown-docs > generated.md - if ! diff -u ${./docs/tools/hivectl-cli.md} generated.md; then - echo "" >&2 - echo "ERROR: docs/tools/hivectl-cli.md is out of date — regenerate it:" >&2 - echo " nix build .#default && ./result/bin/hivectl markdown-docs > docs/tools/hivectl-cli.md" >&2 - exit 1 - fi - touch "$out" - ''; + import ./nix/checks.nix { + inherit + pkgs + craneLib + rust + self + system + treefmt-eval + ; } ); }; diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index f55494be..62135c0e 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -489,7 +489,7 @@ pub fn configured_effort() -> Option<&'static str> { /// Context-window size in tokens for a given model name. /// -/// Canonical per-model sizes are declared in `harness-base.nix` as +/// Canonical per-model sizes are declared in the harness nix modules as /// `hyperhive.contextWindowTokens` and injected as /// `HIVE_CONTEXT_WINDOW_TOKENS_` env vars — so this function /// normally just reads them. The Rust code carries no model knowledge; diff --git a/hive-ag3nt/src/mcp_config.rs b/hive-ag3nt/src/mcp_config.rs index 8bcb001a..3275bece 100644 --- a/hive-ag3nt/src/mcp_config.rs +++ b/hive-ag3nt/src/mcp_config.rs @@ -14,7 +14,7 @@ pub const SERVER_NAME: &str = "hyperhive"; /// Default loopback port the built-in hyperhive MCP surface is served on /// (streamable HTTP, via the persistent `hive-mcp-http` daemon). Overridable /// via `hyperhive.mcp.httpPort`; **must match that option's default** in -/// `nix/templates/harness-base.nix`. Safe as a single fixed value across all +/// `nix/templates/harness/`. Safe as a single fixed value across all /// agents because each container runs in its own private network namespace, /// so `127.0.0.1:` is per-container-private (no cross-agent collision). pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790; @@ -246,13 +246,13 @@ pub fn builtin_tools_arg() -> String { } /// Where the NixOS module writes the per-agent extra-MCP spec (see -/// `nix/templates/harness-base.nix`). Each entry becomes an additional +/// `nix/templates/harness/`). Each entry becomes an additional /// `mcpServers.` block in the rendered claude config + a /// `mcp____` pattern in `--allowedTools`. const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; /// Where the NixOS module writes the per-agent send allow-list (see -/// `nix/templates/harness-base.nix`). Empty list = unrestricted (the +/// `nix/templates/harness/`). Empty list = unrestricted (the /// default). Non-empty list constrains `mcp__hyperhive__send`'s `to` /// field; the manager is always implicitly permitted regardless of /// the list contents. diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 1610ce3b..74f8ad0f 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -158,7 +158,7 @@ async fn set_nspawn_flags( // Claude credentials land at `/home//.claude` so the // `claude` CLI (which reads `$HOME/.claude`) finds them. The // harness service's environment sets `HOME` to the same path - // (`agent-base.nix` / `manager.nix`), so no `--setenv` plumbing + // (`agent.nix` / `ruth.nix` templates), so no `--setenv` plumbing // is needed here — the bind alone is enough. let claude_mount = container_claude_mount(agent_name); diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index c8840634..55f0acab 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -169,7 +169,7 @@ pub fn agent_uid_gid(agent_name: &str) -> Option<(u32, u32)> { /// uid/gid via [`agent_uid_gid`] and shells out to `std::os::unix::fs::chown`. /// Silently no-ops when the container isn't built yet (`None` from /// [`agent_uid_gid`]) and logs at debug on chown syscall failure — the -/// activation script in `harness-base.nix` is the steady-state safety +/// activation script in the harness user module is the steady-state safety /// net. Used by per-agent state writers in `forge` + `matrix` so the /// agent can read the file without waiting for the next container /// rebuild. diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 6ae393f2..31005988 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -701,7 +701,7 @@ struct OtelConfig { /// Read the hive-wide OTEL config from env, or `None` when OTEL is off. /// Mirrors `hive_ca_source` — host state surfaced to the meta renderer /// so it can bake build-time `hyperhive.otel.*` config into each agent -/// (the per-agent options harness-base.nix consumes). Returns `None` +/// (the per-agent options the harness modules consume). Returns `None` /// when the endpoint signal is absent so the renderer emits no /// `hyperhive.otel.*` lines and agents keep the disabled default. fn otel_config() -> Option { @@ -914,7 +914,7 @@ where // re-locks THIS input instead of re-hashing the whole `hyperhive` // source. Threaded to each agent below as `hyperhive.docs.source`. // Empty = hive-c0re not built with the option wired up (legacy); - // agents then keep the harness-base default (`pkgs.hyperhive-docs`). + // agents then keep the harness default (`hyperhive.packages.reference-docs`). if !docs_flake.is_empty() { // `flake = false`: the docs/ tree is a plain source (no flake.nix), // so nix must treat it as raw source, not evaluate it as a flake. @@ -971,8 +971,8 @@ where "#, ); // Point the in-container docs dir (`$HIVE_DOCS_DIR`) at the narrow - // `hyperhive-docs` input instead of the harness-base default - // (`pkgs.hyperhive-docs`, built from the now-docs-stripped source). + // `hyperhive-docs` input instead of the harness default + // (`hyperhive.packages.reference-docs`, built from the now-docs-stripped source). // `inputs."hyperhive-docs"` is reachable via the outputs `@inputs` // capture. Emitted only when the input exists (docs_flake non-empty). if !docs_flake.is_empty() { @@ -1000,7 +1000,7 @@ where ); } // Hive-wide OTEL stats export (`services.hyperhive.otel.*`): inject the - // build-time `hyperhive.otel.*` config harness-base.nix consumes (its + // build-time `hyperhive.otel.*` config the harness modules consume (its // otelEnv + otelExecStart wrapper + LoadCredential). Host-driven, so // the same config lands on every agent; emitted only when enabled. // Mirrors the CA-cert injection above — host state -> build-time agent @@ -1055,7 +1055,7 @@ where # `iris`, `root`, …). UID auto-assigned by NixOS; the # per-agent override here is what makes # `hyperhive.user.name` match the agent's identity - # instead of the harness-base default of `"agent"`. + # instead of the harness default of `"agent"`. hyperhive.user.name = name; programs.git.config.user = { name = name; @@ -1416,7 +1416,7 @@ mod tests { "old-style follows must not appear when flake args are set:\n{out}" ); // the narrow docs source is its own non-flake input, and each - // agent's docs dir resolves from it rather than pkgs.hyperhive-docs. + // agent's docs dir resolves from it rather than hyperhive.packages.reference-docs. assert!( out.contains("hyperhive-docs.url = \"path:/nix/store/bbbb-hyperhive-docs-source\""), "expected hyperhive-docs input url:\n{out}" @@ -1434,7 +1434,7 @@ mod tests { #[test] fn render_flake_omits_docs_input_when_docs_flake_empty() { // Legacy / not-wired-up: empty docs_flake emits no docs input and - // leaves each agent on the harness-base default (pkgs.hyperhive-docs). + // leaves each agent on the harness default (hyperhive.packages.reference-docs). let out = render_flake( "github:example/hyperhive", "", @@ -1674,7 +1674,7 @@ mod tests { // services.hyperhive.otel.* -> HYPERHIVE_OTEL_* on hive-c0re's unit // -> injected as build-time hyperhive.otel.* into every agent. With // no endpoint signal, no hyperhive.otel lines are emitted (agents - // keep the harness-base disabled default). + // keep the the harness modules disabled default). // // SAFETY: single-threaded mutation of process env vars no other // test asserts on; restored before returning. diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 99bea27b..63be260a 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -42,7 +42,7 @@ pub const HOST_SOCKET: &str = "/run/hyperhive/host.sock"; /// `/run/hive-agent` — per-agent runtime socket dir root (web + bound /// markers), one subdir per agent. -// nix: agent container bind-mount / `RuntimeDirectory` (harness-base.nix) — must match. +// nix: agent container bind-mount / `RuntimeDirectory` (the harness nix modules) — must match. pub const AGENT_SOCKET_DIR: &str = "/run/hive-agent"; /// Default broker db path (`db/broker.sqlite`). Exposed as a `&str` for @@ -162,7 +162,7 @@ pub fn agent_sockets_file() -> PathBuf { /// bind-mounted into each container as `/agents/`). A `&str` (the /// dashboard state-file allow-list uses it for `strip_prefix` / /// `starts_with` checks), so it stays a const; [`agents_root`] wraps it. -// nix: agent container bind-mount source (harness-base.nix / agent-base.nix) — must match. +// nix: agent container bind-mount source (harness modules / agent.nix template) — must match. // sh4re: `hive_sh4re::priv_proto::AGENT_STATE_ROOT` is the same value and must stay in sync; // the privsep boundary prevents importing across the crate. pub const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents"; @@ -200,7 +200,7 @@ pub fn applied_rev_marker(name: &str) -> PathBuf { } /// `meta/` — the meta flake working tree (inputs, `flake.lock`, `.git`). -// nix: bind-mounted read-only into agent containers as `/meta` (harness-base.nix) — must match. +// nix: bind-mounted read-only into agent containers as `/meta` (the harness nix modules) — must match. #[must_use] pub fn meta_root() -> PathBuf { state_root().join("meta") @@ -222,7 +222,7 @@ pub fn meta_git_index_lock() -> PathBuf { /// `shared/` — the cross-agent `/shared` scratch space. A `&str` (the /// dashboard state-file allow-list uses it for prefix checks), so it /// stays a const; [`shared_root`] wraps it. -// nix: bind-mounted into every agent container as `/shared` (harness-base.nix) — must match. +// nix: bind-mounted into every agent container as `/shared` (the harness nix modules) — must match. pub const SHARED_ROOT: &str = "/var/lib/hyperhive/shared"; #[must_use] @@ -233,7 +233,7 @@ pub fn shared_root() -> PathBuf { /// `knowledge/` — local checkout of the `internal/knowledge` repo. A /// `&str` (used as a git `-C` arg / clone target throughout the knowledge /// worker), so it stays a const rather than a `PathBuf` fn. -// nix: bind-mounted read-only into agent containers as `/knowledge` (harness-base.nix) — must match. +// nix: bind-mounted read-only into agent containers as `/knowledge` (the harness nix modules) — must match. pub const KNOWLEDGE_DIR: &str = "/var/lib/hyperhive/knowledge"; /// `gateway/` — generated nginx include fragments for the gateway vhost. diff --git a/nix/agent-modules/agent-service.nix b/nix/agent-modules/agent-service.nix new file mode 100644 index 00000000..b836e9be --- /dev/null +++ b/nix/agent-modules/agent-service.nix @@ -0,0 +1,245 @@ +# The hive-ag3nt harness service itself, plus the per-agent knobs it +# reads from its environment: model selection, effort level, +# compaction watermark, and the extra reverse-proxies of the per-agent +# web UI. +{ + pkgs, + lib, + config, + ... +}: +let + userName = config.hyperhive.user.name; + homeDir = "/home/${userName}"; +in +{ + options.hyperhive.model = lib.mkOption { + type = lib.types.str; + default = "haiku"; + example = "sonnet"; + description = '' + Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL` + environment variable; the harness applies it at boot and it takes + priority over any persisted runtime override. The operator can still + switch the model at runtime via the per-agent web UI — that choice + is tracked in the state dir for the current session but is reset by + any rebuild that changes this option. + + Valid values are the short model names that `claude --model` accepts: + `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context + window sizes are looked up at runtime from the + `HIVE_CONTEXT_WINDOW_TOKENS_` env vars injected by the + meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens` + on the host. + ''; + }; + + options.hyperhive.availableModels = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "haiku" + "sonnet" + "opus" + ]; + example = [ + "sonnet" + "opus" + ]; + description = '' + Models offered in the per-agent web UI's model quick-picker. Rendered + into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated) + which the harness surfaces to the agent UI, so the picker lists exactly + these models instead of a hardcoded set. + + Configure hive-wide by setting a shared default (e.g. in your + `agent.nix` shared defaults) or per-agent to narrow the menu — for example a + haiku-only agent can hide `opus` and `sonnet`. The *current* model is + still set by `hyperhive.model` and remains switchable at runtime via the + UI; this option only controls which choices the picker presents. + + Values are the short model names that `claude --model` accepts: + `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). + ''; + }; + + options.hyperhive.effortLevel = lib.mkOption { + type = lib.types.enum [ + "low" + "medium" + "high" + "xhigh" + "max" + ]; + default = "medium"; + example = "high"; + description = '' + Baseline claude effort level for this agent. Rendered into the + `HIVE_DEFAULT_EFFORT` environment variable; the harness resolves + effort as operator-override-file → this env → built-in `"medium"`, + and passes the result to `claude --effort` at turn launch. + + Ascending scale: `"low"` (minimal thinking budget), `"medium"` + (default — balanced), `"high"` (platform default), `"xhigh"` + (recommended for autonomous coding on capable models), `"max"` + (maximum thinking budget, highest cost). The operator can override + at runtime per-agent via the web UI (applied on the next session); + any rebuild that changes this option resets that override. + ''; + }; + + options.hyperhive.autoCompact = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Enable proactive watermark-based compaction. When `true` (the + default) the harness automatically runs a notes-checkpoint turn + followed by `/compact` once the context window crosses 75% of + the model's limit, keeping later turns from hitting the hard + overflow path. Set to `false` to disable proactive compaction + entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path + (compact-on-overflow when the session is already past the limit) + still applies. + + Disable for agents that run large-context models (sonnet/opus) + where the heuristic fires too early and discards useful history + before the session is actually close to the limit. + ''; + }; + + options.hyperhive.extraWebProxies = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = lib.literalExpression ''{ "stats" = "http://127.0.0.1:3737"; }''; + description = '' + Transparent reverse-proxies mounted under `/extra/` in the per-agent web UI. + Each attribute name becomes the sub-path and the value is the upstream. + E.g. `{ "stats" = "http://127.0.0.1:3737"; }` mounts a proxy at + `/agent//extra/stats/` that forwards to port 3737 with the prefix + stripped. All user-declared proxies live under `/extra/` so they can + never conflict with native agent endpoints (`/api/*`, `/events/*`, etc.). + + The upstream value is either an `http(s)://` URL or a Unix domain + socket, spelled `unix:` (e.g. `unix:/run/myapp/http.sock`) — for + agents whose secondary web server only listens on a UDS. + + Intended for agents that run secondary web servers in the same container. + Static assets served by the secondary app must use relative paths to + resolve correctly under the sub-path prefix. + + Sets the `HIVE_EXTRA_WEB_PROXIES` environment variable (JSON object) + on the harness service unit. + ''; + }; + + config = { + assertions = [ + # hyperhive.model must be a non-empty string — an empty value causes + # the harness to pass an invalid model flag to claude. + { + assertion = config.hyperhive.model != ""; + message = "hyperhive.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")"; + } + # The current model must appear in the quick-picker menu, otherwise the + # UI would offer no way back to the model the agent is actually running. + { + assertion = + config.hyperhive.availableModels == [ ] + || builtins.elem config.hyperhive.model config.hyperhive.availableModels; + message = + "hyperhive.model (\"${config.hyperhive.model}\") must be one of " + + "hyperhive.availableModels ([ ${lib.concatStringsSep " " config.hyperhive.availableModels} ]) " + + "— add it to the list or change the model."; + } + ]; + + # HIVE_DEFAULT_MODEL seeds the initial model selection when no + # persisted model choice exists in the state dir. + environment.variables = { + HIVE_DEFAULT_MODEL = config.hyperhive.model; + # Comma-separated menu for the per-agent UI model quick-picker + # (see hyperhive.availableModels). The harness surfaces it to the + # frontend; an empty value falls back to the built-in default list. + HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.hyperhive.availableModels; + # Per-agent baseline effort (see hyperhive.effortLevel). The + # harness resolves operator-override-file → this env → "medium" + # and passes it to claude --effort at turn launch. + HIVE_DEFAULT_EFFORT = config.hyperhive.effortLevel; + } + // lib.optionalAttrs (!config.hyperhive.autoCompact) { + # Zero watermark disables proactive compaction; the reactive path + # (compact-on-overflow) still fires when the session is truly full. + HIVE_COMPACT_WATERMARK_TOKENS = "0"; + }; + + # Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars, + # RuntimeDirectory, User=, standalone-eval fallbacks): + # docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin + # auto-append behaviour: docs/gotchas.md::systemd.services.*.path + # appends /bin to every entry. + systemd.services.hive-ag3nt = + let + binary = "hive-agent"; + in + { + description = "${binary} harness"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + # `/run/wrappers` before `/run/current-system/sw` so setuid + # `sudo` resolves first. Passing the bare prefixes (no trailing + # `/bin`) is intentional — see docs pointer above. + path = [ + "/run/wrappers" + "/run/current-system/sw" + ]; + environment = { + SHELL = "${pkgs.bashInteractive}/bin/bash"; + HOME = homeDir; + HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}"; + HIVE_ASSETS_DIR = "${config.hyperhive.packages.assets}/share/hyperhive"; + # Unix-socket path for the harness web UI. All agents always bind + # here; there is no TCP fallback. Path matches + # `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle + # bind-mounts and gateway upstream config stay in sync. + HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; + # Loopback URL of the persistent `hive-mcp-http` daemon that + # `render_claude_config` points claude at for the built-in + # surface (HTTP is the sole transport — no per-turn stdio child). + # Kept in sync with the `hive-mcp-http` unit's `--http` port + # (see ./mcp.nix) via the same option. Always set — network + # isolation is unconditional, so a fixed per-container port is + # collision-free. + HYPERHIVE_MCP_HTTP_PORT = toString config.hyperhive.mcp.httpPort; + } + // lib.optionalAttrs config.hyperhive.gui.enable { + # Tells the harness which fixed VNC port weston bound, and (by + # its presence) that gui is enabled — the harness `/screen/ws` + # relay reads this instead of a runtime marker file. The port is + # container-local + fixed (network isolation is unconditional), + # so the same value for every gui agent is fine. See + # ./weston-vnc.nix::hyperhive.gui.vncPort. + HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort; + } + // lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) { + # JSON object {"": ""} for the transparent + # reverse-proxies. See `hyperhive.extraWebProxies` option + # and `web_ui/proxy.rs::extra_proxy_service`. + HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.hyperhive.extraWebProxies; + }; + serviceConfig = { + ExecStart = "${config.hyperhive.packages.hive-agent}/bin/${binary}"; + # Pin the journal identity to the binary name (otherwise systemd + # derives SyslogIdentifier from the ExecStart basename). + SyslogIdentifier = binary; + Restart = "on-failure"; + RestartSec = 2; + # Per-service runtime dir owned by `User=` below; the harness + # writes its regenerated claude-{mcp-config,settings,system-prompt} + # files here (`paths::config_dir`). Separate from /run/hive, + # which holds hive-c0re's mcp.sock. + RuntimeDirectory = "hive-config"; + User = userName; + Group = userName; + }; + }; + }; +} diff --git a/nix/agent-modules/bash-env.nix b/nix/agent-modules/bash-env.nix new file mode 100644 index 00000000..302ece25 --- /dev/null +++ b/nix/agent-modules/bash-env.nix @@ -0,0 +1,134 @@ +# Shell-environment feature hooks: the `_bashEnvFragments` +# accumulator, the `/etc/hyperhive/bash-env.sh` file it renders to, +# and the cargo `--message-format short` injector that contributes to +# it. Loaded via `$BASH_ENV` for non-interactive shells (claude's +# `Bash` tool runs `bash -c`) and via `programs.bash` for interactive +# ones. +{ + lib, + config, + ... +}: +{ + # Internal accumulator for shell snippets that should land in + # `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via + # `lib.mkIf` gated on their own option; the lines type merges + # all contributions across modules into one file. Generic by + # design so future hooks don't need to rename this file or + # invent a parallel dispatcher. + options.hyperhive._bashEnvFragments = lib.mkOption { + type = lib.types.lines; + default = ""; + internal = true; + description = '' + Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`. + Feature hooks contribute via `lib.mkIf` gated on their own + option. When empty, the file isn't created, `BASH_ENV` stays + unset, and the interactive bashrc hook is omitted — zero cost + when no feature is on. Internal — set indirectly via the + per-feature options that own the gate (e.g. + `hyperhive.cargo.shortMessages`). + ''; + }; + + options.hyperhive.cargo.shortMessages = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = '' + Auto-inject `--message-format short` on cargo compile + subcommands (`build`, `check`, `clippy`, `test`, `run`, + `doc`, `bench`, `install`, `rustc`, `fix`) when claude (or + anything else) invokes `cargo` inside this container. + Saves tokens + context — the verbose default output floods + the response window with per-crate progress lines that + carry no signal beyond the warning/error summary. + + Implementation: contributes a `cargo` shell function to + `/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`). + Loaded via `BASH_ENV` for non-interactive shells (`bash -c` — + what the claude `Bash` tool runs) and sourced from + `programs.bash.interactiveShellInit` for interactive shells. + The function: + + - handles the `+toolchain` selector prefix (`cargo +nightly + build` works); + - passes through cleanly when the caller already specified + `--message-format` (any form); + - leaves non-compile subcommands (`new`, `add`, `search`, + third-party `cargo-*` subcommands) untouched so they + don't error on the unknown flag. + + Set to `false` for agents that need full cargo output (e.g. + tooling that parses `--message-format json` programmatically + and doesn't pass the flag explicitly). + ''; + }; + + config = { + # Cargo `--message-format short` injector. `command cargo …` falls + # back to the un-wrapped binary in PATH (the rust toolchain's cargo + # — either from `environment.systemPackages` or from whatever + # `nix develop` shell the agent's working in). + hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages '' + # Auto-injects --message-format short on cargo compile + # subcommands so per-crate progress lines don't flood + # claude's context. Bypassed when the caller already passes + # --message-format (any form). + cargo() { + # Strip leading +toolchain selectors (cargo +nightly …). + local pre=() + while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do + pre+=("$1") + shift + done + case "''${1:-}" in + build|check|clippy|test|run|doc|bench|install|rustc|fix) + local sub="$1" + shift + local arg + for arg in "$@"; do + case "$arg" in + --message-format|--message-format=*) + command cargo "''${pre[@]}" "$sub" "$@" + return $? + ;; + esac + done + command cargo "''${pre[@]}" "$sub" --message-format short "$@" + ;; + *) + command cargo "''${pre[@]}" "$@" + ;; + esac + } + ''; + + # Single bash-env file with all configured shell fragments. + # Wiring is gated on at least one fragment being active so a + # fully feature-disabled agent has neither the file nor the + # `BASH_ENV` / interactive sourcing — zero cost in that case. + environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") { + text = config.hyperhive._bashEnvFragments; + }; + + # Non-interactive bash invocations (claude's `Bash` tool runs + # `bash -c`) source $BASH_ENV at startup — drops every active + # feature hook's snippet into scope without touching + # `/etc/profile` (login-only). + environment.variables = lib.mkIf (config.hyperhive._bashEnvFragments != "") { + BASH_ENV = "/etc/hyperhive/bash-env.sh"; + }; + + # Interactive shells don't honour BASH_ENV — wire the same file + # in via the bashrc hook so operator SSH sessions get the same + # hook surface as claude's non-interactive calls. Gated on at + # least one fragment being active so we don't write a no-op + # source line into `/etc/bashrc` on fully-feature-disabled agents. + programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") '' + if [ -r /etc/hyperhive/bash-env.sh ]; then + . /etc/hyperhive/bash-env.sh + fi + ''; + }; +} diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix new file mode 100644 index 00000000..5b7358f7 --- /dev/null +++ b/nix/agent-modules/claude-settings.nix @@ -0,0 +1,376 @@ +# Everything that shapes claude-code's own configuration inside the +# container: the managed settings json (base env + OTEL), the +# onboarding/trust seed, the runtime OTEL auth-header injection, and +# the plugin/marketplace install lists the harness reads at boot. +{ + pkgs, + lib, + config, + ... +}: +let + userName = config.hyperhive.user.name; + homeDir = "/home/${userName}"; + # Hive-wide OpenTelemetry config (host-driven; baked in per-agent by + # meta.rs `otel_config`). + otelCfg = config.hyperhive.otel; + # Hive/swarm display names are forwarded into each agent's build by + # meta.rs as `environment.variables` (per-agent, build-time strings), + # so they can be baked into the resource attributes below without a + # runtime shell. Absent (option unset) → "unknown". + hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown"; + swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown"; + # Base claude-code environment applied to every agent regardless of OTEL. + # Shipped via the managed settings `env` block so claude and `hivectl + # choom` both inherit them without a launch wrapper. + baseClaudeEnv = { + # Suppress analytics, survey pings, and other non-essential outbound + # traffic — agents are headless and don't need any of that. + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; + DO_NOT_TRACK = "1"; + # Disable claude's self-update machinery; package management is nix's job. + DISABLE_AUTOUPDATER = "1"; + DISABLE_UPDATES = "1"; + # Keep plugin updates synchronized on install; prefer HTTPS for fetches. + CLAUDE_CODE_PLUGIN_PREFER_HTTPS = "1"; + CLAUDE_CODE_SYNC_PLUGIN_INSTALL = "1"; + FORCE_AUTOUPDATE_PLUGINS = "1"; + # Suppress the "install GitHub app" prompt — not applicable in-hive. + DISABLE_INSTALL_GITHUB_APP_COMMAND = "1"; + # Disable Anthropic's hosted claude.ai MCP servers; the hive supplies its own. + ENABLE_CLAUDEAI_MCP_SERVERS = "0"; + # Resume an interrupted turn on reconnect (recovers from transient MCP flaps). + CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1"; + # Use the simpler system prompt variant suited to headless operation. + CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1"; + # Tag remote-control sessions with "-" for identification. + CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}"; + }; + # OTEL environment Claude Code reads to export metrics/logs/traces. + # Shipped via the managed claude settings json (below), which claude + # auto-discovers for BOTH the harness turn-loop and `hivectl choom` — + # so telemetry parity is declarative, with no launch wrapper. The + # auth header (`otel.headersCredential`) is deliberately NOT included + # here: it's a secret and this file lives in the world-readable nix + # store. It's injected at *runtime* into the agent's `0600` + # `~/.claude/settings.json` by the `hive-otel-header` oneshot below + # (claude merges the `env` from the user settings on top of these + # managed ones), so the token is read from disk at start and never + # touches the store. + otelSettingsEnv = { + CLAUDE_CODE_ENABLE_TELEMETRY = "1"; + # Attach feedback-survey data to the OTEL pipeline. + CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL = "1"; + OTEL_METRICS_EXPORTER = "otlp"; + OTEL_LOGS_EXPORTER = "otlp"; + OTEL_TRACES_EXPORTER = "otlp"; + OTEL_EXPORTER_OTLP_PROTOCOL = otelCfg.protocol; + OTEL_EXPORTER_OTLP_ENDPOINT = otelCfg.endpoint; + # Force CUMULATIVE temporality — Claude Code defaults to DELTA, + # which Prometheus/Mimir-family backends (incl. grafana-lgtm) + # silently drop without a deltatocumulative processor. + OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative"; + OTEL_RESOURCE_ATTRIBUTES = + "service.name=hyperhive-agent,agent=${userName},hive=${hiveDisplayName},swarm=${swarmDisplayName}" + + lib.optionalString (otelCfg.extraResourceAttributes != "") ",${otelCfg.extraResourceAttributes}"; + # Include the Claude Code version label in emitted metrics. + OTEL_METRICS_INCLUDE_VERSION = "1"; + } + // lib.optionalAttrs (otelCfg.metricIntervalMs != null) { + OTEL_METRIC_EXPORT_INTERVAL = toString otelCfg.metricIntervalMs; + }; +in +{ + # OTEL stats export is configured ONCE at host level via + # `services.hyperhive.otel.*` (see nix/host-modules/hive-c0re.nix) and + # injected into every agent's build by the meta-flake renderer + # (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are + # the build-time implementation surface that injection writes into; + # they are not meant to be set directly in an agent.nix. Marked + # `internal` so the host option is the only documented operator knob. + options.hyperhive.otel = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + internal = true; + description = '' + Export this agent's Claude Code stats (token usage, cost, tool + calls) to an OTLP endpoint via Claude Code's built-in + OpenTelemetry. Each agent's harness exports directly to the + collector, so it keeps working even when hive-c0re is down. + Host-driven: set `services.hyperhive.otel.enable` instead. + ''; + }; + + endpoint = lib.mkOption { + type = lib.types.str; + default = ""; + internal = true; + description = '' + OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`. + Host-driven via `services.hyperhive.otel.endpoint`. + ''; + }; + + protocol = lib.mkOption { + type = lib.types.enum [ + "http/protobuf" + "http/json" + "grpc" + ]; + default = "http/protobuf"; + internal = true; + description = '' + OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. + Host-driven via `services.hyperhive.otel.protocol`. + ''; + }; + + headersCredential = lib.mkOption { + # `str`, not `path`: a `path`-typed *relative* literal (e.g. + # `./otel-headers`) is hash-copied into the world-readable nix store + # at eval time, which would defeat the whole point of this option. + # Keep it a string and require an absolute runtime path so the secret + # is only ever read from disk by systemd at start, never nix-stored. + type = lib.types.nullOr lib.types.str; + default = null; + internal = true; + description = '' + Absolute path to an operator-provided secret file whose contents + become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. + `Authorization=Bearer `). Host-driven via + `services.hyperhive.otel.headersCredential`. + + The rest of the OTEL config ships in the world-readable managed + claude settings json, but the header is a secret, so it's handled + separately: hive-c0re forwards this file into the container's + systemd credential store, and the `hive-otel-header` oneshot + reads it at runtime (`LoadCredential`) and writes it into the + agent's `0600` `~/.claude/settings.json` `env` block. The token + is read from disk at start and never copied into the nix store or + the world-readable settings file. + ''; + }; + + extraResourceAttributes = lib.mkOption { + type = lib.types.str; + default = ""; + internal = true; + description = '' + Extra comma-separated entries appended to + `OTEL_RESOURCE_ATTRIBUTES` after the built-in + `service.name` / `agent` / `hive` / `swarm` labels. + Host-driven via `services.hyperhive.otel.extraResourceAttributes`. + ''; + }; + + metricIntervalMs = lib.mkOption { + type = lib.types.nullOr lib.types.ints.positive; + default = null; + internal = true; + description = '' + Metric export interval in milliseconds, set as + `OTEL_METRIC_EXPORT_INTERVAL`. Null leaves Claude Code's 60s + default. Host-driven via `services.hyperhive.otel.metricIntervalMs`. + ''; + }; + + debug = lib.mkOption { + type = lib.types.bool; + default = false; + internal = true; + description = '' + Emit OTEL SDK diagnostics to stderr (`CLAUDE_CODE_OTEL_DIAG_STDERR=1`). + Host-driven via `services.hyperhive.otel.debug`. + ''; + }; + }; + + options.hyperhive.claudeMarketplaces = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "anthropics/claude-plugins-official" ]; + example = [ + "anthropics/claude-plugins-official" + "anthropics/claude-plugins-community" + ]; + description = '' + Claude Code plugin marketplaces to add at harness boot. Each + entry is passed to `claude plugin marketplace add ` + (`owner/repo`, full git URL, or local path). Idempotent — + re-adding an existing marketplace is treated as success. + Required before `hyperhive.claudePlugins` entries that + reference a marketplace (e.g. `foo@claude-plugins-official`). + Rendered to `/etc/hyperhive/claude-marketplaces.json`. + + Defaults to Anthropic's official marketplace; agents get it + out of the box without any per-agent.nix wiring. + ''; + }; + + options.hyperhive.claudePlugins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "formatter@my-marketplace" + "thinking-tools@anthropics" + ]; + description = '' + Claude Code plugins to install at harness boot. Each entry is + passed verbatim to `claude plugin install ` once per + container start, before the turn loop opens. `claude plugin + install` is expected to be idempotent, so reinstalling on every + boot is cheap. Failures log a warning but do not abort boot — a + missing plugin is preferable to a non-serving agent. Rendered to + `/etc/hyperhive/claude-plugins.json`; the harness reads it via + `plugins::install_configured`. + ''; + }; + + options.hyperhive.claudePluginsAutoUpdate = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + When true, the harness runs `claude plugin marketplace update` + before installing plugins at boot, pulling the latest index from + all configured marketplaces. Disabled by default — most agents + want pinned plugin versions and the network round-trip adds to + boot time. Enable for agents that should always install the latest + available version of their plugins. + ''; + }; + + config = { + environment.etc."hyperhive/claude-plugins.json".text = + builtins.toJSON config.hyperhive.claudePlugins; + + environment.etc."hyperhive/claude-marketplaces.json".text = + builtins.toJSON config.hyperhive.claudeMarketplaces; + + environment.etc."hyperhive/claude-plugins-auto-update.json".text = + builtins.toJSON config.hyperhive.claudePluginsAutoUpdate; + + # Hive-enforced claude settings. claude-code auto-discovers managed + # settings at this canonical Linux path (precedence #1, read-only, + # un-overridable by user/project/CLI) — so the harness doesn't pass + # `--settings` or copy the blob per turn. effortLevel is + # deliberately NOT shipped here: effort is controlled live via the + # `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider), + # which managed scope would otherwise override and lock. + # Hive-enforced settings merged with a per-agent `env` block at BUILD + # time via `jq` (not eval-time `readFile`, which would be import-from- + # derivation). The `env` block is always present: `baseClaudeEnv` sets + # behaviour flags and the remote-control session prefix for every agent; + # `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code + # auto-discovers this file in every context (harness turn-loop AND + # `hivectl choom`) so no launch wrapper is needed. + environment.etc."claude-code/managed-settings.json".source = + let + baseSettings = "${config.hyperhive.packages.assets}/share/hyperhive/prompts/claude-settings.json"; + # Merge base env (always) with OTEL env (when enabled). jq is always + # run — `baseClaudeEnv` contains per-agent values (e.g. + # CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX) that can't live in the + # static store asset. + allEnv = + baseClaudeEnv + // lib.optionalAttrs otelCfg.enable otelSettingsEnv + // lib.optionalAttrs (otelCfg.enable && otelCfg.debug) { + # SDK diagnostics — noisy; only on when services.hyperhive.otel.debug = true. + CLAUDE_CODE_OTEL_DIAG_STDERR = "1"; + }; + in + pkgs.runCommand "managed-settings.json" { nativeBuildInputs = [ pkgs.jq ]; } '' + jq --argjson env ${lib.escapeShellArg (builtins.toJSON allEnv)} \ + '. + { env: $env }' ${baseSettings} > "$out" + ''; + + # Inject the OTEL auth header (a secret) into the agent's *user* + # claude settings at runtime, keeping it out of the world-readable + # managed settings json above and out of the nix store entirely. + # hive-c0re forwards the operator's `headersCredential` file into + # this container's systemd credential store; this oneshot reads it + # via `LoadCredential` at start and merges `OTEL_EXPORTER_OTLP_HEADERS` + # into `~/.claude/settings.json` (0600, agent-owned). claude layers + # the user `env` on top of the managed one, so both the harness + # turn-loop and `hivectl choom` (same agent user) pick it up. Ordering + # is best-effort (`before`, not a hard dep): if it fails the harness + # still starts and telemetry just exports unauthenticated. + systemd.services.hive-otel-header = + lib.mkIf (config.hyperhive.otel.enable && config.hyperhive.otel.headersCredential != null) + { + description = "Inject the OTEL auth header into the agent's claude user settings"; + wantedBy = [ "multi-user.target" ]; + before = [ "hive-ag3nt.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = userName; + Group = userName; + LoadCredential = [ "otel-headers" ]; + ExecStart = pkgs.writeShellScript "hive-otel-header" '' + set -eu + umask 077 + hdr="$CREDENTIALS_DIRECTORY/otel-headers" + [ -r "$hdr" ] || exit 0 + dir=${homeDir}/.claude + settings="$dir/settings.json" + mkdir -p "$dir" + base='{}' + [ -s "$settings" ] && base="$(cat "$settings")" + printf '%s' "$base" | ${pkgs.jq}/bin/jq \ + --rawfile h "$hdr" \ + '.env = ((.env // {}) + { OTEL_EXPORTER_OTLP_HEADERS: ($h | rtrimstr("\n")) })' \ + > "$settings.tmp" + mv "$settings.tmp" "$settings" + chmod 0600 "$settings" + ''; + }; + }; + + # Seed claude's onboarding + per-project trust state once. claude only + # marks `hasCompletedOnboarding` (global) and the project trust dialog + # as accepted when run *interactively*; the harness only ever runs it + # headless (`--print`) and `claude auth login` doesn't set them either. + # So the first interactive launch (`hivectl choom`) would drop the + # operator into the onboarding/trust walkthrough despite valid OAuth + # creds. This oneshot is the single place hyperhive touches + # `~/.claude.json`: it runs before the harness (so nothing races it), + # is idempotent (skips when the flags are already set), and is + # best-effort (`before`, not a hard dep — a failed seed leaves the file + # untouched and the harness still starts). Credentials live in the + # separate `~/.claude/.credentials.json`, so this never touches secrets. + systemd.services.hive-claude-onboarding = { + description = "Seed claude onboarding + project-trust so choom skips the walkthrough"; + wantedBy = [ "multi-user.target" ]; + before = [ "hive-ag3nt.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = userName; + Group = userName; + ExecStart = pkgs.writeShellScript "hive-claude-onboarding" '' + set -eu + umask 077 + cfg=${homeDir}/.claude.json + dir=/agents/${userName}/state + base='{}' + [ -s "$cfg" ] && base="$(cat "$cfg")" + # Idempotent: nothing to do when already onboarded + trusted. + if printf '%s' "$base" | ${pkgs.jq}/bin/jq -e \ + --arg d "$dir" \ + '.hasCompletedOnboarding == true and (.projects[$d].hasTrustDialogAccepted == true)' \ + >/dev/null 2>&1; then + exit 0 + fi + printf '%s' "$base" | ${pkgs.jq}/bin/jq \ + --arg d "$dir" \ + '.hasCompletedOnboarding = true + | .projects[$d].hasTrustDialogAccepted = true + | .projects[$d].hasCompletedProjectOnboarding = true' \ + > "$cfg.tmp" + mv "$cfg.tmp" "$cfg" + chmod 0600 "$cfg" + ''; + }; + }; + }; +} diff --git a/nix/agent-modules/dashboard-links.nix b/nix/agent-modules/dashboard-links.nix new file mode 100644 index 00000000..3181b1b1 --- /dev/null +++ b/nix/agent-modules/dashboard-links.nix @@ -0,0 +1,73 @@ +# Extra navigation links surfaced on the hive-c0re dashboard card for +# this agent: the option and the oneshot that writes them into the +# state dir where hive-c0re reads them. +{ + lib, + config, + ... +}: +{ + options.hyperhive.dashboardLinks = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + label = lib.mkOption { + type = lib.types.str; + description = "Display label for the link."; + }; + icon = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Optional icon emoji or short glyph."; + }; + url = lib.mkOption { + type = lib.types.str; + description = "Full URL (may include a different port, e.g. http://localhost:9001/stats)."; + }; + }; + } + ); + default = [ ]; + example = lib.literalExpression '' + [ + { label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; } + ] + ''; + description = '' + Extra navigation links surfaced on the hive-c0re dashboard card for + this agent. Declare any additional web UI pages the agent exposes — + stats pages, custom UIs, etc. hive-c0re reads the JSON file this + option produces at each container-view snapshot and attaches the + links to the agent card without any code changes. + ''; + }; + + config = { + # Write declared dashboardLinks to the state dir so hive-c0re can + # read them without accessing the container's /etc/ from the host. + # Best-effort oneshot (always exit 0): + # docs/conventions.md::Best-effort oneshot services. + systemd.services.hive-dashboard-links = lib.mkIf (config.hyperhive.dashboardLinks != [ ]) { + description = "write declarative dashboardLinks to agent state dir"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Pin the journal identity (else it's the `script` store-path wrapper). + SyslogIdentifier = "hive-dashboard-links"; + }; + environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks; + script = '' + # Sub-agents have their state dir bind-mounted at /agents//state. + # Use a glob — exactly one match per container at runtime. + STATE_DIR=$(echo /agents/*/state) + if [ ! -d "$STATE_DIR" ]; then + echo "hive-dashboard-links: no state dir found at /agents/*/state; skipping" + exit 0 + fi + printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json" + echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json" + ''; + }; + }; +} diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix new file mode 100644 index 00000000..687a1c06 --- /dev/null +++ b/nix/agent-modules/default.nix @@ -0,0 +1,220 @@ +# Shared scaffolding for every hyperhive harness container. +# `../templates/agent.nix` and `../templates/ruth.nix` both import +# agents use the same service unit regardless of which entry-point +# they came from. +# +# This is the core module: container plumbing (boot/nix/nixpkgs), +# base tooling, and the cross-cutting `hyperhive.icon` option. Each +# feature lives in its own sibling module (imported below) that +# declares its own `hyperhive.*` options + config. +{ + pkgs, + lib, + config, + # Flake inputs routed through _module.args by the agent flake.nix. + # Default to {} so the module evaluates cleanly even when the agent + # flake doesn't set up the routing pattern (e.g. during standalone + # nixos-rebuild without a flake wrapper). + flakeInputs ? { }, + ... +}: +{ + imports = [ + ./agent-service.nix + ./bash-env.nix + ./claude-settings.nix + ./dashboard-links.nix + ./docs.nix + ./forge.nix + ./frontend.nix + ./github.nix + ./matrix.nix + ./mcp.nix + ./network.nix + ./packages.nix + ./user.nix + ./weston-vnc.nix + (lib.mkRemovedOptionModule [ "hyperhive" "web" "useUnixSocket" ] '' + Unix socket mode is always enabled for all agents. Remove the + setting from your agent.nix. + '') + (lib.mkRemovedOptionModule [ "hyperhive" "allowedBashPatterns" ] '' + The built-in Bash tool is fully disabled; agents use + mcp__bash__run instead. Remove the setting from your agent.nix. + '') + ]; + + options.hyperhive.icon = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression "./icon.svg"; + description = '' + Path to an SVG file used as this agent's icon — shown on the + dashboard and the per-agent web UI (header + favicon). Commit + the SVG into the agent's config repo next to `agent.nix` and + reference it as a relative path (`./icon.svg`). + + When null (the default) the agent falls back to the shared + hyperhive logo. The harness serves the icon (configured or + default) at `GET /icon` on the per-agent web port. + ''; + }; + + config = { + assertions = [ + # Guard the inputs-routed-as-output pattern: the agent flake.nix is + # expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`. + # If `self` leaks into flakeInputs the agent gets a spurious attrset + # entry that can shadow real inputs and is almost certainly a bug. + # Guard with `or {}` so standalone evaluation stays clean when + # flakeInputs is absent from _module.args. + { + assertion = !(builtins.hasAttr "self" (config._module.args.flakeInputs or { })); + message = '' + hyperhive: `flakeInputs` must not contain "self". + In your agent flake.nix, use: + _module.args.flakeInputs = builtins.removeAttrs inputs [ "self" ]; + ''; + } + # hyperhive.icon must reference an SVG file when set. + { + assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon); + message = "hyperhive.icon must point to an .svg file"; + } + ]; + + # Operator-set per-agent icon (hyperhive.icon). When configured, the + # SVG lands at /etc/hyperhive/icon.svg; the harness serves it at + # GET /icon, falling back to the bundled hyperhive logo when absent. + # Consumed by forge-avatar-sync (./forge.nix) and the matrix avatar + # sync (./matrix.nix) too. + environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) { + source = config.hyperhive.icon; + }; + + boot.isNspawnContainer = true; + + # Use a disk-backed /tmp instead of the default tmpfs so large scratch + # writes (nix-develop shells, cargo build dirs, multi-GB downloads) land + # on disk rather than eating container RAM. The tmpfs default mounts + # ~3.2 GB of RAM per container; disk-backed /tmp is effectively unlimited + # and cheaper for agents that do heavy build work. + # + # cleanOnBoot defaults to false in nixpkgs — set it explicitly so /tmp is + # cleared on each container start (D! tmpfiles rule), preserving the + # ephemeral-per-boot semantics agents expect from a tmpfs /tmp, just + # without the RAM cost. + boot.tmp.useTmpfs = false; + boot.tmp.cleanOnBoot = true; + + # Every agent gets flakes + the modern `nix` CLI out of the box. + # Equivalent to passing `--extra-experimental-features 'nix-command + # flakes'` on every invocation. Agents shell out to `nix build` / + # `nix flake` constantly (devshells, ad-hoc evals, fetching their + # own MCP-server flakes); without this they hit the "experimental + # feature not enabled" wall on the first try. + nix.settings.experimental-features = [ + "nix-command" + "flakes" + ]; + + # `lib.mkForce` overrides nixpkgs's normal-priority `false` so + # in-container `nix build` invocations fall back to unsandboxed + # local builds rather than failing on the missing user-namespace. + # See `docs/gotchas.md::Containerized nix-daemon needs + # sandbox-fallback = true` + `docs/security.md` for the rationale. + # + # Note: with NIX_REMOTE=daemon below this becomes a no-op for the + # common case — daemon-routed builds run on the host where sandboxing + # works. It stays as a belt-and-suspenders fallback for any context + # that bypasses the daemon (e.g. direct nix-store invocations). + nix.settings.sandbox-fallback = lib.mkForce true; + + # Route ALL nix invocations in this container through the host + # nix-daemon socket, regardless of whether the caller is root or + # non-root. Without this, root contexts (PID 1, systemd services + # running as root) default to store=auto which resolves to the LOCAL + # store — bypassing the shared daemon, its remote builders, and the + # host's prebuilt derivation cache, causing spurious full rebuilds. + # + # systemd.globalEnvironment sets DefaultEnvironment in systemd.conf, + # so every unit started by PID 1 inherits NIX_REMOTE=daemon. + # Non-root nix clients already default to the daemon socket, so this + # is a no-op for them; it only matters for root services that would + # otherwise silently use the local store. + systemd.globalEnvironment.NIX_REMOTE = "daemon"; + + # `claude-code` is unfree. Each per-agent container's nixosConfiguration + # evaluates its own `nixpkgs` instance, so the operator's host-level + # `nixpkgs.config.allowUnfreePredicate` does not propagate into here — + # we have to allow it inside the container's config as well. + nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; + + # Core tooling every agent gets. Per-bin split packages (see + # nix/packages/default.nix + ./packages.nix) rather than the full + # `hyperhive` bundle — that bundle also carries `hivectl` (a + # host-admin CLI that dials the *host* admin socket — useless and + # unreachable from inside a container — wrapped with + # `wireguard-tools` for `hivectl wg`). The daemon/harness/MCP bins + # the harness execs (hive-agent{,-mcp}, hive-bash-daemon, + # hive-matrix-daemon, hive-bash-mcp, hive-matrix-mcp) are wired via + # their own ExecStart/command lines in the sibling modules — they + # don't need to be on PATH too. Only these two are actually looked + # up on PATH by claude/shell code inside the container: + # `hive-agent-wake` (external wake CLI, docs/turn-loop/mcp.md) and + # `hive-metric` (agent-emitted custom metrics CLI, + # docs/observability.md). + environment.systemPackages = [ + config.hyperhive.packages.hive-agent-wake + config.hyperhive.packages.hive-metric + ] + ++ (with pkgs; [ + claude-code + bashInteractive + coreutils-full + # procps for pkill — used by the web UI's /api/cancel to SIGINT the + # in-flight claude turn. + procps + # jq: JSON processing in shell — useful for parsing API responses, + # forge REST calls, sqlite output, etc. + jq + # curl: HTTP client for forge REST API and other web requests. + curl + ]); + + # HIVE_ASSETS_DIR points at the project's static runtime assets + # (branding + claude prompts; see `nix/packages/assets.nix`). Set + # here so both the harness binary and any user-shell `cargo run` + # inside the container resolve them from the same path. + # SHELL must be set so claude's Bash tool finds a POSIX shell. + # HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the + # host-level `services.hyperhive.c0re.contextWindowTokens` option — not + # set here. + environment.variables = { + HIVE_ASSETS_DIR = "${config.hyperhive.packages.assets}/share/hyperhive"; + SHELL = "${pkgs.bashInteractive}/bin/bash"; + # Route interactive-shell nix invocations through the host daemon. + # Redundant with /etc/profile.d/nix-daemon.sh but ensures it's set + # regardless of which profile files are sourced. + NIX_REMOTE = "daemon"; + }; + + # Git is needed by claude's Bash tool (for the agent <-> manager config + # request flow) and by hive-c0re's own setup_applied / setup_proposed. + # The per-agent `applied//flake.nix` overrides `user.name` and + # `user.email` with the agent's identity — values here are `mkDefault` + # so the per-agent override wins without needing `mkForce`. + programs.git = { + enable = true; + config = { + user = { + name = lib.mkDefault "hyperhive"; + email = lib.mkDefault "hyperhive@local"; + }; + init.defaultBranch = lib.mkDefault "main"; + }; + }; + + system.stateVersion = "25.11"; + }; +} diff --git a/nix/agent-modules/docs.nix b/nix/agent-modules/docs.nix new file mode 100644 index 00000000..1d905d09 --- /dev/null +++ b/nix/agent-modules/docs.nix @@ -0,0 +1,50 @@ +# In-container hyperhive reference docs: the `hyperhive.docs.*` +# options and the `$HIVE_DOCS_DIR` wiring the harness reads. +{ + pkgs, + lib, + config, + ... +}: +{ + options.hyperhive.docs.enable = lib.mkEnableOption '' + make the hyperhive reference docs (the repo `docs/` tree, shipped + read-only as the standalone `hyperhive-docs` derivation) available + in-container. When enabled the harness exposes the docs dir to claude + via `claude --add-dir`, so the markdown is readable at + `$HIVE_DOCS_DIR/`, and appends a single pointer sentence to the agent's + system prompt so it knows the docs exist (see + `hive-ag3nt::prompt::render`). Default-on for the root/manager agent + (see `../templates/ruth.nix`), off elsewhere; any agent can flip it from its + `agent.nix`. + ''; + + options.hyperhive.docs.source = lib.mkOption { + type = lib.types.path; + default = config.hyperhive.packages.reference-docs; + defaultText = lib.literalMD "`hyperhive.packages.reference-docs` (built from the repo `docs/` tree)"; + description = '' + Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR` + when `hyperhive.docs.enable` is set. Defaults to the flake's + `reference-docs` package (the `nix/packages/reference-docs.nix` + build) so a standalone container build from a full checkout + works unchanged. The generated meta flake overrides this with the + narrow `hyperhive-docs` flake input so a doc edit only + re-locks that input instead of rebuilding the container from a + re-hashed `hyperhive` source. + ''; + }; + + config = { + environment.variables = lib.mkIf config.hyperhive.docs.enable { + # The harness reads HIVE_DOCS_DIR and passes it to claude as + # `--add-dir` so the docs are readable, and appends a single + # pointer sentence to the system prompt + # (hive-ag3nt::prompt::render) telling the agent the docs exist. + # Source is `hyperhive.docs.source` (the narrow `hyperhive-docs` + # meta-flake input, or `pkgs.hyperhive-docs` for standalone + # builds). See hive-ag3nt::turn. + HIVE_DOCS_DIR = "${config.hyperhive.docs.source}"; + }; + }; +} diff --git a/nix/agent-modules/forge.nix b/nix/agent-modules/forge.nix new file mode 100644 index 00000000..202ffa2c --- /dev/null +++ b/nix/agent-modules/forge.nix @@ -0,0 +1,191 @@ +# In-container forge (Forgejo) integration: the `tea` CLI login +# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge +# avatar sync. +{ + pkgs, + lib, + config, + ... +}: +let + userName = config.hyperhive.user.name; + homeDir = "/home/${userName}"; + # Same 512×512 rasterization of the agent icon the matrix avatar + # sync uses (./matrix.nix — identical derivation, same store path). + # Only forced when an icon is configured (the avatar-sync unit below + # is gated on `hyperhive.icon != null`). + iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' + rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out + ''; +in +{ + options.hyperhive.forge.url = lib.mkOption { + type = lib.types.str; + default = "http://localhost:3000"; + example = "http://forge.internal:3000"; + description = '' + Base URL of the hyperhive-managed Forgejo. Used at container + boot by a oneshot systemd unit that calls + `tea login add --url --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` + (= `/agents//state/forge-token`) so the agent's claude can + shell out to `tea` without an extra auth dance. No-op when the + forge-token file is missing (i.e. hive-forge isn't running on + the host). + ''; + }; + + config = { + assertions = [ + # hyperhive.forge.url must look like an HTTP URL when non-default. + { + assertion = + config.hyperhive.forge.url == "" + || lib.hasPrefix "http://" config.hyperhive.forge.url + || lib.hasPrefix "https://" config.hyperhive.forge.url; + message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")"; + } + ]; + + environment.systemPackages = [ + # tea: gitea/forgejo CLI client. Configured at boot by the + # tea-login oneshot below if /state/forge-token is present, so + # claude can `tea repos create`, `tea pulls create`, etc. + pkgs.tea + # hive-forge : CLI wrapping common Forgejo REST API operations + # (view, pr, issue, comment, assign, close, labels, branches, etc.). + # The per-bin split package — narrow closure, no hivectl/wireguard. + config.hyperhive.packages.hive-forge + ]; + + # One-shot: tea config.yml from the seeded forge token. Shape + # contract (always exit 0, no set -e, skip-silently, re-runnable): + # docs/conventions.md::Best-effort oneshot services. + systemd.services.tea-login = { + description = "configure tea CLI from hive-forge token (best-effort)"; + wantedBy = [ "multi-user.target" ]; + after = [ "local-fs.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Pin the journal identity (else it's the `script` store-path wrapper). + SyslogIdentifier = "tea-login"; + }; + path = [ + pkgs.curl + pkgs.jq + pkgs.coreutils + ]; + environment.HOME_DIR = homeDir; + environment.AGENT_USER = userName; + script = '' + # No `set -e`: best-effort posture (see docs pointer above). + FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} + # $HYPERHIVE_STATE_DIR is system-wide via the meta flake. + TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" + if [ ! -f "$TOKEN_FILE" ]; then + echo "tea-login: no forge-token at $TOKEN_FILE; skipping" + exit 0 + fi + TOKEN=$(cat "$TOKEN_FILE") + # Resolve the agent username from the forge API. + USER=$(curl -sf --max-time 5 \ + -H "Authorization: token $TOKEN" \ + "$FORGE_URL/api/v1/user" \ + | jq -r '.login // empty' 2>/dev/null || true) + if [ -z "$USER" ]; then + echo "tea-login: could not resolve username from forge API; skipping" + exit 0 + fi + # Config under the agent user's home, chown'd to them; + # service stays root-owned (see docs pointer above). + CONFIG="$HOME_DIR/.config/tea/config.yml" + mkdir -p "$(dirname "$CONFIG")" || true + cat > "$CONFIG" << EOF + logins: + - name: forge + url: $FORGE_URL + token: $TOKEN + default: true + ssh_host: "" + ssh_key: "" + insecure: false + ssh_agent: false + user: $USER + preferences: + editor: false + flag_defaults: + remote: "" + EOF + chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true + echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)" + ''; + }; + + # Path-trigger sibling: re-fires forge-avatar-sync the moment + # `/forge-token` appears. Mirrors the hive-matrix-daemon + # token-watcher pattern — on first agent deployment the container + # boots before hive-c0re has provisioned the forge-token, so the + # service fires too early and exits with "no forge-token found". + # Without this path unit, RemainAfterExit=true would prevent systemd + # from ever re-running the service. See + # docs/persistence.md::forge-avatar-sync. + systemd.paths.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) { + description = "trigger forge-avatar-sync when forge-token appears"; + wantedBy = [ "multi-user.target" ]; + pathConfig.PathExistsGlob = "/agents/*/state/forge-token"; + }; + + # One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract: + # docs/conventions.md::Best-effort oneshot services. + # RemainAfterExit = false so the .path trigger above can re-fire + # this unit when the forge-token arrives after boot. The PNG is + # rasterized at build time (`iconPng`, shared shape with the matrix + # avatar sync), so the unit only exists when an icon is configured + # and needs no librsvg at runtime — Forgejo's Go image library + # can't decode SVG, hence PNG. + systemd.services.forge-avatar-sync = lib.mkIf (config.hyperhive.icon != null) { + description = "sync agent icon to Forgejo user avatar (best-effort)"; + wantedBy = [ "multi-user.target" ]; + after = [ "tea-login.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = false; + # Pin the journal identity (else it's the `script` store-path wrapper). + SyslogIdentifier = "forge-avatar-sync"; + }; + path = [ + pkgs.curl + pkgs.coreutils + pkgs.jq + ]; + script = '' + FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} + # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake + # (systemd.globalEnvironment) to `/agents//state`. + TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" + if [ ! -f "$TOKEN_FILE" ]; then + echo "forge-avatar-sync: no forge-token found; skipping" + exit 0 + fi + TOKEN=$(cat "$TOKEN_FILE") + IMAGE=$(base64 -w 0 < ${iconPng}) + # Forgejo POST /user/avatar expects {"image":""} — just the + # raw base64 string, NOT a data URI (data:image/png;base64,...). + # Use jq to build the payload so the large base64 value is safely quoted. + PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') + RESP=$(curl -sf --max-time 10 \ + -X POST "$FORGE_URL/api/v1/user/avatar" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -w "\n%{http_code}" 2>/dev/null || true) + CODE=$(printf '%s' "$RESP" | tail -1) + if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then + echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" + else + echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" + fi + ''; + }; + }; +} diff --git a/nix/agent-modules/frontend.nix b/nix/agent-modules/frontend.nix new file mode 100644 index 00000000..90a631cf --- /dev/null +++ b/nix/agent-modules/frontend.nix @@ -0,0 +1,156 @@ +# Per-agent web UI static tree: the shipped frontend dist, the +# operator-extendable `extraFiles` overlay, and the merged tree the +# harness serves via `HIVE_STATIC_DIR`. +{ + pkgs, + lib, + config, + ... +}: +{ + options.hyperhive.frontend.dist = lib.mkOption { + type = lib.types.package; + default = config.hyperhive.packages.frontend; + defaultText = lib.literalMD "`hyperhive.packages.frontend` (the flake's frontend dist)"; + description = '' + The shipped frontend dist (built by `nix/packages/frontend.nix`). + Output layout: `dashboard/` (used by hive-c0re on the host) and + `agent/` (used here, layered with `extraFiles` below at + activation time). Override to ship a fully custom per-agent SPA; + the JSON contract (`/api/state`, `/events/stream`, the action + endpoints) is the source of truth for any replacement. + ''; + }; + + options.hyperhive.frontend.mergedDist = lib.mkOption { + type = lib.types.package; + readOnly = true; + description = '' + Computed: the merged static tree consumed by the harness via + `HIVE_STATIC_DIR`. Composed at evaluation time by copying + `hyperhive.frontend.dist`'s `agent/` subdir as the base, then + layering each `extraFiles` entry on top. Read-only — do not set directly. + ''; + }; + + options.hyperhive.frontend.extraFiles = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule ( + { name, ... }: + { + options = { + source = lib.mkOption { + type = lib.types.path; + description = '' + Source file or directory to layer over the default + agent dist. A path (relative to `agent.nix` or + absolute) — nix copies its contents into the merged + static tree. + ''; + }; + target = lib.mkOption { + # First char must be alphanumeric/underscore (rules out + # leading `/`, leading `.`, leading `-`); inner chars + # include `.` and `/` so nested layouts like + # `"games/bitburner"` work. This is the shape check — + # the `..`-segment traversal check is the assertion in + # `config.assertions` below (regex alone can't reject + # mid-path `..` segments without lookahead, which nix + # POSIX regex doesn't support). + type = lib.types.strMatching "^[A-Za-z0-9_][A-Za-z0-9_./-]*$"; + default = name; + defaultText = lib.literalMD "the attribute name"; + description = '' + Destination path within the merged static tree, used + as both the served URL prefix (`//...`) and + the on-disk layout in the merged derivation. Defaults + to the attribute name. Use forward slashes for + nested layouts (e.g. `"games/bitburner"`). + + Constrained shape: must start with an alphanumeric or + `_`, and only contain alphanumerics, `_`, `.`, `/`, + `-`. `..` segments are separately rejected at config + eval time. + ''; + }; + }; + } + ) + ); + default = { }; + example = lib.literalExpression '' + { + bitburner = { + source = ./bitburner-dist; + # served at GET /bitburner/... + }; + } + ''; + description = '' + Per-agent additions layered on top of the default frontend + dist. Each entry copies its `source` into the served static + tree under `target`. Useful for shipping a self-contained + agent-specific surface alongside the standard agent UI (e.g. + the bitburner agent's game page at `/bitburner/`). + + The default agent UI remains served at `/`; entries here only + add new routes and never replace the default. Overwrite + semantics are **hard-fail**: if `target` collides with an + existing file or directory in the default dist (or with a + prior entry's target), the `mergedDist` build aborts with + `refusing to overwrite existing path '' in the + default dist`. To override a default file, fork the dist via + `hyperhive.frontend.dist` instead — `extraFiles` is for + pure additions. + + `target` must be a relative path inside the static dir. An + assertion rejects leading `/` and `..` segments at config + eval time (string-concat-into-paths safety, even though + agent.nix goes through operator review before deploy). + ''; + }; + + config = { + assertions = [ + # hyperhive.frontend.extraFiles[*].target is concatenated into + # $out during the mergedDist build. The option's strMatching + # type already rejects leading `/`, leading `.`, and the + # weirder characters; this assertion catches mid-path `..` + # segments (e.g. `foo/../etc/passwd`) that the type's regex + # can't easily express without lookahead. agent.nix is + # operator-reviewed, so this is belt-and-braces — but it's the + # kind of mistake that's easy to make and hard to spot. + { + assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) ( + lib.attrValues config.hyperhive.frontend.extraFiles + ); + message = '' + hyperhive.frontend.extraFiles: `target` must not contain + `..` path segments. + ''; + } + ]; + + # Merged frontend static tree. Base = `${frontend.dist}/agent/`, + # then each `extraFiles` entry is laid on top at its `target` + # path. The runCommand derivation aborts on overwrite so a + # filename collision with the default dist surfaces as a build + # failure rather than a silent override (operator gets a clear + # nix error rather than a confusing 404 / silent dist swap). + hyperhive.frontend.mergedDist = pkgs.runCommand "hyperhive-agent-frontend-merged" { } ( + '' + mkdir -p $out + cp -r ${config.hyperhive.frontend.dist}/agent/. $out/ + chmod -R u+w $out + '' + + lib.concatMapStrings (entry: '' + mkdir -p $(dirname $out/${entry.target}) + if [ -e $out/${entry.target} ]; then + echo "hyperhive.frontend.extraFiles: refusing to overwrite existing path '${entry.target}' in the default dist" >&2 + exit 1 + fi + cp -r ${entry.source} $out/${entry.target} + '') (lib.attrValues config.hyperhive.frontend.extraFiles) + ); + }; +} diff --git a/nix/agent-modules/github.nix b/nix/agent-modules/github.nix new file mode 100644 index 00000000..d1a9aa6a --- /dev/null +++ b/nix/agent-modules/github.nix @@ -0,0 +1,89 @@ +# GitHub integration (hyperhive.github.enable): a `gh` wrapper + a git +# credential helper, both reading the PAT from the agent's +# `github-token` state file at invocation, so a dashboard-pasted token +# takes effect with no rebuild. The token PATH is baked in at build +# time (nix knows `userName`) — NOT read from `$HIVE_GITHUB_TOKEN_FILE`, +# because claude's Bash tool runs `bash -c` in a minimal env that +# doesn't source `/etc/set-environment`, so the env var isn't present +# where `gh`/`git` actually run. The token value never enters the nix +# store (only its path). github.com only; git auths as `x-access-token` +# + PAT. +{ + pkgs, + lib, + config, + ... +}: +let + userName = config.hyperhive.user.name; + ghWrapper = pkgs.writeShellScriptBin "gh" '' + if [ -r "/agents/${userName}/state/github-token" ]; then + GH_TOKEN="$(cat "/agents/${userName}/state/github-token")" + export GH_TOKEN + fi + exec ${pkgs.gh}/bin/gh "$@" + ''; + gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-github" '' + # git credential-helper protocol: only the `get` action needs an answer. + [ "''${1:-}" = "get" ] || exit 0 + if [ -r "/agents/${userName}/state/github-token" ]; then + # GitHub ignores the username for PAT auth — `x-access-token` is the + # conventional placeholder; the PAT is the password. + printf 'username=x-access-token\n' + printf 'password=%s\n' "$(cat "/agents/${userName}/state/github-token")" + fi + ''; +in +{ + options.hyperhive.github.enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Install the GitHub integration in this agent: a `gh` CLI wrapper and a + git credential helper for `https://github.com`, both authenticated from + an operator-supplied personal access token (PAT). The PAT is written to + `/github-token` out of band --- the dashboard credentials tab or + `hivectl github set-token` --- so giving an agent GitHub is a runtime + paste, no per-agent config or rebuild. The wrappers read the token file + at invocation, so a freshly-pasted PAT takes effect immediately; until + one exists, `gh` / `git push` just fail unauthenticated. + + github.com only. git authenticates as `x-access-token` + the PAT (GitHub + ignores the username for PAT auth); `gh` derives its identity from the + token. Keep the PAT minimally scoped: the agent has passwordless sudo, so + a compromised agent can act within the token's scopes --- scope is the + real blast-radius limiter. + + On by default. Host-driven: set `services.hyperhive.github.enable = false` + to turn the integration off hive-wide (meta.rs propagates the override + into every agent). + ''; + }; + + config = { + # No bare pkgs.gh here — the wrapper *is* `gh` and hardcodes the + # real binary path, so it can't be shadowed. + environment.systemPackages = lib.optionals config.hyperhive.github.enable [ + ghWrapper + gitCredHelper + ]; + + # Wire the GitHub credential helper for `git push` over HTTPS. Host-scoped + # to `https://github.com`, so it never touches the forge (localhost:3000) + # or any other remote. The helper reads the PAT from the agent's + # `github-token` state file at invocation and auths as `x-access-token` + + # the PAT. System /etc/gitconfig merges under the agent's ~/.gitconfig + # (safe.directory), so this is additive. + # Nested-path binding + mkIf (matching the other `environment.etc."…"` + # entries in the harness modules) — a whole-set `environment.etc = {…}` + # here would collide with them at the nix level ("attribute already + # defined"). + environment.etc."gitconfig" = lib.mkIf config.hyperhive.github.enable { + text = '' + [credential "https://github.com"] + helper = hive-github + username = x-access-token + ''; + }; + }; +} diff --git a/nix/agent-modules/matrix.nix b/nix/agent-modules/matrix.nix new file mode 100644 index 00000000..06c50331 --- /dev/null +++ b/nix/agent-modules/matrix.nix @@ -0,0 +1,298 @@ +# Per-agent matrix integration: the `hyperhive.matrix.*` + +# `hyperhive.matrixAccounts` options, the long-running +# hive-matrix-daemon, its token-arrival path trigger, and the +# auto-injected stdio MCP bridge entry. +{ + pkgs, + lib, + config, + ... +}: +let + userName = config.hyperhive.user.name; + # Single source of truth for the default matrix homeserver URL, shared + # by the `hyperhive.matrix.url` option default and the daemon-unit guard + # that decides whether to set a unit-level HIVE_MATRIX_URL (so the two + # cannot drift). Matches the daemon's own built-in default + # (`paths::DEFAULT_HOMESERVER`). + matrixUrlDefault = "http://localhost:8008"; + # Rasterize the operator-set agent icon (`hyperhive.icon`, an SVG) to a + # 512x512 PNG so the matrix daemon can upload it as each account's avatar + # over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar). + # Only forced when an icon is configured — the `HIVE_ICON_PNG` daemon-env + # entry is gated on `hyperhive.icon != null`, so this binding stays lazy + # when no icon is set. + iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' + rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out + ''; +in +{ + options.hyperhive.matrix.enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Enable per-agent matrix integration via `hive-matrix-mcp`. + When true (the default), the harness: + + - runs `hive-matrix-daemon` as a systemd unit that holds a + matrix-sdk Client + sync against the homeserver at + `HIVE_MATRIX_URL` (default `http://localhost:8008` — the + in-host tuwunel from `nix/host-modules/hive-matrix.nix`). The + daemon auto-skips when `/matrix-token` is missing, + and a `systemd.paths` watcher restarts it the moment + hive-c0re provisions the token (same path-trigger shape + as `forge-avatar-sync`). + - exposes the matrix tool surface (send_message, send_dm, + send_reaction, send_reply, mark_read, list_rooms, + list_room_members, read_room) to claude via an auto-injected + `extraMcpServers.matrix` entry. Claude spawns the stdio + `hive-matrix-mcp` bridge per turn, which forwards each tool + call to the daemon over `/run/hive-matrix/socket`. + - wakes the agent on incoming room events via a short teaser + Wake signal (`[matrix] in : …`) + to the hyperhive control socket; the full event stays + unread server-side until `read_room` consumes it. + + Set to `false` for agents that should NOT have matrix tools at + all (e.g. agents on a host without `hyperhive.matrix.enable` on + the meta side). When token file is absent the daemon and MCP + both no-op cleanly anyway, so `false` is rarely necessary. + ''; + }; + + options.hyperhive.matrix.url = lib.mkOption { + type = lib.types.str; + default = matrixUrlDefault; + example = "https://matrix.darkest.space"; + description = '' + Matrix homeserver URL the agent's `hive-matrix-daemon` connects + to. At runtime hive-c0re forwards the isolation-aware URL + (`matrix.` via the gateway) so isolated agents reach + the homeserver without crossing host loopback. Override + per-agent when an agent should talk to an external homeserver + instead (e.g. a federation-only setup or a remote hive's + tuwunel reached via a vpn). + ''; + }; + + options.hyperhive.matrixAccounts = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + tokenFile = lib.mkOption { + type = lib.types.str; + example = "/agents/dmatrix/state/matrix-token-ccc"; + description = '' + Path to this account's bearer-token file. The daemon reads + the token from here to restore the matrix session; how the + file gets populated is the provisioner's concern (an + operator-supplied secret for an external account). The + daemon skips an extra account whose token file is absent. + ''; + }; + sessionDir = lib.mkOption { + type = lib.types.str; + example = "/agents/dmatrix/state/matrix-sdk-state-ccc"; + description = '' + Per-account matrix-sdk sqlite store directory (crypto keys + + event cache). Must differ between accounts so their + sessions do not collide. + ''; + }; + homeserver = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "https://matrix.example.org"; + description = '' + Homeserver URL for this account. When null (the default), + the account falls back to `hyperhive.matrix.url`. Set it for + an account on a different homeserver than the agent's + default (e.g. an external public-matrix account). + ''; + }; + }; + } + ); + default = { }; + example = lib.literalExpression '' + { + ccc = { + tokenFile = "/agents/dmatrix/state/matrix-token-ccc"; + sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc"; + homeserver = "https://matrix.example.org"; + }; + } + ''; + description = '' + Declare *additional* matrix accounts served by the single + `hive-matrix-daemon` (one matrix-sdk Client + sync loop each), + beyond the agent's built-in hive-internal account. The + attribute name keys each account (unique by construction) and is + the handle the matrix MCP tools target via their `account` + argument. + + The **hive-internal account is always present and is the primary**: + it is named `main`, synthesized by the daemon from + `hyperhive.matrix.url` + `/matrix-token` + + `/matrix-sdk-state`, and is the account a tool call acts as + when it omits `account`. You never declare it here --- this option + is only for the extras (e.g. an external public-matrix account). + + Leave empty (the default) for the common single-account case: the + agent then has only `main`. When non-empty, the extras are + serialized to the daemon's `HIVE_MATRIX_ACCOUNTS` environment + variable and the daemon appends them after `main`. Requires + `hyperhive.matrix.enable` (there is no `main` to extend otherwise). + ''; + }; + + config = { + assertions = [ + # Extra matrix accounts only make sense alongside the hive-internal + # `main` account they extend, which exists only when matrix is + # enabled. + { + assertion = config.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable; + message = + "hyperhive.matrixAccounts requires hyperhive.matrix.enable = true " + + "(the extras extend the hive-internal `main` account, which only " + + "exists when matrix is enabled)."; + } + # `main` is reserved for the synthesized hive-internal account; a + # declared extra by that name would silently collide with it. + { + assertion = !builtins.hasAttr "main" config.hyperhive.matrixAccounts; + message = + "hyperhive.matrixAccounts cannot contain a key named \"main\" " + + "--- that name is reserved for the hive-internal account."; + } + # Token files must land at the `matrix-token*` name the daemon + # path-watcher globs (`/agents/*/state/matrix-token*`), or the account + # never gets picked up live (it loads only on a full daemon restart). + # Enforce the basename prefix so a deviating name (e.g. + # `matrix-catgirl-token`) is caught at build time, not silently. + { + assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) ( + lib.attrValues config.hyperhive.matrixAccounts + ); + message = + "every hyperhive.matrixAccounts..tokenFile basename must start with " + + "\"matrix-token\" so the daemon path-watcher glob " + + "(/agents/*/state/matrix-token*) picks it up live. Offending: " + + lib.concatStringsSep ", " ( + lib.mapAttrsToList (n: a: "${n}=${baseNameOf a.tokenFile}") ( + lib.filterAttrs ( + _n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile) + ) config.hyperhive.matrixAccounts + ) + ) + + "."; + } + ]; + + # Auto-inject the matrix stdio MCP bridge alongside the bash entry + # from ./mcp.nix. `lib.mkDefault` so the operator's own agent.nix + # can override the entry. + hyperhive.extraMcpServers = lib.mkIf config.hyperhive.matrix.enable { + matrix = lib.mkDefault { + command = "${config.hyperhive.packages.hive-matrix-mcp}/bin/hive-matrix-mcp"; + args = [ ]; + # Same socket path the hive-matrix-daemon service binds + # via its `RuntimeDirectory = "hive-matrix"`. Keeps the + # bridge + daemon in sync without baking the path into + # the Rust default — the env override wins for both. + env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; + allowedTools = [ "*" ]; + }; + }; + + # Long-running matrix-sdk client + sync per agent. Holds the unix + # socket the stdio `hive-matrix-mcp` bridge connects to + emits + # hyperhive wake signals on incoming room events via + # `/run/hive/mcp.sock`. See + # `docs/persistence.md::Matrix per-agent daemon + token-arrival + # trigger` for the socket-path / first-boot-ordering rationale. + systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { + description = "long-running matrix-sdk Client + MCP daemon socket"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + environment = { + HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; + RUST_LOG = "info"; + } + # Homeserver URL: by default the daemon inherits the host-forwarded + # HIVE_MATRIX_URL (set by hive-c0re to `matrix.` via the + # gateway, since agents run in private netns and can't reach host + # loopback directly), falling back to the daemon's built-in + # localhost default if the forward is absent. A per-agent + # `hyperhive.matrix.url` override (non-default) is set unit-level + # so it wins over the forwarded value; at the default we + # deliberately DON'T set it so the forwarded value isn't shadowed. + // lib.optionalAttrs (config.hyperhive.matrix.url != matrixUrlDefault) { + HIVE_MATRIX_URL = config.hyperhive.matrix.url; + } + # Multi-account: serialize the *extra* accounts to the JSON the + # daemon parses (`accounts::configured`). Only set when extras are + # declared; the daemon always synthesizes the primary `main` + # (hive-internal) account itself from the per-agent paths and + # prepends it, so we emit extras only. Each entry is in the + # daemon's `AccountCfg` serde shape: name (the attr key) / + # token_file / state_dir / optional homeserver. + // lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) { + HIVE_MATRIX_ACCOUNTS = builtins.toJSON ( + lib.mapAttrsToList ( + name: a: + { + inherit name; + token_file = a.tokenFile; + state_dir = a.sessionDir; + } + // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; } + ) config.hyperhive.matrixAccounts + ); + } + # Rasterized agent icon path for the daemon's avatar sync. Only set + # when an icon is configured; absent → the daemon skips avatar setting + # (hive-matrix-mcp::client::sync_avatar returns early on unset env). + // lib.optionalAttrs (config.hyperhive.icon != null) { + HIVE_ICON_PNG = "${iconPng}"; + }; + serviceConfig = { + ExecStart = "${config.hyperhive.packages.hive-matrix-daemon}/bin/hive-matrix-daemon"; + SyslogIdentifier = "hive-matrix-daemon"; + Restart = "on-failure"; + RestartSec = 5; + User = userName; + Group = userName; + RuntimeDirectory = "hive-matrix"; + # Keep /run/hive-matrix across restarts. With the default + # `RuntimeDirectoryPreserve=no`, a `switch-to-configuration` + # restart races the outgoing instance's stop-time cleanup + # (which deletes the dir) against the incoming instance's + # start (which creates it + binds the socket inside it). The + # cleanup can win and delete the dir out from under the fresh + # daemon, which then fails to mkdir under root-owned /run and + # exits — looping on Restart=on-failure until the next boot. + # `yes` stops systemd removing it on stop; it still creates it + # on first start, and it lives on tmpfs so it's gone at + # container reboot regardless. See hive-bash-daemon (./mcp.nix). + RuntimeDirectoryPreserve = "yes"; + }; + }; + + # Re-fire the daemon when the matrix token appears (hive-c0re + # provisions it after agent containers come up). Without this + # the daemon would exit 0 silently on first boot and the MCP + # would have no backend until next restart. See + # `docs/persistence.md` (same section as above). + systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { + description = "trigger hive-matrix-daemon when a matrix token appears"; + wantedBy = [ "multi-user.target" ]; + # `matrix-token*` (not just `matrix-token`) so a secondary + # multi-account token (e.g. `matrix-token-ccc`) landing also + # re-fires the daemon to pick up the freshly-provisioned account. + pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*"; + }; + }; +} diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix new file mode 100644 index 00000000..717ec0b0 --- /dev/null +++ b/nix/agent-modules/mcp.nix @@ -0,0 +1,240 @@ +# The MCP tool surface: the built-in hyperhive server (persistent +# streamable-http daemon), the bash-task backend daemon + its +# auto-injected stdio bridge, the `extraMcpServers` option they hang +# off, and the send-recipient allowlist. +{ + pkgs, + lib, + config, + ... +}: +let + userName = config.hyperhive.user.name; +in +{ + options.hyperhive.allowedRecipients = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "alice" + "manager" + ]; + description = '' + Names this agent is allowed to `send` to via + `mcp__hyperhive__send`. Empty list (the default) means + unrestricted — the agent can message any peer, the + operator, or the manager. Non-empty list constrains the + surface: only the listed names + the manager (always + allowed) get through; anything else returns an error + string to claude without touching the broker. The + operator (`operator`) needs to be in the list if the + agent should be able to surface output on the + dashboard. + + Useful for sandboxing untrusted sub-agents — set + `[ "manager" ]` to scope them to manager-only chatter. + The manager itself is always exempt; this option only + affects sub-agent `send`. + ''; + }; + + options.hyperhive.extraMcpServers = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + command = lib.mkOption { + type = lib.types.str; + description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`."; + }; + args = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Args passed to the MCP server binary."; + }; + env = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Environment variables for the MCP server child process."; + }; + allowedTools = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "*" ]; + example = [ + "send_message" + "join_room" + ]; + description = '' + Tool names this MCP server is auto-approved to call via + `--allowedTools`. Single entry `"*"` (the default) means + "every tool from this server" — convenient but trusting. + Tighten to a specific list when you only want a subset. + Names are bare (e.g. `send_message`); the harness prepends + `mcp____` at build time. + ''; + }; + }; + } + ); + default = { }; + example = lib.literalExpression '' + { + matrix = { + command = "/run/current-system/sw/bin/mcp-matrix"; + args = [ "--config" "/state/matrix.toml" ]; + env.MATRIX_HOMESERVER = "https://matrix.example.org"; + allowedTools = [ "send_message" "join_room" ]; + }; + } + ''; + description = '' + Extra MCP servers claude sees alongside the hyperhive tool surface. + Keys are the server names (claude addresses tools as + `mcp____`). Rendered to `/etc/hyperhive/extra-mcp.json` + at activation time; the harness reads that file at boot and merges + it into `--mcp-config` + `--allowedTools`. Take effect on the + agent's next harness restart (no operator approval needed beyond + whatever brought the new agent.nix into deployed/*). + ''; + }; + + options.hyperhive.mcp.httpPort = lib.mkOption { + type = lib.types.port; + default = 8790; + example = 8791; + description = '' + Loopback port the built-in hyperhive MCP surface is served on. HTTP + is the *sole* transport for the built-in surface: a + long-lived `hive-mcp-http` systemd unit runs + `hive-agent-mcp --http 127.0.0.1:` and `render_claude_config` + points claude at the stable `http://127.0.0.1:/mcp` URL. That + URL survives the per-turn claude re-spawn (and a host-side hive-c0re + restart — each tool call dials the control socket fresh), so there + is no per-turn MCP re-registration race (a resumed stdio child could + emit its first tool call before that turn's async + `initialize`/`tools-list` completed, stranding the agent with `No + such tool` — the http endpoint eliminates that). Extra MCP servers + (matrix/bash) stay stdio bridges regardless. + + Bound loopback-only; the rmcp streamable-http transport's default + `allowed_hosts` (`localhost` / `127.0.0.1` / `::1`) rejects Host + headers from anywhere else, so no auth token is required for a + container-local endpoint. + + Failure-mode note: with no stdio fallback, if `hive-mcp-http` is + down claude hits a dead URL until the unit restarts (guarded by + `Restart=always`, `RestartSec=3`). Intended shape: no per-turn race + while up, a bounded self-healing gap while restarting. + + Safe as a single fixed default across all agents: each container + runs in its own private network namespace (isolation is always-on — + see docs/network.md), so `127.0.0.1:` is per-container-private + and cannot collide across agents. Override only if a container-local + service already occupies this port. + + Must match `mcp_config::DEFAULT_MCP_HTTP_PORT` (the harness always + exports `HYPERHIVE_MCP_HTTP_PORT`, so the const is only a fallback). + ''; + }; + + config = { + # Auto-inject the built-in bash MCP server — always present, every + # agent needs bash tools. `lib.mkDefault` so the operator's own + # agent.nix can override the entry. (The matrix sibling lives in + # ./matrix.nix, gated on hyperhive.matrix.enable.) + hyperhive.extraMcpServers.bash = lib.mkDefault { + command = "${config.hyperhive.packages.hive-bash-mcp}/bin/hive-bash-mcp"; + args = [ ]; + env.HIVE_BASH_SOCKET = "/run/hive-bash/socket"; + allowedTools = [ "*" ]; + }; + + environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; + + environment.etc."hyperhive/send-allow.json".text = + builtins.toJSON config.hyperhive.allowedRecipients; + + # Bash task runner daemon — long-running process that owns subprocess + # monitoring + completion wake signals. Always enabled (every agent + # needs bash tools). The stdio MCP bridge `hive-bash-mcp` connects + # to this daemon's socket per turn. + # Socket dir: /run/hive-bash/ — RuntimeDirectory keeps it on tmpfs. + systemd.services.hive-bash-daemon = { + description = "bash task runner daemon for hive-bash-mcp"; + wantedBy = [ "multi-user.target" ]; + # The daemon runs every bash task via `Command::new("bash")` and the + # commands themselves (hive-forge, git, jq, …) resolve from PATH. + # A standalone daemon has no inherited agent PATH, so without this + # `bash` itself isn't found (spawn fails with ENOENT, the task is + # marked done in 0s with no output / no .out/.err). Mirror the + # harness unit's PATH: NixOS appends `/bin` to each entry → + # /run/wrappers/bin (setuid sudo) + /run/current-system/sw/bin + # (bash, coreutils, hive-forge, …). + path = [ + "/run/wrappers" + "/run/current-system/sw" + ]; + environment = { + HIVE_BASH_SOCKET = "/run/hive-bash/socket"; + HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock"; + RUST_LOG = "info"; + # HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already + # injected via systemd.globalEnvironment by the meta flake + # (set to /agents//harness and /agents//state + # respectively). The daemon uses these to derive its task + + # loose-ends dir paths; without them it falls back to deriving + # harness/ as a sibling of state/, which produces the same + # value but is less robust if the two vars ever diverge. + }; + serviceConfig = { + ExecStart = "${config.hyperhive.packages.hive-bash-daemon}/bin/hive-bash-daemon"; + SyslogIdentifier = "hive-bash-daemon"; + Restart = "on-failure"; + RestartSec = 3; + User = userName; + Group = userName; + RuntimeDirectory = "hive-bash"; + # Keep /run/hive-bash across restarts. With the default + # `RuntimeDirectoryPreserve=no`, a post-rebuild restart races + # stop-time dir cleanup against the fresh daemon's socket-dir + # creation; the daemon loses, fails `mkdir /run/hive-bash` + # (Permission denied, non-root in /run), and loops on + # Restart=on-failure until the next container boot — i.e. the + # bash daemon "doesn't come up post-rebuild". Same shape as + # hive-matrix-daemon (./matrix.nix). + RuntimeDirectoryPreserve = "yes"; + }; + }; + + # Persistent streamable-http MCP daemon for the built-in hyperhive + # surface — the *sole* transport for that surface; always + # wired. Long-lived so claude reconnects to the stable URL each turn + # instead of respawning + re-registering a stdio subprocess (the + # per-turn MCP registration race). It dials the control socket + # (`/run/hive/mcp.sock`, the harness binaries' default) fresh on every + # tool call, so a host-side hive-c0re restart is transparent. + # `before = hive-ag3nt` so the URL is already listening by the time + # the harness renders the first turn's config; the harness/claude also + # reconnect on their own, so ordering is a latency nicety not a hard + # correctness dep. + systemd.services.hive-mcp-http = { + description = "persistent streamable-http MCP daemon for the hyperhive surface"; + wantedBy = [ "multi-user.target" ]; + before = [ "hive-ag3nt.service" ]; + environment.RUST_LOG = "info"; + serviceConfig = { + ExecStart = "${config.hyperhive.packages.hive-agent-mcp}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}"; + SyslogIdentifier = "hive-mcp-http"; + # `always` (not `on-failure`): this endpoint is load-bearing — the + # sole hyperhive-MCP transport, so a down window is total + # hyperhive-MCP loss with no stdio fallback and no per-turn + # self-heal (the URL just stays dead). `always` also covers any + # unforeseen clean-return path and restarts after a stray SIGTERM + # stops it out from under the harness. + Restart = "always"; + RestartSec = 3; + User = userName; + Group = userName; + }; + }; + }; +} diff --git a/nix/agent-modules/network.nix b/nix/agent-modules/network.nix new file mode 100644 index 00000000..a29744c7 --- /dev/null +++ b/nix/agent-modules/network.nix @@ -0,0 +1,82 @@ +# In-container network plumbing: DHCP on the bridge veth, resolvconf +# taken out of the loop, and the oneshot that points resolv.conf at +# the hive bridge resolver. +{ + pkgs, + lib, + ... +}: +{ + # Take resolvconf + dhcpcd out of the /etc/resolv.conf loop so the + # bridge resolver the oneshot below writes actually sticks. At their + # NixOS defaults, resolvconf regenerates resolv.conf from host-tracking + # *after* the oneshot has pointed it at the bridge (dhcpcd re-triggers + # that when the veth comes up under isolation) — silently clobbering the + # bridge nameserver back to the host resolver, which isn't authoritative + # for the hive's own zones, so `forge.` stops resolving. We + # disable resolvconf and tell dhcpcd not to touch resolv.conf (without + # disabling dhcpcd itself, so the veth still gets its address); then + # the hyperhive-isolated-dns oneshot owns resolv.conf. (Same "take + # resolvconf out of the loop" approach the matrix container uses.) + # All agent containers receive their bridge IP via DHCP from the hive + # dnsmasq pool (see nix/host-modules/hive-gateway.nix). useDHCP runs dhcpcd + # on every interface (just eth0 in practice — the nspawn bridge veth). + config = { + networking.useDHCP = true; + networking.resolvconf.enable = false; + networking.dhcpcd.extraConfig = "nohook resolv.conf"; + + # Point resolv.conf at the hive bridge resolver when the container is + # network-isolated. nixos-container copies the *host's* /etc/resolv.conf + # into the container at every start — but the host resolver (e.g. + # 127.0.0.53) is unreachable from a private netns and isn't + # authoritative for the hive's own zones (forge. etc.). The + # bridge dnsmasq (gateway IP) is. hive-priv drops the marker + # `/etc/hyperhive-bridge-dns` (containing the gateway IP) since + # isolation is always on; the oneshot reads it and rewrites + # resolv.conf on every boot. Ordered before the first DNS consumer + # (tea-login) and the network targets so name resolution works for + # the very first turn. + systemd.services.hyperhive-isolated-dns = { + description = "point resolv.conf at the hive bridge resolver (isolated containers)"; + wantedBy = [ "multi-user.target" ]; + after = [ "local-fs.target" ]; + # Ordered before every network consumer that does DNS on first + # boot. `hive-ag3nt` (the harness) is the load-bearing one: its + # first-turn api.anthropic.com lookup must not race the resolv.conf + # rewrite (it only declares `after network.target`, so without this + # edge the harness can start before we've fixed resolv.conf and the + # first turn errors — self-heals next turn, but better not to flap). + # `hive-matrix-daemon` likewise syncs over the network; the `before` + # is a harmless no-op when matrix is disabled (the unit is absent). + before = [ + "network-online.target" + "tea-login.service" + "hive-ag3nt.service" + "hive-matrix-daemon.service" + ]; + unitConfig.ConditionPathExists = "/etc/hyperhive-bridge-dns"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Pin the journal identity; without it systemd derives it from the + # generated `script` store-path wrapper (an opaque `-…-start`). + SyslogIdentifier = "hyperhive-isolated-dns"; + }; + path = [ pkgs.coreutils ]; + script = '' + set -eu + gw=$(tr -d '[:space:]' < /etc/hyperhive-bridge-dns) + if [ -z "$gw" ]; then + echo "hyperhive-isolated-dns: empty marker; leaving resolv.conf as-is" + exit 0 + fi + # resolv.conf is a regular file copied from the host by + # nixos-container; replace it (rm first in case it's a symlink). + rm -f /etc/resolv.conf + printf 'nameserver %s\n' "$gw" > /etc/resolv.conf + echo "hyperhive-isolated-dns: resolv.conf -> nameserver $gw" + ''; + }; + }; +} diff --git a/nix/agent-modules/packages.nix b/nix/agent-modules/packages.nix new file mode 100644 index 00000000..f5a29df1 --- /dev/null +++ b/nix/agent-modules/packages.nix @@ -0,0 +1,22 @@ +# The hyperhive-built packages the harness modules consume, threaded +# in explicitly as an option — no overlay. The flake's +# `nixosModules.{agent-base,ruth}` set this to the flake's own package +# outputs via `lib.mkDefault`, so a per-agent override of an +# individual key still wins. +{ lib, ... }: +{ + options.hyperhive.packages = lib.mkOption { + type = lib.types.attrsOf lib.types.package; + internal = true; + description = '' + hyperhive package outputs consumed by the harness modules: the + per-binary daemon/CLI packages (`hive-agent`, `hive-agent-mcp`, + `hive-agent-wake`, `hive-bash-daemon`, `hive-bash-mcp`, + `hive-forge`, `hive-matrix-daemon`, `hive-matrix-mcp`, + `hive-metric`) plus the `assets`, `frontend` and + `reference-docs` trees. Wired by the flake's agent-base/ruth + nixosModules to `hyperhive.packages..*`; override an + individual key per-agent to swap in a patched binary. + ''; + }; +} diff --git a/nix/agent-modules/user.nix b/nix/agent-modules/user.nix new file mode 100644 index 00000000..f4a2d8b0 --- /dev/null +++ b/nix/agent-modules/user.nix @@ -0,0 +1,215 @@ +# Per-agent unix user: the `hyperhive.user.*` options, the user/group +# declarations, passwordless sudo, and the first-boot migration that +# chowns the bind-mounted state dirs to the agent user. +{ + pkgs, + lib, + config, + ... +}: +let + userName = config.hyperhive.user.name; + homeDir = "/home/${userName}"; +in +{ + # Per-agent unix user the harness + co-process daemons run as. + # Defaults to `"agent"` so a standalone evaluation (e.g. + # `nix flake check` against `nixosConfigurations.agent-base`) builds + # cleanly; the meta-flake's per-agent module rebinds this to the + # agent name (`"damocles"`, `"iris"`, …) so each container has a + # uniquely-named user matching its agent label. UID auto-assigned + # by NixOS (the auto-allocation range for normal users); no hard- + # coded UID. + options.hyperhive.user.name = lib.mkOption { + type = lib.types.strMatching "^[a-z_][a-z0-9_-]{0,30}$"; + default = "agent"; + example = "iris"; + description = '' + Unix user the harness service runs as inside the container. + The meta-flake overrides this to the agent's own name so the + user inside the container matches the agent label (`HIVE_LABEL`). + Stand-alone evaluation defaults to `"agent"` so module evaluation + without the meta-flake wrapper still builds. + + Constraints match `useradd`'s NAME_REGEX: lowercase / `_` start, + total length ≤ 31, no special characters. UID is auto-assigned + by NixOS unless `hyperhive.user.uid` is explicitly set. + ''; + }; + + options.hyperhive.user.uid = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + example = 1100; + description = '' + Optional fixed UID for the per-agent unix user. `null` (default) + lets NixOS auto-assign from the normal-user range (≥ 1000), + which is the right default for most deployments — the UID stays + stable across container rebuilds because each container only has + one normal user and the assignment is written into the container's + `/etc/passwd` at activation time. + + Set an explicit value only when the host needs a predictable UID + for the agent's state files — e.g. if an operator script + references files by numeric UID, or to keep ownership stable + across full container destroy + recreate on a fresh host. + + Values must be in `[1000, 60000)`. Using UIDs < 1000 clashes with + system accounts and is rejected by NixOS. + ''; + }; + + options.hyperhive.user.gid = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + example = 1100; + description = '' + Optional fixed GID for the per-agent unix group. `null` (default) + lets NixOS auto-assign. Usually set alongside `hyperhive.user.uid` + to the same value (the conventional Unix pattern for per-user + groups where uid == gid), but can be set independently. + ''; + }; + + options.hyperhive.user.passwordlessSudo = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = '' + Grant `${config.hyperhive.user.name}` passwordless sudo + (`NOPASSWD: ALL`). True by default so claude's `Bash` tool + keeps working for tools that expect root inside the container + (`systemctl`, package managers in dev shells, etc.) — the + same surface the previous root-user shape had, just elevated + explicitly instead of implicitly. + + Flip to `false` for agents that should be strictly + unprivileged. Anything claude shells out to that needs root + will then fail loudly with the standard sudo error rather + than silently succeeding — easier to spot the leak. + ''; + }; + + config = { + assertions = [ + { + assertion = + config.hyperhive.user.uid == null + || (config.hyperhive.user.uid >= 1000 && config.hyperhive.user.uid < 60000); + message = '' + hyperhive.user.uid must be in [1000, 60000) — values below + 1000 clash with system accounts; values ≥ 60000 are reserved + by NixOS for dynamic allocation. Leave unset (null) to let + NixOS auto-assign. + ''; + } + { + assertion = + config.hyperhive.user.gid == null + || (config.hyperhive.user.gid >= 1000 && config.hyperhive.user.gid < 60000); + message = '' + hyperhive.user.gid must be in [1000, 60000) — same range + constraint as hyperhive.user.uid. + ''; + } + ]; + + # The container activation script (hive-agent-user-migrate) chowns + # the bind-mounted state dir — including credential files written + # by hive-c0re before the container was built — to this user on + # every boot, so agent processes can always read their own tokens. + users.users.${userName} = { + isNormalUser = true; + home = homeDir; + createHome = true; + group = userName; + extraGroups = lib.optional config.hyperhive.user.passwordlessSudo "wheel"; + # Matches /bin/bash on NixOS — the harness's claude shell-outs + # expect a POSIX shell at $SHELL; bashInteractive is already + # the system default for the root user too. + shell = pkgs.bashInteractive; + } + // lib.optionalAttrs (config.hyperhive.user.uid != null) { + uid = config.hyperhive.user.uid; + }; + users.groups.${userName} = + { } + // lib.optionalAttrs (config.hyperhive.user.gid != null) { + gid = config.hyperhive.user.gid; + }; + + # `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool + # keep working with anything that expected root (systemctl, + # nix-env, etc.) without prompting. Flip + # `hyperhive.user.passwordlessSudo = false` to drop both + # the wheel-group membership and this sudoers entry; anything + # that needs root then fails loudly instead of silently + # succeeding. + security.sudo.extraRules = lib.mkIf config.hyperhive.user.passwordlessSudo [ + { + users = [ userName ]; + commands = [ + { + command = "ALL"; + options = [ "NOPASSWD" ]; + } + ]; + } + ]; + + # First-boot migration to the per-agent unix user — creates the + # home dir, chowns the bind-mounted state + `~/.claude/`, and + # (marker-guarded) moves any leftover `/root/.claude` content + # from the previous root-run shape. See + # `docs/persistence.md::First-boot agent-user migration` for the + # step-by-step rationale; this script implements it. + system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' + homeDir=${lib.escapeShellArg homeDir} + userName=${lib.escapeShellArg userName} + mkdir -p "$homeDir" + chown "$userName:$userName" "$homeDir" + marker=/var/lib/hive-agent-user-migrated + if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then + mkdir -p "$homeDir/.claude" + if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then + rm -rf /root/.claude + echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude" + fi + fi + mkdir -p "$(dirname "$marker")" + : > "$marker" + # Scope state + harness chowns to THIS container's own dirs only. + # The glob `/agents/*/state` also matches child-agent state dirs that + # are bind-mounted into parent containers, which would clobber the + # ownership those dirs' own activation scripts set — producing + # intermittent EACCES for the child agent's harness between a parent + # rebuild and the child's next activation. Config dirs are kept broad + # because the parent legitimately owns child proposed-config repos. + if [ -d "/agents/$userName/state" ]; then + chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true + fi + if [ -d "/agents/$userName/harness" ]; then + chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true + fi + # The proposed-config repo is RW-mounted into the editing (parent/ + # manager) agent and owned by it; hive-c0re only pulls from it. Heal + # it to this user too — same as state/harness. In an agent's own + # container its config is RO-mounted, so the chown there just fails + # harmlessly (|| true). + for configDir in /agents/*/config; do + [ -d "$configDir" ] || continue + chown -hR "$userName:$userName" "$configDir" 2>/dev/null || true + done + if [ -d "$homeDir/.claude" ]; then + chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true + # 0755 so hive-core (a different unix user) can list the dir and + # detect a valid claude session. Credential files inside are 0600 + # so secrets stay private regardless of the directory mode. + # ensure_claude_dir sets 0755 on creation but cannot re-chmod after + # hive-agent-user-migrate chowns the dir to the agent user; this + # activation script runs as root and handles the correction. + chmod 755 "$homeDir/.claude" 2>/dev/null || true + fi + ''; + }; +} diff --git a/nix/templates/weston-vnc.nix b/nix/agent-modules/weston-vnc.nix similarity index 99% rename from nix/templates/weston-vnc.nix rename to nix/agent-modules/weston-vnc.nix index aff4c1d3..a9caa8a7 100644 --- a/nix/templates/weston-vnc.nix +++ b/nix/agent-modules/weston-vnc.nix @@ -32,7 +32,7 @@ in { # Optional Weston (Wayland compositor) with the VNC backend, # surfaced as a per-agent `hyperhive.gui.enable` option. Imported - # from harness-base.nix so every sub-agent + the manager sees the + # from ./default.nix so every sub-agent + the manager sees the # option; only those that flip it on get the service. # # Port allocation, weston bind-address quirk, PAM service name, the diff --git a/nix/checks.nix b/nix/checks.nix new file mode 100644 index 00000000..e248c52b --- /dev/null +++ b/nix/checks.nix @@ -0,0 +1,87 @@ +# Flake checks: formatting, the clippy gate, the workspace test run, +# the nix-options docs eval, and the hivectl CLI-reference freshness +# check. Imported per system from flake.nix. +{ + pkgs, + craneLib, + rust, + self, + system, + treefmt-eval, +}: +let + inherit (rust) cleanSrc cargoArtifacts nativeBuildInputs; +in +{ + formatting = treefmt-eval.config.build.check self; + + # Clippy via crane's first-class `cargoClippy` builder. Reuses the + # shared `cargoArtifacts` (deps already built) and runs + # `cargo clippy --workspace --all-targets` directly. + # + # `-D warnings` makes the default/correctness/style lints a + # hard CI gate. `-A clippy::pedantic` then drops the pedantic + # group from that gate: pedantic is the "extra, opinionated" + # group the clippy team grows freely, so denying it means + # every toolchain bump that adds a new pedantic lint breaks CI + # with zero code changes. The `pedantic = warn` + # workspace lint (Cargo.toml) keeps it as advisory signal in + # local `cargo clippy` — it just no longer blocks the build. + # (`-A` rather than `-W` here: `-W clippy::pedantic` would + # re-enable the specific pedantic lints the workspace lints + # table allows, e.g. `must_use_candidate`.) + clippy = craneLib.cargoClippy { + src = cleanSrc; + inherit cargoArtifacts nativeBuildInputs; + pname = "hyperhive-workspace"; + version = "0.1.0"; + cargoClippyExtraArgs = "--workspace --all-targets -- -D warnings"; + }; + + # `cargo test --workspace` lifted out of the package builds so the + # `hyperhive-assets` dep (which `hive-ag3nt::prompt::tests` + # needs via `HIVE_ASSETS_DIR` to assert against the actual + # production prompt template) is scoped to this one check + # instead of bleeding into the binary derivations' input + # hash. Net: editing `hive-ag3nt/prompts/system.md` still + # rebuilds this test check (correct — the tests assert + # against its wording), but `packages.default` and the + # per-container toplevels stay fully cached. + cargo-test = craneLib.cargoTest { + src = cleanSrc; + inherit cargoArtifacts nativeBuildInputs; + pname = "hyperhive-workspace"; + version = "0.1.0"; + cargoTestExtraArgs = "--workspace"; + HIVE_ASSETS_DIR = "${self.packages.${system}.assets}/share/hyperhive"; + }; + + # Nix options docs evaluation. Cheap: pulls in `nixosOptionsDoc` + + # the host module's stub eval, no rust or frontend deps. CI fails + # fast if a module change breaks option declarations or the doc + # rendering. Reuses the `packages..docs` derivation so the + # per-system eval of nix/docs/default.nix happens once. + inherit (self.packages.${system}) docs; + + # `hivectl` CLI reference freshness check. The committed + # markdown at `docs/tools/hivectl-cli.md` is the rendered + # output of the hidden `hivectl markdown-docs` subcommand + # (clap-markdown walks the binary's own command tree). This + # check regenerates it from the built binary and fails if the + # committed copy drifted — so a verb / flag / help-string edit + # that forgets to refresh the doc is caught in CI. Reuses the + # already-built `packages..default` (no extra compile). + # Regenerate locally with: + # nix build .#default + # ./result/bin/hivectl markdown-docs > docs/tools/hivectl-cli.md + hivectl-docs = pkgs.runCommand "hivectl-docs-fresh" { nativeBuildInputs = [ pkgs.diffutils ]; } '' + ${self.packages.${system}.default}/bin/hivectl markdown-docs > generated.md + if ! diff -u ${../docs/tools/hivectl-cli.md} generated.md; then + echo "" >&2 + echo "ERROR: docs/tools/hivectl-cli.md is out of date — regenerate it:" >&2 + echo " nix build .#default && ./result/bin/hivectl markdown-docs > docs/tools/hivectl-cli.md" >&2 + exit 1 + fi + touch "$out" + ''; +} diff --git a/nix/devshell.nix b/nix/devshell.nix new file mode 100644 index 00000000..68885df1 --- /dev/null +++ b/nix/devshell.nix @@ -0,0 +1,18 @@ +# Dev shell: the rust toolchain plus the exact native build inputs +# the crane derivations use (git, sqlite, pkg-config, cmake — see +# ../rust.nix), so a plain `cargo build` / `cargo test` in the shell +# sees the same toolchain shape as CI. +{ pkgs, rust }: +{ + default = pkgs.mkShell { + packages = + rust.nativeBuildInputs + ++ (with pkgs; [ + cargo + clippy + rust-analyzer + rustc + rustfmt + ]); + }; +} diff --git a/nix/docs/default.nix b/nix/docs/default.nix index fdcf36ea..fef90953 100644 --- a/nix/docs/default.nix +++ b/nix/docs/default.nix @@ -23,39 +23,20 @@ let name = "hyperhive-nix-src"; }; - # Stub overlay that satisfies pkgs.hyperhive-* references in module - # option defaults without depending on self's Rust / frontend builds. - # nixosOptionsDoc renders `defaultText` for these options anyway; the - # stubs just prevent attribute-missing eval errors. - docsStubOverlay = _final: _prev: { - hyperhive = pkgs.emptyFile; - hyperhive-frontend = pkgs.emptyDirectory; - hyperhive-assets = pkgs.emptyDirectory; - hyperhive-docs = pkgs.emptyDirectory; - }; - # Stub host system: every hyperhive subsystem `mkForce false` so # heavy build inputs (matrix container, forge, etc.) stay out of # the eval — only option *declarations* matter for the doc walk. - # Import hive-c0re.nix from the content-addressed nixSrc with stub - # package args so the eval doesn't depend on self's Rust builds. + # Import the host-module aggregator from the content-addressed + # nixSrc; the package options (`services.hyperhive.c0re.package` + # etc.) carry no in-module defaults, but with hyperhive disabled + # nothing reads them, so no stubs are needed. hostEval = nixosSystem { system = pkgs.stdenv.hostPlatform.system; modules = [ - (import "${nixSrc}/modules/hive-c0re.nix" { - hyperhivePackage = _system: pkgs.emptyFile; - hyperhiveFrontend = _system: pkgs.emptyFile; - hyperhiveAssets = _system: pkgs.emptyDirectory; - hyperhiveFlake = ""; - hyperhiveDocs = ""; - hyperhiveXdgIcons = _system: pkgs.emptyFile; - agentBaseToplevel = pkgs.emptyFile; - managerToplevel = pkgs.emptyFile; - }) + "${nixSrc}/host-modules" ( { lib, ... }: { - nixpkgs.overlays = [ docsStubOverlay ]; fileSystems."/" = { device = "/dev/null"; fsType = "tmpfs"; @@ -69,14 +50,15 @@ let ]; }; - # Agent module eval from the content-addressed nixSrc. Relative - # imports inside agent-base.nix (e.g. ./harness-base.nix) resolve - # correctly against the nixSrc directory tree. + # Agent module eval from the content-addressed nixSrc. Relative + # imports inside agent.nix (the ../agent-modules dir) resolve + # correctly against the nixSrc directory tree. `hyperhive.packages` + # stays unset — every option default that references it carries a + # `defaultText`, so the doc walk never forces the packages. agentEval = nixosSystem { system = pkgs.stdenv.hostPlatform.system; modules = [ - "${nixSrc}/templates/agent-base.nix" - { nixpkgs.overlays = [ docsStubOverlay ]; } + "${nixSrc}/templates/agent.nix" ]; }; @@ -173,7 +155,7 @@ let `hyperhive.nixosModules.default` to operator host configurations (`services.hyperhive.{enable,domain,c0re,forge,matrix,gateway}.*`). - [per-agent options](agent.md) — options declared in - `nix/templates/harness-base.nix`, visible from every `agent.nix` + `nix/agent-modules/`, visible from every `agent.nix` (`hyperhive.model`, `hyperhive.allowedRecipients`, `hyperhive.extraMcpServers`, `hyperhive.frontend.*`, `hyperhive.forge.*`, `hyperhive.matrix.*`, `hyperhive.gui.*`). diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix new file mode 100644 index 00000000..72451000 --- /dev/null +++ b/nix/host-modules/default.nix @@ -0,0 +1,26 @@ +# The full hyperhive host stack, pulled together in one place — this +# is what the flake exports as `nixosModules.default` (wrapped with +# the package/source wiring; see flake.nix). One import covers +# everything; `services.hyperhive.enable = true` turns the stack on. +# +# 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 = [ + ./hyperhive.nix + ./hive-c0re + ./hive-ci.nix + ./hive-forge + ./hive-gateway + ./hive-matrix.nix + ./hive-network.nix + ./hive-priv.nix + ./hive-tls.nix + ./otel.nix + ./swarm.nix + ]; +} diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix new file mode 100644 index 00000000..d658dd00 --- /dev/null +++ b/nix/host-modules/hive-c0re/default.nix @@ -0,0 +1,218 @@ +# 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, + 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 JSON. 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. + # + # Written to `/etc/hyperhive/serve.json` (managed by + # `environment.etc`) rather than embedded as a store-path argument in + # ExecStart. This keeps ExecStart byte-stable across deploys that only + # change hyperhive module files (gateway, frontend, unrelated nix + # modules) so systemd does NOT restart hive-c0re — and therefore does + # NOT trigger a startup sweep that rebuilds every agent — unless the + # c0re binary itself changes. + serveConfigJson = builtins.toJSON { + hyperhive_flake = cfg.hyperhiveFlake; + hyperhive_docs_flake = cfg.hyperhiveDocs; + nixpkgs_flake = cfg.nixpkgsFlake; + 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; + build_slots = cfg.buildSlots; + }; +in +{ + imports = [ + ./options.nix + ./theme.nix + ]; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ + cfg.package + pkgs.git + # XDG icons + .desktop entries so desktop environments can match + # hyperhive processes to their icon (task managers, CPU monitors, etc.). + cfg.xdgIcons + ]; + + # Serve config at a stable /etc path so hive-c0re's ExecStart + # doesn't embed a volatile store-path argument. See serveConfigJson + # above for the rationale. + environment.etc."hyperhive/serve.json".text = serveConfigJson; + + # 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 [ + cfg.agentBaseToplevel + cfg.managerToplevel + ]; + + # 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 (./hive-priv.nix). + 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)`. + + # 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 the daemon environment + # (./environment.nix) can treat it as non-null. + 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 = 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"; + # 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"; + # Sandboxing. hive-c0re is unprivileged (runs as hive-core, never + # setuid), makes HTTP requests to forge/matrix/Anthropic (keeps INET), + # and delegates all privileged ops to hive-priv via a Unix socket. + # These directives deny the subset of kernel capabilities it + # provably doesn't need without restricting its network or + # filesystem access (RestrictAddressFamilies deferred — needs a + # watched deploy to verify no AF_UNIX/AF_INET gaps in socket paths). + NoNewPrivileges = true; # already runs as unprivileged user + PrivateTmp = true; # uses StateDirectory for tmpfiles, not /tmp + ProtectHome = true; # HOME = /var/lib/hyperhive; no /home/* access needed + # "strict" makes the entire filesystem read-only except for + # StateDirectory (/var/lib/hyperhive) and RuntimeDirectory + # (/run/hyperhive), which systemd keeps writable. No + # ReadWritePaths needed beyond the managed directories because: + # - nix is invoked directly (lifecycle, meta, flake_check), but + # NIX_REMOTE=daemon routes all store writes through the host + # daemon — hive-c0re never writes to /nix itself. + # - flake.lock ops land in the meta worktree under StateDirectory + # (kept writable by systemd). + # - nix build worktrees live in PrivateTmp, not /tmp. + # - /etc writes (bind-mount edits) go through hive-priv via the + # privileged socket; /etc/hyperhive/serve.json is read-only. + ProtectSystem = "strict"; + ProtectKernelTunables = true; # no sysctl writes + ProtectKernelLogs = true; # reads logs via systemd-journal group, not /dev/kmsg + ProtectControlGroups = true; # cgroup writes go through hive-priv, not c0re directly + RestrictNamespaces = true; # namespace creation goes through hive-priv + LockPersonality = true; # no personality changes needed + RestrictRealtime = true; # no real-time scheduling + }; + }; + + # 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"; + }; + }; + }; +} diff --git a/nix/host-modules/hive-c0re/environment.nix b/nix/host-modules/hive-c0re/environment.nix new file mode 100644 index 00000000..f7342f14 --- /dev/null +++ b/nix/host-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/host-modules/hive-c0re/options.nix b/nix/host-modules/hive-c0re/options.nix new file mode 100644 index 00000000..30080d20 --- /dev/null +++ b/nix/host-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/host-modules/hive-c0re/theme.nix b/nix/host-modules/hive-c0re/theme.nix new file mode 100644 index 00000000..7a3c7023 --- /dev/null +++ b/nix/host-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-ci.nix b/nix/host-modules/hive-ci.nix similarity index 98% rename from nix/modules/hive-ci.nix rename to nix/host-modules/hive-ci.nix index 15251720..d4bbfac1 100644 --- a/nix/modules/hive-ci.nix +++ b/nix/host-modules/hive-ci.nix @@ -127,10 +127,10 @@ let fi CORE_TOKEN=$(cat ${coreTokenPath}) # Capture the HTTP status so a stale/invalid core token (401/403) is - # distinguished from a transient forge hiccup. With the old bare - # `curl -sf | jq`, a forge-core-token that's stale for the current - # forge (e.g. after a forge rebuild) 401s and fails silently every - # attempt for the full 60s loop, then exits with a misleading + # distinguished from a transient forge hiccup. A bare + # `curl -sf | jq` would let a forge-core-token that is stale for + # the current forge (e.g. after a forge rebuild) 401 silently on + # every attempt for the full 60s loop and exit with a misleading # "core token absent or forge unreachable" — masking the real cause. # Fail fast + loudly on 401/403 so the failure mode is legible and # the operator/hive-c0re knows to re-mint forge-core-token. diff --git a/nix/modules/hive-forge.nix b/nix/host-modules/hive-forge/default.nix similarity index 94% rename from nix/modules/hive-forge.nix rename to nix/host-modules/hive-forge/default.nix index 8178e196..0bdab6f6 100644 --- a/nix/modules/hive-forge.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -12,25 +12,20 @@ let # ROOT_URL forgejo advertises in clone links + outbound URLs. When # served behind the gateway, `cfg.domain` doubles as both the # forgejo `DOMAIN` setting AND the gateway vhost server-name, so - # ROOT_URL just uses it directly (dropping the port suffix on the - # canonical port for the scheme — 80 for http, 443 for https). The - # gateway always terminates TLS now — self-signed is the implicit floor - # when neither `tls.certDir` nor ACME is configured — so behind the - # gateway the forge is always advertised over `https` on `httpsPort`. - # When direct (gateway off or `behindGateway = false`), keep the + # ROOT_URL just uses it directly. The gateway always terminates TLS + # (self-signed is the implicit floor when neither `tls.certDir` nor + # ACME is configured), so behind the gateway the forge is always + # advertised over `https` on `httpsPort` — the canonical 443 elides + # the port suffix. When direct (`behindGateway = false`), keep the # host:httpPort shape so direct browser access still produces correct # links. Operators can still override via `cfg.rootUrl` for bespoke # shapes. - gatewayTls = true; defaultRootUrl = if cfg.behindGateway then let - scheme = if gatewayTls then "https" else "http"; - port = if gatewayTls then gatewayCfg.httpsPort else gatewayCfg.port; - canonicalPort = if gatewayTls then 443 else 80; - portSuffix = if port == canonicalPort then "" else ":${toString port}"; + portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}"; in - "${scheme}://${cfg.domain}${portSuffix}/" + "https://${cfg.domain}${portSuffix}/" else "http://${cfg.domain}:${toString cfg.httpPort}/"; effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl; @@ -318,16 +313,16 @@ in staticRootWithTheme = pkgs.runCommand "forgejo-static-with-theme" { } '' cp -r --no-preserve=mode,ownership ${cfg.package.data}/. $out/ mkdir -p $out/public/assets/css - cp ${../forge-theme/theme-catppuccin-vibec0re.css} \ + cp ${./theme-catppuccin-vibec0re.css} \ $out/public/assets/css/theme-catppuccin-vibec0re.css # Replace the default Forgejo logo + favicon with the hyperhive # mark. Files in public/assets/img/ are served before built-ins. mkdir -p $out/public/assets/img - cp ${../../branding/hyperhive.svg} $out/public/assets/img/logo.svg - cp ${../../branding/hyperhive.svg} $out/public/assets/img/favicon.svg - cp ${../../branding/hyperhive.png} $out/public/assets/img/logo.png - cp ${../../branding/hyperhive.png} $out/public/assets/img/favicon.png - cp ${../../branding/hyperhive.png} $out/public/assets/img/avatar_default.png + cp ${../../../branding/hyperhive.svg} $out/public/assets/img/logo.svg + cp ${../../../branding/hyperhive.svg} $out/public/assets/img/favicon.svg + cp ${../../../branding/hyperhive.png} $out/public/assets/img/logo.png + cp ${../../../branding/hyperhive.png} $out/public/assets/img/favicon.png + cp ${../../../branding/hyperhive.png} $out/public/assets/img/avatar_default.png ''; in { diff --git a/nix/forge-theme/theme-catppuccin-vibec0re.css b/nix/host-modules/hive-forge/theme-catppuccin-vibec0re.css similarity index 100% rename from nix/forge-theme/theme-catppuccin-vibec0re.css rename to nix/host-modules/hive-forge/theme-catppuccin-vibec0re.css diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix new file mode 100644 index 00000000..24555a1f --- /dev/null +++ b/nix/host-modules/hive-gateway/default.nix @@ -0,0 +1,291 @@ +# Single nginx in front of every hyperhive web surface — dashboard, +# per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known +# delegations — plus the hive-internal dnsmasq resolver, co-located in +# the same `hive-gateway` container (shared host netns, state-free). +# Full vhost map + discovery flow + design rationale in +# `docs/gateway.md`. Layout: ./options.nix (option declarations), +# ./vhosts.nix (the nginx virtual-host tree), ./error-pages.nix +# (styled static pages), ./dnsmasq.nix (resolver + DHCP config). +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.gateway; + hyperhiveDomain = config.services.hyperhive.domain; + matrixCfg = config.services.hyperhive.matrix; + forgeCfg = config.services.hyperhive.forge; + networkCfg = config.services.hyperhive.network; + + # Dashboard SPA dist, static-served by nginx. Read in OUTER scope so + # `config` is the host's (inside the container block it'd be the + # container's). + dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; + + # Self-signed TLS is the implicit floor: when neither an operator cert + # (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway + # generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no + # explicit toggle and no http-only mode — matrix discovery requires https, + # so the gateway always terminates TLS. + # `cfg.useSelfSigned` (options.nix) is the derived single source of truth. + useSelfSigned = cfg.useSelfSigned; +in +{ + imports = [ ./options.nix ]; + + config = lib.mkIf config.services.hyperhive.enable { + assertions = [ + { + assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); + message = '' + services.hyperhive.gateway.tls.acme.enable = true and + tls.certDir are mutually exclusive. Pick one TLS mode. + ''; + } + { + assertion = !cfg.tls.acme.enable || cfg.tls.acme.email != null; + message = '' + services.hyperhive.gateway.tls.acme.enable = true requires + services.hyperhive.gateway.tls.acme.email to be set — + Let's Encrypt needs a contact address for the ACME account. + ''; + } + ]; + + # Ensure bind-mount sources exist at host boot before the gateway + # container's first start. nspawn would auto-create missing dirs; + # tmpfiles rules make the intent explicit and cover the fresh-boot + # window before c0re has run. + # + # /run/hive-agent — per-agent UDS socket dir, written by c0re's + # set_nspawn_flags when agents start. Owned by `hive-core` (the + # unprivileged coordinator user): c0re does the + # `create_dir_all(/run/hive-agent/)` itself, so a root-owned + # parent would EACCES on the very first agent create on a fresh host + # (hive-priv only chowns the subdir afterwards, it doesn't make it). + # /var/lib/hyperhive — hyperhive state dir, created by c0re on + # first run. Also pre-seed agents.conf with an empty-but-valid + # header so nginx can start + include the file before c0re writes + # its first real content (f = create-if-absent, no overwrite). + systemd.tmpfiles.rules = [ + "d /run/hive-agent 0755 hive-core hive-core - -" + "d /var/lib/hyperhive 0755 root root - -" + "d /var/lib/hyperhive/gateway 0755 root root - -" + "f /var/lib/hyperhive/gateway/agents.conf 0644 root root - # Generated by hive-c0re — do not edit.\n" + # Pre-create the htpasswd file so nginx can open it even before any + # users have been added. An empty file causes all auth checks to + # return 401 (no valid credentials), which is the correct no-users + # behaviour. `f` = create-if-absent, never overwrite. + "f /var/lib/hyperhive/gateway/gateway.htpasswd 0644 root root - -" + ]; + + containers.hive-gateway = { + autoStart = true; + ephemeral = false; + # Share host netns — nginx then binds host-level ports directly, + # `localhost` upstream resolution reaches hive-c0re without any + # port-forward dance, and the firewall config below is the only + # layer that matters. + privateNetwork = false; + # dnsmasq refuses to start once a dhcp-range is configured unless it + # holds CAP_NET_ADMIN (DNS-only mode doesn't need it). Private-network + # containers retain NET_ADMIN implicitly, but this container shares the + # host netns (above), so nspawn's default bounding set drops it — grant + # it explicitly. Note this is NET_ADMIN over the *host* netns; the + # gateway container is trusted infra (it already terminates TLS and + # fronts every vhost), so no new trust boundary is crossed. + additionalCapabilities = [ "CAP_NET_ADMIN" ]; + # Bind-mount the per-agent socket dir so nginx inside the gateway + # container can `connect(2)` to the UDS upstreams. + # Read-only (we just connect; harness writes the socket inside + # the agent's own container). Host-side dir is pre-created by a + # tmpfiles rule so nspawn always finds a source at boot. + bindMounts."/run/hive-agent" = { + hostPath = "/run/hive-agent"; + isReadOnly = true; + }; + # Bind-mount ONLY the gateway-specific subdir of the hyperhive + # state dir. Scoped to /var/lib/hyperhive/gateway/ rather than + # the whole parent so the gateway container can't read forge + # tokens or other files that may live at the parent level. + # c0re writes agents.conf under this subdir and triggers an nginx + # reload from the host via systemd-run after each write. + # Pre-created by a tmpfiles rule. + bindMounts."/run/hive-state" = { + hostPath = "/var/lib/hyperhive/gateway"; + isReadOnly = true; + }; + # Operator-provided TLS cert dir (e.g. Let's Encrypt / ACME). + # Only mounted when `tls.certDir` is set; when it is, the self-signed + # floor is off (so the `/run/hive-ca` mount below is absent). nginx + # reads cert + key from `/run/hive-tls/` and ``. + bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) { + hostPath = cfg.tls.certDir; + isReadOnly = true; + }; + # Self-signed mode: the host `hive-tls-ca` service generates a hive + # CA + a leaf signed by it under `services.hyperhive.tls.stateDir`. + # Bind-mount that dir read-only so the in-container import service + # (below) can copy the leaf into nginx's state dir with the right + # owner/mode. Source files: `gateway.pem` + `gateway-key.pem`. + bindMounts."/run/hive-ca" = lib.mkIf useSelfSigned { + hostPath = config.services.hyperhive.tls.stateDir; + isReadOnly = true; + }; + config = + { pkgs, ... }: + let + tlsDir = "/var/lib/hive-gateway/tls"; + # TLS cert + key paths inside the container. + # - self-signed (default): imported hive-CA-signed leaf in the + # persistent state dir. + # - tls.certDir set: operator-provided cert bind-mounted at /run/hive-tls. + tlsCert = + if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem"; + tlsKey = + if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem"; + nginxTree = import ./vhosts.nix { + inherit + lib + cfg + forgeCfg + matrixCfg + hyperhiveDomain + dashboardDist + tlsCert + tlsKey + ; + errorPages = import ./error-pages.nix { inherit pkgs; }; + }; + in + { + system.stateVersion = "26.05"; + + # Keep the host-copied /etc/resolv.conf intact. nixos-container + # copies the host's file in at every container start, but + # resolvconf's host-tracking mode then regenerates it — to an + # empty file, since the host file doesn't cross the boundary + # after start (the same failure the matrix container hit). + # With resolvconf off, nothing touches the copy: nginx's own + # lookups (ACME) and dnsmasq's follow-the-host upstream + # default (see ./dnsmasq.nix) both read the host's resolvers. + networking.resolvconf.enable = false; + + # ACME (Let's Encrypt) integration. nginx vhosts set + # `enableACME = true` via the vhost builder; this provides the + # shared ACME config (acceptTerms + email). The gateway + # container has shared host netns so outbound ACME requests + # work without extra routing config. Certs are stored in the + # container's persistent state (`ephemeral = false`). + security.acme = lib.mkIf cfg.tls.acme.enable { + acceptTerms = true; + defaults.email = cfg.tls.acme.email; + }; + + # Import the host-generated leaf cert before nginx starts. + # The hive CA + gateway leaf are generated on the HOST by + # `hive-tls-ca` (see `hive-tls.nix`) and bind-mounted read-only + # at `/run/hive-ca`; this service copies the leaf into nginx's + # state dir with the owner/mode nginx needs, rather than reading + # the bind-mount directly (the host key is 0600 root:root and a + # cross-namespace bind-mount can't be relaxed in place). nginx + # `Requires=` this via `requiredBy`, so it refuses to start until + # the copy succeeds. ALWAYS runs (no ConditionPathExists) and is + # idempotent — necessary to reconcile broken state from prior + # failed boots (a 0700 dir from a stale UMask, a truncated copy + # from an interrupted oneshot, etc.). The leaf covers the bare + # hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}` + # so all sub-domains validate under the same cert + the hive CA. + # See `docs/gateway.md` ("Self-signed TLS"). + systemd.services.hive-gateway-self-signed-cert = lib.mkIf useSelfSigned { + description = "Import host-generated TLS leaf for hive-gateway"; + wantedBy = [ "multi-user.target" ]; + before = [ "nginx.service" ]; + requiredBy = [ "nginx.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Pin the journal identity (else it's the `script` store-path wrapper). + SyslogIdentifier = "hive-gateway-self-signed-cert"; + }; + path = [ pkgs.coreutils ]; + script = '' + set -eu + mkdir -p ${tlsDir} + # 0755 on BOTH the cert dir and its parent so the nginx + # user can traverse the full path. The parent + # `/var/lib/hive-gateway` lands at 0700 by default (systemd + # StateDirectory / mkdir umask depending on which service + # created it first), which on its own blocks traversal. + # Re-applied every boot in case a prior run left a tighter + # mode behind. + chmod 0755 ${builtins.dirOf tlsDir} + chmod 0755 ${tlsDir} + # Copy the host leaf in. `install` writes atomically with the + # target mode; run as root (container root == host root, + # privateUsers=false) so the 0600 root:root host key is + # readable. Key ends up root:nginx 0640 so nginx-pre-start + # (which runs `nginx -t` as the nginx user, not root) can + # read it — a 0600 root:root key passes the master load but + # fails the pre-start config test with `BIO_new_file() … + # Permission denied`, blocking the unit. Cert is world-read. + install -m 0644 /run/hive-ca/gateway.pem ${tlsCert} + install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey} + ''; + }; + + # nginx reload is triggered from the HOST side by hive-c0re + # via `systemctl -M hive-gateway reload nginx` after each + # agents.conf write — letting systemd resolve the nginx binary + # path avoids exit-203 EXEC failures. A path unit watching the + # bind-mounted file inside the container does not work: an + # IN_MOVED_TO from an atomic rename on the host does not + # propagate across the nspawn mount-namespace boundary. The + # host-side trigger is the correct approach. + + services.nginx = { + enable = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + recommendedGzipSettings = true; + recommendedOptimisation = true; + inherit (nginxTree) appendHttpConfig virtualHosts; + }; + + services.dnsmasq = import ./dnsmasq.nix { + inherit + lib + networkCfg + forgeCfg + matrixCfg + hyperhiveDomain + ; + }; + }; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + cfg.port + # The gateway always terminates TLS (self-signed floor), so + # `httpsPort` is always opened alongside the plain-http `port`. + cfg.httpsPort + ]; + }; + + # `/etc/hosts` entries for local dev — bare hive domain + any + # sub-domain modules that are on. `lib.unique` dedupes if any + # sub-domain happens to equal another. See `docs/gateway.md` + # ("Local dev"). + networking.hosts = lib.mkIf cfg.localHostsEntry { + "127.0.0.1" = lib.unique ( + [ hyperhiveDomain ] + ++ lib.optional (config.services.hyperhive.forge.behindGateway or false + ) config.services.hyperhive.forge.domain + ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost + ); + }; + }; +} diff --git a/nix/host-modules/hive-gateway/dnsmasq.nix b/nix/host-modules/hive-gateway/dnsmasq.nix new file mode 100644 index 00000000..6852df5a --- /dev/null +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -0,0 +1,61 @@ +# Hive-internal DNS resolver + DHCP, co-located in the gateway +# container — single front-door for both DNS and HTTP, saves a +# sibling container. Listens on the bridge interface from +# `services.hyperhive.network`; authoritative for the hive domain + +# sub-domains, forwards everything else upstream. Returns the +# `services.dnsmasq` value for the container config (see +# ./default.nix); the DHCP pool bounds are computed by hive-network. +{ + lib, + networkCfg, + forgeCfg, + matrixCfg, + hyperhiveDomain, +}: +{ + enable = true; + # Don't substitute the container's /etc/resolv.conf — the gateway + # uses the host's resolver for its own outbound traffic; dnsmasq is + # purely for incoming queries from agent containers. + resolveLocalQueries = false; + settings = { + # Bind only on the bridge interface (and lo for health-checks). + # Outside hosts can't even see the listener. + interface = [ + networkCfg.bridgeName + "lo" + ]; + bind-interfaces = true; + port = 53; + # Hive authoritative records — answer queries for the hive domain + # + its sub-domains with the bridge IP, where nginx is reachable + # from every container netns. + # + # The forge / matrix entries are redundant in the common case + # where `forge.domain` / `matrix.gatewayHost` are sub-domains of + # `hyperhive.domain` — dnsmasq's `//` rule already matches + # sub-domains. Kept explicit because operators can override either + # to a cross-domain hostname (e.g. `forge.domain = + # "git.example.com"`); listing them explicitly keeps that case + # routed without needing an extra config block. + address = [ + "/${hyperhiveDomain}/${networkCfg.bridgeIp}" + ] + ++ lib.optional ((forgeCfg.behindGateway or false)) "/${forgeCfg.domain}/${networkCfg.bridgeIp}" + ++ lib.optional ( + matrixCfg.enable && matrixCfg.gatewayHost != null + ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"; + # DHCP pool covering all usable host addresses on the bridge + # subnet — bounds computed by hive-network.nix from + # bridgeIp/bridgePrefixLength. All containers (agents and service + # containers such as hive-ci) receive their IPs dynamically. + dhcp-range = "${networkCfg.dhcpRangeStart},${networkCfg.dhcpRangeEnd},1h"; + dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases"; + # No explicit upstream: non-hive queries follow dnsmasq's + # resolv.conf default — the gateway container's `/etc/resolv.conf`, + # which nixos-container copies from the host at every start, so the + # hive always uses the host's resolvers. resolvconf is disabled in + # the container (see ./default.nix) so nothing regenerates that + # copy. + }; +} diff --git a/nix/host-modules/hive-gateway/error-pages.nix b/nix/host-modules/hive-gateway/error-pages.nix new file mode 100644 index 00000000..3285632c --- /dev/null +++ b/nix/host-modules/hive-gateway/error-pages.nix @@ -0,0 +1,71 @@ +# Static error/help pages the gateway serves for routes it has +# special-cased, all rendered from one Catppuccin-styled template. +# Useful pages instead of nginx's default 404/502 — see +# `docs/gateway.md::Per-agent error pages` for the design rationale + +# page-vs-status semantics. Consumed by ./vhosts.nix. +{ pkgs }: +let + mkPage = + { + name, + title, + accent, + body, + }: + pkgs.writeText "hive-gateway-${name}.html" '' + + + + + ${title} ◆ hyperhive + + + +

◆ ${title}

+ ${body} + + + ''; +in +{ + notFound = mkPage { + name = "agent-not-found"; + title = "agent not found"; + accent = "#cba6f7"; + body = '' +

No agent matches the requested /agent/<name>/ path on this hive.

+

Operator: check the agent name in the dashboard.

+ ''; + }; + + unreachable = mkPage { + name = "agent-unreachable"; + title = "agent unreachable"; + accent = "#f9e2af"; + body = '' +

The agent's harness web server isn't responding. Container restarting, or the agent crashed.

+

Operator: dashboard → check the container status / journal; the page will recover on retry once the harness is back up.

+ ''; + }; + + unauthorized = mkPage { + name = "unauthorized"; + title = "unauthorized"; + accent = "#f38ba8"; + body = '' +

This hive is protected by HTTP Basic auth. Valid credentials are required.

+

Operator: add a user with hivectl gateway create-user:

+
hivectl gateway create-user \
+      <username> --password-stdin
+

Then reload your browser and enter the credentials when prompted.

+ ''; + }; +} diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix new file mode 100644 index 00000000..211a8c3a --- /dev/null +++ b/nix/host-modules/hive-gateway/options.nix @@ -0,0 +1,299 @@ +# Option declarations for `services.hyperhive.gateway.*`. The gateway +# is always run alongside hyperhive (it's the single nginx in front of +# every surface and the only thing exposed to the outside); there is +# no enable flag. An operator who wants their own reverse proxy in +# front points it at the gateway's `port`. +{ + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.gateway; +in +{ + imports = [ + (lib.mkRemovedOptionModule [ "services" "hyperhive" "gateway" "selfSignedTls" ] '' + Self-signed TLS is the implicit default whenever neither + tls.certDir nor tls.acme is configured, and there is no http-only + mode. Remove the setting; configure `tls.certDir` or `tls.acme` + to override the self-signed default. + '') + ]; + + options.services.hyperhive.gateway = { + port = lib.mkOption { + type = lib.types.port; + default = 80; + example = 8080; + description = '' + TCP port the gateway listens on. Default 80 (canonical web + port). nginx inside the container binds <1024 because the + container's init runs as root; if 80 is already taken on the + host (existing nginx, traefik, etc.) override to an unused + port like 8080 or move the conflicting service. + ''; + }; + + upstreamHost = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = '' + Host the gateway proxies non-static requests to. Defaults to + `127.0.0.1` because the gateway container shares the host + netns, so loopback resolves directly to hive-c0re. + ''; + }; + + upstreamPort = lib.mkOption { + type = lib.types.port; + default = 7000; + description = '' + TCP port the gateway proxies non-static requests to. Defaults + to `7000` (hive-c0re's out-of-the-box dashboard port). Operators + who change `services.hyperhive.c0re.dashboardPort` should set + `upstreamPort` to match — kept as a hardcoded default rather + than a cross-reference to keep this module's options eval + independent of c0re's option tree shape. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Open `port` in the host firewall. Off by default (secure-by-default). + Flip to `true` to expose the gateway to + the operator's browser / external clients — required for any + out-of-host reach, since the agents themselves talk to + hive-c0re via the per-agent unix sockets and don't need the + nginx vhost. Leave off when running behind another reverse + proxy (e.g. caddy / traefik on the host) that handles TLS + termination + forwards to `port`. + + **Note**: this used to default to `true`. Add + `services.hyperhive.gateway.openFirewall = true;` to your host + config if external reach stopped working after a recent upgrade. + ''; + }; + + localHostsEntry = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Add an `/etc/hosts` entry mapping `services.hyperhive.domain` + to `127.0.0.1` on the host. Useful for local deployments + + tests where there's no real DNS for `services.hyperhive.domain` + but the operator (or browser-based tests) want to hit + `http://''${services.hyperhive.domain}` to exercise the + gateway shape. Off by default — operators running with real + DNS shouldn't have a stale `/etc/hosts` entry sticking + around. Requires `services.hyperhive.domain` to be set. + ''; + }; + + useSelfSigned = lib.mkOption { + type = lib.types.bool; + internal = true; + readOnly = true; + default = cfg.tls.certDir == null && !cfg.tls.acme.enable; + defaultText = lib.literalExpression "tls.certDir == null && !tls.acme.enable"; + description = '' + Read-only derived flag: `true` when the gateway serves the + self-signed (hive-CA-signed) leaf — i.e. neither `tls.certDir` nor + `tls.acme.enable` is configured. Single source of truth for the + self-signed condition; consumed by the `hive-tls` and `hive-ci` + modules so the derivation isn't duplicated. Internal — not meant to + be set by operators (use `tls.certDir` / `tls.acme` to override the + self-signed default). + ''; + }; + + httpsPort = lib.mkOption { + type = lib.types.port; + default = 443; + example = 8443; + description = '' + TCP port for the TLS-terminated vhosts. Default 443. The gateway + always terminates TLS (self-signed is the implicit floor when no + `tls.certDir` / ACME is configured), so this port is always active + alongside the plain-http `port`. + ''; + }; + + tls = { + certDir = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = lib.literalExpression ''"/var/lib/acme/example.com"''; + description = '' + Path to a host directory containing a TLS certificate and + private key for nginx. When set, nginx listens on `httpsPort` + and uses this cert, overriding the self-signed default — the + auto-generated hive-CA-signed leaf is skipped entirely. + + The directory is bind-mounted read-only into the gateway + container at `/run/hive-tls/`. nginx reads + `/` and `/`. + Default filenames (`cert.pem` / `key.pem`) match the output + layout of nixpkgs's `security.acme` module. + + Typical ACME setup: + ```nix + security.acme.certs."example.com" = { ... }; + services.hyperhive.gateway.tls.certDir = + config.security.acme.certs."example.com".directory; + ``` + + When using an external CA cert, peer hives can declare this + hive in `services.hyperhive.swarm.peers` without + `certFingerprint` — the standard CA bundle validates. + + Mutual exclusion with `tls.acme.enable` — set one or the other, + not both. + ''; + }; + + certName = lib.mkOption { + type = lib.types.str; + default = "cert.pem"; + description = '' + Filename of the TLS certificate within `tls.certDir`. Defaults + to `cert.pem` which matches nixpkgs's `security.acme` output. + ''; + }; + + keyName = lib.mkOption { + type = lib.types.str; + default = "key.pem"; + description = '' + Filename of the TLS private key within `tls.certDir`. Defaults + to `key.pem` which matches nixpkgs's `security.acme` output. + ''; + }; + + acme = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Let nginx inside the gateway container obtain and renew TLS + certificates automatically via ACME (Let's Encrypt). When + enabled, each vhost calls out to Let's Encrypt using the + HTTP-01 challenge on `port` (default 80) and stores certs + inside the gateway container's persistent state dir. + + Requirements: + - `services.hyperhive.domain` must be set and publicly + DNS-resolvable to this host. + - `services.hyperhive.gateway.openFirewall = true` so + Let's Encrypt can reach `/.well-known/acme-challenge/`. + - `tls.acme.email` must be set (ACME account contact). + + Mutual exclusion: `tls.certDir` set together with + `tls.acme.enable = true` fails at eval — pick one TLS source. + + Typical setup: + ```nix + services.hyperhive.gateway = { + openFirewall = true; + tls.acme = { + enable = true; + email = "admin@example.com"; + }; + }; + ``` + + After enabling, peer hives can omit `certFingerprint` in + `swarm.peers` — Let's Encrypt certs are CA-trusted + by default. + ''; + }; + + email = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "admin@example.com"; + description = '' + Email address for the ACME account registration with + Let's Encrypt. Required when `tls.acme.enable = true`. + Let's Encrypt sends expiry warnings to this address. + ''; + }; + }; + }; + + auth = { + enable = lib.mkEnableOption '' + HTTP basic auth on the gateway using an htpasswd file. When + enabled, every request to the gateway's main vhost requires a + valid username and password. nginx's built-in `auth_basic` + module validates credentials against + `/var/lib/hyperhive/gateway/gateway.htpasswd` on the host + (exposed as `/run/hive-state/gateway.htpasswd` inside the + container via the existing gateway state bind-mount). Off by default. + + Manage users with `hivectl gateway create-user`, `delete-user`, + and `list-users` — see `hivectl gateway --help` for usage. + The htpasswd file is created automatically when auth is enabled; + add at least one user before enabling to avoid locking everyone out. + ''; + + realm = lib.mkOption { + type = lib.types.strMatching "[^\"$]*"; + default = "hyperhive"; + example = "my-hive"; + description = '' + HTTP Basic auth `realm` value sent in the `WWW-Authenticate` + header when credentials are absent or rejected. Must not + contain `"` or `$` (nginx string metacharacters). + ''; + }; + }; + + hsts = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Add `Strict-Transport-Security` to all gateway vhosts. + + Disabled by default: HSTS pins HTTPS in the browser's HSTS + preload list; enabling it on a deployment that later loses TLS + will lock browsers out until the max-age expires. Only enable + this when you are certain TLS is permanent. + + The gateway always terminates TLS (self-signed floor), so + HSTS is always served over https when enabled — but mind the + warning above: HSTS pins https in the browser, so only enable it + when TLS is permanent for this deployment. + ''; + }; + + maxAge = lib.mkOption { + type = lib.types.ints.positive; + default = 31536000; + example = 86400; + description = '' + Value for the `max-age` directive in seconds. + Default: 31536000 (1 year), which is the value required for + HSTS preload list submission. Use a shorter value (e.g. 86400) + while testing so browsers forget the pin quickly. + ''; + }; + + includeSubDomains = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to include `includeSubDomains` in the HSTS header. + Only disable this if the gateway host has sub-domains that + intentionally serve plain HTTP. + ''; + }; + }; + }; +} diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix new file mode 100644 index 00000000..ee5531aa --- /dev/null +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -0,0 +1,347 @@ +# nginx virtual-host tree for the gateway container: the `_` default +# server (dashboard, per-agent routing, matrix discovery), the forge +# and matrix sub-domain vhosts, and the Accept-header SPA map for the +# matrix GUI. Pure function — called from ./default.nix inside the +# container config with the outer-scope config values as arguments; +# returns `{ virtualHosts, appendHttpConfig }`. +{ + lib, + cfg, # services.hyperhive.gateway + forgeCfg, + matrixCfg, + hyperhiveDomain, + dashboardDist, + errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized } + tlsCert, + tlsKey, +}: +let + # The gateway always terminates TLS: self-signed is the implicit + # floor when neither `tls.certDir` nor ACME is set, so there is no + # http-only mode. Listen addresses every vhost shares — plain http + # on `cfg.port` plus TLS on `cfg.httpsPort`. See `docs/gateway.md` + # ("TLS modes"). + vhostListen = [ + { + addr = "0.0.0.0"; + port = cfg.port; + } + { + addr = "0.0.0.0"; + port = cfg.httpsPort; + ssl = true; + } + ]; + # nixos `services.nginx.virtualHosts.` ssl attrs merged + # into each vhost. For ACME mode: `enableACME` + `addSSL` — + # NixOS's ACME integration manages the cert lifecycle and sets + # ssl_certificate automatically. For self-signed / certDir: + # explicit cert paths. + vhostTls = + if cfg.tls.acme.enable then + { + addSSL = true; + enableACME = true; + } + else + { + addSSL = true; + sslCertificate = tlsCert; + sslCertificateKey = tlsKey; + }; + + # Public-facing scheme + port-suffix for URLs the gateway + # mints into responses (well-known JSON, the deprecated + # `/matrix/*` 301 redirect, future absolute-URL needs): + # always `https://` (matrix-spec compliance) — the canonical + # 443 elides the port. See `docs/gateway.md` ("Self-signed TLS"). + publicScheme = "https"; + publicPort = cfg.httpsPort; + publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}"; + + # Security headers added at the server scope on every vhost. + # nginx's add_header inheritance rule: a location that defines its + # own add_header does NOT inherit the server-level ones. Any + # location with its own add_header (e.g. CORS on /.well-known or + # /_matrix/) must repeat the security headers explicitly — see those + # locations below. HTML-serving and proxy locations that carry no + # add_header of their own pick these up from the server scope + # automatically. + hstsDirectives = lib.concatStringsSep "; " ( + [ "max-age=${toString cfg.hsts.maxAge}" ] + ++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains" + ); + securityHeaders = '' + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + ${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''} + ''; + + # Forge sub-domain vhost. `server_name = forge.domain`, proxies + # all `/` → forgejo. Tuned for git: `client_max_body_size 1G`, + # `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on + # `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the + # forge isn't behind the gateway. + forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) { + "${forgeCfg.domain}" = vhostTls // { + listen = vhostListen; + extraConfig = securityHeaders; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + client_max_body_size 1G; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + ''; + }; + }; + }; + + # Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`. + # `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll + # timeout). `/` serves fluffychat or 404 if GUI off. nginx + # longer-prefix-wins puts `/_matrix/` ahead of `/`. See + # `docs/gateway.md`. Empty attrset when matrix has no gateway host. + matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) { + "${matrixCfg.gatewayHost}" = vhostTls // { + listen = vhostListen; + extraConfig = securityHeaders; + locations = { + "/_matrix/" = { + proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + client_max_body_size 50M; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + ${securityHeaders} + add_header Access-Control-Allow-Origin *; + ''; + }; + } + // lib.optionalAttrs (matrixCfg.gui.enable) ( + { + # fluffychat at sub-domain root, SPA-fallback via + # the Accept-header `$matrix_spa_target` map. + "/" = { + alias = "${matrixCfg.gui.package}/"; + extraConfig = '' + try_files $uri $uri/ $matrix_spa_target =404; + ''; + }; + } + // { + # FluffyChat boot-config pre-fill so the client's + # `.well-known/matrix/client` lookup hits the + # right delegation endpoint. `domain` is required, so + # this is always present. + "= /config.json" = { + extraConfig = '' + default_type application/json; + return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; + ''; + }; + } + ) + // lib.optionalAttrs (!matrixCfg.gui.enable) { + "/" = { + return = "404"; + }; + }; + }; + }; + + # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link + # shim during the fluffychat sub-domain move). See `docs/gateway.md`. + matrixRedirectLocations = + lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null) + ( + let + target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"; + in + { + "/matrix/" = { + extraConfig = '' + rewrite ^/matrix/(.*)$ ${target}/$1 permanent; + ''; + }; + } + ); + + # `.well-known/matrix/{client,server}` discovery JSON. Points + # clients at `matrixCfg.gatewayHost` when set; falls back to direct + # `:`. CORS `*` per matrix spec. The `m.server` + # port-8448 carve-out is documented inline. See `docs/gateway.md`. + wellKnownLocations = lib.optionalAttrs matrixCfg.enable ( + let + clientBaseUrl = + if matrixCfg.gatewayHost != null then + "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}" + else + "${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}"; + # `m.server` is NOT a URL: per the matrix server-server spec + # (Resolving Server Names) a delegated host with NO port resolves + # to the federation default 8448 (after the SRV check) — the + # https-implies-443 rule does NOT apply here. So the port must be + # explicit even when it's the HTTPS default; `publicPortSuffix` + # (which drops :443) is right for the client base_url above but + # wrong for federation delegation. Without this, peers federate to + # :8448 (closed) while the endpoint actually lives on + # the gateway's 443 vhost. See docs/gateway.md discovery flow. + serverHostPort = + if matrixCfg.gatewayHost != null then + "${matrixCfg.gatewayHost}:${toString publicPort}" + else + "${hyperhiveDomain}:${toString matrixCfg.httpPort}"; + in + { + "= /.well-known/matrix/client" = { + extraConfig = '' + default_type application/json; + ${securityHeaders} + add_header Access-Control-Allow-Origin *; + return 200 '{"m.homeserver":{"base_url":"${clientBaseUrl}"}}'; + ''; + }; + "= /.well-known/matrix/server" = { + extraConfig = '' + default_type application/json; + return 200 '{"m.server":"${serverHostPort}"}'; + ''; + }; + } + ); + + # `/agent/` catch-all 404 + the two internal error-page targets it + # points at. Per-agent `location /agent//` blocks live in the + # runtime-generated `/run/hive-state/agents.conf` (included via + # `extraConfig` on the vhost); nginx longest-prefix-match makes a + # real `/agent//` beat this catch-all. `internal` keeps the + # error pages reachable only through nginx's error handling. + agentLocations = { + "/agent/" = { + extraConfig = '' + error_page 404 = /__hive_agent_not_found; + return 404; + ''; + }; + "= /__hive_agent_not_found" = { + extraConfig = '' + internal; + alias ${errorPages.notFound}; + default_type text/html; + ''; + }; + "= /__hive_agent_unreachable" = { + extraConfig = '' + internal; + alias ${errorPages.unreachable}; + default_type text/html; + ''; + }; + }; + + # Shared auth block — separate locations don't inherit auth_basic, so + # each dashboard location (`/`, `/api/`) needs it or that surface is + # unauthed. `/webhook/` is intentionally excluded: Forgejo cannot + # send HTTP Basic credentials with webhook deliveries, and the HMAC + # secret (`X-Hub-Signature-256`) protects those endpoints instead. + dashboardAuth = lib.optionalString cfg.auth.enable '' + auth_basic "${cfg.auth.realm}"; + auth_basic_user_file /run/hive-state/gateway.htpasswd; + # `=401` keeps the status 401 so the login dialog shows; the + # internal page explains `hivectl gateway create-user`. + error_page 401 =401 /__hive_auth_unauthorized; + ''; + + # Dashboard: nginx static-serves the dist, c0re is API-only. Routing + # is by PATH, never content-type. c0re serves exactly two prefixes — + # `/api/` (all dashboard data + actions + the SSE streams) and + # `/webhook/` (knowledge push + config-PR approval triggers, HMAC- + # guarded) — so those proxy to c0re and everything else serves the + # dist with an SPA fallback to index.html. Path routing is + # deterministic where an Accept-header split would make the SAME url + # behave differently by content-type (e.g. `/api/state` fetched with + # `Accept: text/html` wrongly getting index.html). A new top-level + # c0re route prefix (beyond /api + /webhook) needs a matching + # location added here. + dashboardProxyLocation = { + "/" = { + root = dashboardDist; + extraConfig = '' + try_files $uri /index.html; + ${dashboardAuth} + ''; + }; + "/api/" = { + proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; + proxyWebsockets = true; + extraConfig = '' + # off + 1d keep the SSE streams (/api/dashboard/stream, + # /api/build-logs/id/{id}/stream) live. + proxy_buffering off; + proxy_read_timeout 1d; + ${dashboardAuth} + ''; + }; + "/webhook/" = { + # No dashboardAuth here: Forgejo cannot send HTTP Basic credentials + # with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth + # for these endpoints; hive-c0re verifies it in the handler. + proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; + }; + }; +in +{ + # Accept-header SPA map for the matrix GUI only (see docs/gateway.md + # "SPA fallback"): text/html → index.html, else a sentinel so + # try_files falls through to 404. The dashboard doesn't use an + # Accept-header map — it routes by path (see dashboardProxyLocation). + appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) '' + map $http_accept $matrix_spa_target { + default "/__matrix_spa_no_html_fallback"; + "~*text/html" "/index.html"; + } + ''; + + virtualHosts = { + "_" = vhostTls // { + listen = vhostListen; + locations = + matrixRedirectLocations + // wellKnownLocations + // agentLocations + // dashboardProxyLocation + // lib.optionalAttrs cfg.auth.enable { + # Internal-only target for the 401 error_page above. + # `internal` prevents direct client access; `alias` serves + # the pre-built HTML from the Nix store. + "= /__hive_auth_unauthorized" = { + extraConfig = '' + internal; + alias ${errorPages.unauthorized}; + default_type text/html; + ''; + }; + }; + # Per-agent location blocks, generated at runtime by + # hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf + # on the host. The bind-mount at /run/hive-state/ exposes + # that file here. nginx parses `include` at config-load + # time so a reload (triggered by c0re via systemd-run + # after each agents.conf write) picks up new or removed + # agents without a nixos-rebuild. nginx's longest-prefix- + # match rule ensures `/agent//` from this file beats + # the `/agent/` catch-all above. + extraConfig = securityHeaders + '' + include /run/hive-state/agents.conf; + ''; + }; + } + // forgeVhost + // matrixVhost; +} diff --git a/nix/modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix similarity index 88% rename from nix/modules/hive-matrix.nix rename to nix/host-modules/hive-matrix.nix index b8a7378a..1dc5d65d 100644 --- a/nix/modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -366,39 +366,31 @@ in # gets the host file across the shared-netns boundary), so it # regenerates an empty file and tuwunel dies at boot. # - # The earlier fix turned host-tracking off and trusted resolvconf - # to honour `networking.nameservers` — but that's a RUNTIME - # resolvconf behaviour, not verifiable at eval time, and it STILL - # came up empty in practice. So take resolvconf out of the - # loop entirely and write a STATIC `/etc/resolv.conf` from - # `bridgeIp` that nothing regenerates. Eval-proven: the generated + # Trusting resolvconf to honour `networking.nameservers` doesn't + # work either — that's a RUNTIME resolvconf behaviour, not + # verifiable at eval time, and it still comes up empty in + # practice. So take resolvconf out of the loop entirely and + # write a STATIC `/etc/resolv.conf` from `bridgeIp` that nothing + # regenerates. Eval-proven: the generated # `environment.etc."resolv.conf".text` is `nameserver `. # This container always shares the host netns # (`privateNetwork = false`), so it reaches `bridgeIp` regardless - # of agent-container isolation. Network module off → inherit the host's - # resolv.conf. See `docs/network.md`. - networking = lib.mkMerge [ - (lib.mkIf networkCfg.enable { - # resolvconf is taken out of the loop entirely; the static - # `environment.etc."resolv.conf"` below is the sole source of - # the resolver file (no `nameservers` — nothing would read it). - useHostResolvConf = lib.mkForce false; - resolvconf.enable = lib.mkForce false; - }) - (lib.mkIf (!networkCfg.enable) { - useHostResolvConf = true; - }) - ]; + # of agent-container isolation. See `docs/network.md`. + networking = { + # resolvconf is taken out of the loop entirely; the static + # `environment.etc."resolv.conf"` below is the sole source of + # the resolver file (no `nameservers` — nothing would read it). + useHostResolvConf = lib.mkForce false; + resolvconf.enable = lib.mkForce false; + }; # resolvconf is disabled above, so write the static resolver file # explicitly — NixOS won't synthesise one from `nameservers` once # resolvconf is off, and this is the file tuwunel parses at boot. - environment.etc = lib.mkIf networkCfg.enable { - "resolv.conf".text = '' - nameserver ${networkCfg.bridgeIp} - options edns0 - ''; - }; + environment.etc."resolv.conf".text = '' + nameserver ${networkCfg.bridgeIp} + options edns0 + ''; services.matrix-tuwunel = { enable = true; @@ -444,21 +436,20 @@ in ]; }; - # When the hive network module is on, the matrix container's resolver - # is the dnsmasq that runs in the gateway container (bound at - # `bridgeIp`). Order the matrix container start after the gateway - # container so the resolver is up before tuwunel's first federation - # lookups. tuwunel boots fine without this — it configures the resolver - # from `/etc/resolv.conf` at startup and only queries on-demand (the - # boot failure this module fixes was an *empty* resolv.conf, a parse - # error, not a connectivity one) — so this is robustness, not a boot - # requirement. Soft `after` ordering (not `requires`) keeps the matrix - # container's lifecycle decoupled from the gateway's. The gateway - # always runs alongside hyperhive, so the gateway container unit always - # exists here. (Declarative `containers.` → `container@.service` — the - # nspawn template NixOS generates, confirmed from the live - # `container@hive-matrix.service` host unit.) - systemd.services."container@hive-matrix".after = lib.mkIf networkCfg.enable [ + # The matrix container's resolver is the dnsmasq that runs in the + # gateway container (bound at `bridgeIp`). Order the matrix + # container start after the gateway container so the resolver is up + # before tuwunel's first federation lookups. tuwunel boots fine + # without this — it configures the resolver from `/etc/resolv.conf` + # at startup and only queries on-demand (the boot failure this + # module guards against is an *empty* resolv.conf, a parse error, + # not a connectivity one) — so this is robustness, not a boot + # requirement. Soft `after` ordering (not `requires`) keeps the + # matrix container's lifecycle decoupled from the gateway's. The + # gateway always runs alongside hyperhive, so the gateway container + # unit always exists here. (Declarative `containers.` → + # `container@.service` — the nspawn template NixOS generates.) + systemd.services."container@hive-matrix".after = [ "container@hive-gateway.service" ]; }; diff --git a/nix/modules/hive-network.nix b/nix/host-modules/hive-network.nix similarity index 67% rename from nix/modules/hive-network.nix rename to nix/host-modules/hive-network.nix index 50bd9a6f..c7f6728e 100644 --- a/nix/modules/hive-network.nix +++ b/nix/host-modules/hive-network.nix @@ -5,34 +5,54 @@ }: let cfg = config.services.hyperhive.network; + + # IPv4 helpers for the DHCP-pool computation below — nix integers + # are 64-bit so all /0-/32 values are safe. + ipToInt = + ip: + builtins.foldl' (acc: x: acc * 256 + x) 0 ( + map lib.strings.toIntBase10 (lib.strings.splitString "." ip) + ); + intToIp = + n: + let + a = n / 16777216; + b = (n - a * 16777216) / 65536; + c = (n - a * 16777216 - b * 65536) / 256; + d = n - a * 16777216 - b * 65536 - c * 256; + in + "${toString a}.${toString b}.${toString c}.${toString d}"; + # 2^n via recursion (nix has no pow builtin). + pow2 = n: if n == 0 then 1 else 2 * (pow2 (n - 1)); + hostCount = pow2 (32 - cfg.bridgePrefixLength); + # Mask off host bits to get the network base address. + networkBase = builtins.bitAnd (ipToInt cfg.bridgeIp) (4294967295 - hostCount + 1); in { # Hive-internal network — host-side bridge + per-agent DNS resolver. # Always active when hyperhive is enabled: agent containers run in # private netns behind the bridge. Full design: docs/network.md. + imports = [ + (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "enable" ] '' + The hive network (bridge + dnsmasq resolver + private-netns + isolation) is always on whenever hyperhive is enabled. Remove the + setting. + '') + (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "isolateContainers" ] '' + Network isolation is the only mode and is always on whenever + hyperhive is enabled; the shared-netns path was removed. Remove + the setting. + '') + (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "upstreamDns" ] '' + The hive resolver always follows the host's resolvers now + (dnsmasq reads the gateway container's /etc/resolv.conf, the + host copy made at container start). Configure upstream DNS on + the host itself instead. + '') + ]; + options.services.hyperhive.network = { - enable = lib.mkOption { - type = lib.types.bool; - default = config.services.hyperhive.enable; - defaultText = lib.literalExpression "config.services.hyperhive.enable"; - example = false; - description = '' - **DEPRECATED — ignored.** The hive network (bridge + dnsmasq - resolver + private-netns isolation) is now always on whenever - hyperhive is enabled; setting this to `false` warns and has no - effect. Retained as a no-op so existing configs eval; will be - removed in a future release. - - The network requires `services.hyperhive.domain` to be set — the - dnsmasq resolver is authoritative for `` and its - sub-domains. A bridge interface (`bridgeName`) appears on the host - with `bridgeIp` assigned, the hive-gateway container runs a dnsmasq - on that IP, and each agent container runs in a private netns with a - veth pair on the bridge. - ''; - }; - bridgeName = lib.mkOption { type = lib.types.str; default = "hive-br0"; @@ -71,26 +91,6 @@ in ''; }; - upstreamDns = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ - "1.1.1.1" - "9.9.9.9" - ]; - example = [ - "192.168.1.1" - "8.8.8.8" - ]; - description = '' - Upstream DNS servers dnsmasq forwards non-hive queries to. - Defaults to Cloudflare + Quad9. Override for operators on - private networks who need a specific resolver (corporate - DNS, pi-hole, etc.). The hive resolver itself stays - authoritative for `` and its sub-domains - regardless of upstream choice. - ''; - }; - exposeHostPorts = lib.mkOption { type = lib.types.listOf lib.types.port; default = [ ]; @@ -117,26 +117,41 @@ in ''; }; - isolateContainers = lib.mkOption { - type = lib.types.bool; - default = true; - example = true; + # DHCP pool covering all usable host addresses on the bridge + # subnet, computed from bridgeIp/bridgePrefixLength: .2 (first + # usable after the .1 gateway) to .(hostCount-2) (last usable + # before broadcast). All containers — agents and service + # containers alike — receive their IPs dynamically from this pool; + # there are no hash-derived static assignments. Consumed by the + # dnsmasq that runs in the gateway container (hive-gateway module). + dhcpRangeStart = lib.mkOption { + type = lib.types.str; + internal = true; + readOnly = true; + default = intToIp (networkBase + 2); + defaultText = lib.literalMD "first usable bridge address after the gateway"; description = '' - **DEPRECATED — ignored.** Network isolation is now the only mode and - is always on whenever hyperhive is enabled; the shared-netns path was - removed. This option is retained as a no-op so existing configs eval; - setting it to `false` warns and has no effect. It will be removed in - a future release. + Read-only computed first address of the bridge DHCP pool. ''; }; + + dhcpRangeEnd = lib.mkOption { + type = lib.types.str; + internal = true; + readOnly = true; + default = intToIp (networkBase + hostCount - 2); + defaultText = lib.literalMD "last usable bridge address before broadcast"; + description = '' + Read-only computed last address of the bridge DHCP pool. + ''; + }; + }; config = lib.mkMerge [ # The hive network + container isolation are unconditional whenever - # hyperhive is enabled: the shared-netns mode was removed, so there is - # one mode (private netns behind the bridge). `network.enable` and - # `isolateContainers` are kept as deprecated no-op options (see the - # warnings block below) so existing configs that set them still eval. + # hyperhive is enabled: the shared-netns mode was removed, so there + # is one mode (private netns behind the bridge). (lib.mkIf config.services.hyperhive.enable { assertions = [ { @@ -224,27 +239,5 @@ in (lib.mkIf (config.services.hyperhive.enable && cfg.exposeHostPorts != [ ]) { networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = cfg.exposeHostPorts; }) - - # Deprecation surface for the removed toggles. Both options are kept so - # existing configs that set them to `true` still eval cleanly; setting - # either to `false` no longer does anything (network + isolation are - # unconditional now), so warn rather than silently ignore. - { - # Only warn when hyperhive itself is enabled — otherwise `cfg.enable` - # defaults to `false` (tracking `hyperhive.enable`) and we'd fire a - # spurious deprecation warning on a host that doesn't run hyperhive. - warnings = lib.optionals config.services.hyperhive.enable ( - lib.optional (!cfg.enable) '' - services.hyperhive.network.enable = false is deprecated and ignored - — the hive network is now always on (private-netns isolation is the - only mode). Remove the setting. - '' - ++ lib.optional (!cfg.isolateContainers) '' - services.hyperhive.network.isolateContainers = false is deprecated - and ignored — network isolation is now the only mode and is always - on. Remove the setting. - '' - ); - } ]; } diff --git a/nix/host-modules/hive-priv.nix b/nix/host-modules/hive-priv.nix new file mode 100644 index 00000000..cf5ca4e2 --- /dev/null +++ b/nix/host-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"; + }; + }; + }; +} diff --git a/nix/modules/hive-tls.nix b/nix/host-modules/hive-tls.nix similarity index 74% rename from nix/modules/hive-tls.nix rename to nix/host-modules/hive-tls.nix index d8e82df7..3a1594c7 100644 --- a/nix/modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -18,23 +18,61 @@ let # module's single source of truth (`gateway.useSelfSigned`): true when # neither an operator cert (`tls.certDir`) nor ACME is set. active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned; + + # The leaf-signing action shared by the boot-time `hive-tls-ca` + # generation and the weekly `hive-tls-resign` renewal: fresh key + + # CSR, SAN ext-file, sign under the (stable) CA, tighten modes. + # Takes the TLS state dir as `$1`; each caller keeps its own + # when-to-sign condition. The leaf covers the bare hive domain plus + # `forge.`, `matrix.` and `*.` so all sub-domains validate + # under the same cert + the hive CA. + signLeafScript = pkgs.writeShellScript "hive-tls-sign-leaf" '' + set -euo pipefail + d="$1" + ca="$d/ca.pem" + cak="$d/ca-key.pem" + leaf="$d/gateway.pem" + leafk="$d/gateway-key.pem" + csr="$(mktemp "$d/gateway.csr.XXXXXX")" + ext="$(mktemp "$d/leaf.ext.XXXXXX")" + trap 'rm -f "$csr" "$ext"' EXIT + + openssl req -newkey rsa:4096 -nodes -sha256 \ + -keyout "$leafk" -out "$csr" \ + -subj "/CN=${domain}" + + # printf (not a heredoc) so the ext-file lines carry no leading + # whitespace once nix has stripped the indented-string indent. + { + printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \ + ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \ + ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} + printf 'basicConstraints=critical,CA:FALSE\n' + printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' + printf 'extendedKeyUsage=serverAuth\n' + } > "$ext" + + openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \ + -CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \ + -extfile "$ext" -out "$leaf" + chmod 0600 "$leafk" + chmod 0644 "$leaf" + ''; in { # Host-side TLS trust root for the self-signed gateway mode. # - # `gateway.selfSignedTls` historically generated a *bare* self-signed - # leaf inside the gateway container at first boot. A bare leaf is its - # own trust anchor, so every regeneration is a new anchor and every - # consumer (agents, federation peers) would have to re-trust on each - # rotation — and a runtime-generated, in-container cert can't be wired - # into an agent's build-time trust store at all. + # A bare self-signed leaf would be its own trust anchor, so every + # regeneration would be a new anchor and every consumer (agents, + # federation peers) would have to re-trust on each rotation — and a + # runtime-generated, in-container cert can't be wired into an agent's + # build-time trust store at all. # - # This module moves the anchor to a long-lived **hive CA** held on the - # host. The gateway serves a **leaf** signed by that CA (via the - # existing `tls.certDir` bind-mount path); agents and federation peers - # trust the *CA* once, and leaf rotation never re-breaks them. See - # `docs/gateway.md` ("Self-signed TLS") and issue-tracker discussion of - # agent web-UI reachability. + # So the anchor is a long-lived **hive CA** held on the host. The + # gateway serves a **leaf** signed by that CA (via the `tls.certDir` + # bind-mount path); agents and federation peers trust the *CA* once, + # and leaf rotation never re-breaks them. See `docs/gateway.md` + # ("Self-signed TLS"). options.services.hyperhive.tls = { stateDir = lib.mkOption { @@ -134,30 +172,7 @@ in if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \ || ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then echo "signing fresh gateway leaf at $leaf" - csr="$(mktemp "$d/gateway.csr.XXXXXX")" - ext="$(mktemp "$d/leaf.ext.XXXXXX")" - trap 'rm -f "$csr" "$ext"' EXIT - - openssl req -newkey rsa:4096 -nodes -sha256 \ - -keyout "$leafk" -out "$csr" \ - -subj "/CN=${domain}" - - # printf (not a heredoc) so the ext-file lines carry no leading - # whitespace once nix has stripped the indented-string indent. - { - printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \ - ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \ - ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} - printf 'basicConstraints=critical,CA:FALSE\n' - printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' - printf 'extendedKeyUsage=serverAuth\n' - } > "$ext" - - openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \ - -CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \ - -extfile "$ext" -out "$leaf" - chmod 0600 "$leafk" - chmod 0644 "$leaf" + ${signLeafScript} "$d" fi ''; }; @@ -176,9 +191,8 @@ in # container start). A host-side `systemctl -M hive-gateway` call # triggers the re-import + reload, mirroring how hive-c0re reloads the # gateway after each agents.conf write. A path unit *inside* the - # container was tried first but does not work: IN_MOVED_TO from an - # atomic rename on the host does not propagate across the nspawn - # mount-namespace boundary. + # container cannot do this: IN_MOVED_TO from an atomic rename on the + # host does not propagate across the nspawn mount-namespace boundary. # # `|| true` on propagation so a stopped gateway never fails the unit — # its next boot will import the already-rotated leaf anyway. @@ -202,10 +216,7 @@ in script = '' set -euo pipefail d=${lib.escapeShellArg cfg.stateDir} - ca="$d/ca.pem" - cak="$d/ca-key.pem" leaf="$d/gateway.pem" - leafk="$d/gateway-key.pem" # Re-sign only when the leaf is within half its validity of expiry. # The weekly cadence catches this window well before the leaf lapses. @@ -219,28 +230,7 @@ in echo "gateway leaf missing or near expiry — re-signing under current CA" before="$(sha256sum "$leaf" 2>/dev/null || true)" - csr="$(mktemp "$d/gateway.csr.XXXXXX")" - ext="$(mktemp "$d/leaf.ext.XXXXXX")" - trap 'rm -f "$csr" "$ext"' EXIT - - openssl req -newkey rsa:4096 -nodes -sha256 \ - -keyout "$leafk" -out "$csr" \ - -subj "/CN=${domain}" - - { - printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \ - ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \ - ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} - printf 'basicConstraints=critical,CA:FALSE\n' - printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' - printf 'extendedKeyUsage=serverAuth\n' - } > "$ext" - - openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \ - -CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \ - -extfile "$ext" -out "$leaf" - chmod 0600 "$leafk" - chmod 0644 "$leaf" + ${signLeafScript} "$d" after="$(sha256sum "$leaf" 2>/dev/null || true)" if [ "$before" != "$after" ]; then diff --git a/nix/host-modules/hyperhive.nix b/nix/host-modules/hyperhive.nix new file mode 100644 index 00000000..8b71b523 --- /dev/null +++ b/nix/host-modules/hyperhive.nix @@ -0,0 +1,109 @@ +# Top-level, cross-cutting hyperhive options: the master enable +# switch, the hive's identity (domain + display names), and hive-wide +# feature toggles read by several subsystem modules. Imported by the +# ./default.nix aggregator. +{ + lib, + ... +}: +{ + # 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. Some hives don't want a root agent — 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 root agent is auto-managed as required + infrastructure). Exposed to hive-c0re as `HYPERHIVE_RUTHLESS`. + ''; + }; + + options.services.hyperhive.github.enable = lib.mkOption { + type = lib.types.bool; + default = true; + example = false; + description = '' + Hive-wide switch for the per-agent GitHub integration (the `gh` CLI + wrapper + git credential helper, per `hyperhive.github.enable`). On by + default: every agent gets the integration, inert until a PAT is + provisioned via the dashboard credentials tab or `hivectl github + set-token`. Set `false` to turn it off for the whole hive --- the + meta-flake renderer (`hive-c0re/src/meta.rs`) then injects + `hyperhive.github.enable = false` into every agent. Exposed to hive-c0re + as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off). + ''; + }; +} diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix new file mode 100644 index 00000000..d12da00b --- /dev/null +++ b/nix/host-modules/otel.nix @@ -0,0 +1,117 @@ +# 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 off hive-c0re's unit (see +# ./hive-c0re) 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. +{ + lib, + config, + ... +}: +{ + 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. + ''; + }; + + debug = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Emit OTEL SDK diagnostic messages to every agent's stderr by + setting `CLAUDE_CODE_OTEL_DIAG_STDERR=1`. Useful when + troubleshooting collector connectivity or endpoint config; + leave off in normal operation to avoid noise in agent logs. + Only meaningful when `enable` is true. + ''; + }; + + 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. + ''; + }; + }; + + config = lib.mkIf config.services.hyperhive.c0re.enable { + assertions = 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."; + } + ]; + }; +} diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix new file mode 100644 index 00000000..a6750900 --- /dev/null +++ b/nix/host-modules/swarm.nix @@ -0,0 +1,246 @@ +# Swarm peering: the peer-hive declarations and the optional +# WireGuard inter-hive mesh. The peers are serialised into hive-c0re's +# environment (HYPERHIVE_PEERS / HIVE_PEER_CA_PATHS — see ./hive-c0re) +# and consumed by identity.rs + the dashboard's P33RS tab; the mesh +# config below is host-level networking. +{ + lib, + config, + ... +}: +{ + # Peer hives in the same swarm. Each entry declares a remote hive + # reachable from this host. + 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, a `wg-hive` interface connects 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. + ''; + }; + }; + + # Gated on the c0re daemon being enabled — the mesh is part of the + # coordinator host's networking. + config = lib.mkIf config.services.hyperhive.c0re.enable { + 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"). + ''; + } + ]; + + # WireGuard inter-hive mesh. Brings up a `wg-hive` interface and + # connects to each peer that has `wireguardPublicKey` set. + 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 (host-level networking — not inside containers). + networking.firewall.allowedUDPPorts = lib.mkIf config.services.hyperhive.swarm.wireguard.enable [ + config.services.hyperhive.swarm.wireguard.listenPort + ]; + }; +} diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix deleted file mode 100644 index 0bdaaa5c..00000000 --- a/nix/modules/hive-c0re.nix +++ /dev/null @@ -1,1333 +0,0 @@ -{ - hyperhivePackage, - hyperhiveFrontend, - hyperhiveAssets, - hyperhiveFlake, - hyperhiveDocs, - hyperhiveXdgIcons, - 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 JSON. 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. - # - # Written to `/etc/hyperhive/serve.json` (managed by - # `environment.etc`) rather than embedded as a store-path argument in - # ExecStart. This keeps ExecStart byte-stable across deploys that only - # change hyperhive module files (gateway, frontend, unrelated nix - # modules) so systemd does NOT restart hive-c0re — and therefore does - # NOT trigger a startup sweep that rebuilds every agent — unless the - # c0re binary itself changes. - serveConfigJson = builtins.toJSON { - hyperhive_flake = cfg.hyperhiveFlake; - hyperhive_docs_flake = cfg.hyperhiveDocs; - nixpkgs_flake = cfg.nixpkgsFlake; - 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; - 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 -{ - # 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`. - ''; - }; - - options.services.hyperhive.github.enable = lib.mkOption { - type = lib.types.bool; - default = true; - example = false; - description = '' - Hive-wide switch for the per-agent GitHub integration (the `gh` CLI - wrapper + git credential helper, per `hyperhive.github.enable`). On by - default: every agent gets the integration, inert until a PAT is - provisioned via the dashboard credentials tab or `hivectl github - set-token`. Set `false` to turn it off for the whole hive --- the - meta-flake renderer (`hive-c0re/src/meta.rs`) then injects - `hyperhive.github.enable = false` into every agent. Exposed to hive-c0re - as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off). - ''; - }; - - # 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. - ''; - }; - - debug = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Emit OTEL SDK diagnostic messages to every agent's stderr by - setting `CLAUDE_CODE_OTEL_DIAG_STDERR=1`. Useful when - troubleshooting collector connectivity or endpoint config; - leave off in normal operation to avoid noise in agent logs. - Only meaningful when `enable` is true. - ''; - }; - - 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. - ''; - }; - hyperhiveDocs = lib.mkOption { - type = lib.types.str; - default = hyperhiveDocs; - 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. - ''; - }; - 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 ${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"`. - ''; - }; - - 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 like the pre-DAG rebuild queue did; 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. - ''; - }; - }; - - config = lib.mkIf cfg.enable { - environment.systemPackages = [ - cfg.package - pkgs.git - # XDG icons + .desktop entries so desktop environments can match - # hyperhive processes to their icon (task managers, CPU monitors, etc.). - (hyperhiveXdgIcons pkgs.stdenv.hostPlatform.system) - ]; - - # Serve config at a stable /etc path so hive-c0re's ExecStart - # doesn't embed a volatile store-path argument. See serveConfigJson - # above for the rationale. - environment.etc."hyperhive/serve.json".text = serveConfigJson; - - # 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; - # 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.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 - ) - ); - }; - serviceConfig = { - ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config /etc/hyperhive/serve.json"; - 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"; - # Sandboxing. hive-c0re is unprivileged (runs as hive-core, never - # setuid), makes HTTP requests to forge/matrix/Anthropic (keeps INET), - # and delegates all privileged ops to hive-priv via a Unix socket. - # These directives deny the subset of kernel capabilities it - # provably doesn't need without restricting its network or - # filesystem access (RestrictAddressFamilies deferred — needs a - # watched deploy to verify no AF_UNIX/AF_INET gaps in socket paths). - NoNewPrivileges = true; # already runs as unprivileged user - PrivateTmp = true; # uses StateDirectory for tmpfiles, not /tmp - ProtectHome = true; # HOME = /var/lib/hyperhive; no /home/* access needed - # "strict" makes the entire filesystem read-only except for - # StateDirectory (/var/lib/hyperhive) and RuntimeDirectory - # (/run/hyperhive), which systemd keeps writable. No - # ReadWritePaths needed beyond the managed directories because: - # - nix is invoked directly (lifecycle, meta, flake_check), but - # NIX_REMOTE=daemon routes all store writes through the host - # daemon — hive-c0re never writes to /nix itself. - # - flake.lock ops land in the meta worktree under StateDirectory - # (kept writable by systemd). - # - nix build worktrees live in PrivateTmp, not /tmp. - # - /etc writes (bind-mount edits) go through hive-priv via the - # privileged socket; /etc/hyperhive/serve.json is read-only. - ProtectSystem = "strict"; - ProtectKernelTunables = true; # no sysctl writes - ProtectKernelLogs = true; # reads logs via systemd-journal group, not /dev/kmsg - ProtectControlGroups = true; # cgroup writes go through hive-priv, not c0re directly - RestrictNamespaces = true; # namespace creation goes through hive-priv - LockPersonality = true; # no personality changes needed - RestrictRealtime = true; # no real-time scheduling - }; - }; - - # 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"; - # 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-gateway.nix b/nix/modules/hive-gateway.nix deleted file mode 100644 index 71dcb87b..00000000 --- a/nix/modules/hive-gateway.nix +++ /dev/null @@ -1,1074 +0,0 @@ -{ - pkgs, - lib, - config, - ... -}: -let - cfg = config.services.hyperhive.gateway; - hyperhiveDomain = config.services.hyperhive.domain; - matrixCfg = config.services.hyperhive.matrix; - forgeCfg = config.services.hyperhive.forge; - networkCfg = config.services.hyperhive.network; - - # DHCP pool covering all usable host addresses on the bridge subnet. - # All containers (agents and service containers such as hive-ci) receive - # their IPs dynamically; there are no hash-derived static assignments. - # Range: .2 to .(hostCount-2) — skipping .0 (network), .1 (gateway/host - # bridge), and the broadcast address. - # - # IPv4 helpers — nix integers are 64-bit so all /0-/32 values are safe. - ipToInt = - ip: - builtins.foldl' (acc: x: acc * 256 + x) 0 ( - map lib.strings.toIntBase10 (lib.strings.splitString "." ip) - ); - intToIp = - n: - let - a = n / 16777216; - b = (n - a * 16777216) / 65536; - c = (n - a * 16777216 - b * 65536) / 256; - d = n - a * 16777216 - b * 65536 - c * 256; - in - "${toString a}.${toString b}.${toString c}.${toString d}"; - # 2^n via recursion (nix has no pow builtin). - pow2 = n: if n == 0 then 1 else 2 * (pow2 (n - 1)); - hostCount = pow2 (32 - networkCfg.bridgePrefixLength); - # Mask off host bits to get the network base address. - networkBase = builtins.bitAnd (ipToInt networkCfg.bridgeIp) (4294967295 - hostCount + 1); - # DHCP range: .2 (first usable after gateway) to .(hostCount-2) (last usable). - dhcpStart = intToIp (networkBase + 2); # skip .0 (network) and .1 (gateway) - dhcpEnd = intToIp (networkBase + hostCount - 2); # last usable = broadcast - 1 - - # Dashboard SPA dist, static-served by nginx below. Read in OUTER scope so - # `config` is the host's (inside the container block it'd be the container's). - dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; - - # Self-signed TLS is the implicit floor: when neither an operator cert - # (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway - # generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no - # explicit toggle and no http-only mode — matrix discovery requires https, - # so the gateway always terminates TLS. The deprecated `selfSignedTls` - # option is a no-op kept only so existing configs eval (see warnings). - useSelfSigned = cfg.tls.certDir == null && !cfg.tls.acme.enable; - - # Static error pages for `/agent//` mishaps. - # Useful pages of nginx's default 404/502 for routes - # we've already special-cased. See `docs/gateway.md::Per-agent - # error pages` for the design rationale + page-vs-status semantics. - agentErrorPagesDir = pkgs.runCommand "hyperhive-agent-error-pages" { } '' - mkdir -p $out - cat > $out/not-found.html <<'EOF' - - - - - agent not found ◆ hyperhive - - - -

◆ agent not found

-

No agent matches the requested /agent/<name>/ path on this hive.

-

Operator: check the agent name in the dashboard.

- - - EOF - cat > $out/unreachable.html <<'EOF' - - - - - agent unreachable ◆ hyperhive - - - -

◆ agent unreachable

-

The agent's harness web server isn't responding. Container restarting, or the agent crashed.

-

Operator: dashboard → check the container status / journal; the page will recover on retry once the harness is back up.

- - - EOF - ''; -in -{ - # Single nginx in front of every hyperhive web surface — dashboard, - # per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known - # delegations. Container `hive-gateway`, shared host netns, - # state-free. Full vhost map + discovery flow + design rationale in - # `docs/gateway.md`. - - options.services.hyperhive.gateway = { - # The gateway is always run alongside hyperhive (it's the single nginx - # in front of every surface and the only thing exposed to the outside); - # there is no enable flag. An operator who wants their own reverse proxy - # in front points it at the gateway's `port`. The gateway config below - # is gated on the top-level `services.hyperhive.enable`. - - port = lib.mkOption { - type = lib.types.port; - default = 80; - example = 8080; - description = '' - TCP port the gateway listens on. Default 80 (canonical web - port). nginx inside the container binds <1024 because the - container's init runs as root; if 80 is already taken on the - host (existing nginx, traefik, etc.) override to an unused - port like 8080 or move the conflicting service. - ''; - }; - - upstreamHost = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = '' - Host the gateway proxies non-static requests to. Defaults to - `127.0.0.1` because the gateway container shares the host - netns, so loopback resolves directly to hive-c0re. - ''; - }; - - upstreamPort = lib.mkOption { - type = lib.types.port; - default = 7000; - description = '' - TCP port the gateway proxies non-static requests to. Defaults - to `7000` (hive-c0re's out-of-the-box dashboard port). Operators - who change `services.hyperhive.c0re.dashboardPort` should set - `upstreamPort` to match — kept as a hardcoded default rather - than a cross-reference to keep this module's options eval - independent of c0re's option tree shape. - ''; - }; - - openFirewall = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Open `port` in the host firewall. Off by default (secure-by-default). - Flip to `true` to expose the gateway to - the operator's browser / external clients — required for any - out-of-host reach, since the agents themselves talk to - hive-c0re via the per-agent unix sockets and don't need the - nginx vhost. Leave off when running behind another reverse - proxy (e.g. caddy / traefik on the host) that handles TLS - termination + forwards to `port`. - - **Note**: this used to default to `true`. Add - `services.hyperhive.gateway.openFirewall = true;` to your host - config if external reach stopped working after a recent upgrade. - ''; - }; - - localHostsEntry = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Add an `/etc/hosts` entry mapping `services.hyperhive.domain` - to `127.0.0.1` on the host. Useful for local deployments + - tests where there's no real DNS for `services.hyperhive.domain` - but the operator (or browser-based tests) want to hit - `http://''${services.hyperhive.domain}` to exercise the - gateway shape. Off by default — operators running with real - DNS shouldn't have a stale `/etc/hosts` entry sticking - around. Requires `services.hyperhive.domain` to be set. - ''; - }; - - selfSignedTls = lib.mkOption { - type = lib.types.bool; - default = true; - example = false; - description = '' - **DEPRECATED — ignored.** Self-signed TLS is now the implicit - default: when neither `tls.certDir` nor `tls.acme.enable` is - configured, the gateway generates and serves a hive-CA-signed - leaf (see the `hive-tls` module). There is no explicit toggle and - no http-only mode — matrix discovery hardcodes - `https:///.well-known/matrix/client`, so the gateway always - terminates TLS. This option is retained as a no-op so existing - configs eval; setting it (to either value) warns and has no - effect, and it will be removed in a future release. Use - `tls.certDir` or `tls.acme` to override the self-signed default. - - See `docs/gateway.md` ("Self-signed TLS"). - ''; - }; - - useSelfSigned = lib.mkOption { - type = lib.types.bool; - internal = true; - readOnly = true; - default = useSelfSigned; - defaultText = lib.literalExpression "tls.certDir == null && !tls.acme.enable"; - description = '' - Read-only derived flag: `true` when the gateway serves the - self-signed (hive-CA-signed) leaf — i.e. neither `tls.certDir` nor - `tls.acme.enable` is configured. Single source of truth for the - self-signed condition; consumed by the `hive-tls` and `hive-ci` - modules so the derivation isn't duplicated. Internal — not meant to - be set by operators (use `tls.certDir` / `tls.acme` to override the - self-signed default). - ''; - }; - - httpsPort = lib.mkOption { - type = lib.types.port; - default = 443; - example = 8443; - description = '' - TCP port for the TLS-terminated vhosts. Default 443. The gateway - always terminates TLS (self-signed is the implicit floor when no - `tls.certDir` / ACME is configured), so this port is always active - alongside the plain-http `port`. - ''; - }; - - tls = { - certDir = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - example = lib.literalExpression ''"/var/lib/acme/example.com"''; - description = '' - Path to a host directory containing a TLS certificate and - private key for nginx. When set, nginx listens on `httpsPort` - and uses this cert, overriding the self-signed default — the - auto-generated hive-CA-signed leaf is skipped entirely. - - The directory is bind-mounted read-only into the gateway - container at `/run/hive-tls/`. nginx reads - `/` and `/`. - Default filenames (`cert.pem` / `key.pem`) match the output - layout of nixpkgs's `security.acme` module. - - Typical ACME setup: - ```nix - security.acme.certs."example.com" = { ... }; - services.hyperhive.gateway.tls.certDir = - config.security.acme.certs."example.com".directory; - ``` - - When using an external CA cert, peer hives can declare this - hive in `services.hyperhive.swarm.peers` without - `certFingerprint` — the standard CA bundle validates. - - Mutual exclusion with `tls.acme.enable` — set one or the other, - not both. - ''; - }; - - certName = lib.mkOption { - type = lib.types.str; - default = "cert.pem"; - description = '' - Filename of the TLS certificate within `tls.certDir`. Defaults - to `cert.pem` which matches nixpkgs's `security.acme` output. - ''; - }; - - keyName = lib.mkOption { - type = lib.types.str; - default = "key.pem"; - description = '' - Filename of the TLS private key within `tls.certDir`. Defaults - to `key.pem` which matches nixpkgs's `security.acme` output. - ''; - }; - - acme = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Let nginx inside the gateway container obtain and renew TLS - certificates automatically via ACME (Let's Encrypt). When - enabled, each vhost calls out to Let's Encrypt using the - HTTP-01 challenge on `port` (default 80) and stores certs - inside the gateway container's persistent state dir. - - Requirements: - - `services.hyperhive.domain` must be set and publicly - DNS-resolvable to this host. - - `services.hyperhive.gateway.openFirewall = true` so - Let's Encrypt can reach `/.well-known/acme-challenge/`. - - `tls.acme.email` must be set (ACME account contact). - - Mutual exclusion: `tls.certDir` set together with - `tls.acme.enable = true` fails at eval — pick one TLS source. - - Typical setup: - ```nix - services.hyperhive.gateway = { - openFirewall = true; - tls.acme = { - enable = true; - email = "admin@example.com"; - }; - }; - ``` - - After enabling, peer hives can omit `certFingerprint` in - `swarm.peers` — Let's Encrypt certs are CA-trusted - by default. - ''; - }; - - email = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "admin@example.com"; - description = '' - Email address for the ACME account registration with - Let's Encrypt. Required when `tls.acme.enable = true`. - Let's Encrypt sends expiry warnings to this address. - ''; - }; - }; - }; - - auth = { - enable = lib.mkEnableOption '' - HTTP basic auth on the gateway using an htpasswd file. When - enabled, every request to the gateway's main vhost requires a - valid username and password. nginx's built-in `auth_basic` - module validates credentials against - `/var/lib/hyperhive/gateway/gateway.htpasswd` on the host - (exposed as `/run/hive-state/gateway.htpasswd` inside the - container via the existing gateway state bind-mount). Off by default. - - Manage users with `hivectl gateway create-user`, `delete-user`, - and `list-users` — see `hivectl gateway --help` for usage. - The htpasswd file is created automatically when auth is enabled; - add at least one user before enabling to avoid locking everyone out. - ''; - - realm = lib.mkOption { - type = lib.types.strMatching "[^\"$]*"; - default = "hyperhive"; - example = "my-hive"; - description = '' - HTTP Basic auth `realm` value sent in the `WWW-Authenticate` - header when credentials are absent or rejected. Must not - contain `"` or `$` (nginx string metacharacters). - ''; - }; - }; - - hsts = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Add `Strict-Transport-Security` to all gateway vhosts. - - Disabled by default: HSTS pins HTTPS in the browser's HSTS - preload list; enabling it on a deployment that later loses TLS - will lock browsers out until the max-age expires. Only enable - this when you are certain TLS is permanent. - - The gateway always terminates TLS now (self-signed floor), so - HSTS is always served over https when enabled — but mind the - warning above: HSTS pins https in the browser, so only enable it - when TLS is permanent for this deployment. - ''; - }; - - maxAge = lib.mkOption { - type = lib.types.ints.positive; - default = 31536000; - example = 86400; - description = '' - Value for the `max-age` directive in seconds. - Default: 31536000 (1 year), which is the value required for - HSTS preload list submission. Use a shorter value (e.g. 86400) - while testing so browsers forget the pin quickly. - ''; - }; - - includeSubDomains = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Whether to include `includeSubDomains` in the HSTS header. - Only disable this if the gateway host has sub-domains that - intentionally serve plain HTTP. - ''; - }; - }; - - }; - - config = lib.mkIf config.services.hyperhive.enable { - assertions = [ - { - assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); - message = '' - services.hyperhive.gateway.tls.acme.enable = true and - tls.certDir are mutually exclusive. Pick one TLS mode. - ''; - } - { - assertion = !cfg.tls.acme.enable || cfg.tls.acme.email != null; - message = '' - services.hyperhive.gateway.tls.acme.enable = true requires - services.hyperhive.gateway.tls.acme.email to be set — - Let's Encrypt needs a contact address for the ACME account. - ''; - } - ]; - - # Deprecation surface for the removed `selfSignedTls` toggle. Self-signed - # is now the implicit floor (used whenever neither `tls.certDir` nor - # `tls.acme` is set), so the toggle no longer does anything. Warn only - # when it's set to `false` — that's the case that previously meant - # "http-only / external-only", which no longer exists; `= true` matches - # the effective behaviour and stays silent to avoid noise. - warnings = lib.optional (!cfg.selfSignedTls) '' - services.hyperhive.gateway.selfSignedTls = false is deprecated and - ignored — self-signed TLS is now the default whenever no other TLS - source is configured, and there is no http-only mode. Remove the - setting; configure `tls.certDir` or `tls.acme` to override the - self-signed default. - ''; - - # Ensure bind-mount sources exist at host boot before the gateway - # container's first start. nspawn would auto-create missing dirs - # tmpfiles rules make the intent explicit - # and cover the fresh-boot window before c0re has run. - # - # /run/hive-agent — per-agent UDS socket dir, written by c0re's - # set_nspawn_flags when agents start. Owned by `hive-core` (the - # unprivileged coordinator user): c0re does the - # `create_dir_all(/run/hive-agent/)` itself, so a root-owned - # parent would EACCES on the very first agent create on a fresh host - # (hive-priv only chowns the subdir afterwards, it doesn't make it). - # /var/lib/hyperhive — hyperhive state dir, created by c0re on - # first run. Also pre-seed agents.conf with an empty-but-valid - # header so nginx can start + include the file before c0re writes - # its first real content (f = create-if-absent, no overwrite). - systemd.tmpfiles.rules = [ - "d /run/hive-agent 0755 hive-core hive-core - -" - "d /var/lib/hyperhive 0755 root root - -" - "d /var/lib/hyperhive/gateway 0755 root root - -" - "f /var/lib/hyperhive/gateway/agents.conf 0644 root root - # Generated by hive-c0re — do not edit.\n" - # Pre-create the htpasswd file so nginx can open it even before any - # users have been added. An empty file causes all auth checks to - # return 401 (no valid credentials), which is the correct no-users - # behaviour. `f` = create-if-absent, never overwrite. - "f /var/lib/hyperhive/gateway/gateway.htpasswd 0644 root root - -" - ]; - - containers.hive-gateway = { - autoStart = true; - ephemeral = false; - # Share host netns — nginx then binds host-level ports directly, - # `localhost` upstream resolution reaches hive-c0re without any - # port-forward dance, and the firewall config below is the only - # layer that matters. - privateNetwork = false; - # dnsmasq refuses to start once a dhcp-range is configured unless it - # holds CAP_NET_ADMIN (DNS-only mode doesn't need it). Private-network - # containers retain NET_ADMIN implicitly, but this container shares the - # host netns (above), so nspawn's default bounding set drops it — grant - # it explicitly. Note this is NET_ADMIN over the *host* netns; the - # gateway container is trusted infra (it already terminates TLS and - # fronts every vhost), so no new trust boundary is crossed. - additionalCapabilities = [ "CAP_NET_ADMIN" ]; - # Bind-mount the per-agent socket dir so nginx inside the gateway - # container can `connect(2)` to the UDS upstreams. - # Read-only (we just connect; harness writes the socket inside - # the agent's own container). Host-side dir is pre-created by a - # tmpfiles rule so nspawn always finds a source at boot. - bindMounts."/run/hive-agent" = { - hostPath = "/run/hive-agent"; - isReadOnly = true; - }; - # Bind-mount ONLY the gateway-specific subdir of the hyperhive - # state dir. Scoped to /var/lib/hyperhive/gateway/ rather than - # the whole parent so the gateway container can't read forge - # tokens or other files that may live at the parent level. - # c0re writes agents.conf under this subdir and triggers an nginx - # reload from the host via systemd-run after each write. - # Pre-created by a tmpfiles rule. - bindMounts."/run/hive-state" = { - hostPath = "/var/lib/hyperhive/gateway"; - isReadOnly = true; - }; - # Operator-provided TLS cert dir (e.g. Let's Encrypt / ACME). - # Only mounted when `tls.certDir` is set; when it is, the self-signed - # floor is off (so the `/run/hive-ca` mount above is absent). nginx - # reads cert + key from `/run/hive-tls/` and ``. - bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) { - hostPath = cfg.tls.certDir; - isReadOnly = true; - }; - # Self-signed mode: the host `hive-tls-ca` service generates a hive - # CA + a leaf signed by it under `services.hyperhive.tls.stateDir`. - # Bind-mount that dir read-only so the in-container import service - # (below) can copy the leaf into nginx's state dir with the right - # owner/mode. Source files: `gateway.pem` + `gateway-key.pem`. - bindMounts."/run/hive-ca" = lib.mkIf useSelfSigned { - hostPath = config.services.hyperhive.tls.stateDir; - isReadOnly = true; - }; - config = - { pkgs, ... }: - let - tlsDir = "/var/lib/hive-gateway/tls"; - # TLS cert + key paths inside the container. - # - self-signed (default): imported hive-CA-signed leaf in the - # persistent state dir. - # - tls.certDir set: operator-provided cert bind-mounted at /run/hive-tls. - tlsCert = - if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem"; - tlsKey = - if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem"; - # The gateway always terminates TLS now: self-signed is the - # implicit floor (`useSelfSigned`) when neither `tls.certDir` nor - # ACME is set, so there is no http-only mode. Kept as a named - # binding for the vhost listen/ssl wiring below. - hasTls = true; - # Listen addresses every vhost shares. Plain http on `cfg.port` - # always; `cfg.httpsPort` with TLS sits beside it when TLS is - # active (any mode). See `docs/gateway.md` ("TLS modes"). - vhostListen = [ - { - addr = "0.0.0.0"; - port = cfg.port; - } - ] - ++ lib.optional hasTls { - addr = "0.0.0.0"; - port = cfg.httpsPort; - ssl = true; - }; - # nixos `services.nginx.virtualHosts.` ssl attrs merged - # into each vhost. For ACME mode: `enableACME` + `addSSL` — - # NixOS's ACME integration manages the cert lifecycle and sets - # ssl_certificate automatically. For self-signed / certDir: - # explicit cert paths. Empty for http-only. - vhostTls = - if cfg.tls.acme.enable then - { - addSSL = true; - enableACME = true; - } - else - lib.optionalAttrs hasTls { - addSSL = true; - sslCertificate = tlsCert; - sslCertificateKey = tlsKey; - }; - - # Public-facing scheme + port-suffix for URLs the gateway - # mints into responses (well-known JSON, the deprecated - # `/matrix/*` 301 redirect, future absolute-URL needs). - # When TLS is active (self-signed OR operator cert), prefer - # `https://` (matrix-spec compliance) — 443 elides the - # port. Otherwise fall back to the plain-http listen with the - # bare port. See `docs/gateway.md` ("Self-signed TLS"). - publicScheme = if hasTls then "https" else "http"; - publicPort = if hasTls then cfg.httpsPort else cfg.port; - publicPortDefault = if hasTls then 443 else 80; - publicPortSuffix = if publicPort == publicPortDefault then "" else ":${toString publicPort}"; - - # Security headers added at the server scope on every vhost. - # nginx's add_header inheritance rule: a location that defines its - # own add_header does NOT inherit the server-level ones. Any - # location with its own add_header (e.g. CORS on /.well-known or - # /_matrix/) must repeat the security headers explicitly — see those - # locations below. HTML-serving and proxy locations that carry no - # add_header of their own pick these up from the server scope - # automatically. - hstsDirectives = lib.concatStringsSep "; " ( - [ "max-age=${toString cfg.hsts.maxAge}" ] - ++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains" - ); - securityHeaders = '' - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - ${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''} - ''; - - # Forge sub-domain vhost. `server_name = forge.domain`, proxies - # all `/` → forgejo. Tuned for git: `client_max_body_size 1G`, - # `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on - # `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the - # forge isn't behind the gateway. - forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) { - "${forgeCfg.domain}" = vhostTls // { - listen = vhostListen; - extraConfig = securityHeaders; - locations."/" = { - proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - client_max_body_size 1G; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - ''; - }; - }; - }; - - # Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`. - # `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll - # timeout). `/` serves fluffychat or 404 if GUI off. nginx - # longer-prefix-wins puts `/_matrix/` ahead of `/`. See - # `docs/gateway.md`. Empty attrset when matrix has no gateway host. - matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) { - "${matrixCfg.gatewayHost}" = vhostTls // { - listen = vhostListen; - extraConfig = securityHeaders; - locations = { - "/_matrix/" = { - proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - client_max_body_size 50M; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - ${securityHeaders} - add_header Access-Control-Allow-Origin *; - ''; - }; - } - // lib.optionalAttrs (matrixCfg.gui.enable) ( - { - # fluffychat at sub-domain root, SPA-fallback via - # the Accept-header `$matrix_spa_target` map. - "/" = { - alias = "${matrixCfg.gui.package}/"; - extraConfig = '' - try_files $uri $uri/ $matrix_spa_target =404; - ''; - }; - } - // { - # FluffyChat boot-config pre-fill so the client's - # `.well-known/matrix/client` lookup hits the - # right delegation endpoint. `domain` is required, so - # this is always present. - "= /config.json" = { - extraConfig = '' - default_type application/json; - return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; - ''; - }; - } - ) - // lib.optionalAttrs (!matrixCfg.gui.enable) { - "/" = { - return = "404"; - }; - }; - }; - }; - - # `_` (default) server location groups, lifted out of the inline - # `//`-chain so each conditional group reads on its own. Composed - # into the `_` vhost's `locations` below alongside the still-inline - # auth-401 group (a self-contained `lib.optionalAttrs`). - - # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link - # shim during the fluffychat sub-domain move). See `docs/gateway.md`. - matrixRedirectLocations = - lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null) - ( - let - target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"; - in - { - "/matrix/" = { - extraConfig = '' - rewrite ^/matrix/(.*)$ ${target}/$1 permanent; - ''; - }; - } - ); - - # `.well-known/matrix/{client,server}` discovery JSON. Points - # clients at `matrixCfg.gatewayHost` when set; falls back to direct - # `:`. CORS `*` per matrix spec. The `m.server` - # port-8448 carve-out is documented inline. See `docs/gateway.md`. - wellKnownLocations = lib.optionalAttrs matrixCfg.enable ( - let - clientBaseUrl = - if matrixCfg.gatewayHost != null then - "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}" - else - "${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}"; - # `m.server` is NOT a URL: per the matrix server-server spec - # (Resolving Server Names) a delegated host with NO port resolves - # to the federation default 8448 (after the SRV check) — the - # https-implies-443 rule does NOT apply here. So the port must be - # explicit even when it's the HTTPS default; `publicPortSuffix` - # (which drops :443) is right for the client base_url above but - # wrong for federation delegation. Without this, peers federate to - # :8448 (closed) while the endpoint actually lives on - # the gateway's 443 vhost. See docs/gateway.md discovery flow. - serverHostPort = - if matrixCfg.gatewayHost != null then - "${matrixCfg.gatewayHost}:${toString publicPort}" - else - "${hyperhiveDomain}:${toString matrixCfg.httpPort}"; - in - { - "= /.well-known/matrix/client" = { - extraConfig = '' - default_type application/json; - ${securityHeaders} - add_header Access-Control-Allow-Origin *; - return 200 '{"m.homeserver":{"base_url":"${clientBaseUrl}"}}'; - ''; - }; - "= /.well-known/matrix/server" = { - extraConfig = '' - default_type application/json; - return 200 '{"m.server":"${serverHostPort}"}'; - ''; - }; - } - ); - - # `/agent/` catch-all 404 + the two internal error-page targets it - # points at. Per-agent `location /agent//` blocks live in the - # runtime-generated `/run/hive-state/agents.conf` (included via - # `extraConfig` on the vhost); nginx longest-prefix-match makes a - # real `/agent//` beat this catch-all. `internal` keeps the - # error pages reachable only through nginx's error handling. - agentLocations = { - "/agent/" = { - extraConfig = '' - error_page 404 = /__hive_agent_not_found; - return 404; - ''; - }; - "= /__hive_agent_not_found" = { - extraConfig = '' - internal; - alias ${agentErrorPagesDir}/not-found.html; - default_type text/html; - ''; - }; - "= /__hive_agent_unreachable" = { - extraConfig = '' - internal; - alias ${agentErrorPagesDir}/unreachable.html; - default_type text/html; - ''; - }; - }; - - # Shared auth block — separate locations don't inherit auth_basic, so - # each dashboard location (`/`, `/api/`) needs it or that surface is - # unauthed. `/webhook/` is intentionally excluded: Forgejo cannot - # send HTTP Basic credentials with webhook deliveries, and the HMAC - # secret (`X-Hub-Signature-256`) protects those endpoints instead. - dashboardAuth = lib.optionalString cfg.auth.enable '' - auth_basic "${cfg.auth.realm}"; - auth_basic_user_file /run/hive-state/gateway.htpasswd; - # `=401` keeps the status 401 so the login dialog shows; the - # internal page explains `hivectl gateway create-user`. - error_page 401 =401 /__hive_auth_unauthorized; - ''; - - # Dashboard: nginx static-serves the dist, c0re is API-only. Routing - # is by PATH, never content-type. c0re serves exactly two prefixes — - # `/api/` (all dashboard data + actions + the SSE streams) and - # `/webhook/` (knowledge push + config-PR approval triggers, HMAC- - # guarded) — so those proxy to c0re and everything else serves the - # dist with an SPA fallback to index.html. - # The earlier `map $http_accept` Accept-header split made the SAME - # url behave differently by content-type (e.g. `/api/state` fetched - # with `Accept: text/html` wrongly got index.html); path routing is - # deterministic. A new top-level c0re route prefix (beyond /api + - # /webhook) would need a matching location added here. - dashboardProxyLocation = { - "/" = { - root = dashboardDist; - extraConfig = '' - try_files $uri /index.html; - ${dashboardAuth} - ''; - }; - "/api/" = { - proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; - proxyWebsockets = true; - extraConfig = '' - # off + 1d keep the SSE streams (/api/dashboard/stream, - # /api/build-logs/id/{id}/stream) live. - proxy_buffering off; - proxy_read_timeout 1d; - ${dashboardAuth} - ''; - }; - "/webhook/" = { - # No dashboardAuth here: Forgejo cannot send HTTP Basic credentials - # with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth - # for these endpoints; hive-c0re verifies it in the handler. - proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; - }; - }; - in - { - system.stateVersion = "26.05"; - - # ACME (Let's Encrypt) integration. nginx vhosts set - # `enableACME = true` via `vhostTls`; this provides the - # shared ACME config (acceptTerms + email). The gateway - # container has shared host netns so outbound ACME requests - # work without extra routing config. Certs are stored in the - # container's persistent state (`ephemeral = false`). - security.acme = lib.mkIf cfg.tls.acme.enable { - acceptTerms = true; - defaults.email = cfg.tls.acme.email; - }; - - # Import the host-generated leaf cert before nginx starts. - # The hive CA + gateway leaf are generated on the HOST by - # `hive-tls-ca` (see `hive-tls.nix`) and bind-mounted read-only - # at `/run/hive-ca`; this service copies the leaf into nginx's - # state dir with the owner/mode nginx needs, rather than reading - # the bind-mount directly (the host key is 0600 root:root and a - # cross-namespace bind-mount can't be relaxed in place). nginx - # `Requires=` this via `requiredBy`, so it refuses to start until - # the copy succeeds. ALWAYS runs (no ConditionPathExists) and is - # idempotent — necessary to reconcile broken state from prior - # failed boots (a 0700 dir from a stale UMask, a truncated copy - # from an interrupted oneshot, etc.). The leaf covers the bare - # hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}` - # so all sub-domains validate under the same cert + the hive CA. - # See `docs/gateway.md` ("Self-signed TLS"). - systemd.services.hive-gateway-self-signed-cert = lib.mkIf useSelfSigned { - description = "Import host-generated TLS leaf for hive-gateway"; - wantedBy = [ "multi-user.target" ]; - before = [ "nginx.service" ]; - requiredBy = [ "nginx.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "hive-gateway-self-signed-cert"; - }; - path = [ pkgs.coreutils ]; - script = '' - set -eu - mkdir -p ${tlsDir} - # 0755 on BOTH the cert dir and its parent so the nginx - # user can traverse the full path. The parent - # `/var/lib/hive-gateway` lands at 0700 by default (systemd - # StateDirectory / mkdir umask depending on which service - # created it first), which on its own blocks traversal. - # Re-applied every boot in case a prior run left a tighter - # mode behind. - chmod 0755 ${builtins.dirOf tlsDir} - chmod 0755 ${tlsDir} - # Copy the host leaf in. `install` writes atomically with the - # target mode; run as root (container root == host root, - # privateUsers=false) so the 0600 root:root host key is - # readable. Key ends up root:nginx 0640 so nginx-pre-start - # (which runs `nginx -t` as the nginx user, not root) can - # read it — a 0600 root:root key passes the master load but - # fails the pre-start config test with `BIO_new_file() … - # Permission denied`, blocking the unit. Cert is world-read. - install -m 0644 /run/hive-ca/gateway.pem ${tlsCert} - install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey} - ''; - }; - - # nginx reload is triggered from the HOST side by hive-c0re - # via `systemctl -M hive-gateway reload nginx` after each - # agents.conf write — letting systemd resolve the nginx binary - # path avoids exit-203 EXEC failures. A path unit watching the - # bind-mounted file inside the container was tried first but - # doesn't work: an IN_MOVED_TO from an atomic rename on the host - # does not propagate across the nspawn mount-namespace boundary. - # The host-side trigger is the correct approach. - - services.nginx = { - enable = true; - recommendedProxySettings = true; - recommendedTlsSettings = true; - recommendedGzipSettings = true; - recommendedOptimisation = true; - # Accept-header SPA map for the matrix GUI only (see docs/gateway.md - # "SPA fallback"): text/html → index.html, else a sentinel so - # try_files falls through to 404. The dashboard no longer uses an - # Accept-header map — it routes by path (see dashboardProxyLocation). - appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) '' - map $http_accept $matrix_spa_target { - default "/__matrix_spa_no_html_fallback"; - "~*text/html" "/index.html"; - } - ''; - virtualHosts = { - "_" = vhostTls // { - listen = vhostListen; - locations = - matrixRedirectLocations - // wellKnownLocations - // agentLocations - // dashboardProxyLocation - // lib.optionalAttrs cfg.auth.enable { - # Internal-only target for the 401 error_page above. - # `internal` prevents direct client access; `alias` serves - # the pre-built HTML from the Nix store. - "= /__hive_auth_unauthorized" = - let - page = pkgs.writeText "hive-gateway-unauthorized.html" '' - - - - - unauthorized ◆ hyperhive - - - -

◆ unauthorized

-

This hive is protected by HTTP Basic auth. Valid credentials are required.

-

Operator: add a user with hivectl gateway create-user:

-
hivectl gateway create-user \
-                          <username> --password-stdin
-

Then reload your browser and enter the credentials when prompted.

- - - ''; - in - { - extraConfig = '' - internal; - alias ${page}; - default_type text/html; - ''; - }; - }; - # Per-agent location blocks, generated at runtime by - # hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf - # on the host. The bind-mount at /run/hive-state/ exposes - # that file here. nginx parses `include` at config-load - # time so a reload (triggered by c0re via systemd-run - # after each agents.conf write) picks up new or removed - # agents without a nixos-rebuild. nginx's longest-prefix- - # match rule ensures `/agent//` from this file beats - # the `/agent/` catch-all above. - extraConfig = securityHeaders + '' - include /run/hive-state/agents.conf; - ''; - }; - } - // forgeVhost - // matrixVhost; - }; - - # Hive-internal DNS resolver, co-located in the - # gateway container — single - # front-door for both DNS and HTTP, saves a sibling - # container. Listens on the bridge interface from - # `services.hyperhive.network`; authoritative for the hive - # domain + sub-domains, forwards everything else upstream. - services.dnsmasq = lib.mkIf networkCfg.enable { - enable = true; - # Don't substitute the container's /etc/resolv.conf — - # the gateway uses the host's resolver for its own - # outbound traffic; dnsmasq is purely for incoming - # queries from agent containers. - resolveLocalQueries = false; - settings = { - # Bind only on the bridge interface (and lo for - # health-checks). Outside hosts can't even see the - # listener. - interface = [ - networkCfg.bridgeName - "lo" - ]; - bind-interfaces = true; - port = 53; - # Don't read /etc/resolv.conf — we control upstream - # explicitly to dodge dependency on the gateway - # container's own resolver state. - no-resolv = true; - server = networkCfg.upstreamDns; - # Hive authoritative records — answer queries for the - # hive domain + its sub-domains with the bridge IP - # (where nginx is reachable from container netns once - # per-agent netns isolation lands; today it's the host - # loopback alias and works in either shape). - # - # The forge / matrix entries are redundant in the - # common case where `forge.domain` / - # `matrix.gatewayHost` are sub-domains of - # `hyperhive.domain` — dnsmasq's `//` rule - # already matches sub-domains. - # Kept explicit because operators can override either - # to a cross-domain hostname (e.g. - # `forge.domain = "git.example.com"`); listing them - # explicitly keeps that case routed without needing - # an extra config block. - address = [ - "/${hyperhiveDomain}/${networkCfg.bridgeIp}" - ] - ++ lib.optional ((forgeCfg.behindGateway or false)) "/${forgeCfg.domain}/${networkCfg.bridgeIp}" - ++ lib.optional ( - matrixCfg.enable && matrixCfg.gatewayHost != null - ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"; - # DHCP pool covering all usable host addresses on the bridge subnet. - # Range is computed from bridgeIp/bridgePrefixLength at eval time: - # .2 (first after gateway) to .(hostCount-2) (last usable before - # broadcast). All containers — agents and service containers alike — - # receive their IPs dynamically from this pool. - dhcp-range = "${dhcpStart},${dhcpEnd},1h"; - dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases"; - }; - }; - }; - }; - - networking.firewall = lib.mkIf cfg.openFirewall { - allowedTCPPorts = [ - cfg.port - # The gateway always terminates TLS now (self-signed floor), so - # `httpsPort` is always opened alongside the plain-http `port`. - cfg.httpsPort - ]; - }; - - # `/etc/hosts` entries for local dev — bare hive domain + any - # sub-domain modules that are on. `lib.unique` dedupes if any - # sub-domain happens to equal another. See `docs/gateway.md` - # ("Local dev"). - networking.hosts = lib.mkIf cfg.localHostsEntry { - "127.0.0.1" = lib.unique ( - [ hyperhiveDomain ] - ++ lib.optional (config.services.hyperhive.forge.behindGateway or false - ) config.services.hyperhive.forge.domain - ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost - ); - }; - }; -} diff --git a/nix/assets.nix b/nix/packages/assets.nix similarity index 89% rename from nix/assets.nix rename to nix/packages/assets.nix index 46b4ce9c..0c0d9a41 100644 --- a/nix/assets.nix +++ b/nix/packages/assets.nix @@ -12,7 +12,7 @@ # $out/share/hyperhive/branding/{hyperhive,agent-configs}.{svg,png} # $out/share/hyperhive/prompts/{system.md, claude-settings.json} # -# The repo docs/ tree is a SEPARATE derivation (nix/reference-docs.nix) +# The repo docs/ tree is a SEPARATE derivation (./reference-docs.nix) # so agents can consume the docs without the branding+prompt assets and # the website repo can reuse it — see that file. @@ -22,13 +22,13 @@ stdenv.mkDerivation { # Narrow `srcs` (branding/ + hive-ag3nt/prompts/) is what decouples # this derivation's input hash from the rest of the tree. srcs = [ - ../branding - ../hive-ag3nt/prompts + ../../branding + ../../hive-ag3nt/prompts ]; unpackPhase = '' runHook preUnpack - cp -r ${../branding} branding - cp -r ${../hive-ag3nt/prompts} prompts + cp -r ${../../branding} branding + cp -r ${../../hive-ag3nt/prompts} prompts chmod -R u+w branding prompts runHook postUnpack ''; diff --git a/nix/packages/default.nix b/nix/packages/default.nix new file mode 100644 index 00000000..7221a725 --- /dev/null +++ b/nix/packages/default.nix @@ -0,0 +1,183 @@ +# All flake package outputs. Imported per system from flake.nix; the +# shared rust build wiring (cleanSrc / cargoArtifacts / +# nativeBuildInputs) comes in via `rust` (see ../rust.nix). +{ + pkgs, + craneLib, + rust, + self, + nixpkgs, +}: +let + inherit (pkgs) lib; + inherit (rust) cleanSrc cargoArtifacts nativeBuildInputs; + + docsAttrs = import ../docs { + inherit pkgs self; + inherit (nixpkgs) lib; + inherit (nixpkgs.lib) nixosSystem; + }; + + # Every per-binary package: name → description. The single source of + # truth for the bin list — it drives the per-bin extractor packages + # and the `default` bundle, so adding a binary is one entry here. + daemonBins = { + hive-c0re = "hyperhive host coordinator daemon"; + hive-priv = "hyperhive privileged root helper"; + hive-agent = "hyperhive in-container agent harness serve loop"; + hive-agent-mcp = "hyperhive agent-surface MCP server"; + hive-agent-wake = "hyperhive external wake CLI — push a message into an agent's own inbox"; + hive-bash-daemon = "hyperhive per-agent bash-task runner daemon"; + hive-bash-mcp = "hyperhive bash-task MCP bridge"; + hive-matrix-daemon = "hyperhive per-agent matrix-sdk daemon"; + hive-matrix-mcp = "hyperhive matrix MCP bridge"; + hive-metric = "hyperhive agent-emitted custom metrics CLI"; + hive-forge = "hyperhive Forgejo CLI"; + }; + + # ONE compile of the whole workspace (every bin, sharing the + # prebuilt `cargoArtifacts` dep cache). The per-bin packages below + # are cheap copy-extractors over this, so workspace lib crates + # (hive-sh4re, hive-claude, …) compile exactly once instead of once + # per bin derivation. + # + # Tests are kept in the separate `checks.cargo-test` derivation + # (carries the hyperhive-assets build input for the prompt-template + # assertions in hive-ag3nt::prompt::tests). Keeping them out of this + # derivation means a prompt edit doesn't bust the cargo cache. + workspaceBuild = craneLib.buildPackage { + src = cleanSrc; + inherit cargoArtifacts nativeBuildInputs; + pname = "hyperhive-workspace"; + version = "0.1.0"; + doCheck = false; + }; + + # Per-bin extractor: COPIES one binary out of the workspace build. + # A copy, not a symlink — a symlink would keep the whole workspace + # output (and thus every other binary) in the consumer's runtime + # closure, defeating the point of the per-bin split. The copied + # binary's RPATH references only the libs it links, so nix's + # reference scan gives each package a narrow closure. + mkBinPackage = + bin: description: + pkgs.runCommand bin + { + meta = { + inherit description; + mainProgram = bin; + }; + } + '' + install -Dm755 ${workspaceBuild}/bin/${bin} $out/bin/${bin} + ''; + + # Operator CLI — extractor plus shell completions and the `wg` + # wrapper. Suitable for `nix profile install .#hivectl` / + # `environment.systemPackages = [ …packages..hivectl ]` + # when the operator only wants the admin CLI. + # Completions come from the binary's own `completions ` verb + # (the single source of truth, so they never drift from the actual + # verbs). The wireguard-tools wrap makes `hivectl wg` subcommands + # work before `swarm.wireguard.enable` is set (wg init is the very + # first setup step). Completion generation runs before wrapProgram + # since wrapProgram renames the real binary. + hivectlPkg = + pkgs.runCommand "hivectl" + { + nativeBuildInputs = [ + pkgs.installShellFiles + pkgs.makeWrapper + ]; + meta = { + description = "hyperhive operator CLI"; + mainProgram = "hivectl"; + }; + } + '' + install -Dm755 ${workspaceBuild}/bin/hivectl $out/bin/hivectl + installShellCompletion --cmd hivectl \ + --bash <("$out/bin/hivectl" completions bash) \ + --zsh <("$out/bin/hivectl" completions zsh) \ + --fish <("$out/bin/hivectl" completions fish) + wrapProgram "$out/bin/hivectl" \ + --prefix PATH : ${pkgs.wireguard-tools}/bin + ''; + + # Per-bin split packages. Agent containers depend on the individual + # bins they actually exec/PATH-need (see harness-base.nix) instead + # of the `default` bundle — that keeps `hivectl` (dials the *host* + # admin socket, unreachable from inside a container, drags in + # `wireguard-tools`) and redundant binaries out of every agent's + # closure. + binPkgs = lib.mapAttrs mkBinPackage daemonBins // { + hivectl = hivectlPkg; + }; +in +{ + # All workspace binaries in one derivation — a symlinkJoin over the + # per-bin packages, pure assembly with no extra compilation. The + # host module's `services.hyperhive.c0re.package` and `nix build .#` + # both land here. + default = pkgs.symlinkJoin { + name = "hyperhive"; + paths = lib.attrValues binPkgs; + }; +} +// binPkgs +// { + # Bundled browser assets — see ./frontend.nix. Output is + # $out/{dashboard,agent}/ which the Rust binaries serve via + # tower_http::ServeDir. + frontend = pkgs.callPackage ./frontend.nix { + branding-svg = ../../branding/hyperhive.svg; + }; + # Static runtime assets the rust binaries read via + # `hive_sh4re::assets::*`: branding/* + prompts/*, plus the + # rendered agent-configs.png. Split out of the rust derivation so + # a tweak to e.g. system.md doesn't bust the cargo cache. Build + # input of the `cargo-test` check but NOT of `packages.default`, + # so the binary derivation stays cached when a prompt edit ripples + # through. + assets = pkgs.callPackage ./assets.nix { }; + # The repo docs/ markdown tree as a standalone derivation — + # agents read it in-container (added as a claude additional + # directory) and the website repo reuses it as a flake input, + # neither of which needs the branding/prompt assets. See + # ./reference-docs.nix. (`docs` below is the auto-generated + # nix-options reference, a different artifact.) + reference-docs = pkgs.callPackage ./reference-docs.nix { }; + # XDG icon set + .desktop entries for hyperhive processes. + # Narrow input: only the branding SVG, so unrelated source changes + # don't bust this derivation's cache. + xdg-icons = pkgs.callPackage ./hive-xdg-icons.nix { + hyperhiveSvg = ../../branding/hyperhive.svg; + }; + + # Pre-built per-container system closures. Exposed as packages + # so operators can `nix build .#agent-base-toplevel` (or wire + # them into their host system closure via the + # `preBuildAgentTemplates` option on the hive-c0re module — + # see nix/host-modules/hive-c0re.nix). Speeds up the first agent + # spawn dramatically because the heavy lifting (nixpkgs + + # claude-code + hive-ag3nt binary) is already in the store + # when the meta evaluator goes to build the container. + # + # nixosConfigurations are pinned to x86_64-linux (nixos- + # containers only run native arch), so these toplevels are + # only useful on an x86_64-linux host — flake check across + # systems still tolerates evaluating them on aarch64 because + # they're plain derivations, but `nix build` from a non-x86 + # host would only succeed via a remote x86 builder. + agent-base-toplevel = self.nixosConfigurations.agent-base.config.system.build.toplevel; + ruth-toplevel = self.nixosConfigurations.ruth.config.system.build.toplevel; + + # Auto-generated nix options reference for hyperhive. + # `docs` bundles host + agent pages into one tree; the split + # outputs are useful when consumers only want one surface. + # All three are pure markdown — no rust or frontend deps in + # the closure, so `nix build .#docs` is cheap. + docs = docsAttrs.bundle; + docs-host = docsAttrs.host; + docs-agent = docsAttrs.agent; +} diff --git a/nix/frontend.nix b/nix/packages/frontend.nix similarity index 99% rename from nix/frontend.nix rename to nix/packages/frontend.nix index 94cd38a3..242752b5 100644 --- a/nix/frontend.nix +++ b/nix/packages/frontend.nix @@ -33,7 +33,7 @@ buildNpmPackage { pname = "hyperhive-frontend"; version = "0.0.0"; - src = ../frontend; + src = ../../frontend; # Computed from `frontend/package-lock.json` via # prefetch-npm-deps frontend/package-lock.json diff --git a/nix/packages/hive-forge-tools.nix b/nix/packages/hive-forge-tools.nix deleted file mode 100644 index 6ebfff4f..00000000 --- a/nix/packages/hive-forge-tools.nix +++ /dev/null @@ -1,35 +0,0 @@ -{ pkgs, lib }: -# hive-forge — Forgejo CLI wrapper for hyperhive. -# -# Previously a ~600-line bash script. Rewritten as a proper Rust -# binary in `/hive-forge` so we get: -# - typed clap subcommands (`hive-forge --help` instead of -# reading the case statement), -# - one reqwest client with consistent error surfaces (no more -# `curl --fail-with-body` repeated per verb), -# - sane shell quoting (no more HEREDOC-eaten-by-positional traps), -# - and a single test surface. -# -# This Nix file is now a thin extractor: it pulls just the -# `hive-forge` binary out of the hyperhive workspace package so -# agents that already imported this file via -# `pkgs.callPackage ../packages/hive-forge-tools.nix { }` keep -# working, getting only the verb they need on PATH (not the full -# `hive-c0re` / `hive` surface). -# -# Requires the hyperhive overlay (see `flake.nix`'s `overlays.default`) -# so `pkgs.hyperhive` resolves. -let - _ = lib; # placeholder; kept so callers don't break when reading the args. -in -pkgs.runCommand "hive-forge" - { - meta = { - description = "Forgejo CLI wrapper for hyperhive (Rust)"; - mainProgram = "hive-forge"; - }; - } - '' - mkdir -p $out/bin - ln -s ${pkgs.hyperhive}/bin/hive-forge $out/bin/hive-forge - '' diff --git a/nix/reference-docs.nix b/nix/packages/reference-docs.nix similarity index 98% rename from nix/reference-docs.nix rename to nix/packages/reference-docs.nix index 74a5c8c2..216a398d 100644 --- a/nix/reference-docs.nix +++ b/nix/packages/reference-docs.nix @@ -19,7 +19,7 @@ stdenv.mkDerivation { version = "0.1.0"; # Narrow src (just docs/) keeps this derivation's input hash decoupled # from the rest of the tree — a doc edit only re-hashes this. - src = ../docs; + src = ../../docs; # No build: pure markdown, nothing to compile or render. dontBuild = true; diff --git a/nix/rust.nix b/nix/rust.nix new file mode 100644 index 00000000..5d15fae8 --- /dev/null +++ b/nix/rust.nix @@ -0,0 +1,59 @@ +# Shared crane wiring for the rust workspace: the cargo source +# filter, the once-built dependency artifacts, and the native build +# inputs every rust derivation (packages + checks) consumes. +# Imported per system from flake.nix. +{ pkgs, craneLib }: +let + inherit (pkgs) lib; +in +rec { + # Shared between buildDepsOnly + buildPackage + cargoClippy/cargoTest + # so every derivation in the chain sees the same toolchain shape. + # git: hive-c0re's `lifecycle::tests::setup_proposed_*` shell out to + # `git init` + commit under `cargo test` in the sandbox. + # sqlite: matrix-sdk's `sqlite` feature (`hive-matrix-mcp` workspace + # member) — the matrix-sdk-sqlite + rusqlite stack links against + # system libsqlite3 by default. + # cmake: builds `aws-lc-sys` (BoringSSL) from source — pulled in by + # the `rustls` (aws-lc-rs) crypto provider under the OTLP/reqwest + # stack in `hive-metric`. + nativeBuildInputs = [ + pkgs.git + pkgs.sqlite + pkgs.pkg-config + pkgs.cmake + ]; + + # Narrowed source tree the rust derivations consume: everything + # cargo cares about (Cargo.toml/Cargo.lock + *.rs/*.toml), selected + # via `lib.fileset` rather than crane's `cleanCargoSource` filter — + # filesets drop directories that contain no selected files, so + # adding or renaming a non-rust directory (nix modules, docs, …) + # does NOT bust this src hash (an empty dir under a plain source + # filter would). All non-rust runtime assets — branding + the claude + # prompt template + claude-settings.json — live in the separate + # `hyperhive-assets` derivation and are loaded by the binaries at + # runtime from `$HIVE_ASSETS_DIR`. Net effect: only `*.rs` / + # `*.toml` / `Cargo.lock` edits rebuild the rust derivations. + cleanSrc = lib.fileset.toSource { + root = ../.; + fileset = craneLib.fileset.commonCargoSources ../.; + }; + + # Build the workspace's dependency tree once, cached as its own + # derivation. Package builds and checks reuse this via + # `inherit cargoArtifacts;` so a workspace-only edit doesn't rebuild + # deps. All consumers use the same `cleanSrc` so the input hash + # stays consistent across the chain. + cargoArtifacts = craneLib.buildDepsOnly { + src = cleanSrc; + # Workspace Cargo.toml is virtual (no `[package].name`), so crane + # can't auto-derive a name. Spell it out explicitly — keeps the + # derivation name stable across crane bumps + silences the + # placeholder warning. Same `pname` for the dep + check + # derivations so they share a clean naming family. + pname = "hyperhive-workspace"; + version = "0.1.0"; + inherit nativeBuildInputs; + }; +} diff --git a/nix/sources.nix b/nix/sources.nix new file mode 100644 index 00000000..90083ee9 --- /dev/null +++ b/nix/sources.nix @@ -0,0 +1,49 @@ +# Named, explicitly-filtered source views of this repo. Each real +# consumer gets its own filtered derivation used as `src`, rather than +# an inline filter at the use site. Companion to the rust `cleanSrc` +# (crane's cargo-source filter — see ./rust.nix). +{ lib }: +{ + # Filtered view of this repo handed to hive-c0re as the `hyperhive` + # meta-flake input. Drops files no nix or cargo derivation reads — + # shell helper scripts and root-level markdown — so editing them + # does NOT change the store path and therefore does NOT force a + # rebuild of every agent container. + # + # Dropped: scripts/, docs/, root-level *.md (README/CLAUDE/TODO/…). + # Kept (build needs them): .nix, .rs, Cargo.*, branding/, + # frontend/, prompts/, flake.lock. docs/ is deliberately dropped: + # it is shipped to agent containers as its OWN narrow meta-flake + # input (`hyperhiveDocsSource`, below), threaded through hive-c0re + # → the meta flake → `hyperhive.docs.source`. Keeping docs/ out of + # THIS source means a doc edit only re-hashes the docs input (a + # cheap re-link), not the whole flake source (which would rebuild + # every agent container). The `reference-docs` build from `../docs` + # stays only for standalone `nix build .#reference-docs` from a + # full checkout — the meta path never evaluates it. + hyperhiveFlakeSource = lib.cleanSourceWith { + name = "hyperhive-flake-source"; + src = ../.; + filter = + path: type: + let + # Repo-relative path (strip the absolute source-dir prefix). + rel = lib.removePrefix (toString ../. + "/") (toString path); + in + !(lib.hasPrefix "scripts/" rel || rel == "scripts") + && !(lib.hasPrefix "docs/" rel || rel == "docs") + && !(type == "regular" && !lib.hasInfix "/" rel && lib.hasSuffix ".md" rel); + }; + + # The repo `docs/` tree as a standalone narrow source. Its store + # path moves ONLY on doc edits, decoupled from `hyperhiveFlakeSource`. + # hive-c0re threads this to the meta flake as the `hyperhive-docs` + # input (same pattern as the `hyperhive` input); the harness resolves + # `$HIVE_DOCS_DIR` from it via `hyperhive.docs.source`. Evaluated + # here on the host where docs/ exists — it cannot be derived from + # inside the docs-stripped `hyperhiveFlakeSource`. + hyperhiveDocsSource = lib.cleanSourceWith { + name = "hyperhive-docs-source"; + src = ../docs; + }; +} diff --git a/nix/templates/agent-base.nix b/nix/templates/agent.nix similarity index 82% rename from nix/templates/agent-base.nix rename to nix/templates/agent.nix index e905e813..ea51ff8e 100644 --- a/nix/templates/agent-base.nix +++ b/nix/templates/agent.nix @@ -1,6 +1,6 @@ { ... }: { - imports = [ ./harness-base.nix ]; + imports = [ ../agent-modules ]; # Entry-point for sub-agent containers. Referenced from `flake.nix` # (`nixosConfigurations.agent-base`) and the meta-flake's # `applied//flake.nix`. diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix deleted file mode 100644 index f85fb7a0..00000000 --- a/nix/templates/harness-base.nix +++ /dev/null @@ -1,2205 +0,0 @@ -{ - pkgs, - lib, - config, - # Flake inputs routed through _module.args by the agent flake.nix. - # Default to {} so the module evaluates cleanly even when the agent - # flake doesn't set up the routing pattern (e.g. during standalone - # nixos-rebuild without a flake wrapper). - flakeInputs ? { }, - ... -}: -let - # Agent user metadata. `userName` defaults to `"agent"` when the - # meta-flake doesn't inject the per-agent override (stand-alone - # `nixos-rebuild` against `nixosConfigurations.agent-base` works - # without erroring on a missing per-agent name). `homeDir` derives - # from `userName` to keep them coupled. - userName = config.hyperhive.user.name; - homeDir = "/home/${userName}"; - # GitHub integration (hyperhive.github.enable): a `gh` wrapper + a git - # credential helper, both reading the PAT from the agent's `github-token` - # state file at invocation, so a dashboard-pasted token takes effect with no - # rebuild. The token PATH is baked in at build time (nix knows `userName`) — - # NOT read from `$HIVE_GITHUB_TOKEN_FILE`, because claude's Bash tool runs - # `bash -c` in a minimal env that doesn't source `/etc/set-environment`, so - # the env var isn't present where `gh`/`git` actually run. The token value - # never enters the nix store (only its path). github.com only; git auths as - # `x-access-token` + PAT. - ghWrapper = pkgs.writeShellScriptBin "gh" '' - if [ -r "/agents/${userName}/state/github-token" ]; then - GH_TOKEN="$(cat "/agents/${userName}/state/github-token")" - export GH_TOKEN - fi - exec ${pkgs.gh}/bin/gh "$@" - ''; - gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-github" '' - # git credential-helper protocol: only the `get` action needs an answer. - [ "''${1:-}" = "get" ] || exit 0 - if [ -r "/agents/${userName}/state/github-token" ]; then - # GitHub ignores the username for PAT auth — `x-access-token` is the - # conventional placeholder; the PAT is the password. - printf 'username=x-access-token\n' - printf 'password=%s\n' "$(cat "/agents/${userName}/state/github-token")" - fi - ''; - # Hive-wide OpenTelemetry config (host-driven; baked in per-agent by - # meta.rs `otel_config`). - otelCfg = config.hyperhive.otel; - # Hive/swarm display names are forwarded into each agent's build by - # meta.rs as `environment.variables` (per-agent, build-time strings), - # so they can be baked into the resource attributes below without a - # runtime shell. Absent (option unset) → "unknown". - hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown"; - swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown"; - # OTEL environment Claude Code reads to export metrics/logs/traces. - # Shipped via the managed claude settings json (below), which claude - # auto-discovers for BOTH the harness turn-loop and `hivectl choom` — - # so telemetry parity is declarative, with no launch wrapper. The - # auth header (`otel.headersCredential`) is deliberately NOT included - # here: it's a secret and this file lives in the world-readable nix - # store. It's injected at *runtime* into the agent's `0600` - # `~/.claude/settings.json` by the `hive-otel-header` oneshot below - # (claude merges the `env` from the user settings on top of these - # managed ones), so the token is read from disk at start and never - # touches the store. - # Base claude-code environment applied to every agent regardless of OTEL. - # Shipped via the managed settings `env` block so claude and `hivectl - # choom` both inherit them without a launch wrapper. - baseClaudeEnv = { - # Suppress analytics, survey pings, and other non-essential outbound - # traffic — agents are headless and don't need any of that. - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; - DO_NOT_TRACK = "1"; - # Disable claude's self-update machinery; package management is nix's job. - DISABLE_AUTOUPDATER = "1"; - DISABLE_UPDATES = "1"; - # Keep plugin updates synchronized on install; prefer HTTPS for fetches. - CLAUDE_CODE_PLUGIN_PREFER_HTTPS = "1"; - CLAUDE_CODE_SYNC_PLUGIN_INSTALL = "1"; - FORCE_AUTOUPDATE_PLUGINS = "1"; - # Suppress the "install GitHub app" prompt — not applicable in-hive. - DISABLE_INSTALL_GITHUB_APP_COMMAND = "1"; - # Disable Anthropic's hosted claude.ai MCP servers; the hive supplies its own. - ENABLE_CLAUDEAI_MCP_SERVERS = "0"; - # Resume an interrupted turn on reconnect (recovers from transient MCP flaps). - CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1"; - # Use the simpler system prompt variant suited to headless operation. - CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1"; - # Tag remote-control sessions with "-" for identification. - CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}"; - }; - otelSettingsEnv = { - CLAUDE_CODE_ENABLE_TELEMETRY = "1"; - # Attach feedback-survey data to the OTEL pipeline. - CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL = "1"; - OTEL_METRICS_EXPORTER = "otlp"; - OTEL_LOGS_EXPORTER = "otlp"; - OTEL_TRACES_EXPORTER = "otlp"; - OTEL_EXPORTER_OTLP_PROTOCOL = otelCfg.protocol; - OTEL_EXPORTER_OTLP_ENDPOINT = otelCfg.endpoint; - # Force CUMULATIVE temporality — Claude Code defaults to DELTA, - # which Prometheus/Mimir-family backends (incl. grafana-lgtm) - # silently drop without a deltatocumulative processor. - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative"; - OTEL_RESOURCE_ATTRIBUTES = - "service.name=hyperhive-agent,agent=${userName},hive=${hiveDisplayName},swarm=${swarmDisplayName}" - + lib.optionalString (otelCfg.extraResourceAttributes != "") ",${otelCfg.extraResourceAttributes}"; - # Include the Claude Code version label in emitted metrics. - OTEL_METRICS_INCLUDE_VERSION = "1"; - } - // lib.optionalAttrs (otelCfg.metricIntervalMs != null) { - OTEL_METRIC_EXPORT_INTERVAL = toString otelCfg.metricIntervalMs; - }; - # Single source of truth for the default matrix homeserver URL, shared - # by the `hyperhive.matrix.url` option default and the daemon-unit guard - # that decides whether to set a unit-level HIVE_MATRIX_URL (so the two - # cannot drift). Matches the daemon's own built-in default - # (`paths::DEFAULT_HOMESERVER`). - matrixUrlDefault = "http://localhost:8008"; - # Rasterize the operator-set agent icon (`hyperhive.icon`, an SVG) to a - # 512x512 PNG so the matrix daemon can upload it as each account's avatar - # over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar). - # Replaces the old `matrix-avatar-sync` curl oneshot. Only forced when an - # icon is configured — the `HIVE_ICON_PNG` daemon-env entry is gated on - # `hyperhive.icon != null`, so this binding stays lazy when no icon is set. - iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } '' - rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out - ''; -in -{ - # Shared scaffolding for every hyperhive harness container. - # `agent-base.nix` and `manager.nix` both import this; all agents - # use the same service unit regardless of which entry-point they came from. - - # Optional feature modules. Each declares its own `hyperhive.*` - # option(s), default-off, so every agent has them available but - # only opts in from its own `agent.nix`. - imports = [ ./weston-vnc.nix ]; - - # Per-agent unix user the harness + co-process daemons run as. - # Defaults to `"agent"` so a standalone evaluation (e.g. - # `nix flake check` against `nixosConfigurations.agent-base`) builds - # cleanly; the meta-flake's per-agent module rebinds this to the - # agent name (`"damocles"`, `"iris"`, …) so each container has a - # uniquely-named user matching its agent label. UID auto-assigned - # by NixOS (the auto-allocation range for normal users); no hard- - # coded UID. - options.hyperhive.user.name = lib.mkOption { - type = lib.types.strMatching "^[a-z_][a-z0-9_-]{0,30}$"; - default = "agent"; - example = "iris"; - description = '' - Unix user the harness service runs as inside the container. - The meta-flake overrides this to the agent's own name so the - user inside the container matches the agent label (`HIVE_LABEL`). - Stand-alone evaluation defaults to `"agent"` so module evaluation - without the meta-flake wrapper still builds. - - Constraints match `useradd`'s NAME_REGEX: lowercase / `_` start, - total length ≤ 31, no special characters. UID is auto-assigned - by NixOS unless `hyperhive.user.uid` is explicitly set. - ''; - }; - - options.hyperhive.user.uid = lib.mkOption { - type = lib.types.nullOr lib.types.int; - default = null; - example = 1100; - description = '' - Optional fixed UID for the per-agent unix user. `null` (default) - lets NixOS auto-assign from the normal-user range (≥ 1000), - which is the right default for most deployments — the UID stays - stable across container rebuilds because each container only has - one normal user and the assignment is written into the container's - `/etc/passwd` at activation time. - - Set an explicit value only when the host needs a predictable UID - for the agent's state files — e.g. if an operator script - references files by numeric UID, or to keep ownership stable - across full container destroy + recreate on a fresh host. - - Values must be in `[1000, 60000)`. Using UIDs < 1000 clashes with - system accounts and is rejected by NixOS. - ''; - }; - - options.hyperhive.user.gid = lib.mkOption { - type = lib.types.nullOr lib.types.int; - default = null; - example = 1100; - description = '' - Optional fixed GID for the per-agent unix group. `null` (default) - lets NixOS auto-assign. Usually set alongside `hyperhive.user.uid` - to the same value (the conventional Unix pattern for per-user - groups where uid == gid), but can be set independently. - ''; - }; - - options.hyperhive.user.passwordlessSudo = lib.mkOption { - type = lib.types.bool; - default = true; - example = false; - description = '' - Grant `${config.hyperhive.user.name}` passwordless sudo - (`NOPASSWD: ALL`). True by default so claude's `Bash` tool - keeps working for tools that expect root inside the container - (`systemctl`, package managers in dev shells, etc.) — the - same surface the previous root-user shape had, just elevated - explicitly instead of implicitly. - - Flip to `false` for agents that should be strictly - unprivileged. Anything claude shells out to that needs root - will then fail loudly with the standard sudo error rather - than silently succeeding — easier to spot the leak. - ''; - }; - - options.hyperhive.web.useUnixSocket = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Deprecated. Unix socket mode is now always enabled for all agents. - Setting this option to `true` has no effect and the option will be - removed in a future version. Safe to drop from agent configs. - ''; - }; - - options.hyperhive.model = lib.mkOption { - type = lib.types.str; - default = "haiku"; - example = "sonnet"; - description = '' - Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL` - environment variable; the harness applies it at boot and it takes - priority over any persisted runtime override. The operator can still - switch the model at runtime via the per-agent web UI — that choice - is tracked in the state dir for the current session but is reset by - any rebuild that changes this option. - - Valid values are the short model names that `claude --model` accepts: - `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context - window sizes are looked up at runtime from the - `HIVE_CONTEXT_WINDOW_TOKENS_` env vars injected by the - meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens` - on the host. - ''; - }; - - options.hyperhive.availableModels = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ - "haiku" - "sonnet" - "opus" - ]; - example = [ - "sonnet" - "opus" - ]; - description = '' - Models offered in the per-agent web UI's model quick-picker. Rendered - into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated) - which the harness surfaces to the agent UI, so the picker lists exactly - these models instead of a hardcoded set. - - Configure hive-wide by setting a shared default (e.g. in your - `agent-base.nix`) or per-agent to narrow the menu — for example a - haiku-only agent can hide `opus` and `sonnet`. The *current* model is - still set by `hyperhive.model` and remains switchable at runtime via the - UI; this option only controls which choices the picker presents. - - Values are the short model names that `claude --model` accepts: - `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). - ''; - }; - - options.hyperhive.effortLevel = lib.mkOption { - type = lib.types.enum [ - "low" - "medium" - "high" - "xhigh" - "max" - ]; - default = "medium"; - example = "high"; - description = '' - Baseline claude effort level for this agent. Rendered into the - `HIVE_DEFAULT_EFFORT` environment variable; the harness resolves - effort as operator-override-file → this env → built-in `"medium"`, - and passes the result to `claude --effort` at turn launch. - - Ascending scale: `"low"` (minimal thinking budget), `"medium"` - (default — balanced), `"high"` (platform default), `"xhigh"` - (recommended for autonomous coding on capable models), `"max"` - (maximum thinking budget, highest cost). The operator can override - at runtime per-agent via the web UI (applied on the next session); - any rebuild that changes this option resets that override. - ''; - }; - - options.hyperhive.docs.enable = lib.mkEnableOption '' - make the hyperhive reference docs (the repo `docs/` tree, shipped - read-only as the standalone `hyperhive-docs` derivation) available - in-container. When enabled the harness exposes the docs dir to claude - via `claude --add-dir`, so the markdown is readable at - `$HIVE_DOCS_DIR/`, and appends a single pointer sentence to the agent's - system prompt so it knows the docs exist (see - `hive-ag3nt::prompt::render`). Default-on for the root/manager agent - (see `manager.nix`), off elsewhere; any agent can flip it from its - `agent.nix`. - ''; - - options.hyperhive.docs.source = lib.mkOption { - type = lib.types.path; - default = pkgs.hyperhive-docs; - defaultText = lib.literalMD "`pkgs.hyperhive-docs` (built from `../docs`)"; - description = '' - Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR` - when `hyperhive.docs.enable` is set. Defaults to the overlay's - `pkgs.hyperhive-docs` (the `nix/reference-docs.nix` build from - `../docs`) so a standalone container build from a full checkout - works unchanged. The generated meta flake overrides this with the - narrow `hyperhive-docs` flake input so a doc edit only - re-locks that input instead of rebuilding the container from a - re-hashed `hyperhive` source. - ''; - }; - - options.hyperhive.allowedBashPatterns = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - description = '' - Deprecated - has no effect. The built-in Bash tool is fully - disabled regardless of this list; agents use mcp__bash__run - instead. Remove this option from your agent.nix. - ''; - visible = false; - }; - - # OTEL stats export is configured ONCE at host level via - # `services.hyperhive.otel.*` (see nix/modules/hive-c0re.nix) and - # injected into every agent's build by the meta-flake renderer - # (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are - # the build-time implementation surface that injection writes into; - # they are not meant to be set directly in an agent.nix. Marked - # `internal` so the host option is the only documented operator knob. - options.hyperhive.otel = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - internal = true; - description = '' - Export this agent's Claude Code stats (token usage, cost, tool - calls) to an OTLP endpoint via Claude Code's built-in - OpenTelemetry. Each agent's harness exports directly to the - collector, so it keeps working even when hive-c0re is down. - Host-driven: set `services.hyperhive.otel.enable` instead. - ''; - }; - - endpoint = lib.mkOption { - type = lib.types.str; - default = ""; - internal = true; - description = '' - OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`. - Host-driven via `services.hyperhive.otel.endpoint`. - ''; - }; - - protocol = lib.mkOption { - type = lib.types.enum [ - "http/protobuf" - "http/json" - "grpc" - ]; - default = "http/protobuf"; - internal = true; - description = '' - OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`. - Host-driven via `services.hyperhive.otel.protocol`. - ''; - }; - - headersCredential = lib.mkOption { - # `str`, not `path`: a `path`-typed *relative* literal (e.g. - # `./otel-headers`) is hash-copied into the world-readable nix store - # at eval time, which would defeat the whole point of this option. - # Keep it a string and require an absolute runtime path so the secret - # is only ever read from disk by systemd at start, never nix-stored. - type = lib.types.nullOr lib.types.str; - default = null; - internal = true; - description = '' - Absolute path to an operator-provided secret file whose contents - become `OTEL_EXPORTER_OTLP_HEADERS` (e.g. - `Authorization=Bearer `). Host-driven via - `services.hyperhive.otel.headersCredential`. - - The rest of the OTEL config ships in the world-readable managed - claude settings json, but the header is a secret, so it's handled - separately: hive-c0re forwards this file into the container's - systemd credential store, and the `hive-otel-header` oneshot - reads it at runtime (`LoadCredential`) and writes it into the - agent's `0600` `~/.claude/settings.json` `env` block. The token - is read from disk at start and never copied into the nix store or - the world-readable settings file. - ''; - }; - - extraResourceAttributes = lib.mkOption { - type = lib.types.str; - default = ""; - internal = true; - description = '' - Extra comma-separated entries appended to - `OTEL_RESOURCE_ATTRIBUTES` after the built-in - `service.name` / `agent` / `hive` / `swarm` labels. - Host-driven via `services.hyperhive.otel.extraResourceAttributes`. - ''; - }; - - metricIntervalMs = lib.mkOption { - type = lib.types.nullOr lib.types.ints.positive; - default = null; - internal = true; - description = '' - Metric export interval in milliseconds, set as - `OTEL_METRIC_EXPORT_INTERVAL`. Null leaves Claude Code's 60s - default. Host-driven via `services.hyperhive.otel.metricIntervalMs`. - ''; - }; - - debug = lib.mkOption { - type = lib.types.bool; - default = false; - internal = true; - description = '' - Emit OTEL SDK diagnostics to stderr (`CLAUDE_CODE_OTEL_DIAG_STDERR=1`). - Host-driven via `services.hyperhive.otel.debug`. - ''; - }; - }; - - options.hyperhive.allowedRecipients = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ - "alice" - "manager" - ]; - description = '' - Names this agent is allowed to `send` to via - `mcp__hyperhive__send`. Empty list (the default) means - unrestricted — the agent can message any peer, the - operator, or the manager. Non-empty list constrains the - surface: only the listed names + the manager (always - allowed) get through; anything else returns an error - string to claude without touching the broker. The - operator (`operator`) needs to be in the list if the - agent should be able to surface output on the - dashboard. - - Useful for sandboxing untrusted sub-agents — set - `[ "manager" ]` to scope them to manager-only chatter. - The manager itself is always exempt; this option only - affects sub-agent `send`. - ''; - }; - - options.hyperhive.extraMcpServers = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = { - command = lib.mkOption { - type = lib.types.str; - description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`."; - }; - args = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - description = "Args passed to the MCP server binary."; - }; - env = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = { }; - description = "Environment variables for the MCP server child process."; - }; - allowedTools = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ "*" ]; - example = [ - "send_message" - "join_room" - ]; - description = '' - Tool names this MCP server is auto-approved to call via - `--allowedTools`. Single entry `"*"` (the default) means - "every tool from this server" — convenient but trusting. - Tighten to a specific list when you only want a subset. - Names are bare (e.g. `send_message`); the harness prepends - `mcp____` at build time. - ''; - }; - }; - } - ); - default = { }; - example = lib.literalExpression '' - { - matrix = { - command = "/run/current-system/sw/bin/mcp-matrix"; - args = [ "--config" "/state/matrix.toml" ]; - env.MATRIX_HOMESERVER = "https://matrix.example.org"; - allowedTools = [ "send_message" "join_room" ]; - }; - } - ''; - description = '' - Extra MCP servers claude sees alongside the hyperhive tool surface. - Keys are the server names (claude addresses tools as - `mcp____`). Rendered to `/etc/hyperhive/extra-mcp.json` - at activation time; the harness reads that file at boot and merges - it into `--mcp-config` + `--allowedTools`. Take effect on the - agent's next harness restart (no operator approval needed beyond - whatever brought the new agent.nix into deployed/*). - ''; - }; - - options.hyperhive.matrix.enable = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Enable per-agent matrix integration via `hive-matrix-mcp`. - When true (the default), the harness: - - - runs `hive-matrix-daemon` as a systemd unit that holds a - matrix-sdk Client + sync against the homeserver at - `HIVE_MATRIX_URL` (default `http://localhost:8008` — the - in-host tuwunel from `nix/modules/hive-matrix.nix`). The - daemon auto-skips when `/matrix-token` is missing, - and a `systemd.paths` watcher restarts it the moment - hive-c0re provisions the token (same path-trigger shape - as `forge-avatar-sync`). - - exposes the matrix tool surface (send_message, send_dm, - send_reaction, send_reply, mark_read, list_rooms, - list_room_members, read_room) to claude via an auto-injected - `extraMcpServers.matrix` entry. Claude spawns the stdio - `hive-matrix-mcp` bridge per turn, which forwards each tool - call to the daemon over `/run/hive-matrix/socket`. - - wakes the agent on incoming room events via a short teaser - Wake signal (`[matrix] in : …`) - to the hyperhive control socket; the full event stays - unread server-side until `read_room` consumes it. - - Set to `false` for agents that should NOT have matrix tools at - all (e.g. agents on a host without `hyperhive.matrix.enable` on - the meta side). When token file is absent the daemon and MCP - both no-op cleanly anyway, so `false` is rarely necessary. - ''; - }; - - options.hyperhive.matrix.url = lib.mkOption { - type = lib.types.str; - default = matrixUrlDefault; - example = "https://matrix.darkest.space"; - description = '' - Matrix homeserver URL the agent's `hive-matrix-daemon` connects - to. At runtime hive-c0re forwards the isolation-aware URL - (`matrix.` via the gateway) so isolated agents reach - the homeserver without crossing host loopback. Override - per-agent when an agent should talk to an external homeserver - instead (e.g. a federation-only setup or a remote hive's - tuwunel reached via a vpn). - ''; - }; - - options.hyperhive.matrixAccounts = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = { - tokenFile = lib.mkOption { - type = lib.types.str; - example = "/agents/dmatrix/state/matrix-token-ccc"; - description = '' - Path to this account's bearer-token file. The daemon reads - the token from here to restore the matrix session; how the - file gets populated is the provisioner's concern (an - operator-supplied secret for an external account). The - daemon skips an extra account whose token file is absent. - ''; - }; - sessionDir = lib.mkOption { - type = lib.types.str; - example = "/agents/dmatrix/state/matrix-sdk-state-ccc"; - description = '' - Per-account matrix-sdk sqlite store directory (crypto keys - + event cache). Must differ between accounts so their - sessions do not collide. - ''; - }; - homeserver = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "https://matrix.example.org"; - description = '' - Homeserver URL for this account. When null (the default), - the account falls back to `hyperhive.matrix.url`. Set it for - an account on a different homeserver than the agent's - default (e.g. an external public-matrix account). - ''; - }; - }; - } - ); - default = { }; - example = lib.literalExpression '' - { - ccc = { - tokenFile = "/agents/dmatrix/state/matrix-token-ccc"; - sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc"; - homeserver = "https://matrix.example.org"; - }; - } - ''; - description = '' - Declare *additional* matrix accounts served by the single - `hive-matrix-daemon` (one matrix-sdk Client + sync loop each), - beyond the agent's built-in hive-internal account. This replaces - the wasteful "one MCP server + daemon per account" pattern. The - attribute name keys each account (unique by construction) and is - the handle the matrix MCP tools target via their `account` - argument. - - The **hive-internal account is always present and is the primary**: - it is named `main`, synthesized by the daemon from - `hyperhive.matrix.url` + `/matrix-token` + - `/matrix-sdk-state`, and is the account a tool call acts as - when it omits `account`. You never declare it here --- this option - is only for the extras (e.g. an external public-matrix account). - - Leave empty (the default) for the common single-account case: the - agent then has only `main`, exactly as before. When non-empty, the - extras are serialized to the daemon's `HIVE_MATRIX_ACCOUNTS` - environment variable and the daemon appends them after `main`. - Requires `hyperhive.matrix.enable` (there is no `main` to extend - otherwise). - ''; - }; - - options.hyperhive.github.enable = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Install the GitHub integration in this agent: a `gh` CLI wrapper and a - git credential helper for `https://github.com`, both authenticated from - an operator-supplied personal access token (PAT). The PAT is written to - `/github-token` out of band --- the dashboard credentials tab or - `hivectl github set-token` --- so giving an agent GitHub is a runtime - paste, no per-agent config or rebuild. The wrappers read the token file - at invocation, so a freshly-pasted PAT takes effect immediately; until - one exists, `gh` / `git push` just fail unauthenticated. - - github.com only. git authenticates as `x-access-token` + the PAT (GitHub - ignores the username for PAT auth); `gh` derives its identity from the - token. Keep the PAT minimally scoped: the agent has passwordless sudo, so - a compromised agent can act within the token's scopes --- scope is the - real blast-radius limiter. - - On by default. Host-driven: set `services.hyperhive.github.enable = false` - to turn the integration off hive-wide (meta.rs propagates the override - into every agent). - ''; - }; - - options.hyperhive.frontend.dist = lib.mkOption { - type = lib.types.package; - default = pkgs.hyperhive-frontend; - defaultText = lib.literalExpression "pkgs.hyperhive-frontend"; - description = '' - The shipped frontend dist (built by `nix/frontend.nix`). Output - layout: `dashboard/` (used by hive-c0re on the host) and - `agent/` (used here, layered with `extraFiles` below at - activation time). Override to ship a fully custom per-agent SPA; - the JSON contract (`/api/state`, `/events/stream`, the action - endpoints) is the source of truth for any replacement. - ''; - }; - - options.hyperhive.frontend.mergedDist = lib.mkOption { - type = lib.types.package; - readOnly = true; - description = '' - Computed: the merged static tree consumed by the harness via - `HIVE_STATIC_DIR`. Composed at evaluation time by copying - `hyperhive.frontend.dist`'s `agent/` subdir as the base, then - layering each `extraFiles` entry on top. Read-only — do not set directly. - ''; - }; - - options.hyperhive.frontend.extraFiles = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule ( - { name, ... }: - { - options = { - source = lib.mkOption { - type = lib.types.path; - description = '' - Source file or directory to layer over the default - agent dist. A path (relative to `agent.nix` or - absolute) — nix copies its contents into the merged - static tree. - ''; - }; - target = lib.mkOption { - # First char must be alphanumeric/underscore (rules out - # leading `/`, leading `.`, leading `-`); inner chars - # include `.` and `/` so nested layouts like - # `"games/bitburner"` work. This is the shape check — - # the `..`-segment traversal check is the assertion in - # `config.assertions` below (regex alone can't reject - # mid-path `..` segments without lookahead, which nix - # POSIX regex doesn't support). - type = lib.types.strMatching "^[A-Za-z0-9_][A-Za-z0-9_./-]*$"; - default = name; - defaultText = lib.literalMD "the attribute name"; - description = '' - Destination path within the merged static tree, used - as both the served URL prefix (`//...`) and - the on-disk layout in the merged derivation. Defaults - to the attribute name. Use forward slashes for - nested layouts (e.g. `"games/bitburner"`). - - Constrained shape: must start with an alphanumeric or - `_`, and only contain alphanumerics, `_`, `.`, `/`, - `-`. `..` segments are separately rejected at config - eval time. - ''; - }; - }; - } - ) - ); - default = { }; - example = lib.literalExpression '' - { - bitburner = { - source = ./bitburner-dist; - # served at GET /bitburner/... - }; - } - ''; - description = '' - Per-agent additions layered on top of the default frontend - dist. Each entry copies its `source` into the served static - tree under `target`. Useful for shipping a self-contained - agent-specific surface alongside the standard agent UI (e.g. - the bitburner agent's game page at `/bitburner/`). - - The default agent UI remains served at `/`; entries here only - add new routes and never replace the default. Overwrite - semantics are **hard-fail**: if `target` collides with an - existing file or directory in the default dist (or with a - prior entry's target), the `mergedDist` build aborts with - `refusing to overwrite existing path '' in the - default dist`. To override a default file, fork the dist via - `hyperhive.frontend.dist` instead — `extraFiles` is for - pure additions. - - `target` must be a relative path inside the static dir. An - assertion rejects leading `/` and `..` segments at config - eval time (string-concat-into-paths safety, even though - agent.nix goes through operator review before deploy). - ''; - }; - - options.hyperhive.forge.url = lib.mkOption { - type = lib.types.str; - default = "http://localhost:3000"; - example = "http://forge.internal:3000"; - description = '' - Base URL of the hyperhive-managed Forgejo. Used at container - boot by a oneshot systemd unit that calls - `tea login add --url --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` - (= `/agents//state/forge-token`) so the agent's claude can - shell out to `tea` without an extra auth dance. No-op when the - forge-token file is missing (i.e. hive-forge isn't running on - the host). - ''; - }; - - options.hyperhive.dashboardLinks = lib.mkOption { - type = lib.types.listOf ( - lib.types.submodule { - options = { - label = lib.mkOption { - type = lib.types.str; - description = "Display label for the link."; - }; - icon = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Optional icon emoji or short glyph."; - }; - url = lib.mkOption { - type = lib.types.str; - description = "Full URL (may include a different port, e.g. http://localhost:9001/stats)."; - }; - }; - } - ); - default = [ ]; - example = lib.literalExpression '' - [ - { label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; } - ] - ''; - description = '' - Extra navigation links surfaced on the hive-c0re dashboard card for - this agent. Declare any additional web UI pages the agent exposes — - stats pages, custom UIs, etc. hive-c0re reads the JSON file this - option produces at each container-view snapshot and attaches the - links to the agent card without any code changes. - ''; - }; - - options.hyperhive.claudeMarketplaces = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ "anthropics/claude-plugins-official" ]; - example = [ - "anthropics/claude-plugins-official" - "anthropics/claude-plugins-community" - ]; - description = '' - Claude Code plugin marketplaces to add at harness boot. Each - entry is passed to `claude plugin marketplace add ` - (`owner/repo`, full git URL, or local path). Idempotent — - re-adding an existing marketplace is treated as success. - Required before `hyperhive.claudePlugins` entries that - reference a marketplace (e.g. `foo@claude-plugins-official`). - Rendered to `/etc/hyperhive/claude-marketplaces.json`. - - Defaults to Anthropic's official marketplace; agents get it - out of the box without any per-agent.nix wiring. - ''; - }; - - options.hyperhive.claudePlugins = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ - "formatter@my-marketplace" - "thinking-tools@anthropics" - ]; - description = '' - Claude Code plugins to install at harness boot. Each entry is - passed verbatim to `claude plugin install ` once per - container start, before the turn loop opens. `claude plugin - install` is expected to be idempotent, so reinstalling on every - boot is cheap. Failures log a warning but do not abort boot — a - missing plugin is preferable to a non-serving agent. Rendered to - `/etc/hyperhive/claude-plugins.json`; the harness reads it via - `plugins::install_configured`. - ''; - }; - - options.hyperhive.claudePluginsAutoUpdate = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - When true, the harness runs `claude plugin marketplace update` - before installing plugins at boot, pulling the latest index from - all configured marketplaces. Disabled by default — most agents - want pinned plugin versions and the network round-trip adds to - boot time. Enable for agents that should always install the latest - available version of their plugins. - ''; - }; - - options.hyperhive.icon = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - example = lib.literalExpression "./icon.svg"; - description = '' - Path to an SVG file used as this agent's icon — shown on the - dashboard and the per-agent web UI (header + favicon). Commit - the SVG into the agent's config repo next to `agent.nix` and - reference it as a relative path (`./icon.svg`). - - When null (the default) the agent falls back to the shared - hyperhive logo. The harness serves the icon (configured or - default) at `GET /icon` on the per-agent web port. - ''; - }; - - options.hyperhive.extraWebProxies = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = { }; - example = lib.literalExpression ''{ "stats" = "http://127.0.0.1:3737"; }''; - description = '' - Transparent reverse-proxies mounted under `/extra/` in the per-agent web UI. - Each attribute name becomes the sub-path and the value is the upstream. - E.g. `{ "stats" = "http://127.0.0.1:3737"; }` mounts a proxy at - `/agent//extra/stats/` that forwards to port 3737 with the prefix - stripped. All user-declared proxies live under `/extra/` so they can - never conflict with native agent endpoints (`/api/*`, `/events/*`, etc.). - - The upstream value is either an `http(s)://` URL or a Unix domain - socket, spelled `unix:` (e.g. `unix:/run/myapp/http.sock`) — for - agents whose secondary web server only listens on a UDS. - - Intended for agents that run secondary web servers in the same container. - Static assets served by the secondary app must use relative paths to - resolve correctly under the sub-path prefix. - - Sets the `HIVE_EXTRA_WEB_PROXIES` environment variable (JSON object) - on the harness service unit. - ''; - }; - - # Internal accumulator for shell snippets that should land in - # `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via - # `lib.mkIf` gated on their own option; the lines type merges - # all contributions across modules into one file. Loaded via - # `$BASH_ENV` for non-interactive shells (claude's `Bash` tool - # runs `bash -c`) and via `programs.bash.interactiveShellInit` - # for interactive shells. Generic by design so future hooks - # don't need to rename this file or invent a parallel dispatcher. - options.hyperhive._bashEnvFragments = lib.mkOption { - type = lib.types.lines; - default = ""; - internal = true; - description = '' - Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`. - Feature hooks contribute via `lib.mkIf` gated on their own - option. When empty, the file isn't created, `BASH_ENV` stays - unset, and the interactive bashrc hook is omitted — zero cost - when no feature is on. Internal — set indirectly via the - per-feature options that own the gate (e.g. - `hyperhive.cargo.shortMessages`). - ''; - }; - - options.hyperhive.cargo.shortMessages = lib.mkOption { - type = lib.types.bool; - default = true; - example = false; - description = '' - Auto-inject `--message-format short` on cargo compile - subcommands (`build`, `check`, `clippy`, `test`, `run`, - `doc`, `bench`, `install`, `rustc`, `fix`) when claude (or - anything else) invokes `cargo` inside this container. - Saves tokens + context — the verbose default output floods - the response window with per-crate progress lines that - carry no signal beyond the warning/error summary. - - Implementation: contributes a `cargo` shell function to - `/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`). - Loaded via `BASH_ENV` for non-interactive shells (`bash -c` — - what the claude `Bash` tool runs) and sourced from - `programs.bash.interactiveShellInit` for interactive shells. - The function: - - - handles the `+toolchain` selector prefix (`cargo +nightly - build` works); - - passes through cleanly when the caller already specified - `--message-format` (any form); - - leaves non-compile subcommands (`new`, `add`, `search`, - third-party `cargo-*` subcommands) untouched so they - don't error on the unknown flag. - - Set to `false` for agents that need full cargo output (e.g. - tooling that parses `--message-format json` programmatically - and doesn't pass the flag explicitly). - ''; - }; - - options.hyperhive.autoCompact = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Enable proactive watermark-based compaction. When `true` (the - default) the harness automatically runs a notes-checkpoint turn - followed by `/compact` once the context window crosses 75% of - the model's limit, keeping later turns from hitting the hard - overflow path. Set to `false` to disable proactive compaction - entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path - (compact-on-overflow when the session is already past the limit) - still applies. - - Disable for agents that run large-context models (sonnet/opus) - where the heuristic fires too early and discards useful history - before the session is actually close to the limit. - ''; - }; - - options.hyperhive.mcp.httpPort = lib.mkOption { - type = lib.types.port; - default = 8790; - example = 8791; - description = '' - Loopback port the built-in hyperhive MCP surface is served on. HTTP - is the *sole* transport for the built-in surface: a - long-lived `hive-mcp-http` systemd unit runs - `hive-agent-mcp --http 127.0.0.1:` and `render_claude_config` - points claude at the stable `http://127.0.0.1:/mcp` URL. That - URL survives the per-turn claude re-spawn (and a host-side hive-c0re - restart — each tool call dials the control socket fresh), so there - is no per-turn MCP re-registration race (a resumed stdio child could - emit its first tool call before that turn's async - `initialize`/`tools-list` completed, stranding the agent with `No - such tool` — the http endpoint eliminates that). Extra MCP servers - (matrix/bash) stay stdio bridges regardless. - - Bound loopback-only; the rmcp streamable-http transport's default - `allowed_hosts` (`localhost` / `127.0.0.1` / `::1`) rejects Host - headers from anywhere else, so no auth token is required for a - container-local endpoint. - - Failure-mode note: with no stdio fallback, if `hive-mcp-http` is - down claude hits a dead URL until the unit restarts (guarded by - `Restart=always`, `RestartSec=3`). Intended shape: no per-turn race - while up, a bounded self-healing gap while restarting. - - Safe as a single fixed default across all agents: each container - runs in its own private network namespace (isolation is always-on — - see docs/network.md), so `127.0.0.1:` is per-container-private - and cannot collide across agents. Override only if a container-local - service already occupies this port. - - Must match `mcp_config::DEFAULT_MCP_HTTP_PORT` (the harness always - exports `HYPERHIVE_MCP_HTTP_PORT`, so the const is only a fallback). - ''; - }; - - config = { - warnings = lib.optional (config.hyperhive.allowedBashPatterns != [ ]) '' - hyperhive.allowedBashPatterns is deprecated and has no effect. - The built-in Bash tool is fully disabled; agents use mcp__bash__run instead. - Remove allowedBashPatterns from your agent.nix. - ''; - - assertions = [ - # Guard the inputs-routed-as-output pattern: the agent flake.nix is - # expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`. - # If `self` leaks into flakeInputs the agent gets a spurious attrset - # entry that can shadow real inputs and is almost certainly a bug. - # Guard with `or {}` so standalone evaluation stays clean when - # flakeInputs is absent from _module.args. - { - assertion = !(builtins.hasAttr "self" (config._module.args.flakeInputs or { })); - message = '' - hyperhive: `flakeInputs` must not contain "self". - In your agent flake.nix, use: - _module.args.flakeInputs = builtins.removeAttrs inputs [ "self" ]; - ''; - } - # hyperhive.model must be a non-empty string — an empty value causes - # the harness to pass an invalid model flag to claude. - { - assertion = config.hyperhive.model != ""; - message = "hyperhive.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")"; - } - # The current model must appear in the quick-picker menu, otherwise the - # UI would offer no way back to the model the agent is actually running. - { - assertion = - config.hyperhive.availableModels == [ ] - || builtins.elem config.hyperhive.model config.hyperhive.availableModels; - message = - "hyperhive.model (\"${config.hyperhive.model}\") must be one of " - + "hyperhive.availableModels ([ ${lib.concatStringsSep " " config.hyperhive.availableModels} ]) " - + "— add it to the list or change the model."; - } - # hyperhive.forge.url must look like an HTTP URL when non-default. - { - assertion = - config.hyperhive.forge.url == "" - || lib.hasPrefix "http://" config.hyperhive.forge.url - || lib.hasPrefix "https://" config.hyperhive.forge.url; - message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")"; - } - # hyperhive.icon must reference an SVG file when set. - { - assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon); - message = "hyperhive.icon must point to an .svg file"; - } - # Extra matrix accounts only make sense alongside the hive-internal - # `main` account they extend, which exists only when matrix is - # enabled. - { - assertion = config.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable; - message = - "hyperhive.matrixAccounts requires hyperhive.matrix.enable = true " - + "(the extras extend the hive-internal `main` account, which only " - + "exists when matrix is enabled)."; - } - # `main` is reserved for the synthesized hive-internal account; a - # declared extra by that name would silently collide with it. - { - assertion = !builtins.hasAttr "main" config.hyperhive.matrixAccounts; - message = - "hyperhive.matrixAccounts cannot contain a key named \"main\" " - + "--- that name is reserved for the hive-internal account."; - } - # Token files must land at the `matrix-token*` name the daemon - # path-watcher globs (`/agents/*/state/matrix-token*`), or the account - # never gets picked up live (it loads only on a full daemon restart). - # Enforce the basename prefix so a deviating name (e.g. the historical - # `matrix-catgirl-token`) is caught at build time, not silently. - { - assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) ( - lib.attrValues config.hyperhive.matrixAccounts - ); - message = - "every hyperhive.matrixAccounts..tokenFile basename must start with " - + "\"matrix-token\" so the daemon path-watcher glob " - + "(/agents/*/state/matrix-token*) picks it up live. Offending: " - + lib.concatStringsSep ", " ( - lib.mapAttrsToList (n: a: "${n}=${baseNameOf a.tokenFile}") ( - lib.filterAttrs ( - _n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile) - ) config.hyperhive.matrixAccounts - ) - ) - + "."; - } - # hyperhive.frontend.extraFiles[*].target is concatenated into - # $out during the mergedDist build. The option's strMatching - # type already rejects leading `/`, leading `.`, and the - # weirder characters; this assertion catches mid-path `..` - # segments (e.g. `foo/../etc/passwd`) that the type's regex - # can't easily express without lookahead. agent.nix is - # operator-reviewed, so this is belt-and-braces — but it's the - # kind of mistake that's easy to make and hard to spot. - { - assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) ( - lib.attrValues config.hyperhive.frontend.extraFiles - ); - message = '' - hyperhive.frontend.extraFiles: `target` must not contain - `..` path segments. - ''; - } - { - assertion = - config.hyperhive.user.uid == null - || (config.hyperhive.user.uid >= 1000 && config.hyperhive.user.uid < 60000); - message = '' - hyperhive.user.uid must be in [1000, 60000) — values below - 1000 clash with system accounts; values ≥ 60000 are reserved - by NixOS for dynamic allocation. Leave unset (null) to let - NixOS auto-assign. - ''; - } - { - assertion = - config.hyperhive.user.gid == null - || (config.hyperhive.user.gid >= 1000 && config.hyperhive.user.gid < 60000); - message = '' - hyperhive.user.gid must be in [1000, 60000) — same range - constraint as hyperhive.user.uid. - ''; - } - ]; - - # Per-agent unix user. Runs the hive harness + - # co-process daemons under a non-root principal. UID auto-assigned by - # NixOS unless `hyperhive.user.uid` is set. The container activation - # script (hive-agent-user-migrate) chowns the bind-mounted state dir - # — including credential files written by hive-c0re before the - # container was built — to this user on every boot, so agent - # processes can always read their own tokens. - users.users.${userName} = { - isNormalUser = true; - home = homeDir; - createHome = true; - group = userName; - extraGroups = lib.optional config.hyperhive.user.passwordlessSudo "wheel"; - # Matches /bin/bash on NixOS — the harness's claude shell-outs - # expect a POSIX shell at $SHELL; bashInteractive is already - # the system default for the root user too (see SHELL env - # var declaration below). - shell = pkgs.bashInteractive; - } - // lib.optionalAttrs (config.hyperhive.user.uid != null) { - uid = config.hyperhive.user.uid; - }; - users.groups.${userName} = - { } - // lib.optionalAttrs (config.hyperhive.user.gid != null) { - gid = config.hyperhive.user.gid; - }; - - # `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool - # keep working with anything that expected root (systemctl, - # nix-env, etc.) without prompting — same surface as the - # previous root-by-default shape, just elevated explicitly. - # Flip `hyperhive.user.passwordlessSudo = false` to drop both - # the wheel-group membership and this sudoers entry; anything - # that needs root then fails loudly instead of silently - # succeeding. - security.sudo.extraRules = lib.mkIf config.hyperhive.user.passwordlessSudo [ - { - users = [ userName ]; - commands = [ - { - command = "ALL"; - options = [ "NOPASSWD" ]; - } - ]; - } - ]; - - # First-boot migration to the per-agent unix user — creates the - # home dir, chowns the bind-mounted state + `~/.claude/`, and - # (marker-guarded) moves any leftover `/root/.claude` content - # from the previous root-run shape. See - # `docs/persistence.md::First-boot agent-user migration` for the - # step-by-step rationale; this script implements it. - system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' - homeDir=${lib.escapeShellArg homeDir} - userName=${lib.escapeShellArg userName} - mkdir -p "$homeDir" - chown "$userName:$userName" "$homeDir" - marker=/var/lib/hive-agent-user-migrated - if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then - mkdir -p "$homeDir/.claude" - if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then - rm -rf /root/.claude - echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude" - fi - fi - mkdir -p "$(dirname "$marker")" - : > "$marker" - # Scope state + harness chowns to THIS container's own dirs only. - # The glob `/agents/*/state` also matches child-agent state dirs that - # are bind-mounted into parent containers, which would clobber the - # ownership those dirs' own activation scripts set — producing - # intermittent EACCES for the child agent's harness between a parent - # rebuild and the child's next activation. Config dirs are kept broad - # because the parent legitimately owns child proposed-config repos. - if [ -d "/agents/$userName/state" ]; then - chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true - fi - if [ -d "/agents/$userName/harness" ]; then - chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true - fi - # The proposed-config repo is RW-mounted into the editing (parent/ - # manager) agent and owned by it; hive-c0re only pulls from it. Heal - # it to this user too — same as state/harness. In an agent's own - # container its config is RO-mounted, so the chown there just fails - # harmlessly (|| true). - for configDir in /agents/*/config; do - [ -d "$configDir" ] || continue - chown -hR "$userName:$userName" "$configDir" 2>/dev/null || true - done - if [ -d "$homeDir/.claude" ]; then - chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true - # 0755 so hive-core (a different unix user) can list the dir and - # detect a valid claude session. Credential files inside are 0600 - # so secrets stay private regardless of the directory mode. - # ensure_claude_dir sets 0755 on creation but cannot re-chmod after - # hive-agent-user-migrate chowns the dir to the agent user; this - # activation script runs as root and handles the correction. - chmod 755 "$homeDir/.claude" 2>/dev/null || true - fi - ''; - - # Auto-inject built-in MCP servers. bash is always present; matrix is - # conditional on hyperhive.matrix.enable. Both use lib.mkDefault so - # the operator's own agent.nix can override individual entries. - hyperhive.extraMcpServers = lib.mkMerge [ - { - bash = lib.mkDefault { - command = "${pkgs.hive-bash-mcp}/bin/hive-bash-mcp"; - args = [ ]; - env.HIVE_BASH_SOCKET = "/run/hive-bash/socket"; - allowedTools = [ "*" ]; - }; - } - (lib.mkIf config.hyperhive.matrix.enable { - matrix = lib.mkDefault { - command = "${pkgs.hive-matrix-mcp}/bin/hive-matrix-mcp"; - args = [ ]; - # Same socket path the hive-matrix-daemon service binds - # via its `RuntimeDirectory = "hive-matrix"`. Keeps the - # bridge + daemon in sync without baking the path into - # the Rust default — the env override wins for both. - env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; - allowedTools = [ "*" ]; - }; - }) - ]; - - environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; - - # Operator-set per-agent icon (hyperhive.icon). When configured, the - # SVG lands at /etc/hyperhive/icon.svg; the harness serves it at - # GET /icon, falling back to the bundled hyperhive logo when absent. - environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) { - source = config.hyperhive.icon; - }; - - # Cargo `--message-format short` injector. Contributes a `cargo` - # shell function to `hyperhive._bashEnvFragments`; the bash-env - # infrastructure below packages that into a single file sourced - # by both non-interactive and interactive shells. - # `command cargo …` falls back to the un-wrapped binary in PATH - # (the rust toolchain's cargo — either from `environment.systemPackages` - # or from whatever `nix develop` shell the agent's working in). - hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages '' - # Auto-injects --message-format short on cargo compile - # subcommands so per-crate progress lines don't flood - # claude's context. Bypassed when the caller already passes - # --message-format (any form). - cargo() { - # Strip leading +toolchain selectors (cargo +nightly …). - local pre=() - while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do - pre+=("$1") - shift - done - case "''${1:-}" in - build|check|clippy|test|run|doc|bench|install|rustc|fix) - local sub="$1" - shift - local arg - for arg in "$@"; do - case "$arg" in - --message-format|--message-format=*) - command cargo "''${pre[@]}" "$sub" "$@" - return $? - ;; - esac - done - command cargo "''${pre[@]}" "$sub" --message-format short "$@" - ;; - *) - command cargo "''${pre[@]}" "$@" - ;; - esac - } - ''; - - # Single bash-env file with all configured shell fragments. - # Wiring is gated on at least one fragment being active so a - # fully feature-disabled agent has neither the file nor the - # `BASH_ENV` / interactive sourcing — zero cost in that case. - environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") { - text = config.hyperhive._bashEnvFragments; - }; - - environment.etc."hyperhive/send-allow.json".text = - builtins.toJSON config.hyperhive.allowedRecipients; - - environment.etc."hyperhive/claude-plugins.json".text = - builtins.toJSON config.hyperhive.claudePlugins; - - environment.etc."hyperhive/claude-marketplaces.json".text = - builtins.toJSON config.hyperhive.claudeMarketplaces; - - environment.etc."hyperhive/claude-plugins-auto-update.json".text = - builtins.toJSON config.hyperhive.claudePluginsAutoUpdate; - - # Hive-enforced claude settings. claude-code auto-discovers managed - # settings at this canonical Linux path (precedence #1, read-only, - # un-overridable by user/project/CLI) — so the harness no longer - # passes `--settings` or copies the blob per turn. effortLevel is - # deliberately NOT shipped here: effort is controlled live via the - # `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider), - # which managed scope would otherwise override and lock. - # Hive-enforced settings merged with a per-agent `env` block at BUILD - # time via `jq` (not eval-time `readFile`, which would be import-from- - # derivation). The `env` block is always present: `baseClaudeEnv` sets - # behaviour flags and the remote-control session prefix for every agent; - # `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code - # auto-discovers this file in every context (harness turn-loop AND - # `hivectl choom`) so no launch wrapper is needed. - environment.etc."claude-code/managed-settings.json".source = - let - baseSettings = "${pkgs.hyperhive-assets}/share/hyperhive/prompts/claude-settings.json"; - # Merge base env (always) with OTEL env (when enabled). jq is always - # run — `baseClaudeEnv` contains per-agent values (e.g. - # CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX) that can't live in the - # static store asset. - allEnv = - baseClaudeEnv - // lib.optionalAttrs otelCfg.enable otelSettingsEnv - // lib.optionalAttrs (otelCfg.enable && otelCfg.debug) { - # SDK diagnostics — noisy; only on when services.hyperhive.otel.debug = true. - CLAUDE_CODE_OTEL_DIAG_STDERR = "1"; - }; - in - pkgs.runCommand "managed-settings.json" { nativeBuildInputs = [ pkgs.jq ]; } '' - jq --argjson env ${lib.escapeShellArg (builtins.toJSON allEnv)} \ - '. + { env: $env }' ${baseSettings} > "$out" - ''; - - # Inject the OTEL auth header (a secret) into the agent's *user* - # claude settings at runtime, keeping it out of the world-readable - # managed settings json above and out of the nix store entirely. - # hive-c0re forwards the operator's `headersCredential` file into - # this container's systemd credential store; this oneshot reads it - # via `LoadCredential` at start and merges `OTEL_EXPORTER_OTLP_HEADERS` - # into `~/.claude/settings.json` (0600, agent-owned). claude layers - # the user `env` on top of the managed one, so both the harness - # turn-loop and `hivectl choom` (same agent user) pick it up. Ordering - # is best-effort (`before`, not a hard dep): if it fails the harness - # still starts and telemetry just exports unauthenticated. - systemd.services.hive-otel-header = - lib.mkIf (config.hyperhive.otel.enable && config.hyperhive.otel.headersCredential != null) - { - description = "Inject the OTEL auth header into the agent's claude user settings"; - wantedBy = [ "multi-user.target" ]; - before = [ "hive-ag3nt.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - User = userName; - Group = userName; - LoadCredential = [ "otel-headers" ]; - ExecStart = pkgs.writeShellScript "hive-otel-header" '' - set -eu - umask 077 - hdr="$CREDENTIALS_DIRECTORY/otel-headers" - [ -r "$hdr" ] || exit 0 - dir=${homeDir}/.claude - settings="$dir/settings.json" - mkdir -p "$dir" - base='{}' - [ -s "$settings" ] && base="$(cat "$settings")" - printf '%s' "$base" | ${pkgs.jq}/bin/jq \ - --rawfile h "$hdr" \ - '.env = ((.env // {}) + { OTEL_EXPORTER_OTLP_HEADERS: ($h | rtrimstr("\n")) })' \ - > "$settings.tmp" - mv "$settings.tmp" "$settings" - chmod 0600 "$settings" - ''; - }; - }; - - # Seed claude's onboarding + per-project trust state once. claude only - # marks `hasCompletedOnboarding` (global) and the project trust dialog - # as accepted when run *interactively*; the harness only ever runs it - # headless (`--print`) and `claude auth login` doesn't set them either. - # So the first interactive launch (`hivectl choom`) would drop the - # operator into the onboarding/trust walkthrough despite valid OAuth - # creds. This oneshot is the single place hyperhive touches - # `~/.claude.json`: it runs before the harness (so nothing races it), - # is idempotent (skips when the flags are already set), and is - # best-effort (`before`, not a hard dep — a failed seed leaves the file - # untouched and the harness still starts). Credentials live in the - # separate `~/.claude/.credentials.json`, so this never touches secrets. - systemd.services.hive-claude-onboarding = { - description = "Seed claude onboarding + project-trust so choom skips the walkthrough"; - wantedBy = [ "multi-user.target" ]; - before = [ "hive-ag3nt.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - User = userName; - Group = userName; - ExecStart = pkgs.writeShellScript "hive-claude-onboarding" '' - set -eu - umask 077 - cfg=${homeDir}/.claude.json - dir=/agents/${userName}/state - base='{}' - [ -s "$cfg" ] && base="$(cat "$cfg")" - # Idempotent: nothing to do when already onboarded + trusted. - if printf '%s' "$base" | ${pkgs.jq}/bin/jq -e \ - --arg d "$dir" \ - '.hasCompletedOnboarding == true and (.projects[$d].hasTrustDialogAccepted == true)' \ - >/dev/null 2>&1; then - exit 0 - fi - printf '%s' "$base" | ${pkgs.jq}/bin/jq \ - --arg d "$dir" \ - '.hasCompletedOnboarding = true - | .projects[$d].hasTrustDialogAccepted = true - | .projects[$d].hasCompletedProjectOnboarding = true' \ - > "$cfg.tmp" - mv "$cfg.tmp" "$cfg" - chmod 0600 "$cfg" - ''; - }; - }; - - # Merged frontend static tree. Base = `${frontend.dist}/agent/`, - # then each `extraFiles` entry is laid on top at its `target` - # path. The runCommand derivation aborts on overwrite so a - # filename collision with the default dist surfaces as a build - # failure rather than a silent override (operator gets a clear - # nix error rather than a confusing 404 / silent dist swap). - hyperhive.frontend.mergedDist = pkgs.runCommand "hyperhive-agent-frontend-merged" { } ( - '' - mkdir -p $out - cp -r ${config.hyperhive.frontend.dist}/agent/. $out/ - chmod -R u+w $out - '' - + lib.concatMapStrings (entry: '' - mkdir -p $(dirname $out/${entry.target}) - if [ -e $out/${entry.target} ]; then - echo "hyperhive.frontend.extraFiles: refusing to overwrite existing path '${entry.target}' in the default dist" >&2 - exit 1 - fi - cp -r ${entry.source} $out/${entry.target} - '') (lib.attrValues config.hyperhive.frontend.extraFiles) - ); - - # HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted - # model choice exists in the state dir. SHELL must be set so claude's - # Bash tool finds a POSIX shell. - # HIVE_ASSETS_DIR points at the project's static runtime assets - # (branding + claude prompts; see `nix/assets.nix`). Set here so - # both the harness binary and any user-shell `cargo run` inside the - # container resolve them from the same path. - # HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the - # host-level `services.hyperhive.c0re.contextWindowTokens` option — not set here. - environment.variables = { - HIVE_DEFAULT_MODEL = config.hyperhive.model; - # HIVE_AVAILABLE_MODELS is the comma-separated menu for the per-agent - # UI model quick-picker (see hyperhive.availableModels). The harness - # surfaces it to the frontend; an empty value falls back to the - # built-in default list. - HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.hyperhive.availableModels; - # HIVE_DEFAULT_EFFORT is the per-agent baseline effort (see - # hyperhive.effortLevel). The harness resolves operator-override-file - # → this env → "medium" and passes it to claude --effort at turn launch. - HIVE_DEFAULT_EFFORT = config.hyperhive.effortLevel; - HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; - SHELL = "${pkgs.bashInteractive}/bin/bash"; - # Route interactive-shell nix invocations through the host daemon. - # Redundant with /etc/profile.d/nix-daemon.sh but ensures it's set - # regardless of which profile files are sourced. - NIX_REMOTE = "daemon"; - } - // lib.optionalAttrs (!config.hyperhive.autoCompact) { - # Zero watermark disables proactive compaction; the reactive path - # (compact-on-overflow) still fires when the session is truly full. - HIVE_COMPACT_WATERMARK_TOKENS = "0"; - } - // lib.optionalAttrs config.hyperhive.docs.enable { - # hyperhive.docs.enable: the in-container reference-docs tree. The - # harness reads HIVE_DOCS_DIR and passes it to claude as `--add-dir` - # so the docs are readable, and appends a single pointer sentence to - # the system prompt (hive-ag3nt::prompt::render) telling the agent the - # docs exist. Source is `hyperhive.docs.source` (the narrow - # `hyperhive-docs` meta-flake input, or `pkgs.hyperhive-docs` for - # standalone builds). See hive-ag3nt::turn. - HIVE_DOCS_DIR = "${config.hyperhive.docs.source}"; - } - // lib.optionalAttrs (config.hyperhive._bashEnvFragments != "") { - # Non-interactive bash invocations (claude's `Bash` tool runs - # `bash -c`) source $BASH_ENV at startup — drops every active - # feature hook's snippet into scope without touching - # `/etc/profile` (login-only). Interactive shells source the - # same file via the `interactiveShellInit` hook below so - # behaviour matches across both modes. - BASH_ENV = "/etc/hyperhive/bash-env.sh"; - }; - - # Interactive shells don't honour BASH_ENV — wire the same file - # in via the bashrc hook so operator SSH sessions get the same - # hook surface as claude's non-interactive calls. Gated on at - # least one fragment being active so we don't write a no-op - # source line into `/etc/bashrc` on fully-feature-disabled agents. - programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") '' - if [ -r /etc/hyperhive/bash-env.sh ]; then - . /etc/hyperhive/bash-env.sh - fi - ''; - - boot.isNspawnContainer = true; - - # Use a disk-backed /tmp instead of the default tmpfs so large scratch - # writes (nix-develop shells, cargo build dirs, multi-GB downloads) land - # on disk rather than eating container RAM. The tmpfs default mounts - # ~3.2 GB of RAM per container; disk-backed /tmp is effectively unlimited - # and cheaper for agents that do heavy build work. - # - # cleanOnBoot defaults to false in nixpkgs — set it explicitly so /tmp is - # cleared on each container start (D! tmpfiles rule), preserving the same - # ephemeral-per-boot semantics agents already expect from the old tmpfs, - # just without the RAM cost. - boot.tmp.useTmpfs = false; - boot.tmp.cleanOnBoot = true; - - # Every agent gets flakes + the modern `nix` CLI out of the box. - # Equivalent to passing `--extra-experimental-features 'nix-command - # flakes'` on every invocation. Agents shell out to `nix build` / - # `nix flake` constantly (devshells, ad-hoc evals, fetching their - # own MCP-server flakes); without this they hit the "experimental - # feature not enabled" wall on the first try. - nix.settings.experimental-features = [ - "nix-command" - "flakes" - ]; - - # `lib.mkForce` overrides nixpkgs's normal-priority `false` so - # in-container `nix build` invocations fall back to unsandboxed - # local builds rather than failing on the missing user-namespace. - # See `docs/gotchas.md::Containerized nix-daemon needs - # sandbox-fallback = true` + `docs/security.md` for the rationale. - # - # Note: with NIX_REMOTE=daemon below this becomes a no-op for the - # common case — daemon-routed builds run on the host where sandboxing - # works. It stays as a belt-and-suspenders fallback for any context - # that bypasses the daemon (e.g. direct nix-store invocations). - nix.settings.sandbox-fallback = lib.mkForce true; - - # Route ALL nix invocations in this container through the host - # nix-daemon socket, regardless of whether the caller is root or - # non-root. Without this, root contexts (PID 1, systemd services - # running as root) default to store=auto which resolves to the LOCAL - # store — bypassing the shared daemon, its remote builders, and the - # host's prebuilt derivation cache, causing spurious full rebuilds. - # - # systemd.globalEnvironment sets DefaultEnvironment in systemd.conf, - # so every unit started by PID 1 inherits NIX_REMOTE=daemon. - # Non-root nix clients already default to the daemon socket, so this - # is a no-op for them; it only matters for root services that would - # otherwise silently use the local store. - systemd.globalEnvironment.NIX_REMOTE = "daemon"; - - # `claude-code` is unfree. Each per-agent container's nixosConfiguration - # evaluates its own `nixpkgs` instance, so the operator's host-level - # `nixpkgs.config.allowUnfreePredicate` does not propagate into here — - # we have to allow it inside the container's config as well. - nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; - - environment.systemPackages = - with pkgs; - [ - # Per-bin split packages (see flake.nix `packages..hive-*`) - # rather than the full `pkgs.hyperhive` bundle — that bundle also - # carries `hivectl` (a host-admin CLI that dials the *host* admin - # socket — useless and unreachable from inside a container — - # wrapped with `wireguard-tools` for `hivectl wg`) and a second, - # redundant `hive-forge` copy (already provided below via - # `hive-forge-tools.nix`) — closure bloat + dead surface with no - # functional upside inside a container. The daemon/harness/MCP - # bins the harness execs (hive-agent{,-mcp}, hive-bash-daemon, - # hive-matrix-daemon, hive-bash-mcp, hive-matrix-mcp) are wired - # via their own ExecStart/command lines above with the matching - # `pkgs.hive-*` package — they don't need to be on PATH too. - # Only these two are actually looked up on PATH by claude/shell - # code inside the container: `hive-agent-wake` (external wake CLI, - # docs/turn-loop/mcp.md) and `hive-metric` (agent-emitted custom - # metrics CLI, docs/observability.md). - hive-agent-wake - hive-metric - claude-code - bashInteractive - coreutils-full - # procps for pkill — used by the web UI's /api/cancel to SIGINT the - # in-flight claude turn. - procps - # tea: gitea/forgejo CLI client. Configured at boot by the - # tea-login oneshot below if /state/forge-token is present, so - # claude can `tea repos create`, `tea pulls create`, etc. - tea - # jq: JSON processing in shell — useful for parsing API responses, - # forge REST calls, sqlite output, etc. - jq - # curl: HTTP client for forge REST API and other web requests. - curl - # hive-forge : CLI wrapping common Forgejo REST API operations - # (view, pr, issue, comment, assign, close, labels, branches, etc.) - (pkgs.callPackage ../packages/hive-forge-tools.nix { }) - ] - ++ lib.optionals config.hyperhive.github.enable [ - # gh wrapper + git credential helper for hyperhive.github.enable. - # (No bare pkgs.gh here — the wrapper *is* `gh` and hardcodes the real - # binary path, so it can't be shadowed.) - ghWrapper - gitCredHelper - ]; - - # Wire the GitHub credential helper for `git push` over HTTPS. Host-scoped - # to `https://github.com`, so it never touches the forge (localhost:3000) - # or any other remote. Gated on hyperhive.github.enable; the helper reads - # the PAT from the agent's `github-token` state file at invocation and auths - # as `x-access-token` + the PAT. System /etc/gitconfig merges under the - # agent's ~/.gitconfig (safe.directory), so this is additive. - # Nested-path binding + mkIf (matching the other `environment.etc."…"` - # entries above) — a whole-set `environment.etc = {…}` here would collide - # with them at the nix level ("attribute already defined"). - environment.etc."gitconfig" = lib.mkIf config.hyperhive.github.enable { - text = '' - [credential "https://github.com"] - helper = hive-github - username = x-access-token - ''; - }; - - # One-shot: tea config.yml from the seeded forge token. Shape - # contract (always exit 0, no set -e, skip-silently, re-runnable): - # docs/conventions.md::Best-effort oneshot services. - # Take resolvconf + dhcpcd out of the /etc/resolv.conf loop so the - # bridge resolver the oneshot below writes actually sticks. At their - # NixOS defaults, resolvconf regenerates resolv.conf from host-tracking - # *after* the oneshot has pointed it at the bridge (dhcpcd re-triggers - # that when the veth comes up under isolation) — silently clobbering the - # bridge nameserver back to the host resolver, which isn't authoritative - # for the hive's own zones, so `forge.` stops resolving. We - # disable resolvconf and tell dhcpcd not to touch resolv.conf (without - # disabling dhcpcd itself, so the veth still gets its address); then - # the hyperhive-isolated-dns oneshot owns resolv.conf. (Same "take - # resolvconf out of the loop" approach the matrix container uses.) - # All agent containers receive their bridge IP via DHCP from the hive - # dnsmasq pool (see hive-gateway.nix). useDHCP runs dhcpcd on every - # interface (just eth0 in practice — the nspawn bridge veth). - networking.useDHCP = true; - networking.resolvconf.enable = false; - networking.dhcpcd.extraConfig = "nohook resolv.conf"; - - # Point resolv.conf at the hive bridge resolver when the container is - # network-isolated. nixos-container copies the *host's* /etc/resolv.conf - # into the container at every start — but the host resolver (e.g. - # 127.0.0.53) is unreachable from a private netns and isn't - # authoritative for the hive's own zones (forge. etc.). The - # bridge dnsmasq (gateway IP) is. hive-priv drops the marker - # `/etc/hyperhive-bridge-dns` (containing the gateway IP) since - # isolation is always on; the oneshot reads it and rewrites - # resolv.conf on every boot. Ordered before the first DNS consumer - # (tea-login) and the network targets so name resolution works for - # the very first turn. - systemd.services.hyperhive-isolated-dns = { - description = "point resolv.conf at the hive bridge resolver (isolated containers)"; - wantedBy = [ "multi-user.target" ]; - after = [ "local-fs.target" ]; - # Ordered before every network consumer that does DNS on first - # boot. `hive-ag3nt` (the harness) is the load-bearing one: its - # first-turn api.anthropic.com lookup must not race the resolv.conf - # rewrite (it only declares `after network.target`, so without this - # edge the harness can start before we've fixed resolv.conf and the - # first turn errors — self-heals next turn, but better not to flap). - # `hive-matrix-daemon` likewise syncs over the network; the `before` - # is a harmless no-op when matrix is disabled (the unit is absent). - before = [ - "network-online.target" - "tea-login.service" - "hive-ag3nt.service" - "hive-matrix-daemon.service" - ]; - unitConfig.ConditionPathExists = "/etc/hyperhive-bridge-dns"; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - # Pin the journal identity; without it systemd derives it from the - # generated `script` store-path wrapper (an opaque `-…-start`). - SyslogIdentifier = "hyperhive-isolated-dns"; - }; - path = [ pkgs.coreutils ]; - script = '' - set -eu - gw=$(tr -d '[:space:]' < /etc/hyperhive-bridge-dns) - if [ -z "$gw" ]; then - echo "hyperhive-isolated-dns: empty marker; leaving resolv.conf as-is" - exit 0 - fi - # resolv.conf is a regular file copied from the host by - # nixos-container; replace it (rm first in case it's a symlink). - rm -f /etc/resolv.conf - printf 'nameserver %s\n' "$gw" > /etc/resolv.conf - echo "hyperhive-isolated-dns: resolv.conf -> nameserver $gw" - ''; - }; - - systemd.services.tea-login = { - description = "configure tea CLI from hive-forge token (best-effort)"; - wantedBy = [ "multi-user.target" ]; - after = [ "local-fs.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "tea-login"; - }; - path = [ - pkgs.curl - pkgs.python3 - pkgs.coreutils - ]; - environment.HOME_DIR = homeDir; - environment.AGENT_USER = userName; - script = '' - # No `set -e`: best-effort posture (see docs pointer above). - FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} - # $HYPERHIVE_STATE_DIR is system-wide via the meta flake. - TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" - if [ ! -f "$TOKEN_FILE" ]; then - echo "tea-login: no forge-token at $TOKEN_FILE; skipping" - exit 0 - fi - TOKEN=$(cat "$TOKEN_FILE") - # Resolve the agent username from the forge API. - USER=$(curl -sf --max-time 5 \ - -H "Authorization: token $TOKEN" \ - "$FORGE_URL/api/v1/user" \ - | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ - 2>/dev/null || true) - if [ -z "$USER" ]; then - echo "tea-login: could not resolve username from forge API; skipping" - exit 0 - fi - # Config under the agent user's home, chown'd to them; - # service stays root-owned (see docs pointer above). - CONFIG="$HOME_DIR/.config/tea/config.yml" - mkdir -p "$(dirname "$CONFIG")" || true - cat > "$CONFIG" << EOF - logins: - - name: forge - url: $FORGE_URL - token: $TOKEN - default: true - ssh_host: "" - ssh_key: "" - insecure: false - ssh_agent: false - user: $USER - preferences: - editor: false - flag_defaults: - remote: "" - EOF - chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true - echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)" - ''; - }; - - # Path-trigger sibling: re-fires forge-avatar-sync the moment - # `/forge-token` appears. Mirrors the hive-matrix-daemon - # token-watcher pattern — on first agent deployment the container boots before - # hive-c0re has provisioned the forge-token, so the service fires - # too early and exits with "no forge-token found". Without this path - # unit, RemainAfterExit=true would prevent systemd from ever - # re-running the service. See docs/persistence.md::forge-avatar-sync. - systemd.paths.forge-avatar-sync = { - description = "trigger forge-avatar-sync when forge-token appears"; - wantedBy = [ "multi-user.target" ]; - pathConfig.PathExistsGlob = "/agents/*/state/forge-token"; - }; - - # One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract: - # docs/conventions.md::Best-effort oneshot services. - # RemainAfterExit = false (unlike the old true) so the .path trigger - # above can re-fire this unit when the forge-token arrives after boot. - systemd.services.forge-avatar-sync = { - description = "sync agent icon to Forgejo user avatar (best-effort)"; - wantedBy = [ "multi-user.target" ]; - after = [ "tea-login.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = false; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "forge-avatar-sync"; - }; - path = [ - pkgs.curl - pkgs.coreutils - pkgs.jq - pkgs.librsvg - ]; - script = '' - ICON=/etc/hyperhive/icon.svg - if [ ! -f "$ICON" ]; then - echo "forge-avatar-sync: no icon configured; skipping" - exit 0 - fi - FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} - # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake - # (systemd.globalEnvironment) to `/agents//state`. - TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" - if [ ! -f "$TOKEN_FILE" ]; then - echo "forge-avatar-sync: no forge-token found; skipping" - exit 0 - fi - TOKEN=$(cat "$TOKEN_FILE") - # Rasterize SVG → PNG (Forgejo's Go image library can't decode SVG). - PNG=$(mktemp --suffix=.png) - if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then - echo "forge-avatar-sync: rsvg-convert failed; skipping" - rm -f "$PNG" - exit 0 - fi - IMAGE=$(base64 -w 0 < "$PNG") - rm -f "$PNG" - # Forgejo POST /user/avatar expects {"image":""} — just the - # raw base64 string, NOT a data URI (data:image/png;base64,...). - # Use jq to build the payload so the large base64 value is safely quoted. - PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') - RESP=$(curl -sf --max-time 10 \ - -X POST "$FORGE_URL/api/v1/user/avatar" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD" \ - -w "\n%{http_code}" 2>/dev/null || true) - CODE=$(printf '%s' "$RESP" | tail -1) - if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then - echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" - else - echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" - fi - ''; - }; - - # Long-running matrix-sdk client + sync per agent. Holds the unix - # socket the stdio `hive-matrix-mcp` bridge connects to + emits - # hyperhive wake signals on incoming room events via - # `/run/hive/mcp.sock`. See - # `docs/persistence.md::Matrix per-agent daemon + token-arrival - # trigger` for the socket-path / first-boot-ordering rationale. - systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { - description = "long-running matrix-sdk Client + MCP daemon socket"; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - environment = { - HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; - RUST_LOG = "info"; - } - # Homeserver URL: by default the daemon inherits the host-forwarded - # HIVE_MATRIX_URL (set by hive-c0re to `matrix.` via the - # gateway, since agents run in private netns and can't reach host - # loopback directly), falling back to the daemon's built-in - # localhost default if the forward is absent. A per-agent - # `hyperhive.matrix.url` override (non-default) is set unit-level - # so it wins over the forwarded value; at the default we - # deliberately DON'T set it so the forwarded value isn't shadowed. - // lib.optionalAttrs (config.hyperhive.matrix.url != matrixUrlDefault) { - HIVE_MATRIX_URL = config.hyperhive.matrix.url; - } - # Multi-account: serialize the *extra* accounts to the JSON the - # daemon parses (`accounts::configured`). Only set when extras are - # declared; the daemon always synthesizes the primary `main` - # (hive-internal) account itself from the per-agent paths and - # prepends it, so we emit extras only. Each entry is in the - # daemon's `AccountCfg` serde shape: name (the attr key) / - # token_file / state_dir / optional homeserver. - // lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) { - HIVE_MATRIX_ACCOUNTS = builtins.toJSON ( - lib.mapAttrsToList ( - name: a: - { - inherit name; - token_file = a.tokenFile; - state_dir = a.sessionDir; - } - // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; } - ) config.hyperhive.matrixAccounts - ); - } - # Rasterized agent icon path for the daemon's avatar sync. Only set - # when an icon is configured; absent → the daemon skips avatar setting - # (hive-matrix-mcp::client::sync_avatar returns early on unset env). - // lib.optionalAttrs (config.hyperhive.icon != null) { - HIVE_ICON_PNG = "${iconPng}"; - }; - serviceConfig = { - ExecStart = "${pkgs.hive-matrix-daemon}/bin/hive-matrix-daemon"; - SyslogIdentifier = "hive-matrix-daemon"; - Restart = "on-failure"; - RestartSec = 5; - User = userName; - Group = userName; - RuntimeDirectory = "hive-matrix"; - # Keep /run/hive-matrix across restarts. With the default - # `RuntimeDirectoryPreserve=no`, a `switch-to-configuration` - # restart races the outgoing instance's stop-time cleanup - # (which deletes the dir) against the incoming instance's - # start (which creates it + binds the socket inside it). The - # cleanup can win and delete the dir out from under the fresh - # daemon, which then fails to mkdir under root-owned /run and - # exits — looping on Restart=on-failure until the next boot. - # `yes` stops systemd removing it on stop; it still creates it - # on first start, and it lives on tmpfs so it's gone at - # container reboot regardless. See hive-bash-daemon below. - RuntimeDirectoryPreserve = "yes"; - }; - }; - - # Bash task runner daemon — long-running process that owns subprocess - # monitoring + completion wake signals. Always enabled (every agent - # needs bash tools). The stdio MCP bridge `hive-bash-mcp` connects - # to this daemon's socket per turn. - # Socket dir: /run/hive-bash/ — RuntimeDirectory keeps it on tmpfs. - systemd.services.hive-bash-daemon = { - description = "bash task runner daemon for hive-bash-mcp"; - wantedBy = [ "multi-user.target" ]; - # The daemon runs every bash task via `Command::new("bash")` and the - # commands themselves (hive-forge, git, jq, …) resolve from PATH. - # Pre-split this ran inside hive-ag3nt.service and inherited the - # agent's PATH; the standalone daemon needs the same or `bash` itself - # isn't found (spawn fails with ENOENT, the task is marked done in - # 0s with no output / no .out/.err). Mirror the harness unit's PATH: - # NixOS appends `/bin` to each entry → /run/wrappers/bin (setuid - # sudo) + /run/current-system/sw/bin (bash, coreutils, hive-forge, …). - path = [ - "/run/wrappers" - "/run/current-system/sw" - ]; - environment = { - HIVE_BASH_SOCKET = "/run/hive-bash/socket"; - HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock"; - RUST_LOG = "info"; - # HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already - # injected via systemd.globalEnvironment by the meta flake - # (set to /agents//harness and /agents//state - # respectively). Listed here for explicitness — the daemon - # uses these to derive its task + loose-ends dir paths. - # Without them the daemon falls back to deriving harness/ as a - # sibling of state/, which produces the same value but is - # less robust if the two vars ever diverge. - }; - serviceConfig = { - ExecStart = "${pkgs.hive-bash-daemon}/bin/hive-bash-daemon"; - SyslogIdentifier = "hive-bash-daemon"; - Restart = "on-failure"; - RestartSec = 3; - User = userName; - Group = userName; - RuntimeDirectory = "hive-bash"; - # See the matching note on hive-matrix-daemon. Without this, a - # post-rebuild restart races stop-time dir cleanup against the - # fresh daemon's socket-dir creation; the daemon loses, fails - # `mkdir /run/hive-bash` (Permission denied, non-root in /run), - # and loops on Restart=on-failure until the next container - # boot — i.e. the bash daemon "doesn't come up post-rebuild". - RuntimeDirectoryPreserve = "yes"; - }; - }; - - # Persistent streamable-http MCP daemon for the built-in hyperhive - # surface — the *sole* transport for that surface; always - # wired. Long-lived so claude reconnects to the stable URL each turn - # instead of respawning + re-registering a stdio subprocess (the - # per-turn MCP registration race). It dials the control socket - # (`/run/hive/mcp.sock`, the harness binaries' default) fresh on every - # tool call, so a host-side hive-c0re restart is transparent. - # `before = hive-ag3nt` so the URL is already listening by the time - # the harness renders the first turn's config; the harness/claude also - # reconnect on their own, so ordering is a latency nicety not a hard - # correctness dep. - systemd.services.hive-mcp-http = { - description = "persistent streamable-http MCP daemon for the hyperhive surface"; - wantedBy = [ "multi-user.target" ]; - before = [ "hive-ag3nt.service" ]; - environment.RUST_LOG = "info"; - serviceConfig = { - ExecStart = "${pkgs.hive-agent-mcp}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}"; - SyslogIdentifier = "hive-mcp-http"; - # `always` (not `on-failure`): this endpoint is load-bearing — the - # sole hyperhive-MCP transport, so a down window is total - # hyperhive-MCP loss with no stdio fallback and no per-turn - # self-heal (the URL just stays dead). `always` also covers any - # unforeseen clean-return path and restarts after a stray SIGTERM - # stops it out from under the harness. - Restart = "always"; - RestartSec = 3; - User = userName; - Group = userName; - }; - }; - - # Re-fire the daemon when the matrix token appears (hive-c0re - # provisions it after agent containers come up). Without this - # the daemon would exit 0 silently on first boot and the MCP - # would have no backend until next restart. See - # `docs/persistence.md` (same section as above). - systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { - description = "trigger hive-matrix-daemon when a matrix token appears"; - wantedBy = [ "multi-user.target" ]; - # `matrix-token*` (not just `matrix-token`) so a secondary - # multi-account token (e.g. `matrix-token-ccc`) landing also - # re-fires the daemon to pick up the freshly-provisioned account. - pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*"; - }; - - # Write declared dashboardLinks to the state dir so hive-c0re can - # read them without accessing the container's /etc/ from the host. - # Best-effort oneshot (always exit 0): - # docs/conventions.md::Best-effort oneshot services. - systemd.services.hive-dashboard-links = lib.mkIf (config.hyperhive.dashboardLinks != [ ]) { - description = "write declarative dashboardLinks to agent state dir"; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "hive-dashboard-links"; - }; - environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks; - script = '' - # Sub-agents have their state dir bind-mounted at /agents//state. - # Use a glob — exactly one match per container at runtime. - STATE_DIR=$(echo /agents/*/state) - if [ ! -d "$STATE_DIR" ]; then - echo "hive-dashboard-links: no state dir found at /agents/*/state; skipping" - exit 0 - fi - printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json" - echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json" - ''; - }; - - # Git is needed by claude's Bash tool (for the agent <-> manager config - # request flow) and by hive-c0re's own setup_applied / setup_proposed. - # The per-agent `applied//flake.nix` overrides `user.name` and - # `user.email` with the agent's identity — values here are `mkDefault` - # so the per-agent override wins without needing `mkForce`. - programs.git = { - enable = true; - config = { - user = { - name = lib.mkDefault "hyperhive"; - email = lib.mkDefault "hyperhive@local"; - }; - init.defaultBranch = lib.mkDefault "main"; - }; - }; - - # Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars, - # RuntimeDirectory, User=, standalone-eval fallbacks): - # docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin - # auto-append behaviour: docs/gotchas.md::systemd.services.*.path - # appends /bin to every entry. - systemd.services.hive-ag3nt = - let - binary = "hive-agent"; - # OTEL is shipped declaratively via the managed claude settings - # json (`environment.etc."claude-code/managed-settings.json"`, - # `otelSettingsEnv` in the top-level let) — claude reads it for - # both the harness turn-loop and `hivectl choom`, so there's no - # launch wrapper or per-unit OTEL env here anymore. - in - { - description = "${binary} harness"; - wantedBy = [ "multi-user.target" ]; - after = [ "network.target" ]; - # `/run/wrappers` before `/run/current-system/sw` so setuid - # `sudo` resolves first. Passing the bare prefixes (no trailing - # `/bin`) is intentional — see docs pointer above. - path = [ - "/run/wrappers" - "/run/current-system/sw" - ]; - environment = { - SHELL = "${pkgs.bashInteractive}/bin/bash"; - HOME = homeDir; - HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}"; - HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; - # Unix-socket path for the harness web UI. All agents always bind - # here; TCP fallback is removed. Path matches - # `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle - # bind-mounts and gateway upstream config stay in sync. - HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; - # Loopback URL of the persistent `hive-mcp-http` daemon that - # `render_claude_config` points claude at for the built-in - # surface (HTTP is the sole transport — no per-turn stdio child). - # Kept in sync with the `hive-mcp-http` unit's `--http` port - # above via the same option. Always set — network isolation is - # unconditional, so a fixed per-container port is collision-free. - HYPERHIVE_MCP_HTTP_PORT = toString config.hyperhive.mcp.httpPort; - } - // lib.optionalAttrs config.hyperhive.gui.enable { - # Tells the harness which fixed VNC port weston bound, and (by - # its presence) that gui is enabled — the harness `/screen/ws` - # relay reads this instead of a runtime marker file. The port is - # container-local + fixed (network isolation is unconditional), - # so the same value for every gui agent is fine. See - # nix/templates/weston-vnc.nix::hyperhive.gui.vncPort. - HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort; - } - // lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) { - # JSON object {"": ""} for the transparent - # reverse-proxies. See `hyperhive.extraWebProxies` option - # and `web_ui/proxy.rs::extra_proxy_service`. - HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.hyperhive.extraWebProxies; - }; - serviceConfig = { - ExecStart = "${pkgs.hive-agent}/bin/${binary}"; - # Pin the journal identity to the binary name (otherwise systemd - # derives SyslogIdentifier from the ExecStart basename). - SyslogIdentifier = binary; - Restart = "on-failure"; - RestartSec = 2; - # Per-service runtime dir owned by `User=` below; the harness - # writes its regenerated claude-{mcp-config,settings,system-prompt} - # files here (`paths::config_dir`). Separate from /run/hive, - # which holds hive-c0re's mcp.sock. - RuntimeDirectory = "hive-config"; - User = userName; - Group = userName; - }; - }; - - system.stateVersion = "25.11"; - }; -} diff --git a/nix/templates/manager.nix b/nix/templates/ruth.nix similarity index 93% rename from nix/templates/manager.nix rename to nix/templates/ruth.nix index b6a9d1ea..0902001f 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/ruth.nix @@ -3,7 +3,7 @@ # Entry-point for the privileged root agent (ruth). Referenced from # `flake.nix` (`nixosConfigurations.ruth`) and the meta-flake's # `applied/ruth/flake.nix`. - imports = [ ./harness-base.nix ]; + imports = [ ../agent-modules ]; # The root/manager bootstraps a fresh hive, so it gets the hyperhive # reference docs made available by default (readable at diff --git a/nix/treefmt.nix b/nix/treefmt.nix new file mode 100644 index 00000000..3d05e507 --- /dev/null +++ b/nix/treefmt.nix @@ -0,0 +1,11 @@ +# treefmt-nix module — shared by `nix fmt` (the flake `formatter` +# output) and the `formatting` flake check. +{ + projectRootFile = "flake.nix"; + programs = { + keep-sorted.enable = true; + nixfmt.enable = true; + rustfmt.enable = true; + taplo.enable = true; + }; +}