{ pkgs, lib, config, # Flake inputs routed through _module.args by the agent flake.nix. # Default to {} so the module evaluates cleanly even when the agent # flake doesn't set up the routing pattern (e.g. during standalone # nixos-rebuild without a flake wrapper). flakeInputs ? { }, ... }: let # Agent user metadata. `userName` defaults to `"agent"` when the # meta-flake doesn't inject the per-agent override (stand-alone # `nixos-rebuild` against `nixosConfigurations.agent-base` works # without erroring on a missing per-agent name). `homeDir` derives # from `userName` to keep them coupled. userName = config.hyperhive.user.name; homeDir = "/home/${userName}"; # 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"; in { # Shared scaffolding for every hyperhive harness container. # `agent-base.nix` and `manager.nix` both import this; all agents # use the same service unit regardless of which entry-point they came from. # Optional feature modules. Each declares its own `hyperhive.*` # option(s), default-off, so every agent has them available but # only opts in from its own `agent.nix`. imports = [ ./weston-vnc.nix ]; # Per-agent unix user the harness + co-process daemons run as. # Defaults to `"agent"` so a standalone evaluation (e.g. # `nix flake check` against `nixosConfigurations.agent-base`) builds # cleanly; the meta-flake's per-agent module rebinds this to the # agent name (`"damocles"`, `"iris"`, …) so each container has a # uniquely-named user matching its agent label. UID auto-assigned # by NixOS (the auto-allocation range for normal users); no hard- # coded UID. options.hyperhive.user.name = lib.mkOption { type = lib.types.strMatching "^[a-z_][a-z0-9_-]{0,30}$"; default = "agent"; example = "iris"; description = '' Unix user the harness service runs as inside the container. The meta-flake overrides this to the agent's own name so the user inside the container matches the agent label (`HIVE_LABEL`). Stand-alone evaluation defaults to `"agent"` so module evaluation without the meta-flake wrapper still builds. Constraints match `useradd`'s NAME_REGEX: lowercase / `_` start, total length ≤ 31, no special characters. UID is auto-assigned by NixOS; no `uid =` override surface (intentional — pinning across rebuilds isn't a concern when the home and state dirs stay bind-mounted from the host). ''; }; options.hyperhive.user.passwordlessSudo = lib.mkOption { type = lib.types.bool; default = true; example = false; description = '' Grant `${config.hyperhive.user.name}` passwordless sudo (`NOPASSWD: ALL`). True by default so claude's `Bash` tool keeps working for tools that expect root inside the container (`systemctl`, package managers in dev shells, etc.) — the same surface the previous root-user shape had, just elevated explicitly instead of implicitly. Flip to `false` for agents that should be strictly unprivileged. Anything claude shells out to that needs root will then fail loudly with the standard sudo error rather than silently succeeding — easier to spot the leak. ''; }; options.hyperhive.web.useUnixSocket = lib.mkOption { type = lib.types.bool; default = false; example = true; description = '' Deprecated. Unix socket mode is now always enabled for all agents. Setting this option to `true` has no effect and the option will be removed in a future version. Safe to drop from agent configs. ''; }; options.hyperhive.model = lib.mkOption { type = lib.types.str; default = "haiku"; example = "sonnet"; description = '' Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL` environment variable; the harness applies it at boot and it takes priority over any persisted runtime override. The operator can still switch the model at runtime via the per-agent web UI — that choice is tracked in the state dir for the current session but is reset by any rebuild that changes this option. Valid values are the short model names that `claude --model` accepts: `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context window sizes are looked up at runtime from the `HIVE_CONTEXT_WINDOW_TOKENS_` env vars injected by the meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens` on the host. ''; }; options.hyperhive.availableModels = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "haiku" "sonnet" "opus" ]; example = [ "sonnet" "opus" ]; description = '' Models offered in the per-agent web UI's model quick-picker. Rendered into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated) which the harness surfaces to the agent UI, so the picker lists exactly these models instead of a hardcoded set. Configure hive-wide by setting a shared default (e.g. in your `agent-base.nix`) or per-agent to narrow the menu — for example a haiku-only agent can hide `opus` and `sonnet`. The *current* model is still set by `hyperhive.model` and remains switchable at runtime via the UI; this option only controls which choices the picker presents. Values are the short model names that `claude --model` accepts: `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). ''; }; options.hyperhive.effortLevel = lib.mkOption { type = lib.types.enum [ "medium" "high" "xhigh" ]; 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. `"medium"` (the default) keeps token spend low and works well with the harness across families. `"high"` matches the current platform default; `"xhigh"` is recommended for coding / high-autonomy work on capable models (Opus 4.8+) at higher token 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.allowedBashPatterns = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; description = '' Deprecated - has no effect. The built-in Bash tool is fully disabled regardless of this list; agents use mcp__bash__run instead. Remove this option from your agent.nix. ''; visible = false; }; options.hyperhive.allowedRecipients = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "alice" "manager" ]; description = '' Names this agent is allowed to `send` to via `mcp__hyperhive__send`. Empty list (the default) means unrestricted — the agent can message any peer, the operator, or the manager. Non-empty list constrains the surface: only the listed names + the manager (always allowed) get through; anything else returns an error string to claude without touching the broker. The operator (`operator`) needs to be in the list if the agent should be able to surface output on the dashboard. Useful for sandboxing untrusted sub-agents — set `[ "manager" ]` to scope them to manager-only chatter. The manager itself is always exempt; this option only affects sub-agent `send`. ''; }; options.hyperhive.extraMcpServers = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { command = lib.mkOption { type = lib.types.str; description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`."; }; args = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; description = "Args passed to the MCP server binary."; }; env = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { }; description = "Environment variables for the MCP server child process."; }; allowedTools = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "*" ]; example = [ "send_message" "join_room" ]; description = '' Tool names this MCP server is auto-approved to call via `--allowedTools`. Single entry `"*"` (the default) means "every tool from this server" — convenient but trusting. Tighten to a specific list when you only want a subset. Names are bare (e.g. `send_message`); the harness prepends `mcp____` at build time. ''; }; }; } ); default = { }; example = lib.literalExpression '' { matrix = { command = "/run/current-system/sw/bin/mcp-matrix"; args = [ "--config" "/state/matrix.toml" ]; env.MATRIX_HOMESERVER = "https://matrix.example.org"; allowedTools = [ "send_message" "join_room" ]; }; } ''; description = '' Extra MCP servers claude sees alongside the hyperhive tool surface. Keys are the server names (claude addresses tools as `mcp____`). Rendered to `/etc/hyperhive/extra-mcp.json` at activation time; the harness reads that file at boot and merges it into `--mcp-config` + `--allowedTools`. Take effect on the agent's next harness restart (no operator approval needed beyond whatever brought the new agent.nix into deployed/*). ''; }; options.hyperhive.matrix.enable = lib.mkOption { type = lib.types.bool; default = true; description = '' Enable per-agent matrix integration via `hive-matrix-mcp`. When true (the default), the harness: - runs `hive-matrix-daemon` as a systemd unit that holds a matrix-sdk Client + sync against the homeserver at `HIVE_MATRIX_URL` (default `http://localhost:8008` — the in-host tuwunel from `nix/modules/hive-matrix.nix`). The daemon auto-skips when `/matrix-token` is missing, and a `systemd.paths` watcher restarts it the moment hive-c0re provisions the token (same path-trigger shape as `matrix-avatar-sync`). - exposes the matrix tool surface (send_message, send_dm, send_reaction, send_reply, mark_read, list_rooms, list_room_members, read_room) to claude via an auto-injected `extraMcpServers.matrix` entry. Claude spawns the stdio `hive-matrix-mcp` bridge per turn, which forwards each tool call to the daemon over `/run/hive-matrix/socket`. - wakes the agent on incoming room events via a short teaser Wake signal (`[matrix] in : …`) to the hyperhive control socket; the full event stays unread server-side until `read_room` consumes it. Set to `false` for agents that should NOT have matrix tools at all (e.g. agents on a host without `hyperhive.matrix.enable` on the meta side). When token file is absent the daemon and MCP both no-op cleanly anyway, so `false` is rarely necessary. ''; }; options.hyperhive.matrix.url = lib.mkOption { type = lib.types.str; default = matrixUrlDefault; example = "https://matrix.darkest.space"; description = '' Matrix homeserver URL the agent's `hive-matrix-daemon` connects to. Default points at the in-host tuwunel (shared netns). Override per-agent when an agent should talk to an external homeserver instead (e.g. a federation-only setup or a remote hive's tuwunel reached via a vpn). ''; }; options.hyperhive.matrixAccounts = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { tokenFile = lib.mkOption { type = lib.types.str; example = "/agents/dmatrix/state/matrix-token-ccc"; description = '' Path to this account's bearer-token file. The daemon reads the token from here to restore the matrix session; how the file gets populated is the provisioner's concern (an operator-supplied secret for an external account). The daemon skips an extra account whose token file is absent. ''; }; sessionDir = lib.mkOption { type = lib.types.str; example = "/agents/dmatrix/state/matrix-sdk-state-ccc"; description = '' Per-account matrix-sdk sqlite store directory (crypto keys + event cache). Must differ between accounts so their sessions do not collide. ''; }; homeserver = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "https://matrix.example.org"; description = '' Homeserver URL for this account. When null (the default), the account falls back to `hyperhive.matrix.url`. Set it for an account on a different homeserver than the agent's default (e.g. an external public-matrix account). ''; }; }; } ); default = { }; example = lib.literalExpression '' { ccc = { tokenFile = "/agents/dmatrix/state/matrix-token-ccc"; sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc"; homeserver = "https://matrix.example.org"; }; } ''; description = '' Declare *additional* matrix accounts served by the single `hive-matrix-daemon` (one matrix-sdk Client + sync loop each), beyond the agent's built-in hive-internal account. This replaces the wasteful "one MCP server + daemon per account" pattern. The attribute name keys each account (unique by construction) and is the handle the matrix MCP tools target via their `account` argument. The **hive-internal account is always present and is the primary**: it is named `main`, synthesized by the daemon from `hyperhive.matrix.url` + `/matrix-token` + `/matrix-sdk-state`, and is the account a tool call acts as when it omits `account`. You never declare it here --- this option is only for the extras (e.g. an external public-matrix account). Leave empty (the default) for the common single-account case: the agent then has only `main`, exactly as before. When non-empty, the extras are serialized to the daemon's `HIVE_MATRIX_ACCOUNTS` environment variable and the daemon appends them after `main`. Requires `hyperhive.matrix.enable` (there is no `main` to extend otherwise). ''; }; options.hyperhive.frontend.dist = lib.mkOption { type = lib.types.package; default = pkgs.hyperhive-frontend; defaultText = lib.literalExpression "pkgs.hyperhive-frontend"; description = '' The shipped frontend dist (built by `nix/frontend.nix`). Output layout: `dashboard/` (used by hive-c0re on the host) and `agent/` (used here, layered with `extraFiles` below at activation time). Override to ship a fully custom per-agent SPA; the JSON contract (`/api/state`, `/events/stream`, the action endpoints) is the source of truth for any replacement. ''; }; options.hyperhive.frontend.mergedDist = lib.mkOption { type = lib.types.package; readOnly = true; description = '' Computed: the merged static tree consumed by the harness via `HIVE_STATIC_DIR`. Composed at evaluation time by copying `hyperhive.frontend.dist`'s `agent/` subdir as the base, then layering each `extraFiles` entry on top. Read-only — do not set directly. ''; }; options.hyperhive.frontend.extraFiles = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule ( { name, ... }: { options = { source = lib.mkOption { type = lib.types.path; description = '' Source file or directory to layer over the default agent dist. A path (relative to `agent.nix` or absolute) — nix copies its contents into the merged static tree. ''; }; target = lib.mkOption { # First char must be alphanumeric/underscore (rules out # leading `/`, leading `.`, leading `-`); inner chars # include `.` and `/` so nested layouts like # `"games/bitburner"` work. This is the shape check — # the `..`-segment traversal check is the assertion in # `config.assertions` below (regex alone can't reject # mid-path `..` segments without lookahead, which nix # POSIX regex doesn't support). type = lib.types.strMatching "^[A-Za-z0-9_][A-Za-z0-9_./-]*$"; default = name; defaultText = lib.literalMD "the attribute name"; description = '' Destination path within the merged static tree, used as both the served URL prefix (`//...`) and the on-disk layout in the merged derivation. Defaults to the attribute name. Use forward slashes for nested layouts (e.g. `"games/bitburner"`). Constrained shape: must start with an alphanumeric or `_`, and only contain alphanumerics, `_`, `.`, `/`, `-`. `..` segments are separately rejected at config eval time. ''; }; }; } ) ); default = { }; example = lib.literalExpression '' { bitburner = { source = ./bitburner-dist; # served at GET /bitburner/... }; } ''; description = '' Per-agent additions layered on top of the default frontend dist. Each entry copies its `source` into the served static tree under `target`. Useful for shipping a self-contained agent-specific surface alongside the standard agent UI (e.g. the bitburner agent's game page at `/bitburner/`). The default agent UI remains served at `/`; entries here only add new routes and never replace the default. Overwrite semantics are **hard-fail**: if `target` collides with an existing file or directory in the default dist (or with a prior entry's target), the `mergedDist` build aborts with `refusing to overwrite existing path '' in the default dist`. To override a default file, fork the dist via `hyperhive.frontend.dist` instead — `extraFiles` is for pure additions. `target` must be a relative path inside the static dir. An assertion rejects leading `/` and `..` segments at config eval time (string-concat-into-paths safety, even though agent.nix goes through operator review before deploy). ''; }; options.hyperhive.forge.url = lib.mkOption { type = lib.types.str; default = "http://localhost:3000"; example = "http://forge.internal:3000"; description = '' Base URL of the hyperhive-managed Forgejo. Used at container boot by a oneshot systemd unit that calls `tea login add --url --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` (= `/agents//state/forge-token`) so the agent's claude can shell out to `tea` without an extra auth dance. No-op when the forge-token file is missing (i.e. hive-forge isn't running on the host). ''; }; options.hyperhive.forge.keepSubscriptions = lib.mkOption { type = lib.types.bool; default = true; description = '' When true (the default), the forge notification poller will NOT auto-unsubscribe from repo watches after delivering a "subscribed"-reason notification. Sub-agents keep their broad subscriptions so they stay informed about repos they contribute to. Set to false for agents (e.g. the manager) that use reason-based filtering and do not need firehose-level repo visibility — they will auto-unsubscribe after receiving a watched-repo notification. ''; }; options.hyperhive.forge.skipNotifyReasons = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "subscribed" "participating" ]; description = '' Forgejo notification `reason` values to suppress in the forge notification poller. Notifications with these reasons are marked read and silently dropped; all others — including notifications with a null or unrecognised reason — are delivered. Drop-list is safer than an allow-list: directed signals (`review_requested`, `assigned`, `mention`) are never silently missed even if Forgejo returns an unexpected reason string. Empty list (the default) delivers all notifications. Set to `[ "subscribed" "participating" ]` for agents like the manager that want only direct mentions and reviews, not the full repo firehose. Rendered to the `HIVE_FORGE_NOTIFY_SKIP_REASONS` environment variable consumed by the harness poller at runtime. ''; }; options.hyperhive.dashboardLinks = lib.mkOption { type = lib.types.listOf ( lib.types.submodule { options = { label = lib.mkOption { type = lib.types.str; description = "Display label for the link."; }; icon = lib.mkOption { type = lib.types.str; default = ""; description = "Optional icon emoji or short glyph."; }; url = lib.mkOption { type = lib.types.str; description = "Full URL (may include a different port, e.g. http://localhost:9001/stats)."; }; }; } ); default = [ ]; example = lib.literalExpression '' [ { label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; } ] ''; description = '' Extra navigation links surfaced on the hive-c0re dashboard card for this agent. Declare any additional web UI pages the agent exposes — stats pages, custom UIs, etc. hive-c0re reads the JSON file this option produces at each container-view snapshot and attaches the links to the agent card without any code changes. ''; }; options.hyperhive.claudeMarketplaces = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "anthropics/claude-plugins-official" ]; example = [ "anthropics/claude-plugins-official" "anthropics/claude-plugins-community" ]; description = '' Claude Code plugin marketplaces to add at harness boot. Each entry is passed to `claude plugin marketplace add ` (`owner/repo`, full git URL, or local path). Idempotent — re-adding an existing marketplace is treated as success. Required before `hyperhive.claudePlugins` entries that reference a marketplace (e.g. `foo@claude-plugins-official`). Rendered to `/etc/hyperhive/claude-marketplaces.json`. Defaults to Anthropic's official marketplace; agents get it out of the box without any per-agent.nix wiring. ''; }; options.hyperhive.claudePlugins = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "formatter@my-marketplace" "thinking-tools@anthropics" ]; description = '' Claude Code plugins to install at harness boot. Each entry is passed verbatim to `claude plugin install ` once per container start, before the turn loop opens. `claude plugin install` is expected to be idempotent, so reinstalling on every boot is cheap. Failures log a warning but do not abort boot — a missing plugin is preferable to a non-serving agent. Rendered to `/etc/hyperhive/claude-plugins.json`; the harness reads it via `plugins::install_configured`. ''; }; options.hyperhive.claudePluginsAutoUpdate = lib.mkOption { type = lib.types.bool; default = false; description = '' When true, the harness runs `claude plugin marketplace update` before installing plugins at boot, pulling the latest index from all configured marketplaces. Disabled by default — most agents want pinned plugin versions and the network round-trip adds to boot time. Enable for agents that should always install the latest available version of their plugins. ''; }; options.hyperhive.icon = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; example = lib.literalExpression "./icon.svg"; description = '' Path to an SVG file used as this agent's icon — shown on the dashboard and the per-agent web UI (header + favicon). Commit the SVG into the agent's config repo next to `agent.nix` and reference it as a relative path (`./icon.svg`). When null (the default) the agent falls back to the shared hyperhive logo. The harness serves the icon (configured or default) at `GET /icon` on the per-agent web port. ''; }; # Internal accumulator for shell snippets that should land in # `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via # `lib.mkIf` gated on their own option; the lines type merges # all contributions across modules into one file. Loaded via # `$BASH_ENV` for non-interactive shells (claude's `Bash` tool # runs `bash -c`) and via `programs.bash.interactiveShellInit` # for interactive shells. Generic by design so future hooks # don't need to rename this file or invent a parallel dispatcher. options.hyperhive._bashEnvFragments = lib.mkOption { type = lib.types.lines; default = ""; internal = true; description = '' Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`. Feature hooks contribute via `lib.mkIf` gated on their own option. When empty, the file isn't created, `BASH_ENV` stays unset, and the interactive bashrc hook is omitted — zero cost when no feature is on. Internal — set indirectly via the per-feature options that own the gate (e.g. `hyperhive.cargo.shortMessages`). ''; }; options.hyperhive.cargo.shortMessages = lib.mkOption { type = lib.types.bool; default = true; example = false; description = '' Auto-inject `--message-format short` on cargo compile subcommands (`build`, `check`, `clippy`, `test`, `run`, `doc`, `bench`, `install`, `rustc`, `fix`) when claude (or anything else) invokes `cargo` inside this container. Saves tokens + context — the verbose default output floods the response window with per-crate progress lines that carry no signal beyond the warning/error summary. Implementation: contributes a `cargo` shell function to `/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`). Loaded via `BASH_ENV` for non-interactive shells (`bash -c` — what the claude `Bash` tool runs) and sourced from `programs.bash.interactiveShellInit` for interactive shells. The function: - handles the `+toolchain` selector prefix (`cargo +nightly build` works); - passes through cleanly when the caller already specified `--message-format` (any form); - leaves non-compile subcommands (`new`, `add`, `search`, third-party `cargo-*` subcommands) untouched so they don't error on the unknown flag. Set to `false` for agents that need full cargo output (e.g. tooling that parses `--message-format json` programmatically and doesn't pass the flag explicitly). ''; }; options.hyperhive.autoCompact = lib.mkOption { type = lib.types.bool; default = true; description = '' Enable proactive watermark-based compaction. When `true` (the default) the harness automatically runs a notes-checkpoint turn followed by `/compact` once the context window crosses 75% of the model's limit, keeping later turns from hitting the hard overflow path. Set to `false` to disable proactive compaction entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path (compact-on-overflow when the session is already past the limit) still applies. Disable for agents that run large-context models (sonnet/opus) where the heuristic fires too early and discards useful history before the session is actually close to the limit. ''; }; config = { warnings = lib.optional (config.hyperhive.allowedBashPatterns != [ ]) '' hyperhive.allowedBashPatterns is deprecated and has no effect. The built-in Bash tool is fully disabled; agents use mcp__bash__run instead. Remove allowedBashPatterns from your agent.nix. ''; assertions = [ # Guard the inputs-routed-as-output pattern: the agent flake.nix is # expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`. # If `self` leaks into flakeInputs the agent gets a spurious attrset # entry that can shadow real inputs and is almost certainly a bug. # Guard with `or {}` so standalone evaluation stays clean when # flakeInputs is absent from _module.args. { assertion = !(builtins.hasAttr "self" (config._module.args.flakeInputs or { })); message = '' hyperhive: `flakeInputs` must not contain "self". In your agent flake.nix, use: _module.args.flakeInputs = builtins.removeAttrs inputs [ "self" ]; ''; } # hyperhive.model must be a non-empty string — an empty value causes # the harness to pass an invalid model flag to claude. { assertion = config.hyperhive.model != ""; message = "hyperhive.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")"; } # The current model must appear in the quick-picker menu, otherwise the # UI would offer no way back to the model the agent is actually running. { assertion = config.hyperhive.availableModels == [ ] || builtins.elem config.hyperhive.model config.hyperhive.availableModels; message = "hyperhive.model (\"${config.hyperhive.model}\") must be one of " + "hyperhive.availableModels ([ ${lib.concatStringsSep " " config.hyperhive.availableModels} ]) " + "— add it to the list or change the model."; } # hyperhive.forge.url must look like an HTTP URL when non-default. { assertion = config.hyperhive.forge.url == "" || lib.hasPrefix "http://" config.hyperhive.forge.url || lib.hasPrefix "https://" config.hyperhive.forge.url; message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")"; } # hyperhive.icon must reference an SVG file when set. { assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon); message = "hyperhive.icon must point to an .svg file"; } # Extra matrix accounts only make sense alongside the hive-internal # `main` account they extend, which exists only when matrix is # enabled. { assertion = config.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable; message = "hyperhive.matrixAccounts requires hyperhive.matrix.enable = true " + "(the extras extend the hive-internal `main` account, which only " + "exists when matrix is enabled)."; } # `main` is reserved for the synthesized hive-internal account; a # declared extra by that name would silently collide with it. { assertion = !builtins.hasAttr "main" config.hyperhive.matrixAccounts; message = "hyperhive.matrixAccounts cannot contain a key named \"main\" " + "--- that name is reserved for the hive-internal account."; } # Token files must land at the `matrix-token*` name the daemon # path-watcher globs (`/agents/*/state/matrix-token*`), or the account # never gets picked up live (it loads only on a full daemon restart). # Enforce the basename prefix so a deviating name (e.g. the historical # `matrix-catgirl-token`) is caught at build time, not silently. { assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) ( lib.attrValues config.hyperhive.matrixAccounts ); message = "every hyperhive.matrixAccounts..tokenFile basename must start with " + "\"matrix-token\" so the daemon path-watcher glob " + "(/agents/*/state/matrix-token*) picks it up live. Offending: " + lib.concatStringsSep ", " ( lib.mapAttrsToList (n: a: "${n}=${baseNameOf a.tokenFile}") ( lib.filterAttrs ( _n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile) ) config.hyperhive.matrixAccounts ) ) + "."; } # hyperhive.frontend.extraFiles[*].target is concatenated into # $out during the mergedDist build. The option's strMatching # type already rejects leading `/`, leading `.`, and the # weirder characters; this assertion catches mid-path `..` # segments (e.g. `foo/../etc/passwd`) that the type's regex # can't easily express without lookahead. agent.nix is # operator-reviewed, so this is belt-and-braces — but it's the # kind of mistake that's easy to make and hard to spot. { assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) ( lib.attrValues config.hyperhive.frontend.extraFiles ); message = '' hyperhive.frontend.extraFiles: `target` must not contain `..` path segments. ''; } ]; # Per-agent unix user. Runs the hive harness + # co-process daemons under a non-root principal. UID auto-assigned by # NixOS. The container activation script (hive-agent-user-migrate) # chowns the bind-mounted state dir — including credential files # written by hive-c0re before the container was built — to this user # on every boot, so agent processes can always read their own tokens. users.users.${userName} = { isNormalUser = true; home = homeDir; createHome = true; group = userName; extraGroups = lib.optional config.hyperhive.user.passwordlessSudo "wheel"; # Matches /bin/bash on NixOS — the harness's claude shell-outs # expect a POSIX shell at $SHELL; bashInteractive is already # the system default for the root user too (see SHELL env # var declaration below). shell = pkgs.bashInteractive; }; users.groups.${userName} = { }; # `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool # keep working with anything that expected root (systemctl, # nix-env, etc.) without prompting — same surface as the # previous root-by-default shape, just elevated explicitly. # Flip `hyperhive.user.passwordlessSudo = false` to drop both # the wheel-group membership and this sudoers entry; anything # that needs root then fails loudly instead of silently # succeeding. security.sudo.extraRules = lib.mkIf config.hyperhive.user.passwordlessSudo [ { users = [ userName ]; commands = [ { command = "ALL"; options = [ "NOPASSWD" ]; } ]; } ]; # First-boot migration to the per-agent unix user — creates the # home dir, chowns the bind-mounted state + `~/.claude/`, and # (marker-guarded) moves any leftover `/root/.claude` content # from the previous root-run shape. See # `docs/persistence.md::First-boot agent-user migration` for the # step-by-step rationale; this script implements it. system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' homeDir=${lib.escapeShellArg homeDir} userName=${lib.escapeShellArg userName} mkdir -p "$homeDir" chown "$userName:$userName" "$homeDir" marker=/var/lib/hive-agent-user-migrated if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then mkdir -p "$homeDir/.claude" if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then rm -rf /root/.claude echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude" fi fi mkdir -p "$(dirname "$marker")" : > "$marker" # Scope state + harness chowns to THIS container's own dirs only. # The glob `/agents/*/state` also matches child-agent state dirs that # are bind-mounted into parent containers, which would clobber the # ownership those dirs' own activation scripts set — producing # intermittent EACCES for the child agent's harness between a parent # rebuild and the child's next activation. Config dirs are kept broad # because the parent legitimately owns child proposed-config repos. if [ -d "/agents/$userName/state" ]; then chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true fi if [ -d "/agents/$userName/harness" ]; then chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true fi # The proposed-config repo is RW-mounted into the editing (parent/ # manager) agent and owned by it; hive-c0re only pulls from it. Heal # it to this user too — same as state/harness. In an agent's own # container its config is RO-mounted, so the chown there just fails # harmlessly (|| true). for configDir in /agents/*/config; do [ -d "$configDir" ] || continue chown -hR "$userName:$userName" "$configDir" 2>/dev/null || true done if [ -d "$homeDir/.claude" ]; then chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true # 0755 so hive-core (a different unix user) can list the dir and # detect a valid claude session. Credential files inside are 0600 # so secrets stay private regardless of the directory mode. # ensure_claude_dir sets 0755 on creation but cannot re-chmod after # hive-agent-user-migrate chowns the dir to the agent user; this # activation script runs as root and handles the correction. chmod 755 "$homeDir/.claude" 2>/dev/null || true fi ''; # Auto-inject built-in MCP servers. bash is always present; matrix is # conditional on hyperhive.matrix.enable. Both use lib.mkDefault so # the operator's own agent.nix can override individual entries. hyperhive.extraMcpServers = lib.mkMerge [ { bash = lib.mkDefault { command = "${pkgs.hyperhive}/bin/hive-bash-mcp"; args = [ ]; env.HIVE_BASH_SOCKET = "/run/hive-bash/socket"; allowedTools = [ "*" ]; }; } (lib.mkIf config.hyperhive.matrix.enable { matrix = lib.mkDefault { command = "${pkgs.hyperhive}/bin/hive-matrix-mcp"; args = [ ]; # Same socket path the hive-matrix-daemon service binds # via its `RuntimeDirectory = "hive-matrix"`. Keeps the # bridge + daemon in sync without baking the path into # the Rust default — the env override wins for both. env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; allowedTools = [ "*" ]; }; }) ]; environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; # Operator-set per-agent icon (hyperhive.icon). When configured, the # SVG lands at /etc/hyperhive/icon.svg; the harness serves it at # GET /icon, falling back to the bundled hyperhive logo when absent. environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) { source = config.hyperhive.icon; }; # Cargo `--message-format short` injector. Contributes a `cargo` # shell function to `hyperhive._bashEnvFragments`; the bash-env # infrastructure below packages that into a single file sourced # by both non-interactive and interactive shells. # `command cargo …` falls back to the un-wrapped binary in PATH # (the rust toolchain's cargo — either from `environment.systemPackages` # or from whatever `nix develop` shell the agent's working in). hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages '' # Auto-injects --message-format short on cargo compile # subcommands so per-crate progress lines don't flood # claude's context. Bypassed when the caller already passes # --message-format (any form). cargo() { # Strip leading +toolchain selectors (cargo +nightly …). local pre=() while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do pre+=("$1") shift done case "''${1:-}" in build|check|clippy|test|run|doc|bench|install|rustc|fix) local sub="$1" shift local arg for arg in "$@"; do case "$arg" in --message-format|--message-format=*) command cargo "''${pre[@]}" "$sub" "$@" return $? ;; esac done command cargo "''${pre[@]}" "$sub" --message-format short "$@" ;; *) command cargo "''${pre[@]}" "$@" ;; esac } ''; # Single bash-env file with all configured shell fragments. # Wiring is gated on at least one fragment being active so a # fully feature-disabled agent has neither the file nor the # `BASH_ENV` / interactive sourcing — zero cost in that case. environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") { text = config.hyperhive._bashEnvFragments; }; environment.etc."hyperhive/send-allow.json".text = builtins.toJSON config.hyperhive.allowedRecipients; environment.etc."hyperhive/claude-plugins.json".text = builtins.toJSON config.hyperhive.claudePlugins; environment.etc."hyperhive/claude-marketplaces.json".text = builtins.toJSON config.hyperhive.claudeMarketplaces; environment.etc."hyperhive/claude-plugins-auto-update.json".text = builtins.toJSON config.hyperhive.claudePluginsAutoUpdate; # Merged frontend static tree. Base = `${frontend.dist}/agent/`, # then each `extraFiles` entry is laid on top at its `target` # path. The runCommand derivation aborts on overwrite so a # filename collision with the default dist surfaces as a build # failure rather than a silent override (operator gets a clear # nix error rather than a confusing 404 / silent dist swap). hyperhive.frontend.mergedDist = pkgs.runCommand "hyperhive-agent-frontend-merged" { } ( '' mkdir -p $out cp -r ${config.hyperhive.frontend.dist}/agent/. $out/ chmod -R u+w $out '' + lib.concatMapStrings (entry: '' mkdir -p $(dirname $out/${entry.target}) if [ -e $out/${entry.target} ]; then echo "hyperhive.frontend.extraFiles: refusing to overwrite existing path '${entry.target}' in the default dist" >&2 exit 1 fi cp -r ${entry.source} $out/${entry.target} '') (lib.attrValues config.hyperhive.frontend.extraFiles) ); # HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted # model choice exists in the state dir. SHELL must be set so claude's # Bash tool finds a POSIX shell. # HIVE_ASSETS_DIR points at the project's static runtime assets # (branding + claude prompts; see `nix/assets.nix`). Set here so # both the harness binary and any user-shell `cargo run` inside the # container resolve them from the same path. # HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the # host-level `services.hyperhive.c0re.contextWindowTokens` option — not set here. environment.variables = { HIVE_DEFAULT_MODEL = config.hyperhive.model; # HIVE_AVAILABLE_MODELS is the comma-separated menu for the per-agent # UI model quick-picker (see hyperhive.availableModels). The harness # surfaces it to the frontend; an empty value falls back to the # built-in default list. HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.hyperhive.availableModels; # HIVE_DEFAULT_EFFORT is the per-agent baseline effort (see # hyperhive.effortLevel). The harness resolves operator-override-file # → this env → "medium" and passes it to claude --effort at turn launch. HIVE_DEFAULT_EFFORT = config.hyperhive.effortLevel; HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; SHELL = "${pkgs.bashInteractive}/bin/bash"; } // lib.optionalAttrs (!config.hyperhive.autoCompact) { # Zero watermark disables proactive compaction; the reactive path # (compact-on-overflow) still fires when the session is truly full. HIVE_COMPACT_WATERMARK_TOKENS = "0"; } // lib.optionalAttrs config.hyperhive.forge.keepSubscriptions { HIVE_FORGE_KEEP_SUBSCRIPTIONS = "1"; } // lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) { HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons; } // lib.optionalAttrs (config.hyperhive._bashEnvFragments != "") { # Non-interactive bash invocations (claude's `Bash` tool runs # `bash -c`) source $BASH_ENV at startup — drops every active # feature hook's snippet into scope without touching # `/etc/profile` (login-only). Interactive shells source the # same file via the `interactiveShellInit` hook below so # behaviour matches across both modes. BASH_ENV = "/etc/hyperhive/bash-env.sh"; }; # Interactive shells don't honour BASH_ENV — wire the same file # in via the bashrc hook so operator SSH sessions get the same # hook surface as claude's non-interactive calls. Gated on at # least one fragment being active so we don't write a no-op # source line into `/etc/bashrc` on fully-feature-disabled agents. programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") '' if [ -r /etc/hyperhive/bash-env.sh ]; then . /etc/hyperhive/bash-env.sh fi ''; boot.isNspawnContainer = true; # 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. nix.settings.sandbox-fallback = lib.mkForce true; # `claude-code` is unfree. Each per-agent container's nixosConfiguration # evaluates its own `nixpkgs` instance, so the operator's host-level # `nixpkgs.config.allowUnfreePredicate` does not propagate into here — # we have to allow it inside the container's config as well. nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; environment.systemPackages = with pkgs; [ hyperhive claude-code bashInteractive coreutils-full # procps for pkill — used by the web UI's /api/cancel to SIGINT the # in-flight claude turn. procps # tea: gitea/forgejo CLI client. Configured at boot by the # tea-login oneshot below if /state/forge-token is present, so # claude can `tea repos create`, `tea pulls create`, etc. tea # jq: JSON processing in shell — useful for parsing API responses, # forge REST calls, sqlite output, etc. jq # curl: HTTP client for forge REST API and other web requests. curl # hive-forge : CLI wrapping common Forgejo REST API operations # (view, pr, issue, comment, assign, close, labels, branches, etc.) (pkgs.callPackage ../packages/hive-forge-tools.nix { }) ]; # One-shot: tea config.yml from the seeded forge token. Shape # contract (always exit 0, no set -e, skip-silently, re-runnable): # docs/conventions.md::Best-effort oneshot services. # Take resolvconf + dhcpcd out of the /etc/resolv.conf loop so the # bridge resolver the oneshot below writes actually sticks. At their # NixOS defaults, resolvconf regenerates resolv.conf from host-tracking # *after* the oneshot has pointed it at the bridge (dhcpcd re-triggers # that when the veth comes up under isolation) — silently clobbering the # bridge nameserver back to the host resolver, which isn't authoritative # for the hive's own zones, so `forge.` stops resolving. We # disable resolvconf and tell dhcpcd not to touch resolv.conf (without # disabling dhcpcd itself, so the veth still gets its address); then # whoever wrote resolv.conf last owns it: the nixos-container host-copy # in shared netns, or the oneshot in isolated mode. (Same "take # resolvconf out of the loop" approach the matrix container uses.) networking.resolvconf.enable = false; networking.dhcpcd.extraConfig = "nohook resolv.conf"; # Point resolv.conf at the hive bridge resolver when the container is # network-isolated. nixos-container copies the *host's* /etc/resolv.conf # into the container at every start — but the host resolver (e.g. # 127.0.0.53) is unreachable from a private netns and isn't # authoritative for the hive's own zones (forge. etc.). The # bridge dnsmasq (gateway IP) is. hive-priv drops the marker # `/etc/hyperhive-bridge-dns` (containing the gateway IP) only when # isolation is on, so this oneshot is inert in shared-netns mode — the # same shared container toplevel does the right thing in both modes. # 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; }; path = [ pkgs.coreutils ]; script = '' set -eu gw=$(tr -d '[:space:]' < /etc/hyperhive-bridge-dns) if [ -z "$gw" ]; then echo "hyperhive-isolated-dns: empty marker; leaving resolv.conf as-is" exit 0 fi # resolv.conf is a regular file copied from the host by # nixos-container; replace it (rm first in case it's a symlink). rm -f /etc/resolv.conf printf 'nameserver %s\n' "$gw" > /etc/resolv.conf echo "hyperhive-isolated-dns: resolv.conf -> nameserver $gw" ''; }; systemd.services.tea-login = { description = "configure tea CLI from hive-forge token (best-effort)"; wantedBy = [ "multi-user.target" ]; after = [ "local-fs.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; }; path = [ pkgs.curl pkgs.python3 pkgs.coreutils ]; environment.HOME_DIR = homeDir; environment.AGENT_USER = userName; script = '' # No `set -e`: best-effort posture (see docs pointer above). FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} # $HYPERHIVE_STATE_DIR is system-wide via the meta flake. TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" if [ ! -f "$TOKEN_FILE" ]; then echo "tea-login: no forge-token at $TOKEN_FILE; skipping" exit 0 fi TOKEN=$(cat "$TOKEN_FILE") # Resolve the agent username from the forge API. USER=$(curl -sf --max-time 5 \ -H "Authorization: token $TOKEN" \ "$FORGE_URL/api/v1/user" \ | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ 2>/dev/null || true) if [ -z "$USER" ]; then echo "tea-login: could not resolve username from forge API; skipping" exit 0 fi # Config under the agent user's home, chown'd to them; # service stays root-owned (see docs pointer above). CONFIG="$HOME_DIR/.config/tea/config.yml" mkdir -p "$(dirname "$CONFIG")" || true cat > "$CONFIG" << EOF logins: - name: forge url: $FORGE_URL token: $TOKEN default: true ssh_host: "" ssh_key: "" insecure: false ssh_agent: false user: $USER preferences: editor: false flag_defaults: remote: "" EOF chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)" ''; }; # Path-trigger sibling: re-fires forge-avatar-sync the moment # `/forge-token` appears. Mirrors the matrix-avatar-sync # pattern — on first agent deployment the container boots before # hive-c0re has provisioned the forge-token, so the service fires # too early and exits with "no forge-token found". Without this path # unit, RemainAfterExit=true would prevent systemd from ever # re-running the service. See docs/persistence.md::forge-avatar-sync. systemd.paths.forge-avatar-sync = { description = "trigger forge-avatar-sync when forge-token appears"; wantedBy = [ "multi-user.target" ]; pathConfig.PathExistsGlob = "/agents/*/state/forge-token"; }; # One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract: # docs/conventions.md::Best-effort oneshot services. # RemainAfterExit = false (unlike the old true) so the .path trigger # above can re-fire this unit when the forge-token arrives after boot. systemd.services.forge-avatar-sync = { description = "sync agent icon to Forgejo user avatar (best-effort)"; wantedBy = [ "multi-user.target" ]; after = [ "tea-login.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = false; }; path = [ pkgs.curl pkgs.coreutils pkgs.jq pkgs.librsvg ]; script = '' ICON=/etc/hyperhive/icon.svg if [ ! -f "$ICON" ]; then echo "forge-avatar-sync: no icon configured; skipping" exit 0 fi FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake # (systemd.globalEnvironment) to `/agents//state`. TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" if [ ! -f "$TOKEN_FILE" ]; then echo "forge-avatar-sync: no forge-token found; skipping" exit 0 fi TOKEN=$(cat "$TOKEN_FILE") # Rasterize SVG → PNG (Forgejo's Go image library can't decode SVG). PNG=$(mktemp --suffix=.png) if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then echo "forge-avatar-sync: rsvg-convert failed; skipping" rm -f "$PNG" exit 0 fi IMAGE=$(base64 -w 0 < "$PNG") rm -f "$PNG" # Forgejo POST /user/avatar expects {"image":""} — just the # raw base64 string, NOT a data URI (data:image/png;base64,...). # Use jq to build the payload so the large base64 value is safely quoted. PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}') RESP=$(curl -sf --max-time 10 \ -X POST "$FORGE_URL/api/v1/user/avatar" \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" \ -w "\n%{http_code}" 2>/dev/null || true) CODE=$(printf '%s' "$RESP" | tail -1) if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)" else echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)" fi ''; }; # Long-running matrix-sdk client + sync per agent. Holds the unix # socket the stdio `hive-matrix-mcp` bridge connects to + emits # hyperhive wake signals on incoming room events via # `/run/hive/mcp.sock`. See # `docs/persistence.md::Matrix per-agent daemon + token-arrival # trigger` for the socket-path / first-boot-ordering rationale. systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { description = "long-running matrix-sdk Client + MCP daemon socket"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; environment = { HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; RUST_LOG = "info"; } # Homeserver URL: by default the daemon inherits the host-forwarded # HIVE_MATRIX_URL (set isolation-aware by hive-c0re: `matrix.` # via the gateway under private-netns isolation, loopback otherwise), # 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 # isolation-aware 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 ); }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; Restart = "on-failure"; RestartSec = 5; User = userName; Group = userName; RuntimeDirectory = "hive-matrix"; # Keep /run/hive-matrix across restarts. With the default # `RuntimeDirectoryPreserve=no`, a `switch-to-configuration` # restart races the outgoing instance's stop-time cleanup # (which deletes the dir) against the incoming instance's # start (which creates it + binds the socket inside it). The # cleanup can win and delete the dir out from under the fresh # daemon, which then fails to mkdir under root-owned /run and # exits — looping on Restart=on-failure until the next boot. # `yes` stops systemd removing it on stop; it still creates it # on first start, and it lives on tmpfs so it's gone at # container reboot regardless. See hive-bash-daemon below. RuntimeDirectoryPreserve = "yes"; }; }; # Bash task runner daemon — long-running process that owns subprocess # monitoring + completion wake signals. Always enabled (every agent # needs bash tools). The stdio MCP bridge `hive-bash-mcp` connects # to this daemon's socket per turn. # Socket dir: /run/hive-bash/ — RuntimeDirectory keeps it on tmpfs. systemd.services.hive-bash-daemon = { description = "bash task runner daemon for hive-bash-mcp"; wantedBy = [ "multi-user.target" ]; # The daemon runs every bash task via `Command::new("bash")` and the # commands themselves (hive-forge, git, jq, …) resolve from PATH. # Pre-split this ran inside hive-ag3nt.service and inherited the # agent's PATH; the standalone daemon needs the same or `bash` itself # isn't found (spawn fails with ENOENT, the task is marked done in # 0s with no output / no .out/.err). Mirror the harness unit's PATH: # NixOS appends `/bin` to each entry → /run/wrappers/bin (setuid # sudo) + /run/current-system/sw/bin (bash, coreutils, hive-forge, …). path = [ "/run/wrappers" "/run/current-system/sw" ]; environment = { HIVE_BASH_SOCKET = "/run/hive-bash/socket"; HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock"; RUST_LOG = "info"; # HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already # injected via systemd.globalEnvironment by the meta flake # (set to /agents//harness and /agents//state # respectively). Listed here for explicitness — the daemon # uses these to derive its task + loose-ends dir paths. # Without them the daemon falls back to deriving harness/ as a # sibling of state/, which produces the same value but is # less robust if the two vars ever diverge. }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-bash-daemon"; Restart = "on-failure"; RestartSec = 3; User = userName; Group = userName; RuntimeDirectory = "hive-bash"; # See the matching note on hive-matrix-daemon. Without this, a # post-rebuild restart races stop-time dir cleanup against the # fresh daemon's socket-dir creation; the daemon loses, fails # `mkdir /run/hive-bash` (Permission denied, non-root in /run), # and loops on Restart=on-failure until the next container # boot — i.e. the bash daemon "doesn't come up post-rebuild". RuntimeDirectoryPreserve = "yes"; }; }; # 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*"; }; # Path-trigger sibling: re-fires matrix-avatar-sync the moment # `/matrix-token` appears. Same first-boot-ordering pattern # as hive-matrix-daemon above. systemd.paths.matrix-avatar-sync = { description = "trigger matrix-avatar-sync when matrix-token appears"; wantedBy = [ "multi-user.target" ]; pathConfig.PathExistsGlob = "/agents/*/state/matrix-token"; }; # One-shot: hyperhive.icon → matrix profile avatar (two-step media # upload + set avatar_url). Shape contract: # docs/conventions.md::Best-effort oneshot services. Protocol + # why RemainAfterExit = false: # docs/persistence.md::matrix-avatar-sync. systemd.services.matrix-avatar-sync = { description = "sync agent icon to matrix profile avatar (best-effort)"; wantedBy = [ "multi-user.target" ]; # No `after = [ "tea-login.service" ]` — matrix has no # equivalent prerequisite; we just need the homeserver up. serviceConfig = { Type = "oneshot"; # RemainAfterExit = false so the .path trigger can re-fire # the unit (see docs/persistence.md::matrix-avatar-sync). RemainAfterExit = false; }; path = [ pkgs.curl pkgs.coreutils pkgs.jq pkgs.librsvg ]; script = '' ICON=/etc/hyperhive/icon.svg if [ ! -f "$ICON" ]; then echo "matrix-avatar-sync: no icon configured; skipping" exit 0 fi # Token written by `hive-c0re::matrix::ensure_user_for` to the # agent's bind-mounted state dir. $HYPERHIVE_STATE_DIR is set # system-wide by the meta flake (systemd.globalEnvironment) to # `/agents//state`. TOKEN_FILE="$HYPERHIVE_STATE_DIR/matrix-token" if [ ! -f "$TOKEN_FILE" ]; then echo "matrix-avatar-sync: no matrix-token at $TOKEN_FILE; skipping" exit 0 fi # Hash-based idempotency: skip the upload if the icon hasn't # changed since the last successful sync. Every upload mints a # new mxc:// URI which triggers a profile state event in every # joined room — uploading the same bytes again produces timeline # spam without changing the visible avatar. The hash file lives # in $HYPERHIVE_STATE_DIR (survives restart, wiped on purge so # purge + re-provision gets a fresh upload). Delete to force # re-upload. HASH_FILE="$HYPERHIVE_STATE_DIR/matrix-avatar-icon-hash" CURRENT_HASH=$(sha256sum "$ICON" | cut -d' ' -f1) if [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE" 2>/dev/null)" = "$CURRENT_HASH" ]; then echo "matrix-avatar-sync: icon unchanged (hash matches); skipping" exit 0 fi TOKEN=$(cat "$TOKEN_FILE") # Local tuwunel reachable on shared host netns at the # default matrix-spec port. Override via # `hyperhive.matrix.url` if the operator runs the # homeserver elsewhere. MATRIX_URL=http://localhost:8008 # whoami → user_id. Needed to scope the avatar set call. # Tolerant of the homeserver being unreachable (`-f` makes # curl fail on 4xx/5xx; `|| true` swallows the exit). USER_ID=$(curl -sf --max-time 5 \ -H "Authorization: Bearer $TOKEN" \ "$MATRIX_URL/_matrix/client/v3/account/whoami" 2>/dev/null \ | jq -r '.user_id // empty' || true) if [ -z "$USER_ID" ]; then echo "matrix-avatar-sync: whoami failed or homeserver unreachable; skipping" exit 0 fi # Rasterize SVG → PNG (matrix media accepts any image type # but we already standardise on PNG for the forge sync). PNG=$(mktemp --suffix=.png) if ! rsvg-convert -f png -w 512 -h 512 "$ICON" -o "$PNG" 2>/dev/null; then echo "matrix-avatar-sync: rsvg-convert failed; skipping" rm -f "$PNG" exit 0 fi # Step 1: upload bytes → mxc:// URI. MXC=$(curl -sf --max-time 10 \ -X POST "$MATRIX_URL/_matrix/media/v3/upload" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: image/png" \ --data-binary "@$PNG" 2>/dev/null \ | jq -r '.content_uri // empty' || true) rm -f "$PNG" if [ -z "$MXC" ]; then echo "matrix-avatar-sync: media upload failed; skipping" exit 0 fi # Step 2: set avatar_url on the profile. PAYLOAD=$(jq -n --arg url "$MXC" '{avatar_url:$url}') CODE=$(curl -s --max-time 10 \ -X PUT "$MATRIX_URL/_matrix/client/v3/profile/$USER_ID/avatar_url" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" \ -o /dev/null -w "%{http_code}" 2>/dev/null || true) if [ "$CODE" = "200" ]; then echo "matrix-avatar-sync: avatar set on $USER_ID" # Persist hash so subsequent runs skip the upload when the # icon hasn't changed. echo "$CURRENT_HASH" > "$HASH_FILE" else echo "matrix-avatar-sync: avatar PUT returned HTTP $CODE — skipping (non-fatal)" fi ''; }; # 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; }; environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks; script = '' # Sub-agents have their state dir bind-mounted at /agents//state. # Use a glob — exactly one match per container at runtime. STATE_DIR=$(echo /agents/*/state) if [ ! -d "$STATE_DIR" ]; then echo "hive-dashboard-links: no state dir found at /agents/*/state; skipping" exit 0 fi printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json" echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json" ''; }; # Git is needed by claude's Bash tool (for the agent <-> manager config # request flow) and by hive-c0re's own setup_applied / setup_proposed. # The per-agent `applied//flake.nix` overrides `user.name` and # `user.email` with the agent's identity — values here are `mkDefault` # so the per-agent override wins without needing `mkForce`. programs.git = { enable = true; config = { user = { name = lib.mkDefault "hyperhive"; email = lib.mkDefault "hyperhive@local"; }; init.defaultBranch = lib.mkDefault "main"; }; }; # Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars, # RuntimeDirectory, User=, standalone-eval fallbacks): # docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin # auto-append behaviour: docs/gotchas.md::systemd.services.*.path # appends /bin to every entry. systemd.services.hive-ag3nt = let binary = "hive"; in { description = "${binary} harness"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; # `/run/wrappers` before `/run/current-system/sw` so setuid # `sudo` resolves first. Passing the bare prefixes (no trailing # `/bin`) is intentional — see docs pointer above. path = [ "/run/wrappers" "/run/current-system/sw" ]; environment = { SHELL = "${pkgs.bashInteractive}/bin/bash"; HOME = homeDir; HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}"; HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; # Unix-socket path for the harness web UI. All agents always bind # here; TCP fallback is removed. Path matches # `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle # bind-mounts and gateway upstream config stay in sync. HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; Restart = "on-failure"; RestartSec = 2; # Per-service runtime dir owned by `User=` below; the harness # writes its regenerated claude-{mcp-config,settings,system-prompt} # files here (`paths::config_dir`). Separate from /run/hive, # which holds hive-c0re's mcp.sock. RuntimeDirectory = "hive-config"; User = userName; Group = userName; }; }; system.stateVersion = "25.11"; }; }