* feat: nix flake, uv2nix build, dev shell and home manager * fixed nix run, updated docs for setup * feat(nix): NixOS module with persistent container mode, managed guards, checks - Replace homeModules.nix with nixosModules.nix (two deployment modes) - Mode A (native): hardened systemd service with ProtectSystem=strict - Mode B (container): persistent Ubuntu container with /nix/store bind-mount, identity-hash-based recreation, GC root protection, symlink-based updates - Add HERMES_MANAGED guards blocking CLI config mutation (config set, setup, gateway install/uninstall) when running under NixOS module - Add nix/checks.nix with build-time verification (binary, CLI, managed guard) - Remove container.nix (no Nix-built OCI image; pulls ubuntu:24.04 at runtime) - Simplify packages.nix (drop fetchFromGitHub submodules, PYTHONPATH wrappers) - Rewrite docs/nixos-setup.md with full options reference, container architecture, secrets management, and troubleshooting guide Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update config.py * feat(nix): add CI workflow and enhanced build checks - GitHub Actions workflow for nix flake check + build on linux/macOS - Entry point sync check to catch pyproject.toml drift - Expanded managed-guard check to cover config edit - Wrap hermes-acp binary in Nix package - Fix Path type mismatch in is_managed() * Update MCP server package name; bundled skills support * fix reading .env. instead have container user a common mounted .env file * feat(nix): container entrypoint with privilege drop and sudo provisioning Container was running as non-root via --user, which broke apt/pip installs and caused crashes when $HOME didn't exist. Replace --user with a Nix-built entrypoint script that provisions the hermes user, sudo (NOPASSWD), and /home/hermes inside the container on first boot, then drops privileges via setpriv. Writable layer persists so setup only runs once. Also expands MCP server options to support HTTP transport and sampling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix group and user creation in container mode * feat(nix): persistent /home/hermes and MESSAGING_CWD in container mode Container mode now bind-mounts ${stateDir}/home to /home/hermes so the agent's home directory survives container recreation. Previously it lived in the writable layer and was lost on image/volume/options changes. Also passes MESSAGING_CWD to the container so the agent finds its workspace and documents, matching native mode behavior. Other changes: - Extract containerDataDir/containerHomeDir bindings (no more magic strings) - Fix entrypoint chown to run unconditionally (volume mounts always exist) - Add schema field to container identity hash for auto-recreation - Add idempotency test (Scenario G) to config-roundtrip check * docs: add Nix & NixOS setup guide to docs site Add comprehensive Nix documentation to the Docusaurus site at website/docs/getting-started/nix-setup.md, covering nix run/profile install, NixOS module (native + container modes), declarative settings, secrets management, MCP servers, managed mode, container architecture, dev shell, flake checks, and full options reference. - Register nix-setup in sidebar after installation page - Add Nix callout tip to installation.md linking to new guide - Add canonical version pointer in docs/nixos-setup.md * docs: remove docs/nixos-setup.md, consolidate into website docs Backfill missing details (restart/restartSec in full example, gateway.pid, 0750 permissions, docker inspect commands) into the canonical website/docs/getting-started/nix-setup.md and delete the old standalone file. * fix(nix): add compression.protect_last_n and target_ratio to config-keys.json New keys were added to DEFAULT_CONFIG on main, causing the config-drift check to fail in CI. * fix(nix): skip checks on aarch64-darwin (onnxruntime wheel missing) The full Python venv includes onnxruntime (via faster-whisper/STT) which lacks a compatible uv2nix wheel on aarch64-darwin. Gate all checks behind stdenv.hostPlatform.isLinux. The package and devShell still evaluate on macOS. * fix(nix): skip flake check and build on macOS CI onnxruntime (transitive dep via faster-whisper) lacks a compatible uv2nix wheel on aarch64-darwin. Run full checks and build on Linux only; macOS CI verifies the flake evaluates without building. * fix(nix): preserve container writable layer across nixos-rebuild The container identity hash included the entrypoint's Nix store path, which changes on every nixpkgs update (due to runtimeShell/stdenv input-addressing). This caused false-positive identity mismatches, triggering container recreation and losing the persistent writable layer. - Use stable symlink (current-entrypoint) like current-package already does - Remove entrypoint from identity hash (only image/volumes/options matter) - Add GC root for entrypoint so nix-collect-garbage doesn't break it - Remove global HERMES_HOME env var from addToSystemPackages (conflicted with interactive CLI use, service already sets its own) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
717 lines
30 KiB
Nix
717 lines
30 KiB
Nix
# nix/nixosModules.nix — NixOS module for hermes-agent
|
|
#
|
|
# Two modes:
|
|
# container.enable = false (default) → native systemd service
|
|
# container.enable = true → OCI container (persistent writable layer)
|
|
#
|
|
# Container mode: hermes runs from /nix/store bind-mounted read-only into a
|
|
# plain Ubuntu container. The writable layer (apt/pip/npm installs) persists
|
|
# across restarts and agent updates. Only image/volume/options changes trigger
|
|
# container recreation. Environment variables are written to $HERMES_HOME/.env
|
|
# and read by hermes at startup — no container recreation needed for env changes.
|
|
#
|
|
# Usage:
|
|
# services.hermes-agent = {
|
|
# enable = true;
|
|
# settings.model = "anthropic/claude-sonnet-4";
|
|
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
|
|
# };
|
|
#
|
|
{ inputs, ... }: {
|
|
flake.nixosModules.default = { config, lib, pkgs, ... }:
|
|
|
|
let
|
|
cfg = config.services.hermes-agent;
|
|
hermes-agent = inputs.self.packages.${pkgs.system}.default;
|
|
|
|
# Deep-merge config type (from 0xrsydn/nix-hermes-agent)
|
|
deepConfigType = lib.types.mkOptionType {
|
|
name = "hermes-config-attrs";
|
|
description = "Hermes YAML config (attrset), merged deeply via lib.recursiveUpdate.";
|
|
check = builtins.isAttrs;
|
|
merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs);
|
|
};
|
|
|
|
# Generate config.yaml from Nix attrset (YAML is a superset of JSON)
|
|
configJson = builtins.toJSON cfg.settings;
|
|
generatedConfigFile = pkgs.writeText "hermes-config.yaml" configJson;
|
|
configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
|
|
|
|
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
|
|
|
|
# Generate .env from non-secret environment attrset
|
|
envFileContent = lib.concatStringsSep "\n" (
|
|
lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment
|
|
);
|
|
# Build documents derivation (from 0xrsydn)
|
|
documentDerivation = pkgs.runCommand "hermes-documents" { } (
|
|
''
|
|
mkdir -p $out
|
|
'' + lib.concatStringsSep "\n" (
|
|
lib.mapAttrsToList (name: value:
|
|
if builtins.isPath value || lib.isStorePath value
|
|
then "cp ${value} $out/${name}"
|
|
else "cat > $out/${name} <<'HERMES_DOC_EOF'\n${value}\nHERMES_DOC_EOF"
|
|
) cfg.documents
|
|
)
|
|
);
|
|
|
|
containerName = "hermes-agent";
|
|
containerDataDir = "/data"; # stateDir mount point inside container
|
|
containerHomeDir = "/home/hermes";
|
|
|
|
# ── Container mode helpers ──────────────────────────────────────────
|
|
containerBin = if cfg.container.backend == "docker"
|
|
then "${pkgs.docker}/bin/docker"
|
|
else "${pkgs.podman}/bin/podman";
|
|
|
|
# Runs as root inside the container on every start. Provisions the
|
|
# hermes user + sudo on first boot (writable layer persists), then
|
|
# drops privileges. Supports arbitrary base images (Debian, Alpine, etc).
|
|
containerEntrypoint = pkgs.writeShellScript "hermes-container-entrypoint" ''
|
|
set -eu
|
|
|
|
HERMES_UID="''${HERMES_UID:?HERMES_UID must be set}"
|
|
HERMES_GID="''${HERMES_GID:?HERMES_GID must be set}"
|
|
|
|
# ── Group: ensure a group with GID=$HERMES_GID exists ──
|
|
# Check by GID (not name) to avoid collisions with pre-existing groups
|
|
# (e.g. GID 100 = "users" on Ubuntu)
|
|
EXISTING_GROUP=$(getent group "$HERMES_GID" 2>/dev/null | cut -d: -f1 || true)
|
|
if [ -n "$EXISTING_GROUP" ]; then
|
|
GROUP_NAME="$EXISTING_GROUP"
|
|
else
|
|
GROUP_NAME="hermes"
|
|
if command -v groupadd >/dev/null 2>&1; then
|
|
groupadd -g "$HERMES_GID" "$GROUP_NAME"
|
|
elif command -v addgroup >/dev/null 2>&1; then
|
|
addgroup -g "$HERMES_GID" "$GROUP_NAME" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# ── User: ensure a user with UID=$HERMES_UID exists ──
|
|
PASSWD_ENTRY=$(getent passwd "$HERMES_UID" 2>/dev/null || true)
|
|
if [ -n "$PASSWD_ENTRY" ]; then
|
|
TARGET_USER=$(echo "$PASSWD_ENTRY" | cut -d: -f1)
|
|
TARGET_HOME=$(echo "$PASSWD_ENTRY" | cut -d: -f6)
|
|
else
|
|
TARGET_USER="hermes"
|
|
TARGET_HOME="/home/hermes"
|
|
if command -v useradd >/dev/null 2>&1; then
|
|
useradd -u "$HERMES_UID" -g "$HERMES_GID" -m -d "$TARGET_HOME" -s /bin/bash "$TARGET_USER"
|
|
elif command -v adduser >/dev/null 2>&1; then
|
|
adduser -u "$HERMES_UID" -D -h "$TARGET_HOME" -s /bin/sh -G "$GROUP_NAME" "$TARGET_USER" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
mkdir -p "$TARGET_HOME"
|
|
chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME"
|
|
|
|
# Ensure HERMES_HOME is owned by the target user
|
|
if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then
|
|
chown -R "$HERMES_UID:$HERMES_GID" "$HERMES_HOME"
|
|
fi
|
|
|
|
# Install sudo on Debian/Ubuntu if missing (first boot only, cached in writable layer)
|
|
if command -v apt-get >/dev/null 2>&1 && ! command -v sudo >/dev/null 2>&1; then
|
|
apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq sudo >/dev/null 2>&1 || true
|
|
fi
|
|
if command -v sudo >/dev/null 2>&1 && [ ! -f /etc/sudoers.d/hermes ]; then
|
|
mkdir -p /etc/sudoers.d
|
|
echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hermes
|
|
chmod 0440 /etc/sudoers.d/hermes
|
|
fi
|
|
|
|
if command -v setpriv >/dev/null 2>&1; then
|
|
exec setpriv --reuid="$HERMES_UID" --regid="$HERMES_GID" --init-groups "$@"
|
|
elif command -v su >/dev/null 2>&1; then
|
|
exec su -s /bin/sh "$TARGET_USER" -c 'exec "$0" "$@"' -- "$@"
|
|
else
|
|
echo "WARNING: no privilege-drop tool (setpriv/su), running as root" >&2
|
|
exec "$@"
|
|
fi
|
|
'';
|
|
|
|
# Identity hash — only recreate container when structural config changes.
|
|
# Package and entrypoint use stable symlinks (current-package, current-entrypoint)
|
|
# so they can update without recreation. Env vars go through $HERMES_HOME/.env.
|
|
containerIdentity = builtins.hashString "sha256" (builtins.toJSON {
|
|
schema = 3; # bump when identity inputs change
|
|
image = cfg.container.image;
|
|
extraVolumes = cfg.container.extraVolumes;
|
|
extraOptions = cfg.container.extraOptions;
|
|
});
|
|
|
|
identityFile = "${cfg.stateDir}/.container-identity";
|
|
|
|
# Default: /var/lib/hermes/workspace → /data/workspace.
|
|
# Custom paths outside stateDir pass through unchanged (user must add extraVolumes).
|
|
containerWorkDir =
|
|
if lib.hasPrefix "${cfg.stateDir}/" cfg.workingDirectory
|
|
then "${containerDataDir}/${lib.removePrefix "${cfg.stateDir}/" cfg.workingDirectory}"
|
|
else cfg.workingDirectory;
|
|
|
|
in {
|
|
options.services.hermes-agent = with lib; {
|
|
enable = mkEnableOption "Hermes Agent gateway service";
|
|
|
|
# ── Package ──────────────────────────────────────────────────────────
|
|
package = mkOption {
|
|
type = types.package;
|
|
default = hermes-agent;
|
|
description = "The hermes-agent package to use.";
|
|
};
|
|
|
|
# ── Service identity ─────────────────────────────────────────────────
|
|
user = mkOption {
|
|
type = types.str;
|
|
default = "hermes";
|
|
description = "System user running the gateway.";
|
|
};
|
|
|
|
group = mkOption {
|
|
type = types.str;
|
|
default = "hermes";
|
|
description = "System group running the gateway.";
|
|
};
|
|
|
|
createUser = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = "Create the user/group automatically.";
|
|
};
|
|
|
|
# ── Directories ──────────────────────────────────────────────────────
|
|
stateDir = mkOption {
|
|
type = types.str;
|
|
default = "/var/lib/hermes";
|
|
description = "State directory. Contains .hermes/ subdir (HERMES_HOME).";
|
|
};
|
|
|
|
workingDirectory = mkOption {
|
|
type = types.str;
|
|
default = "${cfg.stateDir}/workspace";
|
|
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
|
|
description = "Working directory for the agent (MESSAGING_CWD).";
|
|
};
|
|
|
|
# ── Declarative config ───────────────────────────────────────────────
|
|
configFile = mkOption {
|
|
type = types.nullOr types.path;
|
|
default = null;
|
|
description = ''
|
|
Path to an existing config.yaml. If set, takes precedence over
|
|
the declarative `settings` option.
|
|
'';
|
|
};
|
|
|
|
settings = mkOption {
|
|
type = deepConfigType;
|
|
default = { };
|
|
description = ''
|
|
Declarative Hermes config (attrset). Deep-merged across module
|
|
definitions and rendered as config.yaml.
|
|
'';
|
|
example = literalExpression ''
|
|
{
|
|
model = "anthropic/claude-sonnet-4";
|
|
terminal.backend = "local";
|
|
compression = { enabled = true; threshold = 0.85; };
|
|
toolsets = [ "all" ];
|
|
}
|
|
'';
|
|
};
|
|
|
|
# ── Secrets / environment ────────────────────────────────────────────
|
|
environmentFiles = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
description = ''
|
|
Paths to environment files containing secrets (API keys, tokens).
|
|
Contents are merged into $HERMES_HOME/.env at activation time.
|
|
Hermes reads this file on every startup via load_hermes_dotenv().
|
|
'';
|
|
};
|
|
|
|
environment = mkOption {
|
|
type = types.attrsOf types.str;
|
|
default = { };
|
|
description = ''
|
|
Non-secret environment variables. Merged into $HERMES_HOME/.env
|
|
at activation time. Do NOT put secrets here — use environmentFiles.
|
|
'';
|
|
};
|
|
|
|
authFile = mkOption {
|
|
type = types.nullOr types.path;
|
|
default = null;
|
|
description = ''
|
|
Path to an auth.json seed file (OAuth credentials).
|
|
Only copied on first deploy — existing auth.json is preserved.
|
|
'';
|
|
};
|
|
|
|
authFileForceOverwrite = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Always overwrite auth.json from authFile on activation.";
|
|
};
|
|
|
|
# ── Documents ────────────────────────────────────────────────────────
|
|
documents = mkOption {
|
|
type = types.attrsOf (types.either types.str types.path);
|
|
default = { };
|
|
description = ''
|
|
Workspace files (SOUL.md, USER.md, etc.). Keys are filenames,
|
|
values are inline strings or paths. Installed into workingDirectory.
|
|
'';
|
|
example = literalExpression ''
|
|
{
|
|
"SOUL.md" = "You are a helpful AI assistant.";
|
|
"USER.md" = ./documents/USER.md;
|
|
}
|
|
'';
|
|
};
|
|
|
|
# ── MCP Servers ──────────────────────────────────────────────────────
|
|
mcpServers = mkOption {
|
|
type = types.attrsOf (types.submodule {
|
|
options = {
|
|
# Stdio transport
|
|
command = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
description = "MCP server command (stdio transport).";
|
|
};
|
|
args = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
description = "Command-line arguments (stdio transport).";
|
|
};
|
|
env = mkOption {
|
|
type = types.attrsOf types.str;
|
|
default = { };
|
|
description = "Environment variables for the server process (stdio transport).";
|
|
};
|
|
|
|
# HTTP/StreamableHTTP transport
|
|
url = mkOption {
|
|
type = types.nullOr types.str;
|
|
default = null;
|
|
description = "MCP server endpoint URL (HTTP/StreamableHTTP transport).";
|
|
};
|
|
headers = mkOption {
|
|
type = types.attrsOf types.str;
|
|
default = { };
|
|
description = "HTTP headers, e.g. for authentication (HTTP transport).";
|
|
};
|
|
|
|
# Authentication
|
|
auth = mkOption {
|
|
type = types.nullOr (types.enum [ "oauth" ]);
|
|
default = null;
|
|
description = ''
|
|
Authentication method. Set to "oauth" for OAuth 2.1 PKCE flow
|
|
(remote MCP servers). Tokens are stored in $HERMES_HOME/mcp-tokens/.
|
|
'';
|
|
};
|
|
|
|
# Enable/disable
|
|
enabled = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = "Enable or disable this MCP server.";
|
|
};
|
|
|
|
# Common options
|
|
timeout = mkOption {
|
|
type = types.nullOr types.int;
|
|
default = null;
|
|
description = "Tool call timeout in seconds (default: 120).";
|
|
};
|
|
connect_timeout = mkOption {
|
|
type = types.nullOr types.int;
|
|
default = null;
|
|
description = "Initial connection timeout in seconds (default: 60).";
|
|
};
|
|
|
|
# Tool filtering
|
|
tools = mkOption {
|
|
type = types.nullOr (types.submodule {
|
|
options = {
|
|
include = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
description = "Tool allowlist — only these tools are registered.";
|
|
};
|
|
exclude = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
description = "Tool blocklist — these tools are hidden.";
|
|
};
|
|
};
|
|
});
|
|
default = null;
|
|
description = "Filter which tools are exposed by this server.";
|
|
};
|
|
|
|
# Sampling (server-initiated LLM requests)
|
|
sampling = mkOption {
|
|
type = types.nullOr (types.submodule {
|
|
options = {
|
|
enabled = mkOption { type = types.bool; default = true; description = "Enable sampling."; };
|
|
model = mkOption { type = types.nullOr types.str; default = null; description = "Override model for sampling requests."; };
|
|
max_tokens_cap = mkOption { type = types.nullOr types.int; default = null; description = "Max tokens per request."; };
|
|
timeout = mkOption { type = types.nullOr types.int; default = null; description = "LLM call timeout in seconds."; };
|
|
max_rpm = mkOption { type = types.nullOr types.int; default = null; description = "Max requests per minute."; };
|
|
max_tool_rounds = mkOption { type = types.nullOr types.int; default = null; description = "Max tool-use rounds per sampling request."; };
|
|
allowed_models = mkOption { type = types.listOf types.str; default = [ ]; description = "Models the server is allowed to request."; };
|
|
log_level = mkOption {
|
|
type = types.nullOr (types.enum [ "debug" "info" "warning" ]);
|
|
default = null;
|
|
description = "Audit log level for sampling requests.";
|
|
};
|
|
};
|
|
});
|
|
default = null;
|
|
description = "Sampling configuration for server-initiated LLM requests.";
|
|
};
|
|
};
|
|
});
|
|
default = { };
|
|
description = ''
|
|
MCP server configurations (merged into settings.mcp_servers).
|
|
Each server uses either stdio (command/args) or HTTP (url) transport.
|
|
'';
|
|
example = literalExpression ''
|
|
{
|
|
filesystem = {
|
|
command = "npx";
|
|
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/home/user" ];
|
|
};
|
|
remote-api = {
|
|
url = "http://my-server:8080/v0/mcp";
|
|
headers = { Authorization = "Bearer ..."; };
|
|
};
|
|
remote-oauth = {
|
|
url = "https://mcp.example.com/mcp";
|
|
auth = "oauth";
|
|
};
|
|
}
|
|
'';
|
|
};
|
|
|
|
# ── Service behavior ─────────────────────────────────────────────────
|
|
extraArgs = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
description = "Extra command-line arguments for `hermes gateway`.";
|
|
};
|
|
|
|
extraPackages = mkOption {
|
|
type = types.listOf types.package;
|
|
default = [ ];
|
|
description = "Extra packages available on PATH.";
|
|
};
|
|
|
|
restart = mkOption {
|
|
type = types.str;
|
|
default = "always";
|
|
description = "systemd Restart= policy.";
|
|
};
|
|
|
|
restartSec = mkOption {
|
|
type = types.int;
|
|
default = 5;
|
|
description = "systemd RestartSec= value.";
|
|
};
|
|
|
|
addToSystemPackages = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Add hermes CLI to environment.systemPackages.";
|
|
};
|
|
|
|
# ── OCI Container (opt-in) ──────────────────────────────────────────
|
|
container = {
|
|
enable = mkEnableOption "OCI container mode (Ubuntu base, full self-modification support)";
|
|
|
|
backend = mkOption {
|
|
type = types.enum [ "docker" "podman" ];
|
|
default = "docker";
|
|
description = "Container runtime.";
|
|
};
|
|
|
|
extraVolumes = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
description = "Extra volume mounts (host:container:mode format).";
|
|
example = [ "/home/user/projects:/projects:rw" ];
|
|
};
|
|
|
|
extraOptions = mkOption {
|
|
type = types.listOf types.str;
|
|
default = [ ];
|
|
description = "Extra arguments passed to docker/podman run.";
|
|
};
|
|
|
|
image = mkOption {
|
|
type = types.str;
|
|
default = "ubuntu:24.04";
|
|
description = "OCI container image. The container pulls this at runtime via Docker/Podman.";
|
|
};
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable (lib.mkMerge [
|
|
|
|
# ── Merge MCP servers into settings ────────────────────────────────
|
|
(lib.mkIf (cfg.mcpServers != { }) {
|
|
services.hermes-agent.settings.mcp_servers = lib.mapAttrs (_name: srv:
|
|
# Stdio transport
|
|
lib.optionalAttrs (srv.command != null) { inherit (srv) command args; }
|
|
// lib.optionalAttrs (srv.env != { }) { inherit (srv) env; }
|
|
# HTTP transport
|
|
// lib.optionalAttrs (srv.url != null) { inherit (srv) url; }
|
|
// lib.optionalAttrs (srv.headers != { }) { inherit (srv) headers; }
|
|
# Auth
|
|
// lib.optionalAttrs (srv.auth != null) { inherit (srv) auth; }
|
|
# Enable/disable
|
|
// { inherit (srv) enabled; }
|
|
# Common options
|
|
// lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; }
|
|
// lib.optionalAttrs (srv.connect_timeout != null) { inherit (srv) connect_timeout; }
|
|
# Tool filtering
|
|
// lib.optionalAttrs (srv.tools != null) {
|
|
tools = lib.filterAttrs (_: v: v != [ ]) {
|
|
inherit (srv.tools) include exclude;
|
|
};
|
|
}
|
|
# Sampling
|
|
// lib.optionalAttrs (srv.sampling != null) {
|
|
sampling = lib.filterAttrs (_: v: v != null && v != [ ]) {
|
|
inherit (srv.sampling) enabled model max_tokens_cap timeout max_rpm
|
|
max_tool_rounds allowed_models log_level;
|
|
};
|
|
}
|
|
) cfg.mcpServers;
|
|
})
|
|
|
|
# ── User / group ──────────────────────────────────────────────────
|
|
(lib.mkIf cfg.createUser {
|
|
users.groups.${cfg.group} = { };
|
|
users.users.${cfg.user} = {
|
|
isSystemUser = true;
|
|
group = cfg.group;
|
|
home = cfg.stateDir;
|
|
createHome = true;
|
|
shell = pkgs.bashInteractive;
|
|
};
|
|
})
|
|
|
|
# ── Host CLI ──────────────────────────────────────────────────────
|
|
(lib.mkIf cfg.addToSystemPackages {
|
|
environment.systemPackages = [ cfg.package ];
|
|
})
|
|
|
|
# ── Directories ───────────────────────────────────────────────────
|
|
{
|
|
systemd.tmpfiles.rules = [
|
|
"d ${cfg.stateDir} 0755 ${cfg.user} ${cfg.group} - -"
|
|
"d ${cfg.stateDir}/.hermes 0755 ${cfg.user} ${cfg.group} - -"
|
|
"d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -"
|
|
"d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -"
|
|
];
|
|
}
|
|
|
|
# ── Activation: link config + auth + documents ────────────────────
|
|
{
|
|
system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] ''
|
|
# Ensure directories exist (activation runs before tmpfiles)
|
|
mkdir -p ${cfg.stateDir}/.hermes
|
|
mkdir -p ${cfg.stateDir}/home
|
|
mkdir -p ${cfg.workingDirectory}
|
|
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory}
|
|
|
|
# Merge Nix settings into existing config.yaml.
|
|
# Preserves user-added keys (skills, streaming, etc.); Nix keys win.
|
|
# If configFile is user-provided (not generated), overwrite instead of merge.
|
|
${if cfg.configFile != null then ''
|
|
install -o ${cfg.user} -g ${cfg.group} -m 0644 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
|
|
'' else ''
|
|
${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml
|
|
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml
|
|
chmod 0644 ${cfg.stateDir}/.hermes/config.yaml
|
|
''}
|
|
|
|
# Managed mode marker (so interactive shells also detect NixOS management)
|
|
touch ${cfg.stateDir}/.hermes/.managed
|
|
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.managed
|
|
|
|
# Seed auth file if provided
|
|
${lib.optionalString (cfg.authFile != null) ''
|
|
${if cfg.authFileForceOverwrite then ''
|
|
install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json
|
|
'' else ''
|
|
if [ ! -f ${cfg.stateDir}/.hermes/auth.json ]; then
|
|
install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json
|
|
fi
|
|
''}
|
|
''}
|
|
|
|
# Seed .env from Nix-declared environment + environmentFiles.
|
|
# Hermes reads $HERMES_HOME/.env at startup via load_hermes_dotenv(),
|
|
# so this is the single source of truth for both native and container mode.
|
|
${lib.optionalString (cfg.environment != {} || cfg.environmentFiles != []) ''
|
|
ENV_FILE="${cfg.stateDir}/.hermes/.env"
|
|
install -o ${cfg.user} -g ${cfg.group} -m 0600 /dev/null "$ENV_FILE"
|
|
cat > "$ENV_FILE" <<'HERMES_NIX_ENV_EOF'
|
|
${envFileContent}
|
|
HERMES_NIX_ENV_EOF
|
|
${lib.concatStringsSep "\n" (map (f: ''
|
|
if [ -f "${f}" ]; then
|
|
echo "" >> "$ENV_FILE"
|
|
cat "${f}" >> "$ENV_FILE"
|
|
fi
|
|
'') cfg.environmentFiles)}
|
|
''}
|
|
|
|
# Link documents into workspace
|
|
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _value: ''
|
|
install -o ${cfg.user} -g ${cfg.group} -m 0644 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
|
|
'') cfg.documents)}
|
|
'';
|
|
}
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# MODE A: Native systemd service (default)
|
|
# ══════════════════════════════════════════════════════════════════
|
|
(lib.mkIf (!cfg.container.enable) {
|
|
systemd.services.hermes-agent = {
|
|
description = "Hermes Agent Gateway";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network-online.target" ];
|
|
wants = [ "network-online.target" ];
|
|
|
|
environment = {
|
|
HOME = cfg.stateDir;
|
|
HERMES_HOME = "${cfg.stateDir}/.hermes";
|
|
HERMES_MANAGED = "true";
|
|
MESSAGING_CWD = cfg.workingDirectory;
|
|
};
|
|
|
|
serviceConfig = {
|
|
User = cfg.user;
|
|
Group = cfg.group;
|
|
WorkingDirectory = cfg.workingDirectory;
|
|
|
|
# cfg.environment and cfg.environmentFiles are written to
|
|
# $HERMES_HOME/.env by the activation script. load_hermes_dotenv()
|
|
# reads them at Python startup — no systemd EnvironmentFile needed.
|
|
|
|
ExecStart = lib.concatStringsSep " " ([
|
|
"${cfg.package}/bin/hermes"
|
|
"gateway"
|
|
] ++ cfg.extraArgs);
|
|
|
|
Restart = cfg.restart;
|
|
RestartSec = cfg.restartSec;
|
|
|
|
# Hardening
|
|
NoNewPrivileges = true;
|
|
ProtectSystem = "strict";
|
|
ProtectHome = false;
|
|
ReadWritePaths = [ cfg.stateDir ];
|
|
PrivateTmp = true;
|
|
};
|
|
|
|
path = [
|
|
cfg.package
|
|
pkgs.bash
|
|
pkgs.coreutils
|
|
pkgs.git
|
|
] ++ cfg.extraPackages;
|
|
};
|
|
})
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# MODE B: OCI container (persistent writable layer)
|
|
# ══════════════════════════════════════════════════════════════════
|
|
(lib.mkIf cfg.container.enable {
|
|
# Ensure the container runtime is available
|
|
virtualisation.docker.enable = lib.mkDefault (cfg.container.backend == "docker");
|
|
|
|
systemd.services.hermes-agent = {
|
|
description = "Hermes Agent Gateway (container)";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network-online.target" ]
|
|
++ lib.optional (cfg.container.backend == "docker") "docker.service";
|
|
wants = [ "network-online.target" ];
|
|
requires = lib.optional (cfg.container.backend == "docker") "docker.service";
|
|
|
|
preStart = ''
|
|
# Stable symlinks — container references these, not store paths directly
|
|
ln -sfn ${cfg.package} ${cfg.stateDir}/current-package
|
|
ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint
|
|
|
|
# GC roots so nix-collect-garbage doesn't remove store paths in use
|
|
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${cfg.package} 2>/dev/null || true
|
|
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true
|
|
|
|
# Check if container needs (re)creation
|
|
NEED_CREATE=false
|
|
if ! ${containerBin} inspect ${containerName} &>/dev/null; then
|
|
NEED_CREATE=true
|
|
elif [ ! -f ${identityFile} ] || [ "$(cat ${identityFile})" != "${containerIdentity}" ]; then
|
|
echo "Container config changed, recreating..."
|
|
${containerBin} rm -f ${containerName} || true
|
|
NEED_CREATE=true
|
|
fi
|
|
|
|
if [ "$NEED_CREATE" = "true" ]; then
|
|
# Resolve numeric UID/GID — passed to entrypoint for in-container user setup
|
|
HERMES_UID=$(${pkgs.coreutils}/bin/id -u ${cfg.user})
|
|
HERMES_GID=$(${pkgs.coreutils}/bin/id -g ${cfg.user})
|
|
|
|
echo "Creating container..."
|
|
${containerBin} create \
|
|
--name ${containerName} \
|
|
--network=host \
|
|
--entrypoint ${containerDataDir}/current-entrypoint \
|
|
--volume /nix/store:/nix/store:ro \
|
|
--volume ${cfg.stateDir}:${containerDataDir} \
|
|
--volume ${cfg.stateDir}/home:${containerHomeDir} \
|
|
${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \
|
|
--env HERMES_UID="$HERMES_UID" \
|
|
--env HERMES_GID="$HERMES_GID" \
|
|
--env HERMES_HOME=${containerDataDir}/.hermes \
|
|
--env HERMES_MANAGED=true \
|
|
--env HOME=${containerHomeDir} \
|
|
--env MESSAGING_CWD=${containerWorkDir} \
|
|
${lib.concatStringsSep " " cfg.container.extraOptions} \
|
|
${cfg.container.image} \
|
|
${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
|
|
|
|
echo "${containerIdentity}" > ${identityFile}
|
|
fi
|
|
'';
|
|
|
|
script = ''
|
|
exec ${containerBin} start -a ${containerName}
|
|
'';
|
|
|
|
preStop = ''
|
|
${containerBin} stop -t 10 ${containerName} || true
|
|
'';
|
|
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
Restart = cfg.restart;
|
|
RestartSec = cfg.restartSec;
|
|
TimeoutStopSec = 30;
|
|
};
|
|
};
|
|
})
|
|
]);
|
|
};
|
|
}
|