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;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
})
|
||
|
|
]);
|
||
|
|
};
|
||
|
|
}
|