{ 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 (#658). `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}"; in { # Shared scaffolding for any hyperhive harness container — both # sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend # this. The systemd service that actually runs the harness binary # differs per role and lives in the child module. # 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 (#658). # 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.role = lib.mkOption { type = lib.types.enum [ "agent" "manager" ]; default = "agent"; example = "manager"; description = '' Whether this container runs as a sub-agent (`"agent"`, the default — invokes `hive-ag3nt serve`) or as the swarm's manager (`"manager"` — invokes `hive-m1nd serve` and defaults the forge notification surface to mentions-only). meta.rs flips this to `"manager"` for the manager container and leaves it at the default for every sub-agent. Agents and `agent.nix` files don't normally touch this option; it's exposed so a standalone `nixos-rebuild` against `nixosConfigurations.manager` keeps working without the meta-flake wrapper around it. Closes #671: harness + manager templates merged into a single `harness-base.nix` driven by this option. ''; }; 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.allowedBashPatterns = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "git *" "ls *" "cat /agents/*/state/*" ]; description = '' Shell command patterns auto-approved for the `Bash` built-in tool. Empty list (the default) grants wholesale `Bash` approval — claude can run any shell command without a prompt. Non-empty list replaces `Bash` in `--allowedTools` with one `Bash(pattern)` entry per item; only commands matching a pattern are auto-approved; all others require confirmation (which in `--print` mode means they will not run). Use to sandbox agents to a known-safe command vocabulary. Patterns use the same glob syntax claude accepts in `Bash(…)`: `*` matches any string within a word, shell-style. ''; }; 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` (#548 phase 3). 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 (mirrors `matrix-avatar-sync` shape from #571). - 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 = "http://localhost:8008"; 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.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 — consumers (`agent-base.nix`, `manager.nix`) reference this in their systemd service environment; 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. ''; }; 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 = { 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\")"; } # 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"; } # 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 (#658). Runs the hive-ag3nt / hive-m1nd # harness + co-process daemons (hive-matrix-daemon) under a # non-root principal. The user name follows # `hyperhive.user.name` — defaults to `"agent"` for standalone # eval, overridden per-agent by the meta-flake to the agent's # own label so each container has a uniquely-named user. # # UID auto-assigned by NixOS (per mara's #8109: "no hardcoded # uids"). Home is `/home/${userName}`. `wheel` membership + # the sudoers rule below grants `NOPASSWD: ALL` when # `passwordlessSudo` is true — same blast radius as the # previous root-by-default shape, just explicit. 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 from the legacy root-run shape (#658). # Runs on every activation; marker-guarded so the move only # happens once. The bind mount that hive-c0re sets up has # already moved from `/root/.claude` to `${homeDir}/.claude` # by the time we get here (per `lifecycle::CONTAINER_CLAUDE_MOUNT` # — the host-side path stays the same, the container-side # mount target shifts), so the bulk of the data is already at # the new location. This script just: # # - ensures `${homeDir}` exists with correct ownership (covers # the very first boot before useradd's `createHome` has # anything to chown); # - migrates any leftover `/root/.claude` content that an # operator might have populated before #658 deployed (the # bind mount didn't exist in that lifecycle, so claude # would have written into the root user's empty home — # nothing important typically, but safer to move than to # strand); # - chowns the bind-mounted state dir (`/agents/*/state`) so # the agent user can read/write it. system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] '' homeDir=${lib.escapeShellArg homeDir} userName=${lib.escapeShellArg userName} # Always ensure the home dir exists with the right ownership — # useradd's createHome handles the very first creation but # doesn't re-chown if a rebuild changes the user name (rare # but possible if the meta-flake's per-agent name evolves). mkdir -p "$homeDir" chown "$userName:$userName" "$homeDir" # One-time migration of pre-#658 /root/.claude content into the # new home. Marker-guarded so the move only runs once per # container lifetime — subsequent activations skip the legacy # path even if claude were to repopulate /root/.claude for any # reason. 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" # `mv -n` (no-clobber) so any pre-existing files at the # destination (e.g. from the bind mount) win — we never # blow over data already at the new location. 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" # Chown the bind-mounted state dir so the agent user can # read/write it. `/agents/*/state` is the canonical mount # point set by hive-c0re's `set_nspawn_flags`. Wildcard # because each container only sees its own # `/agents//state` (one match); -h to avoid following # any symlinks the agent might have planted in there. for stateDir in /agents/*/state; do [ -d "$stateDir" ] || continue chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true done # Same treatment for the bind-mounted `~/.claude/` dir. Pre-#658 # the harness ran as root and `claude` wrote `.credentials.json` # there 0600 root:root; post-#658 the harness reads # `~/.claude/` as the agent user to decide Online vs # NeedsLogin (`login::has_session`), and the host-side bind # source is still root-owned 0700 from those legacy writes. # Chown recursively so the existing credentials are readable # under the new identity instead of getting silently treated # as "no session" and re-prompting login every boot. if [ -d "$homeDir/.claude" ]; then chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true fi ''; # Auto-inject the matrix MCP entry when matrix is enabled (#548 # phase 3). Operator can override or disable by setting their own # `extraMcpServers.matrix` (nix submodule merge takes the operator's # value) or by flipping `hyperhive.matrix.enable = false`. hyperhive.extraMcpServers = 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"` (#658). Keeps # the bridge + daemon in sync without baking the new 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; }; environment.etc."hyperhive/bash-allow.json".text = builtins.toJSON config.hyperhive.allowedBashPatterns; 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_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; }; 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" ]; # Containers bind-mount the host's nix-daemon socket. The host daemon # may be configured with remote builders or strict sandbox settings # (sandbox-fallback = false) that make local `nix build` invocations # fail inside the container. Enable sandbox-fallback so builds that # can't set up the sandbox (no user-namespaces in nspawn) fall back # to unsandboxed local builds rather than failing outright. # mkForce overrides the nixpkgs nix module which sets this to false # at normal priority -- without it agents get a conflicting definition # error on rebuild. Security implications: see docs/security.md. 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: write tea's config.yml from the seeded forge token so # the agent can use `tea` without interactive prompts. Runs on # every boot so a rotated token (hive-c0re remints on each agent # rebuild) is always reflected. *Always* exits 0 — never fail a # NixOS switch-to-configuration over a missing/temperamental forge. 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`: any subshell failure must not propagate. # A failed unit aborts `nixos-container update` which blocks rebuilds. FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url} # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake # (systemd.globalEnvironment, /agents//state per agent # including manager post-#604). 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 # tea reads config from ~/.config/tea/config.yml. The # agent user's home is $HOME_DIR (set by NixOS via # hyperhive.user.name). Write the config under that # home + chown to the agent user so tea reads it when # invoked as that user. Still runs as root (this # service stays root-owned to avoid bootstrap # ordering issues — see comment near serviceConfig # below), but the artefact it produces is for the # agent user. 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)" ''; }; # One-shot: upload the agent's configured icon to its Forgejo user avatar # so the icon shows up on commits / PRs / issue comments in the forge. # Only runs when `/etc/hyperhive/icon.svg` is present (set via # `hyperhive.icon`). No-op when the forge is unreachable or the icon # is not set. *Always* exits 0. 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 = true; }; 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 (#548 phase 3). # Holds the unix socket the stdio `hive-matrix-mcp` bridge talks # to, and emits hyperhive wake signals on incoming room events # via `/run/hive/mcp.sock`. Conditional on `hyperhive.matrix.enable` # AND token-file presence (the daemon binary itself exits 0 on # missing token, but the path watcher below restarts it the # moment the token lands — same first-boot-ordering pattern as # matrix-avatar-sync.path / #571). 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_URL = config.hyperhive.matrix.url; # Socket path lives inside the systemd-managed runtime dir # (`RuntimeDirectory = "hive-matrix"` → `/run/hive-matrix/`, # owned by the agent user) so the daemon can bind it without # needing root over `/run/` itself (#658). The stdio bridge # picks up the same path via its own `HIVE_MATRIX_SOCKET` env # in `extraMcpServers.matrix` below. HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket"; RUST_LOG = "info"; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; Restart = "on-failure"; RestartSec = 5; # Run as the per-agent unix user (#658). The runtime dir # (`/run/hive-matrix/`) is owned by that user via # `RuntimeDirectory`; claude (also as that user) can # connect to the socket inside it when the stdio bridge # spawns per turn. User = userName; Group = userName; RuntimeDirectory = "hive-matrix"; }; }; # Path-trigger sibling so hive-matrix-daemon fires the moment # `/matrix-token` appears (#548 phase 3, mirrors the # matrix-avatar-sync.path pattern from #571). On clean boot # hive-c0re provisions the token AFTER agent containers come up; # without the trigger the daemon would exit 0 quietly and the # MCP would have no backend until next restart. With the watcher # the daemon comes alive in the same boot cycle as provisioning. # The glob matches every agent (manager sees its own state at # `/agents/hm1nd/state/` via the `/agents` bind). systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable { description = "trigger hive-matrix-daemon when matrix-token appears"; wantedBy = [ "multi-user.target" ]; pathConfig.PathExistsGlob = "/agents/*/state/matrix-token"; }; # Path-trigger sibling so matrix-avatar-sync fires the moment # `/matrix-token` appears (#571 closes argus's first-boot # ordering nag on #567). On a clean boot hive-c0re's matrix # provisioning runs concurrently with the agent container coming # up; without this the oneshot would skip silently because the # token didn't exist yet, and avatar would only land on the next # restart. With the path watcher the appearance of the token # triggers a re-fire of the service so the avatar is set in the # same boot cycle as provisioning completes. The glob matches # every agent (manager sees its own state at `/agents/hm1nd/state/` # via the `/agents` bind). 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: upload the agent's configured icon to its matrix profile # avatar so the icon shows up next to messages in matrix rooms (#548 # phase 2.5). Mirrors the forge-avatar-sync flow above, only differs # in protocol: matrix avatars are a two-step `media upload` → `set # avatar_url` dance, both authenticated by the access_token written # by hive-c0re's `matrix::ensure_user_for`. No-op when the icon # isn't configured, the matrix token isn't present, or the # homeserver isn't reachable. *Always* exits 0. # # Triggered by EITHER boot (`wantedBy = multi-user.target`) OR # the path-watcher above (`matrix-avatar-sync.path`) firing on # token appearance (#571). Both paths re-run the oneshot # idempotently — running the avatar set twice is harmless. 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"; # NB: NOT `RemainAfterExit = true` — we want re-runs from # the path trigger to actually re-execute. With # RemainAfterExit, systemd treats the service as "still # running" after the first exit and the second trigger # becomes a no-op. 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 TOKEN=$(cat "$TOKEN_FILE") # Local tuwunel reachable on shared host netns at the # default matrix-spec port. Override via the future # `hyperhive.matrix.url` if the operator ever runs the # homeserver elsewhere (deferred to #548 phase 4). 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" 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. # Runs every boot; idempotent (overwrite). Always exits 0. 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"; }; }; # Manager-only forge defaults (#671): skip the # subscription/participation firehose so the manager's inbox # only carries direct mentions, reviews, and assignments. Sub- # agents keep the noisier defaults (`keepSubscriptions = true`, # `skipNotifyReasons = [ ]`). `mkDefault` so any agent that # wants to invert it can. hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") { keepSubscriptions = lib.mkDefault false; skipNotifyReasons = lib.mkDefault [ "subscribed" "participating" ]; }; # Harness systemd unit. Role-driven so the same `harness-base.nix` # covers both `nixosConfigurations.agent-base` (`hive-ag3nt serve`) # and `nixosConfigurations.manager` (`hive-m1nd serve`) without a # second template file (#671). Per-agent HIVE_PORT / HIVE_LABEL # come from the meta-flake's generated `applied//flake.nix`; # the manager has hardcoded fallbacks here so `nixosConfigurations.manager` # still builds standalone. systemd.services.${if config.hyperhive.role == "manager" then "hive-m1nd" else "hive-ag3nt"} = let isManager = config.hyperhive.role == "manager"; # Post-#598 there is exactly one harness binary (`hive`), and # it picks its surface from `HIVE_ROLE` at startup. We still # name the systemd unit `hive-ag3nt` / `hive-m1nd` so dashboard # log queries + ExecStartPre paths + ancestor PR diffs keep # working without a unit rename cascade. binary = "hive"; in { description = "${binary}${lib.optionalString isManager " manager"} harness"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; # systemd units get a minimal PATH by default and don't inherit # `environment.systemPackages`. Pointing at `/run/current-system/sw` # gives the harness (and any tools claude shells out to via Bash) # access to everything declared in `systemPackages` — including # anything an agent adds to its own `agent.nix` — without having # to touch the service definition. # # `/run/wrappers/bin` prepended so the `security.wrappers` # setuid shims (notably `sudo`) resolve before the bare # nix-store binaries in `/run/current-system/sw/bin`. # Post-#658 the harness runs as the per-agent user — without # the wrapper dir on PATH, `sudo` resolves to the un-setuid # nix-store binary and refuses with "must be owned by uid 0 # and have the setuid bit set" even when # `hyperhive.user.passwordlessSudo = true` is configured # (#672 fixup pulled forward into this PR to avoid the # regression argus flagged on #676). # # `systemd.services..path` appends `/bin` to each entry, # so the bare prefixes here resolve to `/run/wrappers/bin` + # `/run/current-system/sw/bin` inside the unit's PATH. Passing # the trailing `/bin` ourselves (the natural-looking spelling) # would yield `/run/wrappers/bin/bin` + `/run/current-system/sw/bin/bin`, # neither of which exists — that's how #672 originally landed # broken: every agent had a PATH pointing at non-existent dirs # and `which sudo` kept falling back to the un-setuid binary. path = [ "/run/wrappers" "/run/current-system/sw" ]; environment = { SHELL = "${pkgs.bashInteractive}/bin/bash"; # `HOME` defaults to `/` for systemd services without a User= # set. With #658 the harness runs as the agent user — set HOME # explicitly so claude (which the harness spawns) finds its # `~/.claude/` session dir at the bind-mounted location. HOME = homeDir; # Path to the merged agent static dist. The harness serves this # via `tower_http::ServeDir` for any request it doesn't route to # an API endpoint. `mergedDist` is the agent-default dist with # `hyperhive.frontend.extraFiles` layered on top. HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}"; # Static runtime assets (branding + claude prompts). Set on the # unit directly — `environment.variables` only populates # /etc/profile, which systemd services don't inherit. HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; # Post-#598: the unified `hive` binary picks its surface from # this env var at startup. Default (`"agent"`) matches the # binary's standalone fallback when this is unset. HIVE_ROLE = config.hyperhive.role; } // lib.optionalAttrs isManager { # Standalone-eval fallbacks for `nixosConfigurations.manager`. # meta.rs overrides both via the per-agent generated # `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`); # the values here keep the container sensible if anyone # evaluates the standalone config. # # `HIVE_PORT` = FNV-1a("hm1nd") % 900 + 8100 = 8875 per # `lifecycle::agent_web_port` (#753 dropped the # pre-#753 "manager pinned at 8000" special case). Hardcoded # here because the standalone-eval path doesn't go through # `meta::render_flake`; real deploys pick up the rust-computed # value via meta and never touch this fallback. HIVE_PORT = "8875"; HIVE_LABEL = "hm1nd"; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; Restart = "on-failure"; RestartSec = 2; # `/run/hive-config/` is a per-service runtime dir owned by # the agent user (`User=` below), auto-cleared by systemd on # stop. The harness writes its regenerated # claude-{mcp-config,settings,system-prompt} files there # (see `paths::config_dir`). Kept separate from `/run/hive` # — that bind comes in root-owned from the host and holds # hive-c0re's `mcp.sock` we only connect to (#658 fixup). RuntimeDirectory = "hive-config"; # Run the harness as the per-agent user (#658). claude itself # spawned by the harness then runs as that user too — drops # root inside the container while sudo (`NOPASSWD: ALL` by # default, see `hyperhive.user.passwordlessSudo`) keeps the # previous root-by-default surface available explicitly for # tools that need it. User = userName; Group = userName; }; }; system.stateVersion = "25.11"; }; }