refactor: nix/host-modules + nix/agent-modules layout, update doc paths
This commit is contained in:
parent
cb755b677c
commit
4a48ce5024
52 changed files with 48 additions and 44 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -63,12 +63,16 @@ hand-maintained per-file tree drifts out of sync with the code.
|
|||
### Other top-level dirs
|
||||
|
||||
- **`frontend/`** — npm workspaces → static dashboard + per-agent UI
|
||||
dist, built hermetically by `nix/frontend.nix`. Packages: `shared`
|
||||
(terminal pane + Catppuccin palette), `dashboard` (the operator SPA),
|
||||
`agent` (the default per-container UI).
|
||||
- **`nix/`** — NixOS modules (`modules/hive-{c0re,forge,gateway,matrix,
|
||||
ci,network}.nix`) + per-container templates (`templates/{harness-base,
|
||||
agent-base,manager,weston-vnc}.nix`) + the options-doc derivation.
|
||||
dist, built hermetically by `nix/packages/frontend.nix`. Packages:
|
||||
`shared` (terminal pane + Catppuccin palette), `dashboard` (the
|
||||
operator SPA), `agent` (the default per-container UI).
|
||||
- **`nix/`** — `host-modules/` (the host stack: hyperhive core options,
|
||||
`hive-{c0re,priv,forge,gateway,matrix,network,tls,ci}`, otel, swarm),
|
||||
`agent-modules/` (the per-agent harness feature modules),
|
||||
`templates/{agent,ruth}.nix` (container entry points), `packages/`
|
||||
(flake package outputs), `docs/` (the options-doc derivation), plus
|
||||
`sources.nix` / `rust.nix` / `checks.nix` / `devshell.nix` /
|
||||
`treefmt.nix` behind the thin `flake.nix`.
|
||||
- **`docs/`** — subsystem reference docs (see *Reading paths* below).
|
||||
- **`branding/`**, **`scripts/`** — static assets + helper scripts.
|
||||
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ Tree-shape version:
|
|||
`HYPERHIVE_STATE_DIR` is now injected uniformly via
|
||||
`systemd.globalEnvironment` in `meta.rs` for every container
|
||||
(manager included), so all token/state paths resolve through
|
||||
`$HYPERHIVE_STATE_DIR`. The harness-base shell scripts
|
||||
`$HYPERHIVE_STATE_DIR`. The agent-module shell scripts
|
||||
(tea-login, forge-avatar-sync) simplified from glob+for loops to a
|
||||
direct `$HYPERHIVE_STATE_DIR/<token>` read.
|
||||
|
||||
|
|
@ -215,15 +215,15 @@ nspawn agent. Open questions, not yet wired:
|
|||
## Harness systemd unit shape
|
||||
|
||||
One harness serve binary (`hive-agent`, with its `hive-agent-mcp` /
|
||||
`hive-agent-wake` siblings), one `harness-base.nix` template, one
|
||||
`hive-agent-wake` siblings), one shared `nix/agent-modules/` tree, one
|
||||
service unit (`systemd.services.hive-ag3nt`) for all agents. There
|
||||
is no longer a separate manager service name or role distinction in
|
||||
the harness — privilege differences live server-side in the broker
|
||||
socket (which tool groups and manager-surface calls each agent
|
||||
receives).
|
||||
|
||||
`agent-base.nix` and `manager.nix` both import `harness-base.nix`.
|
||||
`manager.nix` additionally sets forge defaults to suppress the
|
||||
`agent.nix` and `ruth.nix` both import the shared `nix/agent-modules/`.
|
||||
`ruth.nix` additionally sets forge defaults to suppress the
|
||||
subscription/participation firehose so ruth's inbox stays focused
|
||||
on direct mentions, reviews, and assignments.
|
||||
|
||||
|
|
|
|||
|
|
@ -181,6 +181,6 @@ GC on the coordinator host won't reclaim space on the builder.
|
|||
|
||||
## References
|
||||
|
||||
- `nix/modules/hive-ci.nix`: runner configuration, auto-registration script, container setup.
|
||||
- `nix/host-modules/hive-ci.nix`: runner configuration, auto-registration script, container setup.
|
||||
- `.forgejo/workflows/ci.yml`: workflow definition.
|
||||
- `docs/gotchas.md`: nix sandboxing limitations in containers.
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ socket without needing a clean reinstall.
|
|||
## `claude-code` is unfree
|
||||
|
||||
`claude-code` comes from the flake's main `nixpkgs` (nixos-26.05).
|
||||
It's unfree, so `harness-base.nix` sets `config.allowUnfreePredicate`
|
||||
It's unfree, so the agent modules set `config.allowUnfreePredicate`
|
||||
at the container level to whitelist `claude-code` specifically —
|
||||
scoped, only this one package. This is needed because each per-agent
|
||||
`nixosConfiguration` evaluates its own nixpkgs instance and the
|
||||
|
|
@ -204,7 +204,7 @@ containers don't get user-namespaces by default, so `nix build`
|
|||
invocations _inside_ the container can't set up the build sandbox
|
||||
and fail outright if the host daemon's
|
||||
`nix.settings.sandbox-fallback` is `false` (nixpkgs default).
|
||||
`nix/templates/harness-base.nix` does `lib.mkForce true` so builds
|
||||
`nix/agent-modules/default.nix` does `lib.mkForce true` so builds
|
||||
fall back to unsandboxed local builds rather than failing. Security
|
||||
implications: `docs/security.md`.
|
||||
|
||||
|
|
@ -284,7 +284,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
|||
inherit it.
|
||||
- **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is
|
||||
launched with `--socket=wayland-0` so the socket path is
|
||||
deterministic. `harness-base.nix` exports `WAYLAND_DISPLAY=wayland-0`
|
||||
deterministic. `nix/agent-modules/weston-vnc.nix` exports `WAYLAND_DISPLAY=wayland-0`
|
||||
and `XDG_RUNTIME_DIR=/run/gui` as global system environment
|
||||
variables (gated on `hyperhive.gui.enable`) so every systemd service
|
||||
in the container inherits them. Without this, services starting
|
||||
|
|
@ -311,7 +311,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
|||
must never block on weston signalling readiness. A misconfigured
|
||||
weston degrades to a `Restart=on-failure` loop visible in
|
||||
`journalctl`, it does not abort the `nixos-container update`.
|
||||
Same reasoning as the `tea-login` unit in `harness-base.nix`.
|
||||
Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`.
|
||||
- **`[core] idle-time=0`**: disables weston's 300-second idle
|
||||
timeout. Without it the VNC desktop fades to black and
|
||||
desktop-shell shows its click-to-unlock screen — useless for an
|
||||
|
|
@ -325,7 +325,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
|||
`pkgs.nixosOptionsDoc` over two evaluated module trees:
|
||||
`hostEval` (a stub NixOS system loading `hive-c0re.nix` with every
|
||||
hyperhive subsystem `mkForce false` so heavy build inputs stay out of
|
||||
the eval) and `agentEval` (evaluates `agent-base.nix` fresh for the
|
||||
the eval) and `agentEval` (evaluates `agent.nix` fresh for the
|
||||
per-agent options tree).
|
||||
|
||||
Three output trees consumed by `flake.nix`, all **markdown**:
|
||||
|
|
@ -333,7 +333,7 @@ Three output trees consumed by `flake.nix`, all **markdown**:
|
|||
- `docs-host` — operator-facing host module options
|
||||
(`services.hyperhive.*`)
|
||||
- `docs-agent` — per-agent harness options (`hyperhive.*`
|
||||
declared in `nix/templates/harness-base.nix`)
|
||||
declared in `nix/agent-modules/`)
|
||||
- `docs` — bundle of `index.md` + `host.md` + `agent.md`
|
||||
|
||||
Pipeline:
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ which has two upstream gaps for fluffychat's web target:
|
|||
- `native_imaging`'s C source isn't built — emscripten isn't a
|
||||
flutter-builder native build input.
|
||||
|
||||
Both fixed in `nix/modules/hive-matrix.nix` via two derivations:
|
||||
Both fixed in `nix/host-modules/hive-matrix.nix` via two derivations:
|
||||
|
||||
- **`fluffychat-web-imaging`** builds `Imaging.{js,wasm}` from the
|
||||
`native_imaging` C source via `pkgs.emscripten`. Source comes
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ address arithmetic.
|
|||
and `HOST_BRIDGE=<bridgeName>` via `lifecycle::set_nspawn_flags` when
|
||||
creating or updating containers. `LOCAL_ADDRESS` is left empty so the
|
||||
container's dhcpcd acquires an address from the bridge dnsmasq pool
|
||||
(`networking.useDHCP = true` in `harness-base.nix`). This applies uniformly
|
||||
(`networking.useDHCP = true` in `nix/agent-modules/network.nix`). This applies uniformly
|
||||
to all containers — agents and service containers alike.
|
||||
|
||||
`HOST_ADDRESS` is the bridge gateway IP (the address part of
|
||||
|
|
@ -154,7 +154,7 @@ wiring is runtime:
|
|||
|
||||
- `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the
|
||||
gateway IP) into each container's `/etc`.
|
||||
- the `hyperhive-isolated-dns` oneshot (harness-base.nix), gated on that
|
||||
- the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that
|
||||
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
|
||||
It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
|
||||
`tea-login` so the resolver is correct before the first DNS lookup.
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ state dirs need a one-time data shuffle so they don't lose their claude
|
|||
session.
|
||||
|
||||
`system.activationScripts.hive-agent-user-migrate` (in
|
||||
`nix/templates/harness-base.nix`) runs on every activation,
|
||||
`nix/agent-modules/user.nix`) runs on every activation,
|
||||
marker-guarded so the substantive moves only happen once per
|
||||
container lifetime:
|
||||
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ component can't traverse.
|
|||
Agent containers bind-mount the host's `nix-daemon` socket. The host daemon may
|
||||
have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build`
|
||||
inside nspawn containers to fail — containers lack kernel user namespaces, so nix
|
||||
cannot set up its build sandbox. `harness-base.nix` sets `sandbox-fallback = true`
|
||||
cannot set up its build sandbox. the agent modules set `sandbox-fallback = true`
|
||||
so that builds fall back to unsandboxed execution rather than failing outright.
|
||||
|
||||
### Threat model
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Background shell execution via `hive-bash-mcp`. Tools land as
|
||||
`mcp__bash__<tool>` (the MCP server name is `bash`, not `hyperhive`).
|
||||
Available on every agent unconditionally — `harness-base.nix` always
|
||||
Available on every agent unconditionally — `nix/agent-modules/mcp.nix` always
|
||||
injects bash into `hyperhive.extraMcpServers` (with `allowedTools =
|
||||
["*"]`), so `mcp__bash__*` is in `--allowedTools` for every claude
|
||||
invocation regardless of tool groups.
|
||||
|
|
@ -96,7 +96,7 @@ The bash tooling follows the same daemon + stdio-bridge pattern as the
|
|||
matrix MCP:
|
||||
|
||||
- **`hive-bash-daemon`** — long-running process (one per agent container,
|
||||
systemd service in `harness-base.nix`). Owns subprocess management,
|
||||
systemd service in `nix/agent-modules/mcp.nix`). Owns subprocess management,
|
||||
output file writing, `mcp-loose-ends/` state, and wake signal delivery.
|
||||
Listens on `/run/hive-bash/socket` inside the container.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# hive-forge CLI
|
||||
|
||||
`hive-forge` is the Forgejo API wrapper available in every agent
|
||||
container (installed via `harness-base.nix`; lives in `/hive-forge`
|
||||
container (installed via `nix/agent-modules/forge.nix`; lives in `/hive-forge`
|
||||
as a proper Rust binary). Use it instead of ad-hoc curl pipelines.
|
||||
|
||||
## Credentials and repo defaults
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ parsed from a turn (usage, cost, context window, resolved model) as
|
|||
|
||||
Hive-enforced settings ship at `/etc/claude-code/managed-settings.json`
|
||||
(claude-code's canonical managed-settings path — precedence #1,
|
||||
read-only, un-overridable), wired in `nix/templates/harness-base.nix`
|
||||
read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix`
|
||||
from the `prompts/claude-settings.json` asset. `effortLevel` is
|
||||
deliberately not in that file — effort is controlled live via the
|
||||
`--effort` flag (`HIVE_DEFAULT_EFFORT` / the per-agent UI slider), which
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ sentence into the agent's system prompt so it knows the docs exist and
|
|||
where to find them. The tree is served by `claude --add-dir` so the full
|
||||
markdown is readable during every turn.
|
||||
|
||||
Enabled by default only for the root/manager agent (`manager.nix`). Any
|
||||
Enabled by default only for the root/manager agent (`nix/templates/ruth.nix`). Any
|
||||
agent can opt in by adding the line above to its `agent.nix`.
|
||||
|
||||
The `docs/` source is a narrow flake input (`hyperhive-docs`) tracked
|
||||
|
|
|
|||
12
flake.nix
12
flake.nix
|
|
@ -20,7 +20,7 @@
|
|||
# nix/checks.nix flake checks
|
||||
# nix/devshell.nix dev shell
|
||||
# nix/treefmt.nix formatter config
|
||||
# nix/modules/, nix/templates/ the NixOS module + container trees
|
||||
# nix/host-modules/, nix/agent-modules/, nix/templates/ the NixOS module trees
|
||||
outputs =
|
||||
inputs@{
|
||||
self,
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
let
|
||||
# Package wiring for agent containers — the harness modules
|
||||
# consume hyperhive's own packages via the `hyperhive.packages`
|
||||
# option (see nix/templates/harness/packages.nix); no overlay.
|
||||
# option (see nix/agent-modules/packages.nix); no overlay.
|
||||
# `mkDefault` so a per-agent override of an individual key wins.
|
||||
agentPackages =
|
||||
{ lib, pkgs, ... }:
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
./nix/templates/ruth.nix
|
||||
agentPackages
|
||||
];
|
||||
# The full host stack (nix/modules/default.nix aggregator) plus
|
||||
# The full host stack (nix/host-modules/default.nix aggregator) plus
|
||||
# the package/source wiring from this flake. The wiring is a
|
||||
# plain config module setting the `services.hyperhive.c0re.*`
|
||||
# package options via `lib.mkDefault` — no overlay involved, and
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
default =
|
||||
{ lib, pkgs, ... }:
|
||||
{
|
||||
imports = [ ./nix/modules ];
|
||||
imports = [ ./nix/host-modules ];
|
||||
services.hyperhive.c0re = {
|
||||
package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
frontend = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.frontend;
|
||||
|
|
@ -136,8 +136,8 @@
|
|||
managerToplevel = lib.mkDefault self.packages.x86_64-linux.ruth-toplevel;
|
||||
};
|
||||
};
|
||||
hive-ci = ./nix/modules/hive-ci.nix;
|
||||
hive-forge = ./nix/modules/hive-forge;
|
||||
hive-ci = ./nix/host-modules/hive-ci.nix;
|
||||
hive-forge = ./nix/host-modules/hive-forge;
|
||||
};
|
||||
|
||||
nixosConfigurations =
|
||||
|
|
|
|||
245
nix/agent-modules/agent-service.nix
Normal file
245
nix/agent-modules/agent-service.nix
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# The hive-ag3nt harness service itself, plus the per-agent knobs it
|
||||
# reads from its environment: model selection, effort level,
|
||||
# compaction watermark, and the extra reverse-proxies of the per-agent
|
||||
# web UI.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
userName = config.hyperhive.user.name;
|
||||
homeDir = "/home/${userName}";
|
||||
in
|
||||
{
|
||||
options.hyperhive.model = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "haiku";
|
||||
example = "sonnet";
|
||||
description = ''
|
||||
Claude model for this agent. Sets the `HIVE_DEFAULT_MODEL`
|
||||
environment variable; the harness applies it at boot and it takes
|
||||
priority over any persisted runtime override. The operator can still
|
||||
switch the model at runtime via the per-agent web UI — that choice
|
||||
is tracked in the state dir for the current session but is reset by
|
||||
any rebuild that changes this option.
|
||||
|
||||
Valid values are the short model names that `claude --model` accepts:
|
||||
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). Context
|
||||
window sizes are looked up at runtime from the
|
||||
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars injected by the
|
||||
meta flake; override sizes via `services.hyperhive.c0re.contextWindowTokens`
|
||||
on the host.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.availableModels = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [
|
||||
"haiku"
|
||||
"sonnet"
|
||||
"opus"
|
||||
];
|
||||
example = [
|
||||
"sonnet"
|
||||
"opus"
|
||||
];
|
||||
description = ''
|
||||
Models offered in the per-agent web UI's model quick-picker. Rendered
|
||||
into the `HIVE_AVAILABLE_MODELS` environment variable (comma-separated)
|
||||
which the harness surfaces to the agent UI, so the picker lists exactly
|
||||
these models instead of a hardcoded set.
|
||||
|
||||
Configure hive-wide by setting a shared default (e.g. in your
|
||||
`agent.nix` shared defaults) or per-agent to narrow the menu — for example a
|
||||
haiku-only agent can hide `opus` and `sonnet`. The *current* model is
|
||||
still set by `hyperhive.model` and remains switchable at runtime via the
|
||||
UI; this option only controls which choices the picker presents.
|
||||
|
||||
Values are the short model names that `claude --model` accepts:
|
||||
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.effortLevel = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"low"
|
||||
"medium"
|
||||
"high"
|
||||
"xhigh"
|
||||
"max"
|
||||
];
|
||||
default = "medium";
|
||||
example = "high";
|
||||
description = ''
|
||||
Baseline claude effort level for this agent. Rendered into the
|
||||
`HIVE_DEFAULT_EFFORT` environment variable; the harness resolves
|
||||
effort as operator-override-file → this env → built-in `"medium"`,
|
||||
and passes the result to `claude --effort` at turn launch.
|
||||
|
||||
Ascending scale: `"low"` (minimal thinking budget), `"medium"`
|
||||
(default — balanced), `"high"` (platform default), `"xhigh"`
|
||||
(recommended for autonomous coding on capable models), `"max"`
|
||||
(maximum thinking budget, highest cost). The operator can override
|
||||
at runtime per-agent via the web UI (applied on the next session);
|
||||
any rebuild that changes this option resets that override.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.autoCompact = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Enable proactive watermark-based compaction. When `true` (the
|
||||
default) the harness automatically runs a notes-checkpoint turn
|
||||
followed by `/compact` once the context window crosses 75% of
|
||||
the model's limit, keeping later turns from hitting the hard
|
||||
overflow path. Set to `false` to disable proactive compaction
|
||||
entirely (`HIVE_COMPACT_WATERMARK_TOKENS=0`); the reactive path
|
||||
(compact-on-overflow when the session is already past the limit)
|
||||
still applies.
|
||||
|
||||
Disable for agents that run large-context models (sonnet/opus)
|
||||
where the heuristic fires too early and discards useful history
|
||||
before the session is actually close to the limit.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.extraWebProxies = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = lib.literalExpression ''{ "stats" = "http://127.0.0.1:3737"; }'';
|
||||
description = ''
|
||||
Transparent reverse-proxies mounted under `/extra/` in the per-agent web UI.
|
||||
Each attribute name becomes the sub-path and the value is the upstream.
|
||||
E.g. `{ "stats" = "http://127.0.0.1:3737"; }` mounts a proxy at
|
||||
`/agent/<name>/extra/stats/` that forwards to port 3737 with the prefix
|
||||
stripped. All user-declared proxies live under `/extra/` so they can
|
||||
never conflict with native agent endpoints (`/api/*`, `/events/*`, etc.).
|
||||
|
||||
The upstream value is either an `http(s)://` URL or a Unix domain
|
||||
socket, spelled `unix:<path>` (e.g. `unix:/run/myapp/http.sock`) — for
|
||||
agents whose secondary web server only listens on a UDS.
|
||||
|
||||
Intended for agents that run secondary web servers in the same container.
|
||||
Static assets served by the secondary app must use relative paths to
|
||||
resolve correctly under the sub-path prefix.
|
||||
|
||||
Sets the `HIVE_EXTRA_WEB_PROXIES` environment variable (JSON object)
|
||||
on the harness service unit.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
# hyperhive.model must be a non-empty string — an empty value causes
|
||||
# the harness to pass an invalid model flag to claude.
|
||||
{
|
||||
assertion = config.hyperhive.model != "";
|
||||
message = "hyperhive.model must not be empty (set it to e.g. \"haiku\" or \"sonnet\")";
|
||||
}
|
||||
# The current model must appear in the quick-picker menu, otherwise the
|
||||
# UI would offer no way back to the model the agent is actually running.
|
||||
{
|
||||
assertion =
|
||||
config.hyperhive.availableModels == [ ]
|
||||
|| builtins.elem config.hyperhive.model config.hyperhive.availableModels;
|
||||
message =
|
||||
"hyperhive.model (\"${config.hyperhive.model}\") must be one of "
|
||||
+ "hyperhive.availableModels ([ ${lib.concatStringsSep " " config.hyperhive.availableModels} ]) "
|
||||
+ "— add it to the list or change the model.";
|
||||
}
|
||||
];
|
||||
|
||||
# HIVE_DEFAULT_MODEL seeds the initial model selection when no
|
||||
# persisted model choice exists in the state dir.
|
||||
environment.variables = {
|
||||
HIVE_DEFAULT_MODEL = config.hyperhive.model;
|
||||
# Comma-separated menu for the per-agent UI model quick-picker
|
||||
# (see hyperhive.availableModels). The harness surfaces it to the
|
||||
# frontend; an empty value falls back to the built-in default list.
|
||||
HIVE_AVAILABLE_MODELS = lib.concatStringsSep "," config.hyperhive.availableModels;
|
||||
# Per-agent baseline effort (see hyperhive.effortLevel). The
|
||||
# harness resolves operator-override-file → this env → "medium"
|
||||
# and passes it to claude --effort at turn launch.
|
||||
HIVE_DEFAULT_EFFORT = config.hyperhive.effortLevel;
|
||||
}
|
||||
// lib.optionalAttrs (!config.hyperhive.autoCompact) {
|
||||
# Zero watermark disables proactive compaction; the reactive path
|
||||
# (compact-on-overflow) still fires when the session is truly full.
|
||||
HIVE_COMPACT_WATERMARK_TOKENS = "0";
|
||||
};
|
||||
|
||||
# Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars,
|
||||
# RuntimeDirectory, User=, standalone-eval fallbacks):
|
||||
# docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin
|
||||
# auto-append behaviour: docs/gotchas.md::systemd.services.*.path
|
||||
# appends /bin to every entry.
|
||||
systemd.services.hive-ag3nt =
|
||||
let
|
||||
binary = "hive-agent";
|
||||
in
|
||||
{
|
||||
description = "${binary} harness";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
# `/run/wrappers` before `/run/current-system/sw` so setuid
|
||||
# `sudo` resolves first. Passing the bare prefixes (no trailing
|
||||
# `/bin`) is intentional — see docs pointer above.
|
||||
path = [
|
||||
"/run/wrappers"
|
||||
"/run/current-system/sw"
|
||||
];
|
||||
environment = {
|
||||
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
||||
HOME = homeDir;
|
||||
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
|
||||
HIVE_ASSETS_DIR = "${config.hyperhive.packages.assets}/share/hyperhive";
|
||||
# Unix-socket path for the harness web UI. All agents always bind
|
||||
# here; there is no TCP fallback. Path matches
|
||||
# `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
|
||||
# bind-mounts and gateway upstream config stay in sync.
|
||||
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
|
||||
# Loopback URL of the persistent `hive-mcp-http` daemon that
|
||||
# `render_claude_config` points claude at for the built-in
|
||||
# surface (HTTP is the sole transport — no per-turn stdio child).
|
||||
# Kept in sync with the `hive-mcp-http` unit's `--http` port
|
||||
# (see ./mcp.nix) via the same option. Always set — network
|
||||
# isolation is unconditional, so a fixed per-container port is
|
||||
# collision-free.
|
||||
HYPERHIVE_MCP_HTTP_PORT = toString config.hyperhive.mcp.httpPort;
|
||||
}
|
||||
// lib.optionalAttrs config.hyperhive.gui.enable {
|
||||
# Tells the harness which fixed VNC port weston bound, and (by
|
||||
# its presence) that gui is enabled — the harness `/screen/ws`
|
||||
# relay reads this instead of a runtime marker file. The port is
|
||||
# container-local + fixed (network isolation is unconditional),
|
||||
# so the same value for every gui agent is fine. See
|
||||
# ./weston-vnc.nix::hyperhive.gui.vncPort.
|
||||
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
||||
}
|
||||
// lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) {
|
||||
# JSON object {"<path>": "<upstream>"} for the transparent
|
||||
# reverse-proxies. See `hyperhive.extraWebProxies` option
|
||||
# and `web_ui/proxy.rs::extra_proxy_service`.
|
||||
HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.hyperhive.extraWebProxies;
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${config.hyperhive.packages.hive-agent}/bin/${binary}";
|
||||
# Pin the journal identity to the binary name (otherwise systemd
|
||||
# derives SyslogIdentifier from the ExecStart basename).
|
||||
SyslogIdentifier = binary;
|
||||
Restart = "on-failure";
|
||||
RestartSec = 2;
|
||||
# Per-service runtime dir owned by `User=` below; the harness
|
||||
# writes its regenerated claude-{mcp-config,settings,system-prompt}
|
||||
# files here (`paths::config_dir`). Separate from /run/hive,
|
||||
# which holds hive-c0re's mcp.sock.
|
||||
RuntimeDirectory = "hive-config";
|
||||
User = userName;
|
||||
Group = userName;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
134
nix/agent-modules/bash-env.nix
Normal file
134
nix/agent-modules/bash-env.nix
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Shell-environment feature hooks: the `_bashEnvFragments`
|
||||
# accumulator, the `/etc/hyperhive/bash-env.sh` file it renders to,
|
||||
# and the cargo `--message-format short` injector that contributes to
|
||||
# it. Loaded via `$BASH_ENV` for non-interactive shells (claude's
|
||||
# `Bash` tool runs `bash -c`) and via `programs.bash` for interactive
|
||||
# ones.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Internal accumulator for shell snippets that should land in
|
||||
# `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via
|
||||
# `lib.mkIf` gated on their own option; the lines type merges
|
||||
# all contributions across modules into one file. Generic by
|
||||
# design so future hooks don't need to rename this file or
|
||||
# invent a parallel dispatcher.
|
||||
options.hyperhive._bashEnvFragments = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = ''
|
||||
Shell snippets concatenated into `/etc/hyperhive/bash-env.sh`.
|
||||
Feature hooks contribute via `lib.mkIf` gated on their own
|
||||
option. When empty, the file isn't created, `BASH_ENV` stays
|
||||
unset, and the interactive bashrc hook is omitted — zero cost
|
||||
when no feature is on. Internal — set indirectly via the
|
||||
per-feature options that own the gate (e.g.
|
||||
`hyperhive.cargo.shortMessages`).
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.cargo.shortMessages = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = ''
|
||||
Auto-inject `--message-format short` on cargo compile
|
||||
subcommands (`build`, `check`, `clippy`, `test`, `run`,
|
||||
`doc`, `bench`, `install`, `rustc`, `fix`) when claude (or
|
||||
anything else) invokes `cargo` inside this container.
|
||||
Saves tokens + context — the verbose default output floods
|
||||
the response window with per-crate progress lines that
|
||||
carry no signal beyond the warning/error summary.
|
||||
|
||||
Implementation: contributes a `cargo` shell function to
|
||||
`/etc/hyperhive/bash-env.sh` (see `hyperhive._bashEnvFragments`).
|
||||
Loaded via `BASH_ENV` for non-interactive shells (`bash -c` —
|
||||
what the claude `Bash` tool runs) and sourced from
|
||||
`programs.bash.interactiveShellInit` for interactive shells.
|
||||
The function:
|
||||
|
||||
- handles the `+toolchain` selector prefix (`cargo +nightly
|
||||
build` works);
|
||||
- passes through cleanly when the caller already specified
|
||||
`--message-format` (any form);
|
||||
- leaves non-compile subcommands (`new`, `add`, `search`,
|
||||
third-party `cargo-*` subcommands) untouched so they
|
||||
don't error on the unknown flag.
|
||||
|
||||
Set to `false` for agents that need full cargo output (e.g.
|
||||
tooling that parses `--message-format json` programmatically
|
||||
and doesn't pass the flag explicitly).
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
# Cargo `--message-format short` injector. `command cargo …` falls
|
||||
# back to the un-wrapped binary in PATH (the rust toolchain's cargo
|
||||
# — either from `environment.systemPackages` or from whatever
|
||||
# `nix develop` shell the agent's working in).
|
||||
hyperhive._bashEnvFragments = lib.mkIf config.hyperhive.cargo.shortMessages ''
|
||||
# Auto-injects --message-format short on cargo compile
|
||||
# subcommands so per-crate progress lines don't flood
|
||||
# claude's context. Bypassed when the caller already passes
|
||||
# --message-format (any form).
|
||||
cargo() {
|
||||
# Strip leading +toolchain selectors (cargo +nightly …).
|
||||
local pre=()
|
||||
while [ "''${1:0:1}" = "+" ] && [ -n "''${1:-}" ]; do
|
||||
pre+=("$1")
|
||||
shift
|
||||
done
|
||||
case "''${1:-}" in
|
||||
build|check|clippy|test|run|doc|bench|install|rustc|fix)
|
||||
local sub="$1"
|
||||
shift
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--message-format|--message-format=*)
|
||||
command cargo "''${pre[@]}" "$sub" "$@"
|
||||
return $?
|
||||
;;
|
||||
esac
|
||||
done
|
||||
command cargo "''${pre[@]}" "$sub" --message-format short "$@"
|
||||
;;
|
||||
*)
|
||||
command cargo "''${pre[@]}" "$@"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
'';
|
||||
|
||||
# Single bash-env file with all configured shell fragments.
|
||||
# Wiring is gated on at least one fragment being active so a
|
||||
# fully feature-disabled agent has neither the file nor the
|
||||
# `BASH_ENV` / interactive sourcing — zero cost in that case.
|
||||
environment.etc."hyperhive/bash-env.sh" = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
|
||||
text = config.hyperhive._bashEnvFragments;
|
||||
};
|
||||
|
||||
# Non-interactive bash invocations (claude's `Bash` tool runs
|
||||
# `bash -c`) source $BASH_ENV at startup — drops every active
|
||||
# feature hook's snippet into scope without touching
|
||||
# `/etc/profile` (login-only).
|
||||
environment.variables = lib.mkIf (config.hyperhive._bashEnvFragments != "") {
|
||||
BASH_ENV = "/etc/hyperhive/bash-env.sh";
|
||||
};
|
||||
|
||||
# Interactive shells don't honour BASH_ENV — wire the same file
|
||||
# in via the bashrc hook so operator SSH sessions get the same
|
||||
# hook surface as claude's non-interactive calls. Gated on at
|
||||
# least one fragment being active so we don't write a no-op
|
||||
# source line into `/etc/bashrc` on fully-feature-disabled agents.
|
||||
programs.bash.interactiveShellInit = lib.mkIf (config.hyperhive._bashEnvFragments != "") ''
|
||||
if [ -r /etc/hyperhive/bash-env.sh ]; then
|
||||
. /etc/hyperhive/bash-env.sh
|
||||
fi
|
||||
'';
|
||||
};
|
||||
}
|
||||
376
nix/agent-modules/claude-settings.nix
Normal file
376
nix/agent-modules/claude-settings.nix
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# Everything that shapes claude-code's own configuration inside the
|
||||
# container: the managed settings json (base env + OTEL), the
|
||||
# onboarding/trust seed, the runtime OTEL auth-header injection, and
|
||||
# the plugin/marketplace install lists the harness reads at boot.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
userName = config.hyperhive.user.name;
|
||||
homeDir = "/home/${userName}";
|
||||
# Hive-wide OpenTelemetry config (host-driven; baked in per-agent by
|
||||
# meta.rs `otel_config`).
|
||||
otelCfg = config.hyperhive.otel;
|
||||
# Hive/swarm display names are forwarded into each agent's build by
|
||||
# meta.rs as `environment.variables` (per-agent, build-time strings),
|
||||
# so they can be baked into the resource attributes below without a
|
||||
# runtime shell. Absent (option unset) → "unknown".
|
||||
hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown";
|
||||
swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown";
|
||||
# Base claude-code environment applied to every agent regardless of OTEL.
|
||||
# Shipped via the managed settings `env` block so claude and `hivectl
|
||||
# choom` both inherit them without a launch wrapper.
|
||||
baseClaudeEnv = {
|
||||
# Suppress analytics, survey pings, and other non-essential outbound
|
||||
# traffic — agents are headless and don't need any of that.
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
DO_NOT_TRACK = "1";
|
||||
# Disable claude's self-update machinery; package management is nix's job.
|
||||
DISABLE_AUTOUPDATER = "1";
|
||||
DISABLE_UPDATES = "1";
|
||||
# Keep plugin updates synchronized on install; prefer HTTPS for fetches.
|
||||
CLAUDE_CODE_PLUGIN_PREFER_HTTPS = "1";
|
||||
CLAUDE_CODE_SYNC_PLUGIN_INSTALL = "1";
|
||||
FORCE_AUTOUPDATE_PLUGINS = "1";
|
||||
# Suppress the "install GitHub app" prompt — not applicable in-hive.
|
||||
DISABLE_INSTALL_GITHUB_APP_COMMAND = "1";
|
||||
# Disable Anthropic's hosted claude.ai MCP servers; the hive supplies its own.
|
||||
ENABLE_CLAUDEAI_MCP_SERVERS = "0";
|
||||
# Resume an interrupted turn on reconnect (recovers from transient MCP flaps).
|
||||
CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1";
|
||||
# Use the simpler system prompt variant suited to headless operation.
|
||||
CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1";
|
||||
# Tag remote-control sessions with "<hive>-<agent>" for identification.
|
||||
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}";
|
||||
};
|
||||
# OTEL environment Claude Code reads to export metrics/logs/traces.
|
||||
# Shipped via the managed claude settings json (below), which claude
|
||||
# auto-discovers for BOTH the harness turn-loop and `hivectl choom` —
|
||||
# so telemetry parity is declarative, with no launch wrapper. The
|
||||
# auth header (`otel.headersCredential`) is deliberately NOT included
|
||||
# here: it's a secret and this file lives in the world-readable nix
|
||||
# store. It's injected at *runtime* into the agent's `0600`
|
||||
# `~/.claude/settings.json` by the `hive-otel-header` oneshot below
|
||||
# (claude merges the `env` from the user settings on top of these
|
||||
# managed ones), so the token is read from disk at start and never
|
||||
# touches the store.
|
||||
otelSettingsEnv = {
|
||||
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
||||
# Attach feedback-survey data to the OTEL pipeline.
|
||||
CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL = "1";
|
||||
OTEL_METRICS_EXPORTER = "otlp";
|
||||
OTEL_LOGS_EXPORTER = "otlp";
|
||||
OTEL_TRACES_EXPORTER = "otlp";
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL = otelCfg.protocol;
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT = otelCfg.endpoint;
|
||||
# Force CUMULATIVE temporality — Claude Code defaults to DELTA,
|
||||
# which Prometheus/Mimir-family backends (incl. grafana-lgtm)
|
||||
# silently drop without a deltatocumulative processor.
|
||||
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
|
||||
OTEL_RESOURCE_ATTRIBUTES =
|
||||
"service.name=hyperhive-agent,agent=${userName},hive=${hiveDisplayName},swarm=${swarmDisplayName}"
|
||||
+ lib.optionalString (otelCfg.extraResourceAttributes != "") ",${otelCfg.extraResourceAttributes}";
|
||||
# Include the Claude Code version label in emitted metrics.
|
||||
OTEL_METRICS_INCLUDE_VERSION = "1";
|
||||
}
|
||||
// lib.optionalAttrs (otelCfg.metricIntervalMs != null) {
|
||||
OTEL_METRIC_EXPORT_INTERVAL = toString otelCfg.metricIntervalMs;
|
||||
};
|
||||
in
|
||||
{
|
||||
# OTEL stats export is configured ONCE at host level via
|
||||
# `services.hyperhive.otel.*` (see nix/host-modules/hive-c0re.nix) and
|
||||
# injected into every agent's build by the meta-flake renderer
|
||||
# (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are
|
||||
# the build-time implementation surface that injection writes into;
|
||||
# they are not meant to be set directly in an agent.nix. Marked
|
||||
# `internal` so the host option is the only documented operator knob.
|
||||
options.hyperhive.otel = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
internal = true;
|
||||
description = ''
|
||||
Export this agent's Claude Code stats (token usage, cost, tool
|
||||
calls) to an OTLP endpoint via Claude Code's built-in
|
||||
OpenTelemetry. Each agent's harness exports directly to the
|
||||
collector, so it keeps working even when hive-c0re is down.
|
||||
Host-driven: set `services.hyperhive.otel.enable` instead.
|
||||
'';
|
||||
};
|
||||
|
||||
endpoint = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = ''
|
||||
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||
Host-driven via `services.hyperhive.otel.endpoint`.
|
||||
'';
|
||||
};
|
||||
|
||||
protocol = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"http/protobuf"
|
||||
"http/json"
|
||||
"grpc"
|
||||
];
|
||||
default = "http/protobuf";
|
||||
internal = true;
|
||||
description = ''
|
||||
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||
Host-driven via `services.hyperhive.otel.protocol`.
|
||||
'';
|
||||
};
|
||||
|
||||
headersCredential = lib.mkOption {
|
||||
# `str`, not `path`: a `path`-typed *relative* literal (e.g.
|
||||
# `./otel-headers`) is hash-copied into the world-readable nix store
|
||||
# at eval time, which would defeat the whole point of this option.
|
||||
# Keep it a string and require an absolute runtime path so the secret
|
||||
# is only ever read from disk by systemd at start, never nix-stored.
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
internal = true;
|
||||
description = ''
|
||||
Absolute path to an operator-provided secret file whose contents
|
||||
become `OTEL_EXPORTER_OTLP_HEADERS` (e.g.
|
||||
`Authorization=Bearer <token>`). Host-driven via
|
||||
`services.hyperhive.otel.headersCredential`.
|
||||
|
||||
The rest of the OTEL config ships in the world-readable managed
|
||||
claude settings json, but the header is a secret, so it's handled
|
||||
separately: hive-c0re forwards this file into the container's
|
||||
systemd credential store, and the `hive-otel-header` oneshot
|
||||
reads it at runtime (`LoadCredential`) and writes it into the
|
||||
agent's `0600` `~/.claude/settings.json` `env` block. The token
|
||||
is read from disk at start and never copied into the nix store or
|
||||
the world-readable settings file.
|
||||
'';
|
||||
};
|
||||
|
||||
extraResourceAttributes = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = ''
|
||||
Extra comma-separated entries appended to
|
||||
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
|
||||
`service.name` / `agent` / `hive` / `swarm` labels.
|
||||
Host-driven via `services.hyperhive.otel.extraResourceAttributes`.
|
||||
'';
|
||||
};
|
||||
|
||||
metricIntervalMs = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.ints.positive;
|
||||
default = null;
|
||||
internal = true;
|
||||
description = ''
|
||||
Metric export interval in milliseconds, set as
|
||||
`OTEL_METRIC_EXPORT_INTERVAL`. Null leaves Claude Code's 60s
|
||||
default. Host-driven via `services.hyperhive.otel.metricIntervalMs`.
|
||||
'';
|
||||
};
|
||||
|
||||
debug = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
internal = true;
|
||||
description = ''
|
||||
Emit OTEL SDK diagnostics to stderr (`CLAUDE_CODE_OTEL_DIAG_STDERR=1`).
|
||||
Host-driven via `services.hyperhive.otel.debug`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
options.hyperhive.claudeMarketplaces = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "anthropics/claude-plugins-official" ];
|
||||
example = [
|
||||
"anthropics/claude-plugins-official"
|
||||
"anthropics/claude-plugins-community"
|
||||
];
|
||||
description = ''
|
||||
Claude Code plugin marketplaces to add at harness boot. Each
|
||||
entry is passed to `claude plugin marketplace add <source>`
|
||||
(`owner/repo`, full git URL, or local path). Idempotent —
|
||||
re-adding an existing marketplace is treated as success.
|
||||
Required before `hyperhive.claudePlugins` entries that
|
||||
reference a marketplace (e.g. `foo@claude-plugins-official`).
|
||||
Rendered to `/etc/hyperhive/claude-marketplaces.json`.
|
||||
|
||||
Defaults to Anthropic's official marketplace; agents get it
|
||||
out of the box without any per-agent.nix wiring.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.claudePlugins = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [
|
||||
"formatter@my-marketplace"
|
||||
"thinking-tools@anthropics"
|
||||
];
|
||||
description = ''
|
||||
Claude Code plugins to install at harness boot. Each entry is
|
||||
passed verbatim to `claude plugin install <spec>` once per
|
||||
container start, before the turn loop opens. `claude plugin
|
||||
install` is expected to be idempotent, so reinstalling on every
|
||||
boot is cheap. Failures log a warning but do not abort boot — a
|
||||
missing plugin is preferable to a non-serving agent. Rendered to
|
||||
`/etc/hyperhive/claude-plugins.json`; the harness reads it via
|
||||
`plugins::install_configured`.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.claudePluginsAutoUpdate = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
When true, the harness runs `claude plugin marketplace update`
|
||||
before installing plugins at boot, pulling the latest index from
|
||||
all configured marketplaces. Disabled by default — most agents
|
||||
want pinned plugin versions and the network round-trip adds to
|
||||
boot time. Enable for agents that should always install the latest
|
||||
available version of their plugins.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
environment.etc."hyperhive/claude-plugins.json".text =
|
||||
builtins.toJSON config.hyperhive.claudePlugins;
|
||||
|
||||
environment.etc."hyperhive/claude-marketplaces.json".text =
|
||||
builtins.toJSON config.hyperhive.claudeMarketplaces;
|
||||
|
||||
environment.etc."hyperhive/claude-plugins-auto-update.json".text =
|
||||
builtins.toJSON config.hyperhive.claudePluginsAutoUpdate;
|
||||
|
||||
# Hive-enforced claude settings. claude-code auto-discovers managed
|
||||
# settings at this canonical Linux path (precedence #1, read-only,
|
||||
# un-overridable by user/project/CLI) — so the harness doesn't pass
|
||||
# `--settings` or copy the blob per turn. effortLevel is
|
||||
# deliberately NOT shipped here: effort is controlled live via the
|
||||
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider),
|
||||
# which managed scope would otherwise override and lock.
|
||||
# Hive-enforced settings merged with a per-agent `env` block at BUILD
|
||||
# time via `jq` (not eval-time `readFile`, which would be import-from-
|
||||
# derivation). The `env` block is always present: `baseClaudeEnv` sets
|
||||
# behaviour flags and the remote-control session prefix for every agent;
|
||||
# `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code
|
||||
# auto-discovers this file in every context (harness turn-loop AND
|
||||
# `hivectl choom`) so no launch wrapper is needed.
|
||||
environment.etc."claude-code/managed-settings.json".source =
|
||||
let
|
||||
baseSettings = "${config.hyperhive.packages.assets}/share/hyperhive/prompts/claude-settings.json";
|
||||
# Merge base env (always) with OTEL env (when enabled). jq is always
|
||||
# run — `baseClaudeEnv` contains per-agent values (e.g.
|
||||
# CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX) that can't live in the
|
||||
# static store asset.
|
||||
allEnv =
|
||||
baseClaudeEnv
|
||||
// lib.optionalAttrs otelCfg.enable otelSettingsEnv
|
||||
// lib.optionalAttrs (otelCfg.enable && otelCfg.debug) {
|
||||
# SDK diagnostics — noisy; only on when services.hyperhive.otel.debug = true.
|
||||
CLAUDE_CODE_OTEL_DIAG_STDERR = "1";
|
||||
};
|
||||
in
|
||||
pkgs.runCommand "managed-settings.json" { nativeBuildInputs = [ pkgs.jq ]; } ''
|
||||
jq --argjson env ${lib.escapeShellArg (builtins.toJSON allEnv)} \
|
||||
'. + { env: $env }' ${baseSettings} > "$out"
|
||||
'';
|
||||
|
||||
# Inject the OTEL auth header (a secret) into the agent's *user*
|
||||
# claude settings at runtime, keeping it out of the world-readable
|
||||
# managed settings json above and out of the nix store entirely.
|
||||
# hive-c0re forwards the operator's `headersCredential` file into
|
||||
# this container's systemd credential store; this oneshot reads it
|
||||
# via `LoadCredential` at start and merges `OTEL_EXPORTER_OTLP_HEADERS`
|
||||
# into `~/.claude/settings.json` (0600, agent-owned). claude layers
|
||||
# the user `env` on top of the managed one, so both the harness
|
||||
# turn-loop and `hivectl choom` (same agent user) pick it up. Ordering
|
||||
# is best-effort (`before`, not a hard dep): if it fails the harness
|
||||
# still starts and telemetry just exports unauthenticated.
|
||||
systemd.services.hive-otel-header =
|
||||
lib.mkIf (config.hyperhive.otel.enable && config.hyperhive.otel.headersCredential != null)
|
||||
{
|
||||
description = "Inject the OTEL auth header into the agent's claude user settings";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-ag3nt.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
LoadCredential = [ "otel-headers" ];
|
||||
ExecStart = pkgs.writeShellScript "hive-otel-header" ''
|
||||
set -eu
|
||||
umask 077
|
||||
hdr="$CREDENTIALS_DIRECTORY/otel-headers"
|
||||
[ -r "$hdr" ] || exit 0
|
||||
dir=${homeDir}/.claude
|
||||
settings="$dir/settings.json"
|
||||
mkdir -p "$dir"
|
||||
base='{}'
|
||||
[ -s "$settings" ] && base="$(cat "$settings")"
|
||||
printf '%s' "$base" | ${pkgs.jq}/bin/jq \
|
||||
--rawfile h "$hdr" \
|
||||
'.env = ((.env // {}) + { OTEL_EXPORTER_OTLP_HEADERS: ($h | rtrimstr("\n")) })' \
|
||||
> "$settings.tmp"
|
||||
mv "$settings.tmp" "$settings"
|
||||
chmod 0600 "$settings"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Seed claude's onboarding + per-project trust state once. claude only
|
||||
# marks `hasCompletedOnboarding` (global) and the project trust dialog
|
||||
# as accepted when run *interactively*; the harness only ever runs it
|
||||
# headless (`--print`) and `claude auth login` doesn't set them either.
|
||||
# So the first interactive launch (`hivectl choom`) would drop the
|
||||
# operator into the onboarding/trust walkthrough despite valid OAuth
|
||||
# creds. This oneshot is the single place hyperhive touches
|
||||
# `~/.claude.json`: it runs before the harness (so nothing races it),
|
||||
# is idempotent (skips when the flags are already set), and is
|
||||
# best-effort (`before`, not a hard dep — a failed seed leaves the file
|
||||
# untouched and the harness still starts). Credentials live in the
|
||||
# separate `~/.claude/.credentials.json`, so this never touches secrets.
|
||||
systemd.services.hive-claude-onboarding = {
|
||||
description = "Seed claude onboarding + project-trust so choom skips the walkthrough";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-ag3nt.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
ExecStart = pkgs.writeShellScript "hive-claude-onboarding" ''
|
||||
set -eu
|
||||
umask 077
|
||||
cfg=${homeDir}/.claude.json
|
||||
dir=/agents/${userName}/state
|
||||
base='{}'
|
||||
[ -s "$cfg" ] && base="$(cat "$cfg")"
|
||||
# Idempotent: nothing to do when already onboarded + trusted.
|
||||
if printf '%s' "$base" | ${pkgs.jq}/bin/jq -e \
|
||||
--arg d "$dir" \
|
||||
'.hasCompletedOnboarding == true and (.projects[$d].hasTrustDialogAccepted == true)' \
|
||||
>/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$base" | ${pkgs.jq}/bin/jq \
|
||||
--arg d "$dir" \
|
||||
'.hasCompletedOnboarding = true
|
||||
| .projects[$d].hasTrustDialogAccepted = true
|
||||
| .projects[$d].hasCompletedProjectOnboarding = true' \
|
||||
> "$cfg.tmp"
|
||||
mv "$cfg.tmp" "$cfg"
|
||||
chmod 0600 "$cfg"
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
73
nix/agent-modules/dashboard-links.nix
Normal file
73
nix/agent-modules/dashboard-links.nix
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Extra navigation links surfaced on the hive-c0re dashboard card for
|
||||
# this agent: the option and the oneshot that writes them into the
|
||||
# state dir where hive-c0re reads them.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.hyperhive.dashboardLinks = lib.mkOption {
|
||||
type = lib.types.listOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
label = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Display label for the link.";
|
||||
};
|
||||
icon = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Optional icon emoji or short glyph.";
|
||||
};
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Full URL (may include a different port, e.g. http://localhost:9001/stats).";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = [ ];
|
||||
example = lib.literalExpression ''
|
||||
[
|
||||
{ label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; }
|
||||
]
|
||||
'';
|
||||
description = ''
|
||||
Extra navigation links surfaced on the hive-c0re dashboard card for
|
||||
this agent. Declare any additional web UI pages the agent exposes —
|
||||
stats pages, custom UIs, etc. hive-c0re reads the JSON file this
|
||||
option produces at each container-view snapshot and attaches the
|
||||
links to the agent card without any code changes.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
# Write declared dashboardLinks to the state dir so hive-c0re can
|
||||
# read them without accessing the container's /etc/ from the host.
|
||||
# Best-effort oneshot (always exit 0):
|
||||
# docs/conventions.md::Best-effort oneshot services.
|
||||
systemd.services.hive-dashboard-links = lib.mkIf (config.hyperhive.dashboardLinks != [ ]) {
|
||||
description = "write declarative dashboardLinks to agent state dir";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "hive-dashboard-links";
|
||||
};
|
||||
environment.LINKS_JSON = builtins.toJSON config.hyperhive.dashboardLinks;
|
||||
script = ''
|
||||
# Sub-agents have their state dir bind-mounted at /agents/<name>/state.
|
||||
# Use a glob — exactly one match per container at runtime.
|
||||
STATE_DIR=$(echo /agents/*/state)
|
||||
if [ ! -d "$STATE_DIR" ]; then
|
||||
echo "hive-dashboard-links: no state dir found at /agents/*/state; skipping"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$LINKS_JSON" > "$STATE_DIR/hyperhive-dashboard-links.json"
|
||||
echo "hive-dashboard-links: wrote $(printf '%s' "$LINKS_JSON" | wc -c) bytes to $STATE_DIR/hyperhive-dashboard-links.json"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
220
nix/agent-modules/default.nix
Normal file
220
nix/agent-modules/default.nix
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# Shared scaffolding for every hyperhive harness container.
|
||||
# `../templates/agent.nix` and `../templates/ruth.nix` both import
|
||||
# agents use the same service unit regardless of which entry-point
|
||||
# they came from.
|
||||
#
|
||||
# This is the core module: container plumbing (boot/nix/nixpkgs),
|
||||
# base tooling, and the cross-cutting `hyperhive.icon` option. Each
|
||||
# feature lives in its own sibling module (imported below) that
|
||||
# declares its own `hyperhive.*` options + config.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
# Flake inputs routed through _module.args by the agent flake.nix.
|
||||
# Default to {} so the module evaluates cleanly even when the agent
|
||||
# flake doesn't set up the routing pattern (e.g. during standalone
|
||||
# nixos-rebuild without a flake wrapper).
|
||||
flakeInputs ? { },
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
./agent-service.nix
|
||||
./bash-env.nix
|
||||
./claude-settings.nix
|
||||
./dashboard-links.nix
|
||||
./docs.nix
|
||||
./forge.nix
|
||||
./frontend.nix
|
||||
./github.nix
|
||||
./matrix.nix
|
||||
./mcp.nix
|
||||
./network.nix
|
||||
./packages.nix
|
||||
./user.nix
|
||||
./weston-vnc.nix
|
||||
(lib.mkRemovedOptionModule [ "hyperhive" "web" "useUnixSocket" ] ''
|
||||
Unix socket mode is always enabled for all agents. Remove the
|
||||
setting from your agent.nix.
|
||||
'')
|
||||
(lib.mkRemovedOptionModule [ "hyperhive" "allowedBashPatterns" ] ''
|
||||
The built-in Bash tool is fully disabled; agents use
|
||||
mcp__bash__run instead. Remove the setting from your agent.nix.
|
||||
'')
|
||||
];
|
||||
|
||||
options.hyperhive.icon = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
example = lib.literalExpression "./icon.svg";
|
||||
description = ''
|
||||
Path to an SVG file used as this agent's icon — shown on the
|
||||
dashboard and the per-agent web UI (header + favicon). Commit
|
||||
the SVG into the agent's config repo next to `agent.nix` and
|
||||
reference it as a relative path (`./icon.svg`).
|
||||
|
||||
When null (the default) the agent falls back to the shared
|
||||
hyperhive logo. The harness serves the icon (configured or
|
||||
default) at `GET /icon` on the per-agent web port.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
|
||||
# expected to set `_module.args.flakeInputs = builtins.removeAttrs inputs ["self"]`.
|
||||
# If `self` leaks into flakeInputs the agent gets a spurious attrset
|
||||
# entry that can shadow real inputs and is almost certainly a bug.
|
||||
# Guard with `or {}` so standalone evaluation stays clean when
|
||||
# flakeInputs is absent from _module.args.
|
||||
{
|
||||
assertion = !(builtins.hasAttr "self" (config._module.args.flakeInputs or { }));
|
||||
message = ''
|
||||
hyperhive: `flakeInputs` must not contain "self".
|
||||
In your agent flake.nix, use:
|
||||
_module.args.flakeInputs = builtins.removeAttrs inputs [ "self" ];
|
||||
'';
|
||||
}
|
||||
# hyperhive.icon must reference an SVG file when set.
|
||||
{
|
||||
assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon);
|
||||
message = "hyperhive.icon must point to an .svg file";
|
||||
}
|
||||
];
|
||||
|
||||
# Operator-set per-agent icon (hyperhive.icon). When configured, the
|
||||
# SVG lands at /etc/hyperhive/icon.svg; the harness serves it at
|
||||
# GET /icon, falling back to the bundled hyperhive logo when absent.
|
||||
# Consumed by forge-avatar-sync (./forge.nix) and the matrix avatar
|
||||
# sync (./matrix.nix) too.
|
||||
environment.etc."hyperhive/icon.svg" = lib.mkIf (config.hyperhive.icon != null) {
|
||||
source = config.hyperhive.icon;
|
||||
};
|
||||
|
||||
boot.isNspawnContainer = true;
|
||||
|
||||
# Use a disk-backed /tmp instead of the default tmpfs so large scratch
|
||||
# writes (nix-develop shells, cargo build dirs, multi-GB downloads) land
|
||||
# on disk rather than eating container RAM. The tmpfs default mounts
|
||||
# ~3.2 GB of RAM per container; disk-backed /tmp is effectively unlimited
|
||||
# and cheaper for agents that do heavy build work.
|
||||
#
|
||||
# cleanOnBoot defaults to false in nixpkgs — set it explicitly so /tmp is
|
||||
# cleared on each container start (D! tmpfiles rule), preserving the
|
||||
# ephemeral-per-boot semantics agents expect from a tmpfs /tmp, just
|
||||
# without the RAM cost.
|
||||
boot.tmp.useTmpfs = false;
|
||||
boot.tmp.cleanOnBoot = true;
|
||||
|
||||
# Every agent gets flakes + the modern `nix` CLI out of the box.
|
||||
# Equivalent to passing `--extra-experimental-features 'nix-command
|
||||
# flakes'` on every invocation. Agents shell out to `nix build` /
|
||||
# `nix flake` constantly (devshells, ad-hoc evals, fetching their
|
||||
# own MCP-server flakes); without this they hit the "experimental
|
||||
# feature not enabled" wall on the first try.
|
||||
nix.settings.experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
|
||||
# `lib.mkForce` overrides nixpkgs's normal-priority `false` so
|
||||
# in-container `nix build` invocations fall back to unsandboxed
|
||||
# local builds rather than failing on the missing user-namespace.
|
||||
# See `docs/gotchas.md::Containerized nix-daemon needs
|
||||
# sandbox-fallback = true` + `docs/security.md` for the rationale.
|
||||
#
|
||||
# Note: with NIX_REMOTE=daemon below this becomes a no-op for the
|
||||
# common case — daemon-routed builds run on the host where sandboxing
|
||||
# works. It stays as a belt-and-suspenders fallback for any context
|
||||
# that bypasses the daemon (e.g. direct nix-store invocations).
|
||||
nix.settings.sandbox-fallback = lib.mkForce true;
|
||||
|
||||
# Route ALL nix invocations in this container through the host
|
||||
# nix-daemon socket, regardless of whether the caller is root or
|
||||
# non-root. Without this, root contexts (PID 1, systemd services
|
||||
# running as root) default to store=auto which resolves to the LOCAL
|
||||
# store — bypassing the shared daemon, its remote builders, and the
|
||||
# host's prebuilt derivation cache, causing spurious full rebuilds.
|
||||
#
|
||||
# systemd.globalEnvironment sets DefaultEnvironment in systemd.conf,
|
||||
# so every unit started by PID 1 inherits NIX_REMOTE=daemon.
|
||||
# Non-root nix clients already default to the daemon socket, so this
|
||||
# is a no-op for them; it only matters for root services that would
|
||||
# otherwise silently use the local store.
|
||||
systemd.globalEnvironment.NIX_REMOTE = "daemon";
|
||||
|
||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
||||
# evaluates its own `nixpkgs` instance, so the operator's host-level
|
||||
# `nixpkgs.config.allowUnfreePredicate` does not propagate into here —
|
||||
# we have to allow it inside the container's config as well.
|
||||
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
|
||||
|
||||
# Core tooling every agent gets. Per-bin split packages (see
|
||||
# nix/packages/default.nix + ./packages.nix) rather than the full
|
||||
# `hyperhive` bundle — that bundle also carries `hivectl` (a
|
||||
# host-admin CLI that dials the *host* admin socket — useless and
|
||||
# unreachable from inside a container — wrapped with
|
||||
# `wireguard-tools` for `hivectl wg`). The daemon/harness/MCP bins
|
||||
# the harness execs (hive-agent{,-mcp}, hive-bash-daemon,
|
||||
# hive-matrix-daemon, hive-bash-mcp, hive-matrix-mcp) are wired via
|
||||
# their own ExecStart/command lines in the sibling modules — they
|
||||
# don't need to be on PATH too. Only these two are actually looked
|
||||
# up on PATH by claude/shell code inside the container:
|
||||
# `hive-agent-wake` (external wake CLI, docs/turn-loop/mcp.md) and
|
||||
# `hive-metric` (agent-emitted custom metrics CLI,
|
||||
# docs/observability.md).
|
||||
environment.systemPackages = [
|
||||
config.hyperhive.packages.hive-agent-wake
|
||||
config.hyperhive.packages.hive-metric
|
||||
]
|
||||
++ (with pkgs; [
|
||||
claude-code
|
||||
bashInteractive
|
||||
coreutils-full
|
||||
# procps for pkill — used by the web UI's /api/cancel to SIGINT the
|
||||
# in-flight claude turn.
|
||||
procps
|
||||
# jq: JSON processing in shell — useful for parsing API responses,
|
||||
# forge REST calls, sqlite output, etc.
|
||||
jq
|
||||
# curl: HTTP client for forge REST API and other web requests.
|
||||
curl
|
||||
]);
|
||||
|
||||
# HIVE_ASSETS_DIR points at the project's static runtime assets
|
||||
# (branding + claude prompts; see `nix/packages/assets.nix`). Set
|
||||
# here so both the harness binary and any user-shell `cargo run`
|
||||
# inside the container resolve them from the same path.
|
||||
# SHELL must be set so claude's Bash tool finds a POSIX shell.
|
||||
# HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the
|
||||
# host-level `services.hyperhive.c0re.contextWindowTokens` option — not
|
||||
# set here.
|
||||
environment.variables = {
|
||||
HIVE_ASSETS_DIR = "${config.hyperhive.packages.assets}/share/hyperhive";
|
||||
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
||||
# Route interactive-shell nix invocations through the host daemon.
|
||||
# Redundant with /etc/profile.d/nix-daemon.sh but ensures it's set
|
||||
# regardless of which profile files are sourced.
|
||||
NIX_REMOTE = "daemon";
|
||||
};
|
||||
|
||||
# Git is needed by claude's Bash tool (for the agent <-> manager config
|
||||
# request flow) and by hive-c0re's own setup_applied / setup_proposed.
|
||||
# The per-agent `applied/<name>/flake.nix` overrides `user.name` and
|
||||
# `user.email` with the agent's identity — values here are `mkDefault`
|
||||
# so the per-agent override wins without needing `mkForce`.
|
||||
programs.git = {
|
||||
enable = true;
|
||||
config = {
|
||||
user = {
|
||||
name = lib.mkDefault "hyperhive";
|
||||
email = lib.mkDefault "hyperhive@local";
|
||||
};
|
||||
init.defaultBranch = lib.mkDefault "main";
|
||||
};
|
||||
};
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
};
|
||||
}
|
||||
50
nix/agent-modules/docs.nix
Normal file
50
nix/agent-modules/docs.nix
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# In-container hyperhive reference docs: the `hyperhive.docs.*`
|
||||
# options and the `$HIVE_DOCS_DIR` wiring the harness reads.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.hyperhive.docs.enable = lib.mkEnableOption ''
|
||||
make the hyperhive reference docs (the repo `docs/` tree, shipped
|
||||
read-only as the standalone `hyperhive-docs` derivation) available
|
||||
in-container. When enabled the harness exposes the docs dir to claude
|
||||
via `claude --add-dir`, so the markdown is readable at
|
||||
`$HIVE_DOCS_DIR/`, and appends a single pointer sentence to the agent's
|
||||
system prompt so it knows the docs exist (see
|
||||
`hive-ag3nt::prompt::render`). Default-on for the root/manager agent
|
||||
(see `../templates/ruth.nix`), off elsewhere; any agent can flip it from its
|
||||
`agent.nix`.
|
||||
'';
|
||||
|
||||
options.hyperhive.docs.source = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = config.hyperhive.packages.reference-docs;
|
||||
defaultText = lib.literalMD "`hyperhive.packages.reference-docs` (built from the repo `docs/` tree)";
|
||||
description = ''
|
||||
Store path of the reference-docs tree exposed at `$HIVE_DOCS_DIR`
|
||||
when `hyperhive.docs.enable` is set. Defaults to the flake's
|
||||
`reference-docs` package (the `nix/packages/reference-docs.nix`
|
||||
build) so a standalone container build from a full checkout
|
||||
works unchanged. The generated meta flake overrides this with the
|
||||
narrow `hyperhive-docs` flake input so a doc edit only
|
||||
re-locks that input instead of rebuilding the container from a
|
||||
re-hashed `hyperhive` source.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
environment.variables = lib.mkIf config.hyperhive.docs.enable {
|
||||
# The harness reads HIVE_DOCS_DIR and passes it to claude as
|
||||
# `--add-dir` so the docs are readable, and appends a single
|
||||
# pointer sentence to the system prompt
|
||||
# (hive-ag3nt::prompt::render) telling the agent the docs exist.
|
||||
# Source is `hyperhive.docs.source` (the narrow `hyperhive-docs`
|
||||
# meta-flake input, or `pkgs.hyperhive-docs` for standalone
|
||||
# builds). See hive-ag3nt::turn.
|
||||
HIVE_DOCS_DIR = "${config.hyperhive.docs.source}";
|
||||
};
|
||||
};
|
||||
}
|
||||
195
nix/agent-modules/forge.nix
Normal file
195
nix/agent-modules/forge.nix
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# In-container forge (Forgejo) integration: the `tea` CLI login
|
||||
# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge
|
||||
# avatar sync.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
userName = config.hyperhive.user.name;
|
||||
homeDir = "/home/${userName}";
|
||||
in
|
||||
{
|
||||
options.hyperhive.forge.url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "http://localhost:3000";
|
||||
example = "http://forge.internal:3000";
|
||||
description = ''
|
||||
Base URL of the hyperhive-managed Forgejo. Used at container
|
||||
boot by a oneshot systemd unit that calls
|
||||
`tea login add --url <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
||||
(= `/agents/<name>/state/forge-token`) so the agent's claude can
|
||||
shell out to `tea` without an extra auth dance. No-op when the
|
||||
forge-token file is missing (i.e. hive-forge isn't running on
|
||||
the host).
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
# hyperhive.forge.url must look like an HTTP URL when non-default.
|
||||
{
|
||||
assertion =
|
||||
config.hyperhive.forge.url == ""
|
||||
|| lib.hasPrefix "http://" config.hyperhive.forge.url
|
||||
|| lib.hasPrefix "https://" config.hyperhive.forge.url;
|
||||
message = "hyperhive.forge.url must be an http:// or https:// URL (got: \"${config.hyperhive.forge.url}\")";
|
||||
}
|
||||
];
|
||||
|
||||
environment.systemPackages = [
|
||||
# tea: gitea/forgejo CLI client. Configured at boot by the
|
||||
# tea-login oneshot below if /state/forge-token is present, so
|
||||
# claude can `tea repos create`, `tea pulls create`, etc.
|
||||
pkgs.tea
|
||||
# hive-forge <verb>: CLI wrapping common Forgejo REST API operations
|
||||
# (view, pr, issue, comment, assign, close, labels, branches, etc.).
|
||||
# The per-bin split package — narrow closure, no hivectl/wireguard.
|
||||
config.hyperhive.packages.hive-forge
|
||||
];
|
||||
|
||||
# One-shot: tea config.yml from the seeded forge token. Shape
|
||||
# contract (always exit 0, no set -e, skip-silently, re-runnable):
|
||||
# docs/conventions.md::Best-effort oneshot services.
|
||||
systemd.services.tea-login = {
|
||||
description = "configure tea CLI from hive-forge token (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "local-fs.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "tea-login";
|
||||
};
|
||||
path = [
|
||||
pkgs.curl
|
||||
pkgs.python3
|
||||
pkgs.coreutils
|
||||
];
|
||||
environment.HOME_DIR = homeDir;
|
||||
environment.AGENT_USER = userName;
|
||||
script = ''
|
||||
# No `set -e`: best-effort posture (see docs pointer above).
|
||||
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
||||
# $HYPERHIVE_STATE_DIR is system-wide via the meta flake.
|
||||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "tea-login: no forge-token at $TOKEN_FILE; skipping"
|
||||
exit 0
|
||||
fi
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
# Resolve the agent username from the forge API.
|
||||
USER=$(curl -sf --max-time 5 \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$FORGE_URL/api/v1/user" \
|
||||
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \
|
||||
2>/dev/null || true)
|
||||
if [ -z "$USER" ]; then
|
||||
echo "tea-login: could not resolve username from forge API; skipping"
|
||||
exit 0
|
||||
fi
|
||||
# Config under the agent user's home, chown'd to them;
|
||||
# service stays root-owned (see docs pointer above).
|
||||
CONFIG="$HOME_DIR/.config/tea/config.yml"
|
||||
mkdir -p "$(dirname "$CONFIG")" || true
|
||||
cat > "$CONFIG" << EOF
|
||||
logins:
|
||||
- name: forge
|
||||
url: $FORGE_URL
|
||||
token: $TOKEN
|
||||
default: true
|
||||
ssh_host: ""
|
||||
ssh_key: ""
|
||||
insecure: false
|
||||
ssh_agent: false
|
||||
user: $USER
|
||||
preferences:
|
||||
editor: false
|
||||
flag_defaults:
|
||||
remote: ""
|
||||
EOF
|
||||
chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true
|
||||
echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)"
|
||||
'';
|
||||
};
|
||||
|
||||
# Path-trigger sibling: re-fires forge-avatar-sync the moment
|
||||
# `<state>/forge-token` appears. Mirrors the hive-matrix-daemon
|
||||
# token-watcher pattern — on first agent deployment the container
|
||||
# boots before hive-c0re has provisioned the forge-token, so the
|
||||
# service fires too early and exits with "no forge-token found".
|
||||
# Without this path unit, RemainAfterExit=true would prevent systemd
|
||||
# from ever re-running the service. See
|
||||
# docs/persistence.md::forge-avatar-sync.
|
||||
systemd.paths.forge-avatar-sync = {
|
||||
description = "trigger forge-avatar-sync when forge-token appears";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
pathConfig.PathExistsGlob = "/agents/*/state/forge-token";
|
||||
};
|
||||
|
||||
# One-shot: hyperhive.icon → Forgejo profile avatar. Shape contract:
|
||||
# docs/conventions.md::Best-effort oneshot services.
|
||||
# RemainAfterExit = false so the .path trigger above can re-fire
|
||||
# this unit when the forge-token arrives after boot.
|
||||
systemd.services.forge-avatar-sync = {
|
||||
description = "sync agent icon to Forgejo user avatar (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "tea-login.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = false;
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "forge-avatar-sync";
|
||||
};
|
||||
path = [
|
||||
pkgs.curl
|
||||
pkgs.coreutils
|
||||
pkgs.jq
|
||||
pkgs.librsvg
|
||||
];
|
||||
script = ''
|
||||
ICON=/etc/hyperhive/icon.svg
|
||||
if [ ! -f "$ICON" ]; then
|
||||
echo "forge-avatar-sync: no icon configured; skipping"
|
||||
exit 0
|
||||
fi
|
||||
FORGE_URL=${lib.escapeShellArg config.hyperhive.forge.url}
|
||||
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
|
||||
# (systemd.globalEnvironment) to `/agents/<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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue