refactor: split harness-base.nix into harness/ feature modules
This commit is contained in:
parent
c0bd77b314
commit
ecaad48fad
17 changed files with 2392 additions and 2208 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{ ... }:
|
||||
{
|
||||
imports = [ ./harness-base.nix ];
|
||||
imports = [ ./harness ];
|
||||
# Entry-point for sub-agent containers. Referenced from `flake.nix`
|
||||
# (`nixosConfigurations.agent-base`) and the meta-flake's
|
||||
# `applied/<name>/flake.nix`.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
245
nix/templates/harness/agent-service.nix
Normal file
245
nix/templates/harness/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-base.nix`) or per-agent to narrow the menu — for example a
|
||||
haiku-only agent can hide `opus` and `sonnet`. The *current* model is
|
||||
still set by `hyperhive.model` and remains switchable at runtime via the
|
||||
UI; this option only controls which choices the picker presents.
|
||||
|
||||
Values are the short model names that `claude --model` accepts:
|
||||
`"haiku"`, `"sonnet"`, `"opus"` (or any future identifier).
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.effortLevel = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"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 = "${pkgs.hyperhive-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 = "${pkgs.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/templates/harness/bash-env.nix
Normal file
134
nix/templates/harness/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/templates/harness/claude-settings.nix
Normal file
376
nix/templates/harness/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/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 = "${pkgs.hyperhive-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/templates/harness/dashboard-links.nix
Normal file
73
nix/templates/harness/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/templates/harness/default.nix
Normal file
220
nix/templates/harness/default.nix
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# Shared scaffolding for every hyperhive harness container.
|
||||
# `../agent-base.nix` and `../manager.nix` both import this; all
|
||||
# agents use the same service unit regardless of which entry-point
|
||||
# they came from.
|
||||
#
|
||||
# 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
|
||||
./user.nix
|
||||
./weston-vnc.nix
|
||||
];
|
||||
|
||||
options.hyperhive.web.useUnixSocket = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
example = true;
|
||||
description = ''
|
||||
Deprecated. Unix socket mode is now always enabled for all agents.
|
||||
Setting this option to `true` has no effect and the option will be
|
||||
removed in a future version. Safe to drop from agent configs.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.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) rather than the full `pkgs.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 with the matching
|
||||
# `pkgs.hive-*` package — 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 = with pkgs; [
|
||||
hive-agent-wake
|
||||
hive-metric
|
||||
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 = "${pkgs.hyperhive-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/templates/harness/docs.nix
Normal file
50
nix/templates/harness/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 `../manager.nix`), off elsewhere; any agent can flip it from its
|
||||
`agent.nix`.
|
||||
'';
|
||||
|
||||
options.hyperhive.docs.source = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = pkgs.hyperhive-docs;
|
||||
defaultText = lib.literalMD "`pkgs.hyperhive-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
|
||||
`pkgs.hyperhive-docs` (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}";
|
||||
};
|
||||
};
|
||||
}
|
||||
194
nix/templates/harness/forge.nix
Normal file
194
nix/templates/harness/forge.nix
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# 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.)
|
||||
(pkgs.callPackage ../../packages/hive-forge-tools.nix { })
|
||||
];
|
||||
|
||||
# One-shot: tea config.yml from the seeded forge token. Shape
|
||||
# contract (always exit 0, no set -e, skip-silently, re-runnable):
|
||||
# docs/conventions.md::Best-effort oneshot services.
|
||||
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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
156
nix/templates/harness/frontend.nix
Normal file
156
nix/templates/harness/frontend.nix
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Per-agent web UI static tree: the shipped frontend dist, the
|
||||
# operator-extendable `extraFiles` overlay, and the merged tree the
|
||||
# harness serves via `HIVE_STATIC_DIR`.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
options.hyperhive.frontend.dist = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.hyperhive-frontend;
|
||||
defaultText = lib.literalExpression "pkgs.hyperhive-frontend";
|
||||
description = ''
|
||||
The shipped frontend dist (built by `nix/packages/frontend.nix`).
|
||||
Output layout: `dashboard/` (used by hive-c0re on the host) and
|
||||
`agent/` (used here, layered with `extraFiles` below at
|
||||
activation time). Override to ship a fully custom per-agent SPA;
|
||||
the JSON contract (`/api/state`, `/events/stream`, the action
|
||||
endpoints) is the source of truth for any replacement.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.frontend.mergedDist = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
readOnly = true;
|
||||
description = ''
|
||||
Computed: the merged static tree consumed by the harness via
|
||||
`HIVE_STATIC_DIR`. Composed at evaluation time by copying
|
||||
`hyperhive.frontend.dist`'s `agent/` subdir as the base, then
|
||||
layering each `extraFiles` entry on top. Read-only — do not set directly.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.frontend.extraFiles = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule (
|
||||
{ name, ... }:
|
||||
{
|
||||
options = {
|
||||
source = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = ''
|
||||
Source file or directory to layer over the default
|
||||
agent dist. A path (relative to `agent.nix` or
|
||||
absolute) — nix copies its contents into the merged
|
||||
static tree.
|
||||
'';
|
||||
};
|
||||
target = lib.mkOption {
|
||||
# First char must be alphanumeric/underscore (rules out
|
||||
# leading `/`, leading `.`, leading `-`); inner chars
|
||||
# include `.` and `/` so nested layouts like
|
||||
# `"games/bitburner"` work. This is the shape check —
|
||||
# the `..`-segment traversal check is the assertion in
|
||||
# `config.assertions` below (regex alone can't reject
|
||||
# mid-path `..` segments without lookahead, which nix
|
||||
# POSIX regex doesn't support).
|
||||
type = lib.types.strMatching "^[A-Za-z0-9_][A-Za-z0-9_./-]*$";
|
||||
default = name;
|
||||
defaultText = lib.literalMD "the attribute name";
|
||||
description = ''
|
||||
Destination path within the merged static tree, used
|
||||
as both the served URL prefix (`/<target>/...`) and
|
||||
the on-disk layout in the merged derivation. Defaults
|
||||
to the attribute name. Use forward slashes for
|
||||
nested layouts (e.g. `"games/bitburner"`).
|
||||
|
||||
Constrained shape: must start with an alphanumeric or
|
||||
`_`, and only contain alphanumerics, `_`, `.`, `/`,
|
||||
`-`. `..` segments are separately rejected at config
|
||||
eval time.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
bitburner = {
|
||||
source = ./bitburner-dist;
|
||||
# served at GET /bitburner/...
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Per-agent additions layered on top of the default frontend
|
||||
dist. Each entry copies its `source` into the served static
|
||||
tree under `target`. Useful for shipping a self-contained
|
||||
agent-specific surface alongside the standard agent UI (e.g.
|
||||
the bitburner agent's game page at `/bitburner/`).
|
||||
|
||||
The default agent UI remains served at `/`; entries here only
|
||||
add new routes and never replace the default. Overwrite
|
||||
semantics are **hard-fail**: if `target` collides with an
|
||||
existing file or directory in the default dist (or with a
|
||||
prior entry's target), the `mergedDist` build aborts with
|
||||
`refusing to overwrite existing path '<target>' in the
|
||||
default dist`. To override a default file, fork the dist via
|
||||
`hyperhive.frontend.dist` instead — `extraFiles` is for
|
||||
pure additions.
|
||||
|
||||
`target` must be a relative path inside the static dir. An
|
||||
assertion rejects leading `/` and `..` segments at config
|
||||
eval time (string-concat-into-paths safety, even though
|
||||
agent.nix goes through operator review before deploy).
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
# hyperhive.frontend.extraFiles[*].target is concatenated into
|
||||
# $out during the mergedDist build. The option's strMatching
|
||||
# type already rejects leading `/`, leading `.`, and the
|
||||
# weirder characters; this assertion catches mid-path `..`
|
||||
# segments (e.g. `foo/../etc/passwd`) that the type's regex
|
||||
# can't easily express without lookahead. agent.nix is
|
||||
# operator-reviewed, so this is belt-and-braces — but it's the
|
||||
# kind of mistake that's easy to make and hard to spot.
|
||||
{
|
||||
assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) (
|
||||
lib.attrValues config.hyperhive.frontend.extraFiles
|
||||
);
|
||||
message = ''
|
||||
hyperhive.frontend.extraFiles: `target` must not contain
|
||||
`..` path segments.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# Merged frontend static tree. Base = `${frontend.dist}/agent/`,
|
||||
# then each `extraFiles` entry is laid on top at its `target`
|
||||
# path. The runCommand derivation aborts on overwrite so a
|
||||
# filename collision with the default dist surfaces as a build
|
||||
# failure rather than a silent override (operator gets a clear
|
||||
# nix error rather than a confusing 404 / silent dist swap).
|
||||
hyperhive.frontend.mergedDist = pkgs.runCommand "hyperhive-agent-frontend-merged" { } (
|
||||
''
|
||||
mkdir -p $out
|
||||
cp -r ${config.hyperhive.frontend.dist}/agent/. $out/
|
||||
chmod -R u+w $out
|
||||
''
|
||||
+ lib.concatMapStrings (entry: ''
|
||||
mkdir -p $(dirname $out/${entry.target})
|
||||
if [ -e $out/${entry.target} ]; then
|
||||
echo "hyperhive.frontend.extraFiles: refusing to overwrite existing path '${entry.target}' in the default dist" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -r ${entry.source} $out/${entry.target}
|
||||
'') (lib.attrValues config.hyperhive.frontend.extraFiles)
|
||||
);
|
||||
};
|
||||
}
|
||||
89
nix/templates/harness/github.nix
Normal file
89
nix/templates/harness/github.nix
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# GitHub integration (hyperhive.github.enable): a `gh` wrapper + a git
|
||||
# credential helper, both reading the PAT from the agent's
|
||||
# `github-token` state file at invocation, so a dashboard-pasted token
|
||||
# takes effect with no rebuild. The token PATH is baked in at build
|
||||
# time (nix knows `userName`) — NOT read from `$HIVE_GITHUB_TOKEN_FILE`,
|
||||
# because claude's Bash tool runs `bash -c` in a minimal env that
|
||||
# doesn't source `/etc/set-environment`, so the env var isn't present
|
||||
# where `gh`/`git` actually run. The token value never enters the nix
|
||||
# store (only its path). github.com only; git auths as `x-access-token`
|
||||
# + PAT.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
userName = config.hyperhive.user.name;
|
||||
ghWrapper = pkgs.writeShellScriptBin "gh" ''
|
||||
if [ -r "/agents/${userName}/state/github-token" ]; then
|
||||
GH_TOKEN="$(cat "/agents/${userName}/state/github-token")"
|
||||
export GH_TOKEN
|
||||
fi
|
||||
exec ${pkgs.gh}/bin/gh "$@"
|
||||
'';
|
||||
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-github" ''
|
||||
# git credential-helper protocol: only the `get` action needs an answer.
|
||||
[ "''${1:-}" = "get" ] || exit 0
|
||||
if [ -r "/agents/${userName}/state/github-token" ]; then
|
||||
# GitHub ignores the username for PAT auth — `x-access-token` is the
|
||||
# conventional placeholder; the PAT is the password.
|
||||
printf 'username=x-access-token\n'
|
||||
printf 'password=%s\n' "$(cat "/agents/${userName}/state/github-token")"
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.hyperhive.github.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Install the GitHub integration in this agent: a `gh` CLI wrapper and a
|
||||
git credential helper for `https://github.com`, both authenticated from
|
||||
an operator-supplied personal access token (PAT). The PAT is written to
|
||||
`<state>/github-token` out of band --- the dashboard credentials tab or
|
||||
`hivectl github set-token` --- so giving an agent GitHub is a runtime
|
||||
paste, no per-agent config or rebuild. The wrappers read the token file
|
||||
at invocation, so a freshly-pasted PAT takes effect immediately; until
|
||||
one exists, `gh` / `git push` just fail unauthenticated.
|
||||
|
||||
github.com only. git authenticates as `x-access-token` + the PAT (GitHub
|
||||
ignores the username for PAT auth); `gh` derives its identity from the
|
||||
token. Keep the PAT minimally scoped: the agent has passwordless sudo, so
|
||||
a compromised agent can act within the token's scopes --- scope is the
|
||||
real blast-radius limiter.
|
||||
|
||||
On by default. Host-driven: set `services.hyperhive.github.enable = false`
|
||||
to turn the integration off hive-wide (meta.rs propagates the override
|
||||
into every agent).
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
# No bare pkgs.gh here — the wrapper *is* `gh` and hardcodes the
|
||||
# real binary path, so it can't be shadowed.
|
||||
environment.systemPackages = lib.optionals config.hyperhive.github.enable [
|
||||
ghWrapper
|
||||
gitCredHelper
|
||||
];
|
||||
|
||||
# Wire the GitHub credential helper for `git push` over HTTPS. Host-scoped
|
||||
# to `https://github.com`, so it never touches the forge (localhost:3000)
|
||||
# or any other remote. The helper reads the PAT from the agent's
|
||||
# `github-token` state file at invocation and auths as `x-access-token` +
|
||||
# the PAT. System /etc/gitconfig merges under the agent's ~/.gitconfig
|
||||
# (safe.directory), so this is additive.
|
||||
# Nested-path binding + mkIf (matching the other `environment.etc."…"`
|
||||
# entries in the harness modules) — a whole-set `environment.etc = {…}`
|
||||
# here would collide with them at the nix level ("attribute already
|
||||
# defined").
|
||||
environment.etc."gitconfig" = lib.mkIf config.hyperhive.github.enable {
|
||||
text = ''
|
||||
[credential "https://github.com"]
|
||||
helper = hive-github
|
||||
username = x-access-token
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
298
nix/templates/harness/matrix.nix
Normal file
298
nix/templates/harness/matrix.nix
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
# Per-agent matrix integration: the `hyperhive.matrix.*` +
|
||||
# `hyperhive.matrixAccounts` options, the long-running
|
||||
# hive-matrix-daemon, its token-arrival path trigger, and the
|
||||
# auto-injected stdio MCP bridge entry.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
userName = config.hyperhive.user.name;
|
||||
# Single source of truth for the default matrix homeserver URL, shared
|
||||
# by the `hyperhive.matrix.url` option default and the daemon-unit guard
|
||||
# that decides whether to set a unit-level HIVE_MATRIX_URL (so the two
|
||||
# cannot drift). Matches the daemon's own built-in default
|
||||
# (`paths::DEFAULT_HOMESERVER`).
|
||||
matrixUrlDefault = "http://localhost:8008";
|
||||
# Rasterize the operator-set agent icon (`hyperhive.icon`, an SVG) to a
|
||||
# 512x512 PNG so the matrix daemon can upload it as each account's avatar
|
||||
# over the live authenticated Client (see hive-matrix-mcp::client::sync_avatar).
|
||||
# Only forced when an icon is configured — the `HIVE_ICON_PNG` daemon-env
|
||||
# entry is gated on `hyperhive.icon != null`, so this binding stays lazy
|
||||
# when no icon is set.
|
||||
iconPng = pkgs.runCommand "hive-agent-icon.png" { nativeBuildInputs = [ pkgs.librsvg ]; } ''
|
||||
rsvg-convert -f png -w 512 -h 512 ${config.hyperhive.icon} -o $out
|
||||
'';
|
||||
in
|
||||
{
|
||||
options.hyperhive.matrix.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Enable per-agent matrix integration via `hive-matrix-mcp`.
|
||||
When true (the default), the harness:
|
||||
|
||||
- runs `hive-matrix-daemon` as a systemd unit that holds a
|
||||
matrix-sdk Client + sync against the homeserver at
|
||||
`HIVE_MATRIX_URL` (default `http://localhost:8008` — the
|
||||
in-host tuwunel from `nix/modules/hive-matrix.nix`). The
|
||||
daemon auto-skips when `<state>/matrix-token` is missing,
|
||||
and a `systemd.paths` watcher restarts it the moment
|
||||
hive-c0re provisions the token (same path-trigger shape
|
||||
as `forge-avatar-sync`).
|
||||
- exposes the matrix tool surface (send_message, send_dm,
|
||||
send_reaction, send_reply, mark_read, list_rooms,
|
||||
list_room_members, read_room) to claude via an auto-injected
|
||||
`extraMcpServers.matrix` entry. Claude spawns the stdio
|
||||
`hive-matrix-mcp` bridge per turn, which forwards each tool
|
||||
call to the daemon over `/run/hive-matrix/socket`.
|
||||
- wakes the agent on incoming room events via a short teaser
|
||||
Wake signal (`[matrix] <sender> in <room>: <first 100c>…`)
|
||||
to the hyperhive control socket; the full event stays
|
||||
unread server-side until `read_room` consumes it.
|
||||
|
||||
Set to `false` for agents that should NOT have matrix tools at
|
||||
all (e.g. agents on a host without `hyperhive.matrix.enable` on
|
||||
the meta side). When token file is absent the daemon and MCP
|
||||
both no-op cleanly anyway, so `false` is rarely necessary.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.matrix.url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = matrixUrlDefault;
|
||||
example = "https://matrix.darkest.space";
|
||||
description = ''
|
||||
Matrix homeserver URL the agent's `hive-matrix-daemon` connects
|
||||
to. At runtime hive-c0re forwards the isolation-aware URL
|
||||
(`matrix.<domain>` via the gateway) so isolated agents reach
|
||||
the homeserver without crossing host loopback. Override
|
||||
per-agent when an agent should talk to an external homeserver
|
||||
instead (e.g. a federation-only setup or a remote hive's
|
||||
tuwunel reached via a vpn).
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.matrixAccounts = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
tokenFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/agents/dmatrix/state/matrix-token-ccc";
|
||||
description = ''
|
||||
Path to this account's bearer-token file. The daemon reads
|
||||
the token from here to restore the matrix session; how the
|
||||
file gets populated is the provisioner's concern (an
|
||||
operator-supplied secret for an external account). The
|
||||
daemon skips an extra account whose token file is absent.
|
||||
'';
|
||||
};
|
||||
sessionDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/agents/dmatrix/state/matrix-sdk-state-ccc";
|
||||
description = ''
|
||||
Per-account matrix-sdk sqlite store directory (crypto keys
|
||||
+ event cache). Must differ between accounts so their
|
||||
sessions do not collide.
|
||||
'';
|
||||
};
|
||||
homeserver = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "https://matrix.example.org";
|
||||
description = ''
|
||||
Homeserver URL for this account. When null (the default),
|
||||
the account falls back to `hyperhive.matrix.url`. Set it for
|
||||
an account on a different homeserver than the agent's
|
||||
default (e.g. an external public-matrix account).
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
ccc = {
|
||||
tokenFile = "/agents/dmatrix/state/matrix-token-ccc";
|
||||
sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc";
|
||||
homeserver = "https://matrix.example.org";
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Declare *additional* matrix accounts served by the single
|
||||
`hive-matrix-daemon` (one matrix-sdk Client + sync loop each),
|
||||
beyond the agent's built-in hive-internal account. The
|
||||
attribute name keys each account (unique by construction) and is
|
||||
the handle the matrix MCP tools target via their `account`
|
||||
argument.
|
||||
|
||||
The **hive-internal account is always present and is the primary**:
|
||||
it is named `main`, synthesized by the daemon from
|
||||
`hyperhive.matrix.url` + `<state>/matrix-token` +
|
||||
`<state>/matrix-sdk-state`, and is the account a tool call acts as
|
||||
when it omits `account`. You never declare it here --- this option
|
||||
is only for the extras (e.g. an external public-matrix account).
|
||||
|
||||
Leave empty (the default) for the common single-account case: the
|
||||
agent then has only `main`. When non-empty, the extras are
|
||||
serialized to the daemon's `HIVE_MATRIX_ACCOUNTS` environment
|
||||
variable and the daemon appends them after `main`. Requires
|
||||
`hyperhive.matrix.enable` (there is no `main` to extend otherwise).
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
# Extra matrix accounts only make sense alongside the hive-internal
|
||||
# `main` account they extend, which exists only when matrix is
|
||||
# enabled.
|
||||
{
|
||||
assertion = config.hyperhive.matrixAccounts == { } || config.hyperhive.matrix.enable;
|
||||
message =
|
||||
"hyperhive.matrixAccounts requires hyperhive.matrix.enable = true "
|
||||
+ "(the extras extend the hive-internal `main` account, which only "
|
||||
+ "exists when matrix is enabled).";
|
||||
}
|
||||
# `main` is reserved for the synthesized hive-internal account; a
|
||||
# declared extra by that name would silently collide with it.
|
||||
{
|
||||
assertion = !builtins.hasAttr "main" config.hyperhive.matrixAccounts;
|
||||
message =
|
||||
"hyperhive.matrixAccounts cannot contain a key named \"main\" "
|
||||
+ "--- that name is reserved for the hive-internal account.";
|
||||
}
|
||||
# Token files must land at the `matrix-token*` name the daemon
|
||||
# path-watcher globs (`/agents/*/state/matrix-token*`), or the account
|
||||
# never gets picked up live (it loads only on a full daemon restart).
|
||||
# Enforce the basename prefix so a deviating name (e.g. the historical
|
||||
# `matrix-catgirl-token`) is caught at build time, not silently.
|
||||
{
|
||||
assertion = lib.all (a: lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)) (
|
||||
lib.attrValues config.hyperhive.matrixAccounts
|
||||
);
|
||||
message =
|
||||
"every hyperhive.matrixAccounts.<name>.tokenFile basename must start with "
|
||||
+ "\"matrix-token\" so the daemon path-watcher glob "
|
||||
+ "(/agents/*/state/matrix-token*) picks it up live. Offending: "
|
||||
+ lib.concatStringsSep ", " (
|
||||
lib.mapAttrsToList (n: a: "${n}=${baseNameOf a.tokenFile}") (
|
||||
lib.filterAttrs (
|
||||
_n: a: !lib.hasPrefix "matrix-token" (baseNameOf a.tokenFile)
|
||||
) config.hyperhive.matrixAccounts
|
||||
)
|
||||
)
|
||||
+ ".";
|
||||
}
|
||||
];
|
||||
|
||||
# Auto-inject the matrix stdio MCP bridge alongside the bash entry
|
||||
# from ./mcp.nix. `lib.mkDefault` so the operator's own agent.nix
|
||||
# can override the entry.
|
||||
hyperhive.extraMcpServers = lib.mkIf config.hyperhive.matrix.enable {
|
||||
matrix = lib.mkDefault {
|
||||
command = "${pkgs.hive-matrix-mcp}/bin/hive-matrix-mcp";
|
||||
args = [ ];
|
||||
# Same socket path the hive-matrix-daemon service binds
|
||||
# via its `RuntimeDirectory = "hive-matrix"`. Keeps the
|
||||
# bridge + daemon in sync without baking the path into
|
||||
# the Rust default — the env override wins for both.
|
||||
env.HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
|
||||
allowedTools = [ "*" ];
|
||||
};
|
||||
};
|
||||
|
||||
# Long-running matrix-sdk client + sync per agent. Holds the unix
|
||||
# socket the stdio `hive-matrix-mcp` bridge connects to + emits
|
||||
# hyperhive wake signals on incoming room events via
|
||||
# `/run/hive/mcp.sock`. See
|
||||
# `docs/persistence.md::Matrix per-agent daemon + token-arrival
|
||||
# trigger` for the socket-path / first-boot-ordering rationale.
|
||||
systemd.services.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
|
||||
description = "long-running matrix-sdk Client + MCP daemon socket";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
environment = {
|
||||
HIVE_MATRIX_SOCKET = "/run/hive-matrix/socket";
|
||||
RUST_LOG = "info";
|
||||
}
|
||||
# Homeserver URL: by default the daemon inherits the host-forwarded
|
||||
# HIVE_MATRIX_URL (set by hive-c0re to `matrix.<domain>` via the
|
||||
# gateway, since agents run in private netns and can't reach host
|
||||
# loopback directly), falling back to the daemon's built-in
|
||||
# localhost default if the forward is absent. A per-agent
|
||||
# `hyperhive.matrix.url` override (non-default) is set unit-level
|
||||
# so it wins over the forwarded value; at the default we
|
||||
# deliberately DON'T set it so the forwarded value isn't shadowed.
|
||||
// lib.optionalAttrs (config.hyperhive.matrix.url != matrixUrlDefault) {
|
||||
HIVE_MATRIX_URL = config.hyperhive.matrix.url;
|
||||
}
|
||||
# Multi-account: serialize the *extra* accounts to the JSON the
|
||||
# daemon parses (`accounts::configured`). Only set when extras are
|
||||
# declared; the daemon always synthesizes the primary `main`
|
||||
# (hive-internal) account itself from the per-agent paths and
|
||||
# prepends it, so we emit extras only. Each entry is in the
|
||||
# daemon's `AccountCfg` serde shape: name (the attr key) /
|
||||
# token_file / state_dir / optional homeserver.
|
||||
// lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) {
|
||||
HIVE_MATRIX_ACCOUNTS = builtins.toJSON (
|
||||
lib.mapAttrsToList (
|
||||
name: a:
|
||||
{
|
||||
inherit name;
|
||||
token_file = a.tokenFile;
|
||||
state_dir = a.sessionDir;
|
||||
}
|
||||
// lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }
|
||||
) config.hyperhive.matrixAccounts
|
||||
);
|
||||
}
|
||||
# Rasterized agent icon path for the daemon's avatar sync. Only set
|
||||
# when an icon is configured; absent → the daemon skips avatar setting
|
||||
# (hive-matrix-mcp::client::sync_avatar returns early on unset env).
|
||||
// lib.optionalAttrs (config.hyperhive.icon != null) {
|
||||
HIVE_ICON_PNG = "${iconPng}";
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.hive-matrix-daemon}/bin/hive-matrix-daemon";
|
||||
SyslogIdentifier = "hive-matrix-daemon";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
RuntimeDirectory = "hive-matrix";
|
||||
# Keep /run/hive-matrix across restarts. With the default
|
||||
# `RuntimeDirectoryPreserve=no`, a `switch-to-configuration`
|
||||
# restart races the outgoing instance's stop-time cleanup
|
||||
# (which deletes the dir) against the incoming instance's
|
||||
# start (which creates it + binds the socket inside it). The
|
||||
# cleanup can win and delete the dir out from under the fresh
|
||||
# daemon, which then fails to mkdir under root-owned /run and
|
||||
# exits — looping on Restart=on-failure until the next boot.
|
||||
# `yes` stops systemd removing it on stop; it still creates it
|
||||
# on first start, and it lives on tmpfs so it's gone at
|
||||
# container reboot regardless. See hive-bash-daemon (./mcp.nix).
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
# Re-fire the daemon when the matrix token appears (hive-c0re
|
||||
# provisions it after agent containers come up). Without this
|
||||
# the daemon would exit 0 silently on first boot and the MCP
|
||||
# would have no backend until next restart. See
|
||||
# `docs/persistence.md` (same section as above).
|
||||
systemd.paths.hive-matrix-daemon = lib.mkIf config.hyperhive.matrix.enable {
|
||||
description = "trigger hive-matrix-daemon when a matrix token appears";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# `matrix-token*` (not just `matrix-token`) so a secondary
|
||||
# multi-account token (e.g. `matrix-token-ccc`) landing also
|
||||
# re-fires the daemon to pick up the freshly-provisioned account.
|
||||
pathConfig.PathExistsGlob = "/agents/*/state/matrix-token*";
|
||||
};
|
||||
};
|
||||
}
|
||||
257
nix/templates/harness/mcp.nix
Normal file
257
nix/templates/harness/mcp.nix
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
# The MCP tool surface: the built-in hyperhive server (persistent
|
||||
# streamable-http daemon), the bash-task backend daemon + its
|
||||
# auto-injected stdio bridge, the `extraMcpServers` option they hang
|
||||
# off, and the send-recipient allowlist.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
userName = config.hyperhive.user.name;
|
||||
in
|
||||
{
|
||||
options.hyperhive.allowedBashPatterns = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Deprecated - has no effect. The built-in Bash tool is fully
|
||||
disabled regardless of this list; agents use mcp__bash__run
|
||||
instead. Remove this option from your agent.nix.
|
||||
'';
|
||||
visible = false;
|
||||
};
|
||||
|
||||
options.hyperhive.allowedRecipients = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [
|
||||
"alice"
|
||||
"manager"
|
||||
];
|
||||
description = ''
|
||||
Names this agent is allowed to `send` to via
|
||||
`mcp__hyperhive__send`. Empty list (the default) means
|
||||
unrestricted — the agent can message any peer, the
|
||||
operator, or the manager. Non-empty list constrains the
|
||||
surface: only the listed names + the manager (always
|
||||
allowed) get through; anything else returns an error
|
||||
string to claude without touching the broker. The
|
||||
operator (`operator`) needs to be in the list if the
|
||||
agent should be able to surface output on the
|
||||
dashboard.
|
||||
|
||||
Useful for sandboxing untrusted sub-agents — set
|
||||
`[ "manager" ]` to scope them to manager-only chatter.
|
||||
The manager itself is always exempt; this option only
|
||||
affects sub-agent `send`.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.extraMcpServers = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
command = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`.";
|
||||
};
|
||||
args = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Args passed to the MCP server binary.";
|
||||
};
|
||||
env = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
description = "Environment variables for the MCP server child process.";
|
||||
};
|
||||
allowedTools = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "*" ];
|
||||
example = [
|
||||
"send_message"
|
||||
"join_room"
|
||||
];
|
||||
description = ''
|
||||
Tool names this MCP server is auto-approved to call via
|
||||
`--allowedTools`. Single entry `"*"` (the default) means
|
||||
"every tool from this server" — convenient but trusting.
|
||||
Tighten to a specific list when you only want a subset.
|
||||
Names are bare (e.g. `send_message`); the harness prepends
|
||||
`mcp__<server-key>__` at build time.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
matrix = {
|
||||
command = "/run/current-system/sw/bin/mcp-matrix";
|
||||
args = [ "--config" "/state/matrix.toml" ];
|
||||
env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
||||
allowedTools = [ "send_message" "join_room" ];
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Extra MCP servers claude sees alongside the hyperhive tool surface.
|
||||
Keys are the server names (claude addresses tools as
|
||||
`mcp__<key>__<tool>`). Rendered to `/etc/hyperhive/extra-mcp.json`
|
||||
at activation time; the harness reads that file at boot and merges
|
||||
it into `--mcp-config` + `--allowedTools`. Take effect on the
|
||||
agent's next harness restart (no operator approval needed beyond
|
||||
whatever brought the new agent.nix into deployed/*).
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.mcp.httpPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8790;
|
||||
example = 8791;
|
||||
description = ''
|
||||
Loopback port the built-in hyperhive MCP surface is served on. HTTP
|
||||
is the *sole* transport for the built-in surface: a
|
||||
long-lived `hive-mcp-http` systemd unit runs
|
||||
`hive-agent-mcp --http 127.0.0.1:<port>` and `render_claude_config`
|
||||
points claude at the stable `http://127.0.0.1:<port>/mcp` URL. That
|
||||
URL survives the per-turn claude re-spawn (and a host-side hive-c0re
|
||||
restart — each tool call dials the control socket fresh), so there
|
||||
is no per-turn MCP re-registration race (a resumed stdio child could
|
||||
emit its first tool call before that turn's async
|
||||
`initialize`/`tools-list` completed, stranding the agent with `No
|
||||
such tool` — the http endpoint eliminates that). Extra MCP servers
|
||||
(matrix/bash) stay stdio bridges regardless.
|
||||
|
||||
Bound loopback-only; the rmcp streamable-http transport's default
|
||||
`allowed_hosts` (`localhost` / `127.0.0.1` / `::1`) rejects Host
|
||||
headers from anywhere else, so no auth token is required for a
|
||||
container-local endpoint.
|
||||
|
||||
Failure-mode note: with no stdio fallback, if `hive-mcp-http` is
|
||||
down claude hits a dead URL until the unit restarts (guarded by
|
||||
`Restart=always`, `RestartSec=3`). Intended shape: no per-turn race
|
||||
while up, a bounded self-healing gap while restarting.
|
||||
|
||||
Safe as a single fixed default across all agents: each container
|
||||
runs in its own private network namespace (isolation is always-on —
|
||||
see docs/network.md), so `127.0.0.1:<port>` is per-container-private
|
||||
and cannot collide across agents. Override only if a container-local
|
||||
service already occupies this port.
|
||||
|
||||
Must match `mcp_config::DEFAULT_MCP_HTTP_PORT` (the harness always
|
||||
exports `HYPERHIVE_MCP_HTTP_PORT`, so the const is only a fallback).
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
warnings = lib.optional (config.hyperhive.allowedBashPatterns != [ ]) ''
|
||||
hyperhive.allowedBashPatterns is deprecated and has no effect.
|
||||
The built-in Bash tool is fully disabled; agents use mcp__bash__run instead.
|
||||
Remove allowedBashPatterns from your agent.nix.
|
||||
'';
|
||||
|
||||
# Auto-inject the built-in bash MCP server — always present, every
|
||||
# agent needs bash tools. `lib.mkDefault` so the operator's own
|
||||
# agent.nix can override the entry. (The matrix sibling lives in
|
||||
# ./matrix.nix, gated on hyperhive.matrix.enable.)
|
||||
hyperhive.extraMcpServers.bash = lib.mkDefault {
|
||||
command = "${pkgs.hive-bash-mcp}/bin/hive-bash-mcp";
|
||||
args = [ ];
|
||||
env.HIVE_BASH_SOCKET = "/run/hive-bash/socket";
|
||||
allowedTools = [ "*" ];
|
||||
};
|
||||
|
||||
environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers;
|
||||
|
||||
environment.etc."hyperhive/send-allow.json".text =
|
||||
builtins.toJSON config.hyperhive.allowedRecipients;
|
||||
|
||||
# Bash task runner daemon — long-running process that owns subprocess
|
||||
# monitoring + completion wake signals. Always enabled (every agent
|
||||
# needs bash tools). The stdio MCP bridge `hive-bash-mcp` connects
|
||||
# to this daemon's socket per turn.
|
||||
# Socket dir: /run/hive-bash/ — RuntimeDirectory keeps it on tmpfs.
|
||||
systemd.services.hive-bash-daemon = {
|
||||
description = "bash task runner daemon for hive-bash-mcp";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# The daemon runs every bash task via `Command::new("bash")` and the
|
||||
# commands themselves (hive-forge, git, jq, …) resolve from PATH.
|
||||
# A standalone daemon has no inherited agent PATH, so without this
|
||||
# `bash` itself isn't found (spawn fails with ENOENT, the task is
|
||||
# marked done in 0s with no output / no .out/.err). Mirror the
|
||||
# harness unit's PATH: NixOS appends `/bin` to each entry →
|
||||
# /run/wrappers/bin (setuid sudo) + /run/current-system/sw/bin
|
||||
# (bash, coreutils, hive-forge, …).
|
||||
path = [
|
||||
"/run/wrappers"
|
||||
"/run/current-system/sw"
|
||||
];
|
||||
environment = {
|
||||
HIVE_BASH_SOCKET = "/run/hive-bash/socket";
|
||||
HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock";
|
||||
RUST_LOG = "info";
|
||||
# HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already
|
||||
# injected via systemd.globalEnvironment by the meta flake
|
||||
# (set to /agents/<name>/harness and /agents/<name>/state
|
||||
# respectively). The daemon uses these to derive its task +
|
||||
# loose-ends dir paths; without them it falls back to deriving
|
||||
# harness/ as a sibling of state/, which produces the same
|
||||
# value but is less robust if the two vars ever diverge.
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.hive-bash-daemon}/bin/hive-bash-daemon";
|
||||
SyslogIdentifier = "hive-bash-daemon";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 3;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
RuntimeDirectory = "hive-bash";
|
||||
# Keep /run/hive-bash across restarts. With the default
|
||||
# `RuntimeDirectoryPreserve=no`, a post-rebuild restart races
|
||||
# stop-time dir cleanup against the fresh daemon's socket-dir
|
||||
# creation; the daemon loses, fails `mkdir /run/hive-bash`
|
||||
# (Permission denied, non-root in /run), and loops on
|
||||
# Restart=on-failure until the next container boot — i.e. the
|
||||
# bash daemon "doesn't come up post-rebuild". Same shape as
|
||||
# hive-matrix-daemon (./matrix.nix).
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
# Persistent streamable-http MCP daemon for the built-in hyperhive
|
||||
# surface — the *sole* transport for that surface; always
|
||||
# wired. Long-lived so claude reconnects to the stable URL each turn
|
||||
# instead of respawning + re-registering a stdio subprocess (the
|
||||
# per-turn MCP registration race). It dials the control socket
|
||||
# (`/run/hive/mcp.sock`, the harness binaries' default) fresh on every
|
||||
# tool call, so a host-side hive-c0re restart is transparent.
|
||||
# `before = hive-ag3nt` so the URL is already listening by the time
|
||||
# the harness renders the first turn's config; the harness/claude also
|
||||
# reconnect on their own, so ordering is a latency nicety not a hard
|
||||
# correctness dep.
|
||||
systemd.services.hive-mcp-http = {
|
||||
description = "persistent streamable-http MCP daemon for the hyperhive surface";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-ag3nt.service" ];
|
||||
environment.RUST_LOG = "info";
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.hive-agent-mcp}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}";
|
||||
SyslogIdentifier = "hive-mcp-http";
|
||||
# `always` (not `on-failure`): this endpoint is load-bearing — the
|
||||
# sole hyperhive-MCP transport, so a down window is total
|
||||
# hyperhive-MCP loss with no stdio fallback and no per-turn
|
||||
# self-heal (the URL just stays dead). `always` also covers any
|
||||
# unforeseen clean-return path and restarts after a stray SIGTERM
|
||||
# stops it out from under the harness.
|
||||
Restart = "always";
|
||||
RestartSec = 3;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
82
nix/templates/harness/network.nix
Normal file
82
nix/templates/harness/network.nix
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# In-container network plumbing: DHCP on the bridge veth, resolvconf
|
||||
# taken out of the loop, and the oneshot that points resolv.conf at
|
||||
# the hive bridge resolver.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Take resolvconf + dhcpcd out of the /etc/resolv.conf loop so the
|
||||
# bridge resolver the oneshot below writes actually sticks. At their
|
||||
# NixOS defaults, resolvconf regenerates resolv.conf from host-tracking
|
||||
# *after* the oneshot has pointed it at the bridge (dhcpcd re-triggers
|
||||
# that when the veth comes up under isolation) — silently clobbering the
|
||||
# bridge nameserver back to the host resolver, which isn't authoritative
|
||||
# for the hive's own zones, so `forge.<domain>` stops resolving. We
|
||||
# disable resolvconf and tell dhcpcd not to touch resolv.conf (without
|
||||
# disabling dhcpcd itself, so the veth still gets its address); then
|
||||
# the hyperhive-isolated-dns oneshot owns resolv.conf. (Same "take
|
||||
# resolvconf out of the loop" approach the matrix container uses.)
|
||||
# All agent containers receive their bridge IP via DHCP from the hive
|
||||
# dnsmasq pool (see nix/modules/hive-gateway.nix). useDHCP runs dhcpcd
|
||||
# on every interface (just eth0 in practice — the nspawn bridge veth).
|
||||
config = {
|
||||
networking.useDHCP = true;
|
||||
networking.resolvconf.enable = false;
|
||||
networking.dhcpcd.extraConfig = "nohook resolv.conf";
|
||||
|
||||
# Point resolv.conf at the hive bridge resolver when the container is
|
||||
# network-isolated. nixos-container copies the *host's* /etc/resolv.conf
|
||||
# into the container at every start — but the host resolver (e.g.
|
||||
# 127.0.0.53) is unreachable from a private netns and isn't
|
||||
# authoritative for the hive's own zones (forge.<domain> etc.). The
|
||||
# bridge dnsmasq (gateway IP) is. hive-priv drops the marker
|
||||
# `/etc/hyperhive-bridge-dns` (containing the gateway IP) since
|
||||
# isolation is always on; the oneshot reads it and rewrites
|
||||
# resolv.conf on every boot. Ordered before the first DNS consumer
|
||||
# (tea-login) and the network targets so name resolution works for
|
||||
# the very first turn.
|
||||
systemd.services.hyperhive-isolated-dns = {
|
||||
description = "point resolv.conf at the hive bridge resolver (isolated containers)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "local-fs.target" ];
|
||||
# Ordered before every network consumer that does DNS on first
|
||||
# boot. `hive-ag3nt` (the harness) is the load-bearing one: its
|
||||
# first-turn api.anthropic.com lookup must not race the resolv.conf
|
||||
# rewrite (it only declares `after network.target`, so without this
|
||||
# edge the harness can start before we've fixed resolv.conf and the
|
||||
# first turn errors — self-heals next turn, but better not to flap).
|
||||
# `hive-matrix-daemon` likewise syncs over the network; the `before`
|
||||
# is a harmless no-op when matrix is disabled (the unit is absent).
|
||||
before = [
|
||||
"network-online.target"
|
||||
"tea-login.service"
|
||||
"hive-ag3nt.service"
|
||||
"hive-matrix-daemon.service"
|
||||
];
|
||||
unitConfig.ConditionPathExists = "/etc/hyperhive-bridge-dns";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Pin the journal identity; without it systemd derives it from the
|
||||
# generated `script` store-path wrapper (an opaque `<hash>-…-start`).
|
||||
SyslogIdentifier = "hyperhive-isolated-dns";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
set -eu
|
||||
gw=$(tr -d '[:space:]' < /etc/hyperhive-bridge-dns)
|
||||
if [ -z "$gw" ]; then
|
||||
echo "hyperhive-isolated-dns: empty marker; leaving resolv.conf as-is"
|
||||
exit 0
|
||||
fi
|
||||
# resolv.conf is a regular file copied from the host by
|
||||
# nixos-container; replace it (rm first in case it's a symlink).
|
||||
rm -f /etc/resolv.conf
|
||||
printf 'nameserver %s\n' "$gw" > /etc/resolv.conf
|
||||
echo "hyperhive-isolated-dns: resolv.conf -> nameserver $gw"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
215
nix/templates/harness/user.nix
Normal file
215
nix/templates/harness/user.nix
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# Per-agent unix user: the `hyperhive.user.*` options, the user/group
|
||||
# declarations, passwordless sudo, and the first-boot migration that
|
||||
# chowns the bind-mounted state dirs to the agent user.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
userName = config.hyperhive.user.name;
|
||||
homeDir = "/home/${userName}";
|
||||
in
|
||||
{
|
||||
# Per-agent unix user the harness + co-process daemons run as.
|
||||
# Defaults to `"agent"` so a standalone evaluation (e.g.
|
||||
# `nix flake check` against `nixosConfigurations.agent-base`) builds
|
||||
# cleanly; the meta-flake's per-agent module rebinds this to the
|
||||
# agent name (`"damocles"`, `"iris"`, …) so each container has a
|
||||
# uniquely-named user matching its agent label. UID auto-assigned
|
||||
# by NixOS (the auto-allocation range for normal users); no hard-
|
||||
# coded UID.
|
||||
options.hyperhive.user.name = lib.mkOption {
|
||||
type = lib.types.strMatching "^[a-z_][a-z0-9_-]{0,30}$";
|
||||
default = "agent";
|
||||
example = "iris";
|
||||
description = ''
|
||||
Unix user the harness service runs as inside the container.
|
||||
The meta-flake overrides this to the agent's own name so the
|
||||
user inside the container matches the agent label (`HIVE_LABEL`).
|
||||
Stand-alone evaluation defaults to `"agent"` so module evaluation
|
||||
without the meta-flake wrapper still builds.
|
||||
|
||||
Constraints match `useradd`'s NAME_REGEX: lowercase / `_` start,
|
||||
total length ≤ 31, no special characters. UID is auto-assigned
|
||||
by NixOS unless `hyperhive.user.uid` is explicitly set.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.user.uid = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = null;
|
||||
example = 1100;
|
||||
description = ''
|
||||
Optional fixed UID for the per-agent unix user. `null` (default)
|
||||
lets NixOS auto-assign from the normal-user range (≥ 1000),
|
||||
which is the right default for most deployments — the UID stays
|
||||
stable across container rebuilds because each container only has
|
||||
one normal user and the assignment is written into the container's
|
||||
`/etc/passwd` at activation time.
|
||||
|
||||
Set an explicit value only when the host needs a predictable UID
|
||||
for the agent's state files — e.g. if an operator script
|
||||
references files by numeric UID, or to keep ownership stable
|
||||
across full container destroy + recreate on a fresh host.
|
||||
|
||||
Values must be in `[1000, 60000)`. Using UIDs < 1000 clashes with
|
||||
system accounts and is rejected by NixOS.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.user.gid = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = null;
|
||||
example = 1100;
|
||||
description = ''
|
||||
Optional fixed GID for the per-agent unix group. `null` (default)
|
||||
lets NixOS auto-assign. Usually set alongside `hyperhive.user.uid`
|
||||
to the same value (the conventional Unix pattern for per-user
|
||||
groups where uid == gid), but can be set independently.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.user.passwordlessSudo = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
example = false;
|
||||
description = ''
|
||||
Grant `${config.hyperhive.user.name}` passwordless sudo
|
||||
(`NOPASSWD: ALL`). True by default so claude's `Bash` tool
|
||||
keeps working for tools that expect root inside the container
|
||||
(`systemctl`, package managers in dev shells, etc.) — the
|
||||
same surface the previous root-user shape had, just elevated
|
||||
explicitly instead of implicitly.
|
||||
|
||||
Flip to `false` for agents that should be strictly
|
||||
unprivileged. Anything claude shells out to that needs root
|
||||
will then fail loudly with the standard sudo error rather
|
||||
than silently succeeding — easier to spot the leak.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
{
|
||||
assertion =
|
||||
config.hyperhive.user.uid == null
|
||||
|| (config.hyperhive.user.uid >= 1000 && config.hyperhive.user.uid < 60000);
|
||||
message = ''
|
||||
hyperhive.user.uid must be in [1000, 60000) — values below
|
||||
1000 clash with system accounts; values ≥ 60000 are reserved
|
||||
by NixOS for dynamic allocation. Leave unset (null) to let
|
||||
NixOS auto-assign.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
config.hyperhive.user.gid == null
|
||||
|| (config.hyperhive.user.gid >= 1000 && config.hyperhive.user.gid < 60000);
|
||||
message = ''
|
||||
hyperhive.user.gid must be in [1000, 60000) — same range
|
||||
constraint as hyperhive.user.uid.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# The container activation script (hive-agent-user-migrate) chowns
|
||||
# the bind-mounted state dir — including credential files written
|
||||
# by hive-c0re before the container was built — to this user on
|
||||
# every boot, so agent processes can always read their own tokens.
|
||||
users.users.${userName} = {
|
||||
isNormalUser = true;
|
||||
home = homeDir;
|
||||
createHome = true;
|
||||
group = userName;
|
||||
extraGroups = lib.optional config.hyperhive.user.passwordlessSudo "wheel";
|
||||
# Matches /bin/bash on NixOS — the harness's claude shell-outs
|
||||
# expect a POSIX shell at $SHELL; bashInteractive is already
|
||||
# the system default for the root user too.
|
||||
shell = pkgs.bashInteractive;
|
||||
}
|
||||
// lib.optionalAttrs (config.hyperhive.user.uid != null) {
|
||||
uid = config.hyperhive.user.uid;
|
||||
};
|
||||
users.groups.${userName} =
|
||||
{ }
|
||||
// lib.optionalAttrs (config.hyperhive.user.gid != null) {
|
||||
gid = config.hyperhive.user.gid;
|
||||
};
|
||||
|
||||
# `NOPASSWD: ALL` for the agent user. Lets claude's Bash tool
|
||||
# keep working with anything that expected root (systemctl,
|
||||
# nix-env, etc.) without prompting. Flip
|
||||
# `hyperhive.user.passwordlessSudo = false` to drop both
|
||||
# the wheel-group membership and this sudoers entry; anything
|
||||
# that needs root then fails loudly instead of silently
|
||||
# succeeding.
|
||||
security.sudo.extraRules = lib.mkIf config.hyperhive.user.passwordlessSudo [
|
||||
{
|
||||
users = [ userName ];
|
||||
commands = [
|
||||
{
|
||||
command = "ALL";
|
||||
options = [ "NOPASSWD" ];
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
# First-boot migration to the per-agent unix user — creates the
|
||||
# home dir, chowns the bind-mounted state + `~/.claude/`, and
|
||||
# (marker-guarded) moves any leftover `/root/.claude` content
|
||||
# from the previous root-run shape. See
|
||||
# `docs/persistence.md::First-boot agent-user migration` for the
|
||||
# step-by-step rationale; this script implements it.
|
||||
system.activationScripts.hive-agent-user-migrate = lib.stringAfter [ "users" "specialfs" ] ''
|
||||
homeDir=${lib.escapeShellArg homeDir}
|
||||
userName=${lib.escapeShellArg userName}
|
||||
mkdir -p "$homeDir"
|
||||
chown "$userName:$userName" "$homeDir"
|
||||
marker=/var/lib/hive-agent-user-migrated
|
||||
if [ ! -e "$marker" ] && [ -d /root/.claude ] && [ "$(ls -A /root/.claude 2>/dev/null)" ]; then
|
||||
mkdir -p "$homeDir/.claude"
|
||||
if cp -an /root/.claude/. "$homeDir/.claude/" 2>/dev/null; then
|
||||
rm -rf /root/.claude
|
||||
echo "hive-agent-user-migrate: moved /root/.claude → $homeDir/.claude"
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$(dirname "$marker")"
|
||||
: > "$marker"
|
||||
# Scope state + harness chowns to THIS container's own dirs only.
|
||||
# The glob `/agents/*/state` also matches child-agent state dirs that
|
||||
# are bind-mounted into parent containers, which would clobber the
|
||||
# ownership those dirs' own activation scripts set — producing
|
||||
# intermittent EACCES for the child agent's harness between a parent
|
||||
# rebuild and the child's next activation. Config dirs are kept broad
|
||||
# because the parent legitimately owns child proposed-config repos.
|
||||
if [ -d "/agents/$userName/state" ]; then
|
||||
chown -hR "$userName:$userName" "/agents/$userName/state" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "/agents/$userName/harness" ]; then
|
||||
chown -hR "$userName:$userName" "/agents/$userName/harness" 2>/dev/null || true
|
||||
fi
|
||||
# The proposed-config repo is RW-mounted into the editing (parent/
|
||||
# manager) agent and owned by it; hive-c0re only pulls from it. Heal
|
||||
# it to this user too — same as state/harness. In an agent's own
|
||||
# container its config is RO-mounted, so the chown there just fails
|
||||
# harmlessly (|| true).
|
||||
for configDir in /agents/*/config; do
|
||||
[ -d "$configDir" ] || continue
|
||||
chown -hR "$userName:$userName" "$configDir" 2>/dev/null || true
|
||||
done
|
||||
if [ -d "$homeDir/.claude" ]; then
|
||||
chown -hR "$userName:$userName" "$homeDir/.claude" 2>/dev/null || true
|
||||
# 0755 so hive-core (a different unix user) can list the dir and
|
||||
# detect a valid claude session. Credential files inside are 0600
|
||||
# so secrets stay private regardless of the directory mode.
|
||||
# ensure_claude_dir sets 0755 on creation but cannot re-chmod after
|
||||
# hive-agent-user-migrate chowns the dir to the agent user; this
|
||||
# activation script runs as root and handles the correction.
|
||||
chmod 755 "$homeDir/.claude" 2>/dev/null || true
|
||||
fi
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ in
|
|||
{
|
||||
# Optional Weston (Wayland compositor) with the VNC backend,
|
||||
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported
|
||||
# from harness-base.nix so every sub-agent + the manager sees the
|
||||
# from ./default.nix so every sub-agent + the manager sees the
|
||||
# option; only those that flip it on get the service.
|
||||
#
|
||||
# Port allocation, weston bind-address quirk, PAM service name, the
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
# Entry-point for the privileged root agent (ruth). Referenced from
|
||||
# `flake.nix` (`nixosConfigurations.ruth`) and the meta-flake's
|
||||
# `applied/ruth/flake.nix`.
|
||||
imports = [ ./harness-base.nix ];
|
||||
imports = [ ./harness ];
|
||||
|
||||
# The root/manager bootstraps a fresh hive, so it gets the hyperhive
|
||||
# reference docs made available by default (readable at
|
||||
|
|
|
|||
Loading…
Reference in a new issue