mara: 'if we replace it with one thing, that should be named more generic so we dont have to change it for future additions'. extract the BASH_ENV plumbing into a shared shape: - new internal option `hyperhive._bashEnvFragments` (types.lines) accumulates shell snippets across feature modules. - file path is now `/etc/hyperhive/bash-env.sh` (was the cargo-specific bash-cargo-short.sh). - the file + BASH_ENV + interactiveShellInit are gated on `_bashEnvFragments != """ so a fully feature-disabled agent has no overhead. cargo function moves to a `lib.mkIf cargo.shortMessages` contribution to `_bashEnvFragments` — same behaviour, no rename when the next hook (nix-env helper, claude-cmd helpers, whatever) lands.
1414 lines
61 KiB
Nix
1414 lines
61 KiB
Nix
{
|
|
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_<KEY_UPPER>` 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__<server-key>__` at build time.
|
|
'';
|
|
};
|
|
};
|
|
}
|
|
);
|
|
default = { };
|
|
example = lib.literalExpression ''
|
|
{
|
|
matrix = {
|
|
command = "/run/current-system/sw/bin/mcp-matrix";
|
|
args = [ "--config" "/state/matrix.toml" ];
|
|
env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
|
allowedTools = [ "send_message" "join_room" ];
|
|
};
|
|
}
|
|
'';
|
|
description = ''
|
|
Extra MCP servers claude sees alongside the hyperhive tool surface.
|
|
Keys are the server names (claude addresses tools as
|
|
`mcp__<key>__<tool>`). Rendered to `/etc/hyperhive/extra-mcp.json`
|
|
at activation time; the harness reads that file at boot and merges
|
|
it into `--mcp-config` + `--allowedTools`. Take effect on the
|
|
agent's next harness restart (no operator approval needed beyond
|
|
whatever brought the new agent.nix into deployed/*).
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.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 `<state>/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] <sender> in <room>: <first 100c>…`)
|
|
to the hyperhive control socket; the full event stays
|
|
unread server-side until `read_room` consumes it.
|
|
|
|
Set to `false` for agents that should NOT have matrix tools at
|
|
all (e.g. agents on a host without `hyperhive.matrix.enable` on
|
|
the meta side). When token file is absent the daemon and MCP
|
|
both no-op cleanly anyway, so `false` is rarely necessary.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.matrix.url = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "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 (`/<target>/...`) and
|
|
the on-disk layout in the merged derivation. Defaults
|
|
to the attribute name. Use forward slashes for
|
|
nested layouts (e.g. `"games/bitburner"`).
|
|
|
|
Constrained shape: must start with an alphanumeric or
|
|
`_`, and only contain alphanumerics, `_`, `.`, `/`,
|
|
`-`. `..` segments are separately rejected at config
|
|
eval time.
|
|
'';
|
|
};
|
|
};
|
|
}
|
|
)
|
|
);
|
|
default = { };
|
|
example = lib.literalExpression ''
|
|
{
|
|
bitburner = {
|
|
source = ./bitburner-dist;
|
|
# served at GET /bitburner/...
|
|
};
|
|
}
|
|
'';
|
|
description = ''
|
|
Per-agent additions layered on top of the default frontend
|
|
dist. Each entry copies its `source` into the served static
|
|
tree under `target`. Useful for shipping a self-contained
|
|
agent-specific surface alongside the standard agent UI (e.g.
|
|
the bitburner agent's game page at `/bitburner/`).
|
|
|
|
The default agent UI remains served at `/`; entries here only
|
|
add new routes and never replace the default. Overwrite
|
|
semantics are **hard-fail**: if `target` collides with an
|
|
existing file or directory in the default dist (or with a
|
|
prior entry's target), the `mergedDist` build aborts with
|
|
`refusing to overwrite existing path '<target>' in the
|
|
default dist`. To override a default file, fork the dist via
|
|
`hyperhive.frontend.dist` instead — `extraFiles` is for
|
|
pure additions.
|
|
|
|
`target` must be a relative path inside the static dir. An
|
|
assertion rejects leading `/` and `..` segments at config
|
|
eval time (string-concat-into-paths safety, even though
|
|
agent.nix goes through operator review before deploy).
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.forge.url = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "http://localhost:3000";
|
|
example = "http://forge.internal:3000";
|
|
description = ''
|
|
Base URL of the hyperhive-managed Forgejo. Used at container
|
|
boot by a oneshot systemd unit that calls
|
|
`tea login add --url <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
|
(= `/agents/<name>/state/forge-token`) so the agent's claude can
|
|
shell out to `tea` without an extra auth dance. No-op when the
|
|
forge-token file is missing (i.e. hive-forge isn't running on
|
|
the host).
|
|
'';
|
|
};
|
|
|
|
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 <source>`
|
|
(`owner/repo`, full git URL, or local path). Idempotent —
|
|
re-adding an existing marketplace is treated as success.
|
|
Required before `hyperhive.claudePlugins` entries that
|
|
reference a marketplace (e.g. `foo@claude-plugins-official`).
|
|
Rendered to `/etc/hyperhive/claude-marketplaces.json`.
|
|
|
|
Defaults to Anthropic's official marketplace; agents get it
|
|
out of the box without any per-agent.nix wiring.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.claudePlugins = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [ ];
|
|
example = [
|
|
"formatter@my-marketplace"
|
|
"thinking-tools@anthropics"
|
|
];
|
|
description = ''
|
|
Claude Code plugins to install at harness boot. Each entry is
|
|
passed verbatim to `claude plugin install <spec>` once per
|
|
container start, before the turn loop opens. `claude plugin
|
|
install` is expected to be idempotent, so reinstalling on every
|
|
boot is cheap. Failures log a warning but do not abort boot — a
|
|
missing plugin is preferable to a non-serving agent. Rendered to
|
|
`/etc/hyperhive/claude-plugins.json`; the harness reads it via
|
|
`plugins::install_configured`.
|
|
'';
|
|
};
|
|
|
|
options.hyperhive.claudePluginsAutoUpdate = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = ''
|
|
When true, the harness runs `claude plugin marketplace update`
|
|
before installing plugins at boot, pulling the latest index from
|
|
all configured marketplaces. Disabled by default — most agents
|
|
want pinned plugin versions and the network round-trip adds to
|
|
boot time. Enable for agents that should always install the latest
|
|
available version of their plugins.
|
|
'';
|
|
};
|
|
|
|
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 (mara on #779) so
|
|
# future hooks don't need to either 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 (#777).
|
|
|
|
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 = {
|
|
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" ];
|
|
}
|
|
];
|
|
}
|
|
];
|
|
|
|
# Post-#658 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 pre-#658 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"
|
|
for stateDir in /agents/*/state; do
|
|
[ -d "$stateDir" ] || continue
|
|
chown -hR "$userName:$userName" "$stateDir" 2>/dev/null || true
|
|
done
|
|
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;
|
|
};
|
|
|
|
# Cargo `--message-format short` injector (#777). 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 (#777). 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/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;
|
|
}
|
|
// 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 (#777).
|
|
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 <verb>: 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/<name>/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/<name>/state`.
|
|
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
|
if [ ! -f "$TOKEN_FILE" ]; then
|
|
echo "forge-avatar-sync: no forge-token found; skipping"
|
|
exit 0
|
|
fi
|
|
TOKEN=$(cat "$TOKEN_FILE")
|
|
# 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":"<base64>"} — just the
|
|
# raw base64 string, NOT a data URI (data:image/png;base64,...).
|
|
# Use jq to build the payload so the large base64 value is safely quoted.
|
|
PAYLOAD=$(jq -n --arg img "$IMAGE" '{image:$img}')
|
|
RESP=$(curl -sf --max-time 10 \
|
|
-X POST "$FORGE_URL/api/v1/user/avatar" \
|
|
-H "Authorization: token $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$PAYLOAD" \
|
|
-w "\n%{http_code}" 2>/dev/null || true)
|
|
CODE=$(printf '%s' "$RESP" | tail -1)
|
|
if [ "$CODE" = "204" ] || [ "$CODE" = "200" ]; then
|
|
echo "forge-avatar-sync: avatar uploaded (HTTP $CODE)"
|
|
else
|
|
echo "forge-avatar-sync: upload returned HTTP $CODE — skipping (non-fatal)"
|
|
fi
|
|
'';
|
|
};
|
|
|
|
# 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_URL = config.hyperhive.matrix.url;
|
|
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
|
|
RUST_LOG = "info";
|
|
};
|
|
serviceConfig = {
|
|
ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon";
|
|
Restart = "on-failure";
|
|
RestartSec = 5;
|
|
User = userName;
|
|
Group = userName;
|
|
RuntimeDirectory = "hive-matrix";
|
|
};
|
|
};
|
|
|
|
# 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 matrix-token appears";
|
|
wantedBy = [ "multi-user.target" ];
|
|
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token";
|
|
};
|
|
|
|
# Path-trigger sibling so matrix-avatar-sync fires the moment
|
|
# `<state>/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/<name>/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/<name>/state.
|
|
# Use a glob — exactly one match per container at runtime.
|
|
STATE_DIR=$(echo /agents/*/state)
|
|
if [ ! -d "$STATE_DIR" ]; then
|
|
echo "hive-dashboard-links: no state dir found at /agents/*/state; skipping"
|
|
exit 0
|
|
fi
|
|
printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json"
|
|
echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json"
|
|
'';
|
|
};
|
|
|
|
# Git is needed by claude's Bash tool (for the agent <-> manager config
|
|
# request flow) and by hive-c0re's own setup_applied / setup_proposed.
|
|
# The per-agent `applied/<name>/flake.nix` overrides `user.name` and
|
|
# `user.email` with the agent's identity — values here are `mkDefault`
|
|
# so the per-agent override wins without needing `mkForce`.
|
|
programs.git = {
|
|
enable = true;
|
|
config = {
|
|
user = {
|
|
name = lib.mkDefault "hyperhive";
|
|
email = lib.mkDefault "hyperhive@local";
|
|
};
|
|
init.defaultBranch = lib.mkDefault "main";
|
|
};
|
|
};
|
|
|
|
# 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/<name>/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.<name>.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";
|
|
};
|
|
}
|