refactor: nix/host-modules + nix/agent-modules layout, update doc paths
This commit is contained in:
parent
cb755b677c
commit
4a48ce5024
52 changed files with 48 additions and 44 deletions
376
nix/agent-modules/claude-settings.nix
Normal file
376
nix/agent-modules/claude-settings.nix
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# Everything that shapes claude-code's own configuration inside the
|
||||
# container: the managed settings json (base env + OTEL), the
|
||||
# onboarding/trust seed, the runtime OTEL auth-header injection, and
|
||||
# the plugin/marketplace install lists the harness reads at boot.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
userName = config.hyperhive.user.name;
|
||||
homeDir = "/home/${userName}";
|
||||
# Hive-wide OpenTelemetry config (host-driven; baked in per-agent by
|
||||
# meta.rs `otel_config`).
|
||||
otelCfg = config.hyperhive.otel;
|
||||
# Hive/swarm display names are forwarded into each agent's build by
|
||||
# meta.rs as `environment.variables` (per-agent, build-time strings),
|
||||
# so they can be baked into the resource attributes below without a
|
||||
# runtime shell. Absent (option unset) → "unknown".
|
||||
hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown";
|
||||
swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown";
|
||||
# Base claude-code environment applied to every agent regardless of OTEL.
|
||||
# Shipped via the managed settings `env` block so claude and `hivectl
|
||||
# choom` both inherit them without a launch wrapper.
|
||||
baseClaudeEnv = {
|
||||
# Suppress analytics, survey pings, and other non-essential outbound
|
||||
# traffic — agents are headless and don't need any of that.
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
DO_NOT_TRACK = "1";
|
||||
# Disable claude's self-update machinery; package management is nix's job.
|
||||
DISABLE_AUTOUPDATER = "1";
|
||||
DISABLE_UPDATES = "1";
|
||||
# Keep plugin updates synchronized on install; prefer HTTPS for fetches.
|
||||
CLAUDE_CODE_PLUGIN_PREFER_HTTPS = "1";
|
||||
CLAUDE_CODE_SYNC_PLUGIN_INSTALL = "1";
|
||||
FORCE_AUTOUPDATE_PLUGINS = "1";
|
||||
# Suppress the "install GitHub app" prompt — not applicable in-hive.
|
||||
DISABLE_INSTALL_GITHUB_APP_COMMAND = "1";
|
||||
# Disable Anthropic's hosted claude.ai MCP servers; the hive supplies its own.
|
||||
ENABLE_CLAUDEAI_MCP_SERVERS = "0";
|
||||
# Resume an interrupted turn on reconnect (recovers from transient MCP flaps).
|
||||
CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1";
|
||||
# Use the simpler system prompt variant suited to headless operation.
|
||||
CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1";
|
||||
# Tag remote-control sessions with "<hive>-<agent>" for identification.
|
||||
CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX = "${hiveDisplayName}-${userName}";
|
||||
};
|
||||
# OTEL environment Claude Code reads to export metrics/logs/traces.
|
||||
# Shipped via the managed claude settings json (below), which claude
|
||||
# auto-discovers for BOTH the harness turn-loop and `hivectl choom` —
|
||||
# so telemetry parity is declarative, with no launch wrapper. The
|
||||
# auth header (`otel.headersCredential`) is deliberately NOT included
|
||||
# here: it's a secret and this file lives in the world-readable nix
|
||||
# store. It's injected at *runtime* into the agent's `0600`
|
||||
# `~/.claude/settings.json` by the `hive-otel-header` oneshot below
|
||||
# (claude merges the `env` from the user settings on top of these
|
||||
# managed ones), so the token is read from disk at start and never
|
||||
# touches the store.
|
||||
otelSettingsEnv = {
|
||||
CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
||||
# Attach feedback-survey data to the OTEL pipeline.
|
||||
CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL = "1";
|
||||
OTEL_METRICS_EXPORTER = "otlp";
|
||||
OTEL_LOGS_EXPORTER = "otlp";
|
||||
OTEL_TRACES_EXPORTER = "otlp";
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL = otelCfg.protocol;
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT = otelCfg.endpoint;
|
||||
# Force CUMULATIVE temporality — Claude Code defaults to DELTA,
|
||||
# which Prometheus/Mimir-family backends (incl. grafana-lgtm)
|
||||
# silently drop without a deltatocumulative processor.
|
||||
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative";
|
||||
OTEL_RESOURCE_ATTRIBUTES =
|
||||
"service.name=hyperhive-agent,agent=${userName},hive=${hiveDisplayName},swarm=${swarmDisplayName}"
|
||||
+ lib.optionalString (otelCfg.extraResourceAttributes != "") ",${otelCfg.extraResourceAttributes}";
|
||||
# Include the Claude Code version label in emitted metrics.
|
||||
OTEL_METRICS_INCLUDE_VERSION = "1";
|
||||
}
|
||||
// lib.optionalAttrs (otelCfg.metricIntervalMs != null) {
|
||||
OTEL_METRIC_EXPORT_INTERVAL = toString otelCfg.metricIntervalMs;
|
||||
};
|
||||
in
|
||||
{
|
||||
# OTEL stats export is configured ONCE at host level via
|
||||
# `services.hyperhive.otel.*` (see nix/host-modules/hive-c0re.nix) and
|
||||
# injected into every agent's build by the meta-flake renderer
|
||||
# (`hive-c0re/src/meta.rs::otel_config`). These per-agent options are
|
||||
# the build-time implementation surface that injection writes into;
|
||||
# they are not meant to be set directly in an agent.nix. Marked
|
||||
# `internal` so the host option is the only documented operator knob.
|
||||
options.hyperhive.otel = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
internal = true;
|
||||
description = ''
|
||||
Export this agent's Claude Code stats (token usage, cost, tool
|
||||
calls) to an OTLP endpoint via Claude Code's built-in
|
||||
OpenTelemetry. Each agent's harness exports directly to the
|
||||
collector, so it keeps working even when hive-c0re is down.
|
||||
Host-driven: set `services.hyperhive.otel.enable` instead.
|
||||
'';
|
||||
};
|
||||
|
||||
endpoint = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = ''
|
||||
OTLP collector endpoint, set as `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||
Host-driven via `services.hyperhive.otel.endpoint`.
|
||||
'';
|
||||
};
|
||||
|
||||
protocol = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"http/protobuf"
|
||||
"http/json"
|
||||
"grpc"
|
||||
];
|
||||
default = "http/protobuf";
|
||||
internal = true;
|
||||
description = ''
|
||||
OTLP wire protocol, set as `OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||
Host-driven via `services.hyperhive.otel.protocol`.
|
||||
'';
|
||||
};
|
||||
|
||||
headersCredential = lib.mkOption {
|
||||
# `str`, not `path`: a `path`-typed *relative* literal (e.g.
|
||||
# `./otel-headers`) is hash-copied into the world-readable nix store
|
||||
# at eval time, which would defeat the whole point of this option.
|
||||
# Keep it a string and require an absolute runtime path so the secret
|
||||
# is only ever read from disk by systemd at start, never nix-stored.
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
internal = true;
|
||||
description = ''
|
||||
Absolute path to an operator-provided secret file whose contents
|
||||
become `OTEL_EXPORTER_OTLP_HEADERS` (e.g.
|
||||
`Authorization=Bearer <token>`). Host-driven via
|
||||
`services.hyperhive.otel.headersCredential`.
|
||||
|
||||
The rest of the OTEL config ships in the world-readable managed
|
||||
claude settings json, but the header is a secret, so it's handled
|
||||
separately: hive-c0re forwards this file into the container's
|
||||
systemd credential store, and the `hive-otel-header` oneshot
|
||||
reads it at runtime (`LoadCredential`) and writes it into the
|
||||
agent's `0600` `~/.claude/settings.json` `env` block. The token
|
||||
is read from disk at start and never copied into the nix store or
|
||||
the world-readable settings file.
|
||||
'';
|
||||
};
|
||||
|
||||
extraResourceAttributes = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
internal = true;
|
||||
description = ''
|
||||
Extra comma-separated entries appended to
|
||||
`OTEL_RESOURCE_ATTRIBUTES` after the built-in
|
||||
`service.name` / `agent` / `hive` / `swarm` labels.
|
||||
Host-driven via `services.hyperhive.otel.extraResourceAttributes`.
|
||||
'';
|
||||
};
|
||||
|
||||
metricIntervalMs = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.ints.positive;
|
||||
default = null;
|
||||
internal = true;
|
||||
description = ''
|
||||
Metric export interval in milliseconds, set as
|
||||
`OTEL_METRIC_EXPORT_INTERVAL`. Null leaves Claude Code's 60s
|
||||
default. Host-driven via `services.hyperhive.otel.metricIntervalMs`.
|
||||
'';
|
||||
};
|
||||
|
||||
debug = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
internal = true;
|
||||
description = ''
|
||||
Emit OTEL SDK diagnostics to stderr (`CLAUDE_CODE_OTEL_DIAG_STDERR=1`).
|
||||
Host-driven via `services.hyperhive.otel.debug`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
options.hyperhive.claudeMarketplaces = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "anthropics/claude-plugins-official" ];
|
||||
example = [
|
||||
"anthropics/claude-plugins-official"
|
||||
"anthropics/claude-plugins-community"
|
||||
];
|
||||
description = ''
|
||||
Claude Code plugin marketplaces to add at harness boot. Each
|
||||
entry is passed to `claude plugin marketplace add <source>`
|
||||
(`owner/repo`, full git URL, or local path). Idempotent —
|
||||
re-adding an existing marketplace is treated as success.
|
||||
Required before `hyperhive.claudePlugins` entries that
|
||||
reference a marketplace (e.g. `foo@claude-plugins-official`).
|
||||
Rendered to `/etc/hyperhive/claude-marketplaces.json`.
|
||||
|
||||
Defaults to Anthropic's official marketplace; agents get it
|
||||
out of the box without any per-agent.nix wiring.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.claudePlugins = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [
|
||||
"formatter@my-marketplace"
|
||||
"thinking-tools@anthropics"
|
||||
];
|
||||
description = ''
|
||||
Claude Code plugins to install at harness boot. Each entry is
|
||||
passed verbatim to `claude plugin install <spec>` once per
|
||||
container start, before the turn loop opens. `claude plugin
|
||||
install` is expected to be idempotent, so reinstalling on every
|
||||
boot is cheap. Failures log a warning but do not abort boot — a
|
||||
missing plugin is preferable to a non-serving agent. Rendered to
|
||||
`/etc/hyperhive/claude-plugins.json`; the harness reads it via
|
||||
`plugins::install_configured`.
|
||||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.claudePluginsAutoUpdate = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
When true, the harness runs `claude plugin marketplace update`
|
||||
before installing plugins at boot, pulling the latest index from
|
||||
all configured marketplaces. Disabled by default — most agents
|
||||
want pinned plugin versions and the network round-trip adds to
|
||||
boot time. Enable for agents that should always install the latest
|
||||
available version of their plugins.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
environment.etc."hyperhive/claude-plugins.json".text =
|
||||
builtins.toJSON config.hyperhive.claudePlugins;
|
||||
|
||||
environment.etc."hyperhive/claude-marketplaces.json".text =
|
||||
builtins.toJSON config.hyperhive.claudeMarketplaces;
|
||||
|
||||
environment.etc."hyperhive/claude-plugins-auto-update.json".text =
|
||||
builtins.toJSON config.hyperhive.claudePluginsAutoUpdate;
|
||||
|
||||
# Hive-enforced claude settings. claude-code auto-discovers managed
|
||||
# settings at this canonical Linux path (precedence #1, read-only,
|
||||
# un-overridable by user/project/CLI) — so the harness doesn't pass
|
||||
# `--settings` or copy the blob per turn. effortLevel is
|
||||
# deliberately NOT shipped here: effort is controlled live via the
|
||||
# `--effort` CLI flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider),
|
||||
# which managed scope would otherwise override and lock.
|
||||
# Hive-enforced settings merged with a per-agent `env` block at BUILD
|
||||
# time via `jq` (not eval-time `readFile`, which would be import-from-
|
||||
# derivation). The `env` block is always present: `baseClaudeEnv` sets
|
||||
# behaviour flags and the remote-control session prefix for every agent;
|
||||
# `otelSettingsEnv` is merged on top when OTEL is enabled. claude-code
|
||||
# auto-discovers this file in every context (harness turn-loop AND
|
||||
# `hivectl choom`) so no launch wrapper is needed.
|
||||
environment.etc."claude-code/managed-settings.json".source =
|
||||
let
|
||||
baseSettings = "${config.hyperhive.packages.assets}/share/hyperhive/prompts/claude-settings.json";
|
||||
# Merge base env (always) with OTEL env (when enabled). jq is always
|
||||
# run — `baseClaudeEnv` contains per-agent values (e.g.
|
||||
# CLAUDE_REMOTE_CONTROL_SESSION_NAME_PREFIX) that can't live in the
|
||||
# static store asset.
|
||||
allEnv =
|
||||
baseClaudeEnv
|
||||
// lib.optionalAttrs otelCfg.enable otelSettingsEnv
|
||||
// lib.optionalAttrs (otelCfg.enable && otelCfg.debug) {
|
||||
# SDK diagnostics — noisy; only on when services.hyperhive.otel.debug = true.
|
||||
CLAUDE_CODE_OTEL_DIAG_STDERR = "1";
|
||||
};
|
||||
in
|
||||
pkgs.runCommand "managed-settings.json" { nativeBuildInputs = [ pkgs.jq ]; } ''
|
||||
jq --argjson env ${lib.escapeShellArg (builtins.toJSON allEnv)} \
|
||||
'. + { env: $env }' ${baseSettings} > "$out"
|
||||
'';
|
||||
|
||||
# Inject the OTEL auth header (a secret) into the agent's *user*
|
||||
# claude settings at runtime, keeping it out of the world-readable
|
||||
# managed settings json above and out of the nix store entirely.
|
||||
# hive-c0re forwards the operator's `headersCredential` file into
|
||||
# this container's systemd credential store; this oneshot reads it
|
||||
# via `LoadCredential` at start and merges `OTEL_EXPORTER_OTLP_HEADERS`
|
||||
# into `~/.claude/settings.json` (0600, agent-owned). claude layers
|
||||
# the user `env` on top of the managed one, so both the harness
|
||||
# turn-loop and `hivectl choom` (same agent user) pick it up. Ordering
|
||||
# is best-effort (`before`, not a hard dep): if it fails the harness
|
||||
# still starts and telemetry just exports unauthenticated.
|
||||
systemd.services.hive-otel-header =
|
||||
lib.mkIf (config.hyperhive.otel.enable && config.hyperhive.otel.headersCredential != null)
|
||||
{
|
||||
description = "Inject the OTEL auth header into the agent's claude user settings";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-ag3nt.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
LoadCredential = [ "otel-headers" ];
|
||||
ExecStart = pkgs.writeShellScript "hive-otel-header" ''
|
||||
set -eu
|
||||
umask 077
|
||||
hdr="$CREDENTIALS_DIRECTORY/otel-headers"
|
||||
[ -r "$hdr" ] || exit 0
|
||||
dir=${homeDir}/.claude
|
||||
settings="$dir/settings.json"
|
||||
mkdir -p "$dir"
|
||||
base='{}'
|
||||
[ -s "$settings" ] && base="$(cat "$settings")"
|
||||
printf '%s' "$base" | ${pkgs.jq}/bin/jq \
|
||||
--rawfile h "$hdr" \
|
||||
'.env = ((.env // {}) + { OTEL_EXPORTER_OTLP_HEADERS: ($h | rtrimstr("\n")) })' \
|
||||
> "$settings.tmp"
|
||||
mv "$settings.tmp" "$settings"
|
||||
chmod 0600 "$settings"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# Seed claude's onboarding + per-project trust state once. claude only
|
||||
# marks `hasCompletedOnboarding` (global) and the project trust dialog
|
||||
# as accepted when run *interactively*; the harness only ever runs it
|
||||
# headless (`--print`) and `claude auth login` doesn't set them either.
|
||||
# So the first interactive launch (`hivectl choom`) would drop the
|
||||
# operator into the onboarding/trust walkthrough despite valid OAuth
|
||||
# creds. This oneshot is the single place hyperhive touches
|
||||
# `~/.claude.json`: it runs before the harness (so nothing races it),
|
||||
# is idempotent (skips when the flags are already set), and is
|
||||
# best-effort (`before`, not a hard dep — a failed seed leaves the file
|
||||
# untouched and the harness still starts). Credentials live in the
|
||||
# separate `~/.claude/.credentials.json`, so this never touches secrets.
|
||||
systemd.services.hive-claude-onboarding = {
|
||||
description = "Seed claude onboarding + project-trust so choom skips the walkthrough";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-ag3nt.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
ExecStart = pkgs.writeShellScript "hive-claude-onboarding" ''
|
||||
set -eu
|
||||
umask 077
|
||||
cfg=${homeDir}/.claude.json
|
||||
dir=/agents/${userName}/state
|
||||
base='{}'
|
||||
[ -s "$cfg" ] && base="$(cat "$cfg")"
|
||||
# Idempotent: nothing to do when already onboarded + trusted.
|
||||
if printf '%s' "$base" | ${pkgs.jq}/bin/jq -e \
|
||||
--arg d "$dir" \
|
||||
'.hasCompletedOnboarding == true and (.projects[$d].hasTrustDialogAccepted == true)' \
|
||||
>/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$base" | ${pkgs.jq}/bin/jq \
|
||||
--arg d "$dir" \
|
||||
'.hasCompletedOnboarding = true
|
||||
| .projects[$d].hasTrustDialogAccepted = true
|
||||
| .projects[$d].hasCompletedProjectOnboarding = true' \
|
||||
> "$cfg.tmp"
|
||||
mv "$cfg.tmp" "$cfg"
|
||||
chmod 0600 "$cfg"
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue