Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68afbb01f7 | ||
|
|
eba3f206ab | ||
|
|
79fec131d6 | ||
|
|
e0cfed7fe8 | ||
|
|
935e967718 | ||
|
|
4a48ce5024 | ||
|
|
cb755b677c | ||
|
|
7c9d72b9ca | ||
|
|
7ad2bb9211 | ||
|
|
43bbd64f82 | ||
|
|
874a900bdc | ||
|
|
281667d5a8 | ||
|
|
e7689a6804 | ||
|
|
5f9af9fc97 | ||
|
|
ecaad48fad | ||
|
|
c0bd77b314 | ||
|
|
3069c11c3d | ||
|
|
4f8bb6ded2 |
72 changed files with 5695 additions and 5438 deletions
16
CLAUDE.md
16
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 .#<host>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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/<token>` 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -89,8 +89,7 @@ now set unconditionally for every agent. The mechanism:
|
|||
|
||||
1. **Agent side**. `HIVE_WEB_SOCKET=/run/hive-agent/<name>/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/<name>/`) into the agent's container. Dir
|
||||
bind, not file bind — file bind-mounts don't survive the
|
||||
|
|
|
|||
|
|
@ -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:<vnc_port>`.
|
|||
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:<vnc_port>`.
|
|||
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:<vnc_port>`.
|
|||
## 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 `<h2>` 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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`<hive-domain>`, `forge.<hive-domain>`, `matrix.<hive-domain>`
|
||||
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=<bridgeName>` 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 <gateway-ip>` 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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Background shell execution via `hive-bash-mcp`. Tools land as
|
||||
`mcp__bash__<tool>` (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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
594
flake.nix
594
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 <shell>`
|
||||
# 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.<system>.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.<system>.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.<system>.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.<system>.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
|
||||
;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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_<KEY_UPPER>` env vars — so this function
|
||||
/// normally just reads them. The Rust code carries no model knowledge;
|
||||
|
|
|
|||
|
|
@ -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:<port>` 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.<key>` block in the rendered claude config + a
|
||||
/// `mcp__<key>__<tool>` 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.
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ async fn set_nspawn_flags(
|
|||
// Claude credentials land at `/home/<agent>/.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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<OtelConfig> {
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/<name>`). 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.
|
||||
|
|
|
|||
245
nix/agent-modules/agent-service.nix
Normal file
245
nix/agent-modules/agent-service.nix
Normal file
|
|
@ -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_<KEY_UPPER>` 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/<name>/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:<path>` (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 {"<path>": "<upstream>"} 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
134
nix/agent-modules/bash-env.nix
Normal file
134
nix/agent-modules/bash-env.nix
Normal file
|
|
@ -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
|
||||
'';
|
||||
};
|
||||
}
|
||||
376
nix/agent-modules/claude-settings.nix
Normal file
376
nix/agent-modules/claude-settings.nix
Normal file
|
|
@ -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 "<hive>-<agent>" 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 <token>`). 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 <source>`
|
||||
(`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 <spec>` 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"
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
73
nix/agent-modules/dashboard-links.nix
Normal file
73
nix/agent-modules/dashboard-links.nix
Normal file
|
|
@ -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/<name>/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"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
220
nix/agent-modules/default.nix
Normal file
220
nix/agent-modules/default.nix
Normal file
|
|
@ -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/<name>/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";
|
||||
};
|
||||
}
|
||||
50
nix/agent-modules/docs.nix
Normal file
50
nix/agent-modules/docs.nix
Normal file
|
|
@ -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}";
|
||||
};
|
||||
};
|
||||
}
|
||||
191
nix/agent-modules/forge.nix
Normal file
191
nix/agent-modules/forge.nix
Normal file
|
|
@ -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 <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
||||
(= `/agents/<name>/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 <verb>: 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
|
||||
# `<state>/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/<name>/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":"<base64>"} — 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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
156
nix/agent-modules/frontend.nix
Normal file
156
nix/agent-modules/frontend.nix
Normal file
|
|
@ -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 (`/<target>/...`) 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 '<target>' 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)
|
||||
);
|
||||
};
|
||||
}
|
||||
89
nix/agent-modules/github.nix
Normal file
89
nix/agent-modules/github.nix
Normal file
|
|
@ -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
|
||||
`<state>/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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
298
nix/agent-modules/matrix.nix
Normal file
298
nix/agent-modules/matrix.nix
Normal file
|
|
@ -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 `<state>/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] <sender> in <room>: <first 100c>…`)
|
||||
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.<domain>` 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` + `<state>/matrix-token` +
|
||||
`<state>/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.<name>.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.<domain>` 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*";
|
||||
};
|
||||
};
|
||||
}
|
||||
240
nix/agent-modules/mcp.nix
Normal file
240
nix/agent-modules/mcp.nix
Normal file
|
|
@ -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__<server-key>__` 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__<key>__<tool>`). 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:<port>` and `render_claude_config`
|
||||
points claude at the stable `http://127.0.0.1:<port>/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:<port>` 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/<name>/harness and /agents/<name>/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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
82
nix/agent-modules/network.nix
Normal file
82
nix/agent-modules/network.nix
Normal file
|
|
@ -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.<domain>` 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.<domain> 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 `<hash>-…-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"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
22
nix/agent-modules/packages.nix
Normal file
22
nix/agent-modules/packages.nix
Normal file
|
|
@ -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.<system>.*`; override an
|
||||
individual key per-agent to swap in a patched binary.
|
||||
'';
|
||||
};
|
||||
}
|
||||
215
nix/agent-modules/user.nix
Normal file
215
nix/agent-modules/user.nix
Normal file
|
|
@ -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
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
87
nix/checks.nix
Normal file
87
nix/checks.nix
Normal file
|
|
@ -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.<system>.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.<system>.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"
|
||||
'';
|
||||
}
|
||||
18
nix/devshell.nix
Normal file
18
nix/devshell.nix
Normal file
|
|
@ -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
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
|
@ -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.*`).
|
||||
|
|
|
|||
26
nix/host-modules/default.nix
Normal file
26
nix/host-modules/default.nix
Normal file
|
|
@ -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
|
||||
];
|
||||
}
|
||||
218
nix/host-modules/hive-c0re/default.nix
Normal file
218
nix/host-modules/hive-c0re/default.nix
Normal file
|
|
@ -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 `<domain>` 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
180
nix/host-modules/hive-c0re/environment.nix
Normal file
180
nix/host-modules/hive-c0re/environment.nix
Normal file
|
|
@ -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.<domain>`), 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.<domain>`). 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/<name>/` links (never the direct
|
||||
# `<host>:<port>` 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 `<hostname>: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 `<hostname>: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.<domain>`). 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.<domain>.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
|
||||
)
|
||||
);
|
||||
}
|
||||
319
nix/host-modules/hive-c0re/options.nix
Normal file
319
nix/host-modules/hive-c0re/options.nix
Normal file
|
|
@ -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.<system>.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_<KEY_UPPER>`. 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-<name>.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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
79
nix/host-modules/hive-c0re/theme.nix
Normal file
79
nix/host-modules/hive-c0re/theme.nix
Normal file
|
|
@ -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 "<stylix-themed overlay of `frontend`>";
|
||||
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.
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
{
|
||||
291
nix/host-modules/hive-gateway/default.nix
Normal file
291
nix/host-modules/hive-gateway/default.nix
Normal file
|
|
@ -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/<name>)` 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/<certName>` and `<keyName>`.
|
||||
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
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
61
nix/host-modules/hive-gateway/dnsmasq.nix
Normal file
61
nix/host-modules/hive-gateway/dnsmasq.nix
Normal file
|
|
@ -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 `/<domain>/` 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.
|
||||
};
|
||||
}
|
||||
71
nix/host-modules/hive-gateway/error-pages.nix
Normal file
71
nix/host-modules/hive-gateway/error-pages.nix
Normal file
|
|
@ -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" ''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${title} ◆ hyperhive</title>
|
||||
<style>
|
||||
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
|
||||
h1 { color: ${accent}; font-size: 1.5rem; margin: 0 0 0.5rem; }
|
||||
p { max-width: 36rem; margin: 0.5rem auto; color: #a6adc8; }
|
||||
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; font-size: 0.92em; }
|
||||
pre { background: #181825; color: #cdd6f4; text-align: left; display: inline-block; padding: 0.75rem 1.25rem; border-radius: 0.4rem; margin: 0.75rem 0; font-size: 0.88em; line-height: 1.6; }
|
||||
a { color: #89b4fa; }
|
||||
.hint { color: #a6adc8; font-size: 0.9em; margin-top: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>◆ ${title}</h1>
|
||||
${body}
|
||||
</body>
|
||||
</html>
|
||||
'';
|
||||
in
|
||||
{
|
||||
notFound = mkPage {
|
||||
name = "agent-not-found";
|
||||
title = "agent not found";
|
||||
accent = "#cba6f7";
|
||||
body = ''
|
||||
<p>No agent matches the requested <code>/agent/<name>/</code> path on this hive.</p>
|
||||
<p>Operator: check the agent name in <a href="/">the dashboard</a>.</p>
|
||||
'';
|
||||
};
|
||||
|
||||
unreachable = mkPage {
|
||||
name = "agent-unreachable";
|
||||
title = "agent unreachable";
|
||||
accent = "#f9e2af";
|
||||
body = ''
|
||||
<p>The agent's harness web server isn't responding. Container restarting, or the agent crashed.</p>
|
||||
<p>Operator: <a href="/">dashboard</a> → check the container status / journal; the page will recover on retry once the harness is back up.</p>
|
||||
'';
|
||||
};
|
||||
|
||||
unauthorized = mkPage {
|
||||
name = "unauthorized";
|
||||
title = "unauthorized";
|
||||
accent = "#f38ba8";
|
||||
body = ''
|
||||
<p>This hive is protected by HTTP Basic auth. Valid credentials are required.</p>
|
||||
<p class="hint">Operator: add a user with <code>hivectl gateway create-user</code>:</p>
|
||||
<pre>hivectl gateway create-user \
|
||||
<username> --password-stdin</pre>
|
||||
<p class="hint">Then reload your browser and enter the credentials when prompted.</p>
|
||||
'';
|
||||
};
|
||||
}
|
||||
299
nix/host-modules/hive-gateway/options.nix
Normal file
299
nix/host-modules/hive-gateway/options.nix
Normal file
|
|
@ -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
|
||||
`<certDir>/<tls.certName>` and `<certDir>/<tls.keyName>`.
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
347
nix/host-modules/hive-gateway/vhosts.nix
Normal file
347
nix/host-modules/hive-gateway/vhosts.nix
Normal file
|
|
@ -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.<name>` 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
|
||||
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs):
|
||||
# always `https://<host>` (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";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$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
|
||||
# `<hive>:<httpPort>`. 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
|
||||
# <gatewayHost>: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/<name>/` 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/<name>/` 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/<name>/` from this file beats
|
||||
# the `/agent/` catch-all above.
|
||||
extraConfig = securityHeaders + ''
|
||||
include /run/hive-state/agents.conf;
|
||||
'';
|
||||
};
|
||||
}
|
||||
// forgeVhost
|
||||
// matrixVhost;
|
||||
}
|
||||
|
|
@ -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 <bridgeIp>`.
|
||||
# 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.<n>` → `container@<n>.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.<n>` →
|
||||
# `container@<n>.service` — the nspawn template NixOS generates.)
|
||||
systemd.services."container@hive-matrix".after = [
|
||||
"container@hive-gateway.service"
|
||||
];
|
||||
};
|
||||
|
|
@ -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 `<hive-domain>` 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 `<hive-domain>` 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.
|
||||
''
|
||||
);
|
||||
}
|
||||
];
|
||||
}
|
||||
157
nix/host-modules/hive-priv.nix
Normal file
157
nix/host-modules/hive-priv.nix
Normal file
|
|
@ -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 <container>.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/<n>/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#<agent>`) 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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 `*.<domain>` 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
|
||||
109
nix/host-modules/hyperhive.nix
Normal file
109
nix/host-modules/hyperhive.nix
Normal file
|
|
@ -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 `<name>@<domain>`
|
||||
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).
|
||||
'';
|
||||
};
|
||||
}
|
||||
117
nix/host-modules/otel.nix
Normal file
117
nix/host-modules/otel.nix
Normal file
|
|
@ -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 <token>`). hive-c0re forwards this host
|
||||
file into each agent container's credential store via
|
||||
systemd-nspawn `--load-credential=otel-headers:<path>`; 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.";
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
246
nix/host-modules/swarm.nix
Normal file
246
nix/host-modules/swarm.nix
Normal file
|
|
@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
'';
|
||||
183
nix/packages/default.nix
Normal file
183
nix/packages/default.nix
Normal file
|
|
@ -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.<system>.hivectl ]`
|
||||
# when the operator only wants the admin CLI.
|
||||
# Completions come from the binary's own `completions <shell>` 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 <verb> --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
|
||||
''
|
||||
|
|
@ -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;
|
||||
59
nix/rust.nix
Normal file
59
nix/rust.nix
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
49
nix/sources.nix
Normal file
49
nix/sources.nix
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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/<name>/flake.nix`.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
11
nix/treefmt.nix
Normal file
11
nix/treefmt.nix
Normal file
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue