feat: nix flake — uv2nix build, NixOS module, persistent container mode (#20)

* 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>
This commit is contained in:
Siddharth Balyan
2026-03-26 01:08:02 +05:30
committed by GitHub
parent 8f6ef042c1
commit b6461903ff
20 changed files with 2557 additions and 2 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

40
.github/workflows/nix.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Nix
on:
push:
branches: [main]
pull_request:
paths:
- 'flake.nix'
- 'flake.lock'
- 'nix/**'
- 'pyproject.toml'
- 'uv.lock'
- 'hermes_cli/**'
- 'run_agent.py'
- 'acp_adapter/**'
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Check flake
if: runner.os == 'Linux'
run: nix flake check --print-build-logs
- name: Build package
if: runner.os == 'Linux'
run: nix build --print-build-logs
- name: Evaluate flake (macOS)
if: runner.os == 'macOS'
run: nix flake show --json > /dev/null

4
.gitignore vendored
View File

@@ -54,3 +54,7 @@ environments/benchmarks/evals/
# Release script temp files # Release script temp files
.release_notes.md .release_notes.md
mini-swe-agent/ mini-swe-agent/
# Nix
.direnv/
result

181
flake.lock generated Normal file
View File

@@ -0,0 +1,181 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1751274312,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"pyproject-build-systems": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": "pyproject-nix",
"uv2nix": "uv2nix"
},
"locked": {
"lastModified": 1772555609,
"narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "c37f66a953535c394244888598947679af231863",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"pyproject-nix": {
"inputs": {
"nixpkgs": [
"pyproject-build-systems",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769936401,
"narHash": "sha256-kwCOegKLZJM9v/e/7cqwg1p/YjjTAukKPqmxKnAZRgA=",
"owner": "nix-community",
"repo": "pyproject.nix",
"rev": "b0d513eeeebed6d45b4f2e874f9afba2021f7812",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "pyproject.nix",
"type": "github"
}
},
"pyproject-nix_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1772865871,
"narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "e537db02e72d553cea470976b9733581bcf5b3ed",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"pyproject-nix_3": {
"inputs": {
"nixpkgs": [
"uv2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1771518446,
"narHash": "sha256-nFJSfD89vWTu92KyuJWDoTQJuoDuddkJV3TlOl1cOic=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "eb204c6b3335698dec6c7fc1da0ebc3c6df05937",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"pyproject-build-systems": "pyproject-build-systems",
"pyproject-nix": "pyproject-nix_2",
"uv2nix": "uv2nix_2"
}
},
"uv2nix": {
"inputs": {
"nixpkgs": [
"pyproject-build-systems",
"nixpkgs"
],
"pyproject-nix": [
"pyproject-build-systems",
"pyproject-nix"
]
},
"locked": {
"lastModified": 1770770348,
"narHash": "sha256-A2GzkmzdYvdgmMEu5yxW+xhossP+txrYb7RuzRaqhlg=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "5d1b2cb4fe3158043fbafbbe2e46238abbc954b0",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
},
"uv2nix_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": "pyproject-nix_3"
},
"locked": {
"lastModified": 1773039484,
"narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

35
flake.nix Normal file
View File

@@ -0,0 +1,35 @@
{
description = "Hermes Agent - AI agent framework by Nous Research";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
pyproject-nix = {
url = "github:pyproject-nix/pyproject.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
uv2nix = {
url = "github:pyproject-nix/uv2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
pyproject-build-systems = {
url = "github:pyproject-nix/build-system-pkgs";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs:
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
imports = [
./nix/packages.nix
./nix/nixosModules.nix
./nix/checks.nix
./nix/devShell.nix
];
};
}

View File

@@ -46,6 +46,32 @@ from hermes_cli.colors import Colors, color
from hermes_cli.default_soul import DEFAULT_SOUL_MD from hermes_cli.default_soul import DEFAULT_SOUL_MD
# =============================================================================
# Managed mode (NixOS declarative config)
# =============================================================================
def is_managed() -> bool:
"""Check if hermes is running in Nix-managed mode.
Two signals: the HERMES_MANAGED env var (set by the systemd service),
or a .managed marker file in HERMES_HOME (set by the NixOS activation
script, so interactive shells also see it).
"""
if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"):
return True
managed_marker = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))) / ".managed"
return managed_marker.exists()
def managed_error(action: str = "modify configuration"):
"""Print user-friendly error for managed mode."""
print(
f"Cannot {action}: configuration is managed by NixOS (HERMES_MANAGED=true).\n"
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
" sudo nixos-rebuild switch",
file=sys.stderr,
)
# ============================================================================= # =============================================================================
# Config paths # Config paths
# ============================================================================= # =============================================================================
@@ -1342,6 +1368,9 @@ _COMMENTED_SECTIONS = """
def save_config(config: Dict[str, Any]): def save_config(config: Dict[str, Any]):
"""Save configuration to ~/.hermes/config.yaml.""" """Save configuration to ~/.hermes/config.yaml."""
if is_managed():
managed_error("save configuration")
return
from utils import atomic_yaml_write from utils import atomic_yaml_write
ensure_hermes_home() ensure_hermes_home()
@@ -1483,6 +1512,9 @@ def sanitize_env_file() -> int:
def save_env_value(key: str, value: str): def save_env_value(key: str, value: str):
"""Save or update a value in ~/.hermes/.env.""" """Save or update a value in ~/.hermes/.env."""
if is_managed():
managed_error(f"set {key}")
return
if not _ENV_VAR_NAME_RE.match(key): if not _ENV_VAR_NAME_RE.match(key):
raise ValueError(f"Invalid environment variable name: {key!r}") raise ValueError(f"Invalid environment variable name: {key!r}")
value = value.replace("\n", "").replace("\r", "") value = value.replace("\n", "").replace("\r", "")
@@ -1739,6 +1771,9 @@ def show_config():
def edit_config(): def edit_config():
"""Open config file in user's editor.""" """Open config file in user's editor."""
if is_managed():
managed_error("edit configuration")
return
config_path = get_config_path() config_path = get_config_path()
# Ensure config exists # Ensure config exists
@@ -1768,6 +1803,9 @@ def edit_config():
def set_config_value(key: str, value: str): def set_config_value(key: str, value: str):
"""Set a configuration value.""" """Set a configuration value."""
if is_managed():
managed_error("set configuration values")
return
# Check if it's an API key (goes to .env) # Check if it's an API key (goes to .env)
api_keys = [ api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',

View File

@@ -14,7 +14,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
from hermes_cli.setup import ( from hermes_cli.setup import (
print_header, print_info, print_success, print_warning, print_error, print_header, print_info, print_success, print_warning, print_error,
prompt, prompt_choice, prompt_yes_no, prompt, prompt_choice, prompt_yes_no,
@@ -1562,6 +1562,9 @@ def _setup_signal():
def gateway_setup(): def gateway_setup():
"""Interactive setup for messaging platforms + gateway service.""" """Interactive setup for messaging platforms + gateway service."""
if is_managed():
managed_error("run gateway setup")
return
print() print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
@@ -1716,6 +1719,9 @@ def gateway_command(args):
# Service management commands # Service management commands
if subcmd == "install": if subcmd == "install":
if is_managed():
managed_error("install gateway service (managed by NixOS)")
return
force = getattr(args, 'force', False) force = getattr(args, 'force', False)
system = getattr(args, 'system', False) system = getattr(args, 'system', False)
run_as_user = getattr(args, 'run_as_user', None) run_as_user = getattr(args, 'run_as_user', None)
@@ -1729,6 +1735,9 @@ def gateway_command(args):
sys.exit(1) sys.exit(1)
elif subcmd == "uninstall": elif subcmd == "uninstall":
if is_managed():
managed_error("uninstall gateway service (managed by NixOS)")
return
system = getattr(args, 'system', False) system = getattr(args, 'system', False)
if is_linux(): if is_linux():
systemd_uninstall(system=system) systemd_uninstall(system=system)

View File

@@ -3106,6 +3106,10 @@ def run_setup_wizard(args):
hermes setup tools — just tool configuration hermes setup tools — just tool configuration
hermes setup agent — just agent settings hermes setup agent — just agent settings
""" """
from hermes_cli.config import is_managed, managed_error
if is_managed():
managed_error("run setup wizard")
return
ensure_hermes_home() ensure_hermes_home()
config = load_config() config = load_config()

376
nix/checks.nix Normal file
View File

@@ -0,0 +1,376 @@
# nix/checks.nix — Build-time verification tests
#
# Checks are Linux-only: the full Python venv (via uv2nix) includes
# transitive deps like onnxruntime that lack compatible wheels on
# aarch64-darwin. The package and devShell still work on macOS.
{ inputs, ... }: {
perSystem = { pkgs, system, lib, ... }:
let
hermes-agent = inputs.self.packages.${system}.default;
hermesVenv = pkgs.callPackage ./python.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
};
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
in {
checks = lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
# Verify binaries exist and are executable
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
set -e
echo "=== Checking binaries ==="
test -x ${hermes-agent}/bin/hermes || (echo "FAIL: hermes binary missing"; exit 1)
test -x ${hermes-agent}/bin/hermes-agent || (echo "FAIL: hermes-agent binary missing"; exit 1)
echo "PASS: All binaries present"
echo "=== Checking version ==="
${hermes-agent}/bin/hermes version 2>&1 | grep -qi "hermes" || (echo "FAIL: version check"; exit 1)
echo "PASS: Version check"
echo "=== All checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify every pyproject.toml [project.scripts] entry has a wrapped binary
entry-points-sync = pkgs.runCommand "hermes-entry-points-sync" { } ''
set -e
echo "=== Checking entry points match pyproject.toml [project.scripts] ==="
for bin in hermes hermes-agent hermes-acp; do
test -x ${hermes-agent}/bin/$bin || (echo "FAIL: $bin binary missing from Nix package"; exit 1)
echo "PASS: $bin present"
done
mkdir -p $out
echo "ok" > $out/result
'';
# Verify CLI subcommands are accessible
cli-commands = pkgs.runCommand "hermes-cli-commands" { } ''
set -e
export HOME=$(mktemp -d)
echo "=== Checking hermes --help ==="
${hermes-agent}/bin/hermes --help 2>&1 | grep -q "gateway" || (echo "FAIL: gateway subcommand missing"; exit 1)
${hermes-agent}/bin/hermes --help 2>&1 | grep -q "config" || (echo "FAIL: config subcommand missing"; exit 1)
echo "PASS: All subcommands accessible"
echo "=== All CLI checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify bundled skills are present in the package
bundled-skills = pkgs.runCommand "hermes-bundled-skills" { } ''
set -e
echo "=== Checking bundled skills ==="
test -d ${hermes-agent}/share/hermes-agent/skills || (echo "FAIL: skills directory missing"; exit 1)
echo "PASS: skills directory exists"
SKILL_COUNT=$(find ${hermes-agent}/share/hermes-agent/skills -name "SKILL.md" | wc -l)
test "$SKILL_COUNT" -gt 0 || (echo "FAIL: no SKILL.md files found in skills directory"; exit 1)
echo "PASS: $SKILL_COUNT bundled skills found"
grep -q "HERMES_BUNDLED_SKILLS" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_BUNDLED_SKILLS not in wrapper"; exit 1)
echo "PASS: HERMES_BUNDLED_SKILLS set in wrapper"
echo "=== All bundled skills checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify HERMES_MANAGED guard works on all mutation commands
managed-guard = pkgs.runCommand "hermes-managed-guard" { } ''
set -e
export HOME=$(mktemp -d)
check_blocked() {
local label="$1"
shift
OUTPUT=$(HERMES_MANAGED=true "$@" 2>&1 || true)
echo "$OUTPUT" | grep -q "managed by NixOS" || (echo "FAIL: $label not guarded"; echo "$OUTPUT"; exit 1)
echo "PASS: $label blocked in managed mode"
}
echo "=== Checking HERMES_MANAGED guards ==="
check_blocked "config set" ${hermes-agent}/bin/hermes config set model foo
check_blocked "config edit" ${hermes-agent}/bin/hermes config edit
echo "=== All guard checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# ── Config drift detection ────────────────────────────────────────
# Extracts leaf key paths from Python's DEFAULT_CONFIG and compares
# against the committed reference in nix/config-keys.json.
config-drift = pkgs.runCommand "hermes-config-drift" {
nativeBuildInputs = [ pkgs.jq ];
referenceKeys = ./config-keys.json;
} ''
set -e
export HOME=$(mktemp -d)
echo "=== Extracting DEFAULT_CONFIG leaf keys from Python ==="
${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.config import DEFAULT_CONFIG
def leaf_paths(d, prefix=""):
paths = []
for k, v in sorted(d.items()):
path = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict) and v:
paths.extend(leaf_paths(v, path))
else:
paths.append(path)
return paths
json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout)
' > /tmp/actual-keys.json
echo "=== Comparing against reference ==="
jq -r '.[]' $referenceKeys | sort > /tmp/reference.txt
jq -r '.[]' /tmp/actual-keys.json | sort > /tmp/actual.txt
ADDED=$(comm -23 /tmp/actual.txt /tmp/reference.txt || true)
REMOVED=$(comm -13 /tmp/actual.txt /tmp/reference.txt || true)
FAILED=false
if [ -n "$ADDED" ]; then
echo "FAIL: New keys in DEFAULT_CONFIG not in nix/config-keys.json:"
echo "$ADDED" | sed 's/^/ + /'
FAILED=true
fi
if [ -n "$REMOVED" ]; then
echo "FAIL: Keys in nix/config-keys.json missing from DEFAULT_CONFIG:"
echo "$REMOVED" | sed 's/^/ - /'
FAILED=true
fi
if [ "$FAILED" = "true" ]; then
exit 1
fi
ACTUAL_COUNT=$(wc -l < /tmp/actual.txt)
echo "PASS: All $ACTUAL_COUNT config keys match reference"
mkdir -p $out
echo "ok" > $out/result
'';
# ── Config merge + round-trip test ────────────────────────────────
# Tests the merge script (Nix activation behavior) across 7
# scenarios, then verifies Python's load_config() reads correctly.
config-roundtrip = let
# Nix settings used across scenarios
nixSettings = pkgs.writeText "nix-settings.json" (builtins.toJSON {
model = "test/nix-model";
toolsets = ["nix-toolset"];
terminal = { backend = "docker"; timeout = 999; };
mcp_servers = {
nix-server = { command = "echo"; args = ["nix"]; };
};
});
# Pre-built YAML fixtures for each scenario
fixtureB = pkgs.writeText "fixture-b.yaml" ''
model: "old-model"
mcp_servers:
old-server:
url: "http://old"
'';
fixtureC = pkgs.writeText "fixture-c.yaml" ''
skills:
disabled:
- skill-a
- skill-b
session_reset:
mode: idle
idle_minutes: 30
streaming:
enabled: true
fallback_model:
provider: openrouter
model: test-fallback
'';
fixtureD = pkgs.writeText "fixture-d.yaml" ''
model: "user-model"
skills:
disabled:
- skill-x
streaming:
enabled: true
transport: edit
'';
fixtureE = pkgs.writeText "fixture-e.yaml" ''
mcp_servers:
user-server:
url: "http://user-mcp"
nix-server:
command: "old-cmd"
args: ["old"]
'';
fixtureF = pkgs.writeText "fixture-f.yaml" ''
terminal:
cwd: "/user/path"
custom_key: "preserved"
env_passthrough:
- USER_VAR
'';
in pkgs.runCommand "hermes-config-roundtrip" {
nativeBuildInputs = [ pkgs.jq ];
} ''
set -e
export HOME=$(mktemp -d)
ERRORS=""
fail() { ERRORS="$ERRORS\nFAIL: $1"; }
# Helper: run merge then load with Python, output merged JSON
merge_and_load() {
local hermes_home="$1"
export HERMES_HOME="$hermes_home"
${configMergeScript} ${nixSettings} "$hermes_home/config.yaml"
${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.config import load_config
json.dump(load_config(), sys.stdout, default=str)
'
}
#
# Scenario A: Fresh install no existing config.yaml
#
echo "=== Scenario A: Fresh install ==="
A_HOME=$(mktemp -d)
A_CONFIG=$(merge_and_load "$A_HOME")
echo "$A_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "A: model not set from Nix"
echo "$A_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
|| fail "A: MCP nix-server missing"
echo "PASS: Scenario A"
#
# Scenario B: Nix keys override existing values
#
echo "=== Scenario B: Nix overrides ==="
B_HOME=$(mktemp -d)
install -m 0644 ${fixtureB} "$B_HOME/config.yaml"
B_CONFIG=$(merge_and_load "$B_HOME")
echo "$B_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "B: Nix model did not override"
echo "PASS: Scenario B"
#
# Scenario C: User-only keys preserved
#
echo "=== Scenario C: User keys preserved ==="
C_HOME=$(mktemp -d)
install -m 0644 ${fixtureC} "$C_HOME/config.yaml"
C_CONFIG=$(merge_and_load "$C_HOME")
echo "$C_CONFIG" | jq -e '.skills.disabled == ["skill-a", "skill-b"]' > /dev/null \
|| fail "C: skills.disabled not preserved"
echo "$C_CONFIG" | jq -e '.session_reset.mode == "idle"' > /dev/null \
|| fail "C: session_reset.mode not preserved"
echo "$C_CONFIG" | jq -e '.session_reset.idle_minutes == 30' > /dev/null \
|| fail "C: session_reset.idle_minutes not preserved"
echo "$C_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
|| fail "C: streaming.enabled not preserved"
echo "$C_CONFIG" | jq -e '.fallback_model.provider == "openrouter"' > /dev/null \
|| fail "C: fallback_model not preserved"
echo "PASS: Scenario C"
#
# Scenario D: Mixed Nix wins for its keys, user keys preserved
#
echo "=== Scenario D: Mixed merge ==="
D_HOME=$(mktemp -d)
install -m 0644 ${fixtureD} "$D_HOME/config.yaml"
D_CONFIG=$(merge_and_load "$D_HOME")
echo "$D_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "D: Nix model did not override user model"
echo "$D_CONFIG" | jq -e '.skills.disabled == ["skill-x"]' > /dev/null \
|| fail "D: user skills not preserved"
echo "$D_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
|| fail "D: user streaming not preserved"
echo "PASS: Scenario D"
#
# Scenario E: MCP additive merge
#
echo "=== Scenario E: MCP additive merge ==="
E_HOME=$(mktemp -d)
install -m 0644 ${fixtureE} "$E_HOME/config.yaml"
E_CONFIG=$(merge_and_load "$E_HOME")
echo "$E_CONFIG" | jq -e '.mcp_servers."user-server".url == "http://user-mcp"' > /dev/null \
|| fail "E: user MCP server not preserved"
echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
|| fail "E: Nix MCP server did not override same-name user server"
echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".args == ["nix"]' > /dev/null \
|| fail "E: Nix MCP server args wrong"
echo "PASS: Scenario E"
#
# Scenario F: Nested deep merge
#
echo "=== Scenario F: Nested deep merge ==="
F_HOME=$(mktemp -d)
install -m 0644 ${fixtureF} "$F_HOME/config.yaml"
F_CONFIG=$(merge_and_load "$F_HOME")
echo "$F_CONFIG" | jq -e '.terminal.backend == "docker"' > /dev/null \
|| fail "F: Nix terminal.backend did not override"
echo "$F_CONFIG" | jq -e '.terminal.timeout == 999' > /dev/null \
|| fail "F: Nix terminal.timeout did not override"
echo "$F_CONFIG" | jq -e '.terminal.custom_key == "preserved"' > /dev/null \
|| fail "F: terminal.custom_key not preserved"
echo "$F_CONFIG" | jq -e '.terminal.cwd == "/user/path"' > /dev/null \
|| fail "F: user terminal.cwd not preserved when Nix does not set it"
echo "$F_CONFIG" | jq -e '.terminal.env_passthrough == ["USER_VAR"]' > /dev/null \
|| fail "F: user terminal.env_passthrough not preserved"
echo "PASS: Scenario F"
#
# Scenario G: Idempotency merging twice yields the same result
#
echo "=== Scenario G: Idempotency ==="
G_HOME=$(mktemp -d)
install -m 0644 ${fixtureD} "$G_HOME/config.yaml"
${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
FIRST=$(cat "$G_HOME/config.yaml")
${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
SECOND=$(cat "$G_HOME/config.yaml")
if [ "$FIRST" != "$SECOND" ]; then
fail "G: second merge produced different output"
echo "--- first ---"
echo "$FIRST"
echo "--- second ---"
echo "$SECOND"
fi
echo "PASS: Scenario G"
#
# Report
#
if [ -n "$ERRORS" ]; then
echo ""
echo "FAILURES:"
echo -e "$ERRORS"
exit 1
fi
echo ""
echo "=== All 7 merge scenarios passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
};
};
}

129
nix/config-keys.json Normal file
View File

@@ -0,0 +1,129 @@
[
"_config_version",
"agent.max_turns",
"approvals.mode",
"auxiliary.approval.api_key",
"auxiliary.approval.base_url",
"auxiliary.approval.model",
"auxiliary.approval.provider",
"auxiliary.compression.api_key",
"auxiliary.compression.base_url",
"auxiliary.compression.model",
"auxiliary.compression.provider",
"auxiliary.flush_memories.api_key",
"auxiliary.flush_memories.base_url",
"auxiliary.flush_memories.model",
"auxiliary.flush_memories.provider",
"auxiliary.mcp.api_key",
"auxiliary.mcp.base_url",
"auxiliary.mcp.model",
"auxiliary.mcp.provider",
"auxiliary.session_search.api_key",
"auxiliary.session_search.base_url",
"auxiliary.session_search.model",
"auxiliary.session_search.provider",
"auxiliary.skills_hub.api_key",
"auxiliary.skills_hub.base_url",
"auxiliary.skills_hub.model",
"auxiliary.skills_hub.provider",
"auxiliary.vision.api_key",
"auxiliary.vision.base_url",
"auxiliary.vision.model",
"auxiliary.vision.provider",
"auxiliary.vision.timeout",
"auxiliary.web_extract.api_key",
"auxiliary.web_extract.base_url",
"auxiliary.web_extract.model",
"auxiliary.web_extract.provider",
"browser.command_timeout",
"browser.inactivity_timeout",
"browser.record_sessions",
"checkpoints.enabled",
"checkpoints.max_snapshots",
"command_allowlist",
"compression.enabled",
"compression.protect_last_n",
"compression.summary_base_url",
"compression.summary_model",
"compression.summary_provider",
"compression.target_ratio",
"compression.threshold",
"delegation.api_key",
"delegation.base_url",
"delegation.model",
"delegation.provider",
"discord.auto_thread",
"discord.free_response_channels",
"discord.require_mention",
"display.bell_on_complete",
"display.compact",
"display.personality",
"display.resume_display",
"display.show_cost",
"display.show_reasoning",
"display.skin",
"display.streaming",
"honcho",
"human_delay.max_ms",
"human_delay.min_ms",
"human_delay.mode",
"memory.memory_char_limit",
"memory.memory_enabled",
"memory.user_char_limit",
"memory.user_profile_enabled",
"model",
"personalities",
"prefill_messages_file",
"privacy.redact_pii",
"quick_commands",
"security.redact_secrets",
"security.tirith_enabled",
"security.tirith_fail_open",
"security.tirith_path",
"security.tirith_timeout",
"security.website_blocklist.domains",
"security.website_blocklist.enabled",
"security.website_blocklist.shared_files",
"smart_model_routing.cheap_model",
"smart_model_routing.enabled",
"smart_model_routing.max_simple_chars",
"smart_model_routing.max_simple_words",
"stt.enabled",
"stt.local.model",
"stt.openai.model",
"stt.provider",
"terminal.backend",
"terminal.container_cpu",
"terminal.container_disk",
"terminal.container_memory",
"terminal.container_persistent",
"terminal.cwd",
"terminal.daytona_image",
"terminal.docker_forward_env",
"terminal.docker_image",
"terminal.docker_mount_cwd_to_workspace",
"terminal.docker_volumes",
"terminal.env_passthrough",
"terminal.modal_image",
"terminal.persistent_shell",
"terminal.singularity_image",
"terminal.timeout",
"timezone",
"toolsets",
"tts.edge.voice",
"tts.elevenlabs.model_id",
"tts.elevenlabs.voice_id",
"tts.neutts.device",
"tts.neutts.model",
"tts.neutts.ref_audio",
"tts.neutts.ref_text",
"tts.openai.model",
"tts.openai.voice",
"tts.provider",
"voice.auto_tts",
"voice.max_recording_seconds",
"voice.record_key",
"voice.silence_duration",
"voice.silence_threshold",
"whatsapp"
]

33
nix/configMergeScript.nix Normal file
View File

@@ -0,0 +1,33 @@
# nix/configMergeScript.nix — Deep-merge Nix settings into existing config.yaml
#
# Used by the NixOS module activation script and by checks.nix tests.
# Nix keys override; user-added keys (skills, streaming, etc.) are preserved.
{ pkgs }:
pkgs.writeScript "hermes-config-merge" ''
#!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3
import json, yaml, sys
from pathlib import Path
nix_json, config_path = sys.argv[1], Path(sys.argv[2])
with open(nix_json) as f:
nix = json.load(f)
existing = {}
if config_path.exists():
with open(config_path) as f:
existing = yaml.safe_load(f) or {}
def deep_merge(base, override):
result = dict(base)
for k, v in override.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = v
return result
merged = deep_merge(existing, nix)
with open(config_path, "w") as f:
yaml.dump(merged, f, default_flow_style=False, sort_keys=False)
''

51
nix/devShell.nix Normal file
View File

@@ -0,0 +1,51 @@
# nix/devShell.nix — Fast dev shell with stamp-file optimization
{ inputs, ... }: {
perSystem = { pkgs, ... }:
let
python = pkgs.python311;
in {
devShells.default = pkgs.mkShell {
packages = with pkgs; [
python uv nodejs_20 ripgrep git openssh ffmpeg
];
shellHook = ''
echo "Hermes Agent dev shell"
# Composite stamp: changes when nix python or uv change
STAMP_VALUE="${python}:${pkgs.uv}"
STAMP_FILE=".venv/.nix-stamp"
# Create venv if missing
if [ ! -d .venv ]; then
echo "Creating Python 3.11 venv..."
uv venv .venv --python ${python}/bin/python3
fi
source .venv/bin/activate
# Only install if stamp is stale or missing
if [ ! -f "$STAMP_FILE" ] || [ "$(cat "$STAMP_FILE")" != "$STAMP_VALUE" ]; then
echo "Installing Python dependencies..."
uv pip install -e ".[all]"
if [ -d mini-swe-agent ]; then
uv pip install -e ./mini-swe-agent 2>/dev/null || true
fi
if [ -d tinker-atropos ]; then
uv pip install -e ./tinker-atropos 2>/dev/null || true
fi
# Install npm deps
if [ -f package.json ] && [ ! -d node_modules ]; then
echo "Installing npm dependencies..."
npm install
fi
echo "$STAMP_VALUE" > "$STAMP_FILE"
fi
echo "Ready. Run 'hermes' to start."
'';
};
};
}

716
nix/nixosModules.nix Normal file
View File

@@ -0,0 +1,716 @@
# 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;
};
};
})
]);
};
}

54
nix/packages.nix Normal file
View File

@@ -0,0 +1,54 @@
# nix/packages.nix — Hermes Agent package built with uv2nix
{ inputs, ... }: {
perSystem = { pkgs, system, ... }:
let
hermesVenv = pkgs.callPackage ./python.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
};
# Import bundled skills, excluding runtime caches
bundledSkills = pkgs.lib.cleanSourceWith {
src = ../skills;
filter = path: _type:
!(pkgs.lib.hasInfix "/index-cache/" path);
};
runtimeDeps = with pkgs; [
nodejs_20 ripgrep git openssh ffmpeg
];
runtimePath = pkgs.lib.makeBinPath runtimeDeps;
in {
packages.default = pkgs.stdenv.mkDerivation {
pname = "hermes-agent";
version = "0.1.0";
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [ pkgs.makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/share/hermes-agent $out/bin
cp -r ${bundledSkills} $out/share/hermes-agent/skills
${pkgs.lib.concatMapStringsSep "\n" (name: ''
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
--prefix PATH : "${runtimePath}" \
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills
'') [ "hermes" "hermes-agent" "hermes-acp" ]}
runHook postInstall
'';
meta = with pkgs.lib; {
description = "AI agent with advanced tool-calling capabilities";
homepage = "https://github.com/NousResearch/hermes-agent";
mainProgram = "hermes";
license = licenses.mit;
platforms = platforms.unix;
};
};
};
}

28
nix/python.nix Normal file
View File

@@ -0,0 +1,28 @@
# nix/python.nix — uv2nix virtual environment builder
{
python311,
lib,
callPackage,
uv2nix,
pyproject-nix,
pyproject-build-systems,
}:
let
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; };
overlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
};
pythonSet =
(callPackage pyproject-nix.build.packages {
python = python311;
}).overrideScope
(lib.composeManyExtensions [
pyproject-build-systems.overlays.default
overlay
]);
in
pythonSet.mkVirtualEnv "hermes-agent-env" {
hermes-agent = [ "all" ];
}

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from tools.skills_sync import ( from tools.skills_sync import (
_get_bundled_dir,
_read_manifest, _read_manifest,
_write_manifest, _write_manifest,
_discover_bundled_skills, _discover_bundled_skills,
@@ -467,3 +468,24 @@ class TestSyncSkills:
new_bundled_hash = _dir_hash(bundled / "old-skill") new_bundled_hash = _dir_hash(bundled / "old-skill")
assert manifest["old-skill"] == new_bundled_hash assert manifest["old-skill"] == new_bundled_hash
assert manifest["old-skill"] != old_hash assert manifest["old-skill"] != old_hash
class TestGetBundledDir:
def test_env_var_override(self, tmp_path, monkeypatch):
"""HERMES_BUNDLED_SKILLS env var overrides the default path resolution."""
custom_dir = tmp_path / "custom_skills"
custom_dir.mkdir()
monkeypatch.setenv("HERMES_BUNDLED_SKILLS", str(custom_dir))
assert _get_bundled_dir() == custom_dir
def test_default_without_env_var(self, monkeypatch):
"""Without the env var, falls back to relative path from __file__."""
monkeypatch.delenv("HERMES_BUNDLED_SKILLS", raising=False)
result = _get_bundled_dir()
assert result.name == "skills"
def test_env_var_empty_string_ignored(self, monkeypatch):
"""Empty HERMES_BUNDLED_SKILLS should fall back to default."""
monkeypatch.setenv("HERMES_BUNDLED_SKILLS", "")
result = _get_bundled_dir()
assert result.name == "skills"

View File

@@ -37,7 +37,14 @@ MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
def _get_bundled_dir() -> Path: def _get_bundled_dir() -> Path:
"""Locate the bundled skills/ directory in the repo.""" """Locate the bundled skills/ directory.
Checks HERMES_BUNDLED_SKILLS env var first (set by Nix wrapper),
then falls back to the relative path from this source file.
"""
env_override = os.getenv("HERMES_BUNDLED_SKILLS")
if env_override:
return Path(env_override)
return Path(__file__).parent.parent / "skills" return Path(__file__).parent.parent / "skills"

View File

@@ -59,6 +59,10 @@ The only prerequisite is **Git**. The installer automatically handles everything
You do **not** need to install Python, Node.js, ripgrep, or ffmpeg manually. The installer detects what's missing and installs it for you. Just make sure `git` is available (`git --version`). You do **not** need to install Python, Node.js, ripgrep, or ffmpeg manually. The installer detects what's missing and installs it for you. Just make sure `git` is available (`git --version`).
::: :::
:::tip Nix users
If you use Nix (on NixOS, macOS, or Linux), there's a dedicated setup path with a Nix flake, declarative NixOS module, and optional container mode. See the **[Nix & NixOS Setup](./nix-setup.md)** guide.
:::
--- ---
## Manual Installation ## Manual Installation

View File

@@ -0,0 +1,822 @@
---
sidebar_position: 3
title: "Nix & NixOS Setup"
description: "Install and deploy Hermes Agent with Nix — from quick `nix run` to fully declarative NixOS module with container mode"
---
# Nix & NixOS Setup
Hermes Agent ships a Nix flake with three levels of integration:
| Level | Who it's for | What you get |
|-------|-------------|--------------|
| **`nix run` / `nix profile install`** | Any Nix user (macOS, Linux) | Pre-built binary with all deps — then use the standard CLI workflow |
| **NixOS module (native)** | NixOS server deployments | Declarative config, hardened systemd service, managed secrets |
| **NixOS module (container)** | Agents that need self-modification | Everything above, plus a persistent Ubuntu container where the agent can `apt`/`pip`/`npm install` |
:::info What's different from the standard install
The `curl | bash` installer manages Python, Node, and dependencies itself. The Nix flake replaces all of that — every Python dependency is a Nix derivation built by [uv2nix](https://github.com/pyproject-nix/uv2nix), and runtime tools (Node.js, git, ripgrep, ffmpeg) are wrapped into the binary's PATH. There is no runtime pip, no venv activation, no `npm install`.
**For non-NixOS users**, this only changes the install step. Everything after (`hermes setup`, `hermes gateway install`, config editing) works identically to the standard install.
**For NixOS module users**, the entire lifecycle is different: configuration lives in `configuration.nix`, secrets go through sops-nix/agenix, the service is a systemd unit, and CLI config commands are blocked. You manage hermes the same way you manage any other NixOS service.
:::
## Prerequisites
- **Nix with flakes enabled** — [Determinate Nix](https://install.determinate.systems) recommended (enables flakes by default)
- **API keys** for the services you want to use (at minimum: an OpenRouter or Anthropic key)
---
## Quick Start (Any Nix User)
No clone needed. Nix fetches, builds, and runs everything:
```bash
# Run directly (builds on first use, cached after)
nix run github:NousResearch/hermes-agent -- setup
nix run github:NousResearch/hermes-agent -- chat
# Or install persistently
nix profile install github:NousResearch/hermes-agent
hermes setup
hermes chat
```
After `nix profile install`, `hermes`, `hermes-agent`, and `hermes-acp` are on your PATH. From here, the workflow is identical to the [standard installation](./installation.md) — `hermes setup` walks you through provider selection, `hermes gateway install` sets up a launchd (macOS) or systemd user service, and config lives in `~/.hermes/`.
<details>
<summary><strong>Building from a local clone</strong></summary>
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
nix build
./result/bin/hermes setup
```
</details>
---
## NixOS Module
The flake exports `nixosModules.default` — a full NixOS service module that declaratively manages user creation, directories, config generation, secrets, documents, and service lifecycle.
:::note
This module requires NixOS. For non-NixOS systems (macOS, other Linux distros), use `nix profile install` and the standard CLI workflow above.
:::
### Add the Flake Input
```nix
# /etc/nixos/flake.nix (or your system flake)
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
hermes-agent.url = "github:NousResearch/hermes-agent";
};
outputs = { nixpkgs, hermes-agent, ... }: {
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
hermes-agent.nixosModules.default
./configuration.nix
];
};
};
}
```
### Minimal Configuration
```nix
# configuration.nix
{ config, ... }: {
services.hermes-agent = {
enable = true;
settings.model.default = "anthropic/claude-sonnet-4";
environmentFiles = [ config.sops.secrets."hermes-env".path ];
addToSystemPackages = true;
};
}
```
That's it. `nixos-rebuild switch` creates the `hermes` user, generates `config.yaml`, wires up secrets, and starts the gateway — a long-running service that connects the agent to messaging platforms (Telegram, Discord, etc.) and listens for incoming messages.
:::warning Secrets are required
The `environmentFiles` line above assumes you have [sops-nix](https://github.com/Mic92/sops-nix) or [agenix](https://github.com/ryantm/agenix) configured. The file should contain at least one LLM provider key (e.g., `OPENROUTER_API_KEY=sk-or-...`). See [Secrets Management](#secrets-management) for full setup. If you don't have a secrets manager yet, you can use a plain file as a starting point — just ensure it's not world-readable:
```bash
echo "OPENROUTER_API_KEY=sk-or-your-key" | sudo install -m 0600 -o hermes /dev/stdin /var/lib/hermes/env
```
```nix
services.hermes-agent.environmentFiles = [ "/var/lib/hermes/env" ];
```
:::
:::tip addToSystemPackages
Setting `addToSystemPackages = true` does two things: puts the `hermes` CLI on your system PATH **and** sets `HERMES_HOME` system-wide so the interactive CLI shares state (sessions, skills, cron) with the gateway service. Without it, running `hermes` in your shell creates a separate `~/.hermes/` directory.
:::
### Verify It Works
After `nixos-rebuild switch`, check that the service is running:
```bash
# Check service status
systemctl status hermes-agent
# Watch logs (Ctrl+C to stop)
journalctl -u hermes-agent -f
# If addToSystemPackages is true, test the CLI
hermes version
hermes config # shows the generated config
```
### Choosing a Deployment Mode
The module supports two modes, controlled by `container.enable`:
| | **Native** (default) | **Container** |
|---|---|---|
| How it runs | Hardened systemd service on the host | Persistent Ubuntu container with `/nix/store` bind-mounted |
| Security | `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` | Container isolation, runs as unprivileged user inside |
| Agent can self-install packages | No — only tools on the Nix-provided PATH | Yes — `apt`, `pip`, `npm` installs persist across restarts |
| Config surface | Same | Same |
| When to choose | Standard deployments, maximum security, reproducibility | Agent needs runtime package installation, mutable environment, experimental tools |
To enable container mode, add one line:
```nix
{
services.hermes-agent = {
enable = true;
container.enable = true;
# ... rest of config is identical
};
}
```
:::info
Container mode auto-enables `virtualisation.docker.enable` via `mkDefault`. If you use Podman instead, set `container.backend = "podman"` and `virtualisation.docker.enable = false`.
:::
---
## Configuration
### Declarative Settings
The `settings` option accepts an arbitrary attrset that is rendered as `config.yaml`. It supports deep merging across multiple module definitions (via `lib.recursiveUpdate`), so you can split config across files:
```nix
# base.nix
services.hermes-agent.settings = {
model.default = "anthropic/claude-sonnet-4";
toolsets = [ "all" ];
terminal = { backend = "local"; timeout = 180; };
};
# personality.nix
services.hermes-agent.settings = {
display = { compact = false; personality = "kawaii"; };
memory = { memory_enabled = true; user_profile_enabled = true; };
};
```
Both are deep-merged at evaluation time. Nix-declared keys always win over keys in an existing `config.yaml` on disk, but **user-added keys that Nix doesn't touch are preserved**. This means if the agent or a manual edit adds keys like `skills.disabled` or `streaming.enabled`, they survive `nixos-rebuild switch`.
:::note Model naming
`settings.model.default` uses the model identifier your provider expects. With [OpenRouter](https://openrouter.ai) (the default), these look like `"anthropic/claude-sonnet-4"` or `"google/gemini-3-flash"`. If you're using a provider directly (Anthropic, OpenAI), set `settings.model.base_url` to point at their API and use their native model IDs (e.g., `"claude-sonnet-4-20250514"`). When no `base_url` is set, Hermes defaults to OpenRouter.
:::
:::tip Discovering available config keys
The full set of config keys is defined in [`nix/config-keys.json`](https://github.com/NousResearch/hermes-agent/blob/main/nix/config-keys.json) (127 leaf keys). You can paste your existing `config.yaml` into the `settings` attrset — the structure maps 1:1. The build-time `config-drift` check catches any drift between the reference and the Python source.
:::
<details>
<summary><strong>Full example: all commonly customized settings</strong></summary>
```nix
{ config, ... }: {
services.hermes-agent = {
enable = true;
container.enable = true;
# ── Model ──────────────────────────────────────────────────────────
settings = {
model = {
base_url = "https://openrouter.ai/api/v1";
default = "anthropic/claude-opus-4.6";
};
toolsets = [ "all" ];
max_turns = 100;
terminal = { backend = "local"; cwd = "."; timeout = 180; };
compression = {
enabled = true;
threshold = 0.85;
summary_model = "google/gemini-3-flash-preview";
};
memory = { memory_enabled = true; user_profile_enabled = true; };
display = { compact = false; personality = "kawaii"; };
agent = { max_turns = 60; verbose = false; };
};
# ── Secrets ────────────────────────────────────────────────────────
environmentFiles = [ config.sops.secrets."hermes-env".path ];
# ── Documents ──────────────────────────────────────────────────────
documents = {
"SOUL.md" = builtins.readFile /home/user/.hermes/SOUL.md;
"USER.md" = ./documents/USER.md;
};
# ── MCP Servers ────────────────────────────────────────────────────
mcpServers.filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
};
# ── Container options ──────────────────────────────────────────────
container = {
image = "ubuntu:24.04";
backend = "docker";
extraVolumes = [ "/home/user/projects:/projects:rw" ];
extraOptions = [ "--gpus" "all" ];
};
# ── Service tuning ─────────────────────────────────────────────────
addToSystemPackages = true;
extraArgs = [ "--verbose" ];
restart = "always";
restartSec = 5;
};
}
```
</details>
### Escape Hatch: Bring Your Own Config
If you'd rather manage `config.yaml` entirely outside Nix, use `configFile`:
```nix
services.hermes-agent.configFile = /etc/hermes/config.yaml;
```
This bypasses `settings` entirely — no merge, no generation. The file is copied as-is to `$HERMES_HOME/config.yaml` on each activation.
### Customization Cheatsheet
Quick reference for the most common things Nix users want to customize:
| I want to... | Option | Example |
|---|---|---|
| Change the LLM model | `settings.model.default` | `"anthropic/claude-sonnet-4"` |
| Use a different provider endpoint | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` |
| Add API keys | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` |
| Give the agent a personality | `documents."SOUL.md"` | `builtins.readFile ./my-soul.md` |
| Add MCP tool servers | `mcpServers.<name>` | See [MCP Servers](#mcp-servers) |
| Mount host directories into container | `container.extraVolumes` | `[ "/data:/data:rw" ]` |
| Pass GPU access to container | `container.extraOptions` | `[ "--gpus" "all" ]` |
| Use Podman instead of Docker | `container.backend` | `"podman"` |
| Add tools to the service PATH (native only) | `extraPackages` | `[ pkgs.pandoc pkgs.imagemagick ]` |
| Use a custom base image | `container.image` | `"ubuntu:24.04"` |
| Override the hermes package | `package` | `inputs.hermes-agent.packages.${system}.default.override { ... }` |
| Change state directory | `stateDir` | `"/opt/hermes"` |
| Set the agent's working directory | `workingDirectory` | `"/home/user/projects"` |
---
## Secrets Management
:::danger Never put API keys in `settings` or `environment`
Values in Nix expressions end up in `/nix/store`, which is world-readable. Always use `environmentFiles` with a secrets manager.
:::
Both `environment` (non-secret vars) and `environmentFiles` (secret files) are merged into `$HERMES_HOME/.env` at activation time (`nixos-rebuild switch`). Hermes reads this file on every startup, so changes take effect with a `systemctl restart hermes-agent` — no container recreation needed.
### sops-nix
```nix
{
sops = {
defaultSopsFile = ./secrets/hermes.yaml;
age.keyFile = "/home/user/.config/sops/age/keys.txt";
secrets."hermes-env" = { format = "yaml"; };
};
services.hermes-agent.environmentFiles = [
config.sops.secrets."hermes-env".path
];
}
```
The secrets file contains key-value pairs:
```yaml
# secrets/hermes.yaml (encrypted with sops)
hermes-env: |
OPENROUTER_API_KEY=sk-or-...
TELEGRAM_BOT_TOKEN=123456:ABC...
ANTHROPIC_API_KEY=sk-ant-...
```
### agenix
```nix
{
age.secrets.hermes-env.file = ./secrets/hermes-env.age;
services.hermes-agent.environmentFiles = [
config.age.secrets.hermes-env.path
];
}
```
### OAuth / Auth Seeding
For platforms requiring OAuth (e.g., Discord), use `authFile` to seed credentials on first deploy:
```nix
{
services.hermes-agent = {
authFile = config.sops.secrets."hermes/auth.json".path;
# authFileForceOverwrite = true; # overwrite on every activation
};
}
```
The file is only copied if `auth.json` doesn't already exist (unless `authFileForceOverwrite = true`). Runtime OAuth token refreshes are written to the state directory and preserved across rebuilds.
---
## Documents
The `documents` option installs files into the agent's working directory (the `workingDirectory`, which the agent reads as its workspace). Hermes looks for specific filenames by convention:
- **`SOUL.md`** — the agent's system prompt / personality. Hermes reads this on startup and uses it as persistent instructions that shape its behavior across all conversations.
- **`USER.md`** — context about the user the agent is interacting with.
- Any other files you place here are visible to the agent as workspace files.
```nix
{
services.hermes-agent.documents = {
"SOUL.md" = ''
You are a helpful research assistant specializing in NixOS packaging.
Always cite sources and prefer reproducible solutions.
'';
"USER.md" = ./documents/USER.md; # path reference, copied from Nix store
};
}
```
Values can be inline strings or path references. Files are installed on every `nixos-rebuild switch`.
---
## MCP Servers
The `mcpServers` option declaratively configures [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers. Each server uses either **stdio** (local command) or **HTTP** (remote URL) transport.
### Stdio Transport (Local Servers)
```nix
{
services.hermes-agent.mcpServers = {
filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
};
github = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-github" ];
env.GITHUB_PERSONAL_ACCESS_TOKEN = "\${GITHUB_TOKEN}"; # resolved from .env
};
};
}
```
:::tip
Environment variables in `env` values are resolved from `$HERMES_HOME/.env` at runtime. Use `environmentFiles` to inject secrets — never put tokens directly in Nix config.
:::
### HTTP Transport (Remote Servers)
```nix
{
services.hermes-agent.mcpServers.remote-api = {
url = "https://mcp.example.com/v1/mcp";
headers.Authorization = "Bearer \${MCP_REMOTE_API_KEY}";
timeout = 180;
};
}
```
### HTTP Transport with OAuth
Set `auth = "oauth"` for servers using OAuth 2.1. Hermes implements the full PKCE flow — metadata discovery, dynamic client registration, token exchange, and automatic refresh.
```nix
{
services.hermes-agent.mcpServers.my-oauth-server = {
url = "https://mcp.example.com/mcp";
auth = "oauth";
};
}
```
Tokens are stored in `$HERMES_HOME/mcp-tokens/<server-name>.json` and persist across restarts and rebuilds.
<details>
<summary><strong>Initial OAuth authorization on headless servers</strong></summary>
The first OAuth authorization requires a browser-based consent flow. In a headless deployment, Hermes prints the authorization URL to stdout/logs instead of opening a browser.
**Option A: Interactive bootstrap** — run the flow once via `docker exec` (container) or `sudo -u hermes` (native):
```bash
# Container mode
docker exec -it hermes-agent \
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
# Native mode
sudo -u hermes HERMES_HOME=/var/lib/hermes/.hermes \
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
```
The container uses `--network=host`, so the OAuth callback listener on `127.0.0.1` is reachable from the host browser.
**Option B: Pre-seed tokens** — complete the flow on a workstation, then copy tokens:
```bash
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
scp ~/.hermes/mcp-tokens/my-oauth-server{,.client}.json \
server:/var/lib/hermes/.hermes/mcp-tokens/
# Ensure: chown hermes:hermes, chmod 0600
```
</details>
### Sampling (Server-Initiated LLM Requests)
Some MCP servers can request LLM completions from the agent:
```nix
{
services.hermes-agent.mcpServers.analysis = {
command = "npx";
args = [ "-y" "analysis-server" ];
sampling = {
enabled = true;
model = "google/gemini-3-flash";
max_tokens_cap = 4096;
timeout = 30;
max_rpm = 10;
};
};
}
```
---
## Managed Mode
When hermes runs via the NixOS module, the following CLI commands are **blocked** with a descriptive error pointing you to `configuration.nix`:
| Blocked command | Why |
|---|---|
| `hermes setup` | Config is declarative — edit `settings` in your Nix config |
| `hermes config edit` | Config is generated from `settings` |
| `hermes config set <key> <value>` | Config is generated from `settings` |
| `hermes gateway install` | The systemd service is managed by NixOS |
| `hermes gateway uninstall` | The systemd service is managed by NixOS |
This prevents drift between what Nix declares and what's on disk. Detection uses two signals:
1. **`HERMES_MANAGED=true`** environment variable — set by the systemd service, visible to the gateway process
2. **`.managed` marker file** in `HERMES_HOME` — set by the activation script, visible to interactive shells (e.g., `docker exec -it hermes-agent hermes config set ...` is also blocked)
To change configuration, edit your Nix config and run `sudo nixos-rebuild switch`.
---
## Container Architecture
:::info
This section is only relevant if you're using `container.enable = true`. Skip it for native mode deployments.
:::
When container mode is enabled, hermes runs inside a persistent Ubuntu container with the Nix-built binary bind-mounted read-only from the host:
```
Host Container
──── ─────────
/nix/store/...-hermes-agent-0.1.0 ──► /nix/store/... (ro)
/var/lib/hermes/ ──► /data/ (rw)
├── current-package -> /nix/store/... (symlink, updated each rebuild)
├── .gc-root -> /nix/store/... (prevents nix-collect-garbage)
├── .container-identity (sha256 hash, triggers recreation)
├── .hermes/ (HERMES_HOME)
│ ├── .env (merged from environment + environmentFiles)
│ ├── config.yaml (Nix-generated, deep-merged by activation)
│ ├── .managed (marker file)
│ ├── state.db, sessions/, memories/ (runtime state)
│ └── mcp-tokens/ (OAuth tokens for MCP servers)
├── home/ ──► /home/hermes (rw)
└── workspace/ (MESSAGING_CWD)
├── SOUL.md (from documents option)
└── (agent-created files)
Container writable layer (apt/pip/npm): /usr, /usr/local, /tmp
```
The Nix-built binary works inside the Ubuntu container because `/nix/store` is bind-mounted — it brings its own interpreter and all dependencies, so there's no reliance on the container's system libraries. The container entrypoint resolves through a `current-package` symlink: `/data/current-package/bin/hermes gateway run --replace`. On `nixos-rebuild switch`, only the symlink is updated — the container keeps running.
### What Persists Across What
| Event | Container recreated? | `/data` (state) | `/home/hermes` | Writable layer (`apt`/`pip`/`npm`) |
|---|---|---|---|---|
| `systemctl restart hermes-agent` | No | Persists | Persists | Persists |
| `nixos-rebuild switch` (code change) | No (symlink updated) | Persists | Persists | Persists |
| Host reboot | No | Persists | Persists | Persists |
| `nix-collect-garbage` | No (GC root) | Persists | Persists | Persists |
| Image change (`container.image`) | **Yes** | Persists | Persists | **Lost** |
| Volume/options change | **Yes** | Persists | Persists | **Lost** |
| `environment`/`environmentFiles` change | No | Persists | Persists | Persists |
The container is only recreated when its **identity hash** changes. The hash covers: schema version, image, `extraVolumes`, `extraOptions`, and the entrypoint script. Changes to environment variables, settings, documents, or the hermes package itself do **not** trigger recreation.
:::warning Writable layer loss
When the identity hash changes (image upgrade, new volumes, new container options), the container is destroyed and recreated from a fresh pull of `container.image`. Any `apt install`, `pip install`, or `npm install` packages in the writable layer are lost. State in `/data` and `/home/hermes` is preserved (these are bind mounts).
If the agent relies on specific packages, consider baking them into a custom image (`container.image = "my-registry/hermes-base:latest"`) or scripting their installation in the agent's SOUL.md.
:::
### GC Root Protection
The `preStart` script creates a GC root at `${stateDir}/.gc-root` pointing to the current hermes package. This prevents `nix-collect-garbage` from removing the running binary. If the GC root somehow breaks, restarting the service recreates it.
---
## Development
### Dev Shell
The flake provides a development shell with Python 3.11, uv, Node.js, and all runtime tools:
```bash
cd hermes-agent
nix develop
# Shell provides:
# - Python 3.11 + uv (deps installed into .venv on first entry)
# - Node.js 20, ripgrep, git, openssh, ffmpeg on PATH
# - Stamp-file optimization: re-entry is near-instant if deps haven't changed
hermes setup
hermes chat
```
### direnv (Recommended)
The included `.envrc` activates the dev shell automatically:
```bash
cd hermes-agent
direnv allow # one-time
# Subsequent entries are near-instant (stamp file skips dep install)
```
### Flake Checks
The flake includes build-time verification that runs in CI and locally:
```bash
# Run all checks
nix flake check
# Individual checks
nix build .#checks.x86_64-linux.package-contents # binaries exist + version
nix build .#checks.x86_64-linux.entry-points-sync # pyproject.toml ↔ Nix package sync
nix build .#checks.x86_64-linux.cli-commands # gateway/config subcommands
nix build .#checks.x86_64-linux.managed-guard # HERMES_MANAGED blocks mutation
nix build .#checks.x86_64-linux.bundled-skills # skills present in package
nix build .#checks.x86_64-linux.config-drift # config keys match Python source
nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves user keys
```
<details>
<summary><strong>What each check verifies</strong></summary>
| Check | What it tests |
|---|---|
| `package-contents` | `hermes` and `hermes-agent` binaries exist and `hermes version` runs |
| `entry-points-sync` | Every `[project.scripts]` entry in `pyproject.toml` has a wrapped binary in the Nix package |
| `cli-commands` | `hermes --help` exposes `gateway` and `config` subcommands |
| `managed-guard` | `HERMES_MANAGED=true hermes config set ...` prints the NixOS error |
| `bundled-skills` | Skills directory exists, contains SKILL.md files, `HERMES_BUNDLED_SKILLS` is set in wrapper |
| `config-drift` | Leaf keys extracted from Python's `DEFAULT_CONFIG` match the committed `nix/config-keys.json` reference |
| `config-roundtrip` | 7 merge scenarios: fresh install, Nix override, user key preservation, mixed merge, MCP additive merge, nested deep merge, idempotency |
</details>
---
## Options Reference
### Core
| Option | Type | Default | Description |
|---|---|---|---|
| `enable` | `bool` | `false` | Enable the hermes-agent service |
| `package` | `package` | `hermes-agent` | The hermes-agent package to use |
| `user` | `str` | `"hermes"` | System user |
| `group` | `str` | `"hermes"` | System group |
| `createUser` | `bool` | `true` | Auto-create user/group |
| `stateDir` | `str` | `"/var/lib/hermes"` | State directory (`HERMES_HOME` parent) |
| `workingDirectory` | `str` | `"${stateDir}/workspace"` | Agent working directory (`MESSAGING_CWD`) |
| `addToSystemPackages` | `bool` | `false` | Add `hermes` CLI to system PATH and set `HERMES_HOME` system-wide |
### Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| `settings` | `attrs` (deep-merged) | `{}` | Declarative config rendered as `config.yaml`. Supports arbitrary nesting; multiple definitions are merged via `lib.recursiveUpdate` |
| `configFile` | `null` or `path` | `null` | Path to an existing `config.yaml`. Overrides `settings` entirely if set |
### Secrets & Environment
| Option | Type | Default | Description |
|---|---|---|---|
| `environmentFiles` | `listOf str` | `[]` | Paths to env files with secrets. Merged into `$HERMES_HOME/.env` at activation time |
| `environment` | `attrsOf str` | `{}` | Non-secret env vars. **Visible in Nix store** — do not put secrets here |
| `authFile` | `null` or `path` | `null` | OAuth credentials seed. Only copied on first deploy |
| `authFileForceOverwrite` | `bool` | `false` | Always overwrite `auth.json` from `authFile` on activation |
### Documents
| Option | Type | Default | Description |
|---|---|---|---|
| `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Keys are filenames, values are inline strings or paths. Installed into `workingDirectory` on activation |
### MCP Servers
| Option | Type | Default | Description |
|---|---|---|---|
| `mcpServers` | `attrsOf submodule` | `{}` | MCP server definitions, merged into `settings.mcp_servers` |
| `mcpServers.<name>.command` | `null` or `str` | `null` | Server command (stdio transport) |
| `mcpServers.<name>.args` | `listOf str` | `[]` | Command arguments |
| `mcpServers.<name>.env` | `attrsOf str` | `{}` | Environment variables for the server process |
| `mcpServers.<name>.url` | `null` or `str` | `null` | Server endpoint URL (HTTP/StreamableHTTP transport) |
| `mcpServers.<name>.headers` | `attrsOf str` | `{}` | HTTP headers, e.g. `Authorization` |
| `mcpServers.<name>.auth` | `null` or `"oauth"` | `null` | Authentication method. `"oauth"` enables OAuth 2.1 PKCE |
| `mcpServers.<name>.enabled` | `bool` | `true` | Enable or disable this server |
| `mcpServers.<name>.timeout` | `null` or `int` | `null` | Tool call timeout in seconds (default: 120) |
| `mcpServers.<name>.connect_timeout` | `null` or `int` | `null` | Connection timeout in seconds (default: 60) |
| `mcpServers.<name>.tools` | `null` or `submodule` | `null` | Tool filtering (`include`/`exclude` lists) |
| `mcpServers.<name>.sampling` | `null` or `submodule` | `null` | Sampling config for server-initiated LLM requests |
### Service Behavior
| Option | Type | Default | Description |
|---|---|---|---|
| `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` |
| `extraPackages` | `listOf package` | `[]` | Extra packages on service PATH (native mode only) |
| `restart` | `str` | `"always"` | systemd `Restart=` policy |
| `restartSec` | `int` | `5` | systemd `RestartSec=` value |
### Container
| Option | Type | Default | Description |
|---|---|---|---|
| `container.enable` | `bool` | `false` | Enable OCI container mode |
| `container.backend` | `enum ["docker" "podman"]` | `"docker"` | Container runtime |
| `container.image` | `str` | `"ubuntu:24.04"` | Base image (pulled at runtime) |
| `container.extraVolumes` | `listOf str` | `[]` | Extra volume mounts (`host:container:mode`) |
| `container.extraOptions` | `listOf str` | `[]` | Extra args passed to `docker create` |
---
## Directory Layout
### Native Mode
```
/var/lib/hermes/ # stateDir (owned by hermes:hermes, 0750)
├── .hermes/ # HERMES_HOME
│ ├── config.yaml # Nix-generated (deep-merged each rebuild)
│ ├── .managed # Marker: CLI config mutation blocked
│ ├── .env # Merged from environment + environmentFiles
│ ├── auth.json # OAuth credentials (seeded, then self-managed)
│ ├── gateway.pid
│ ├── state.db
│ ├── mcp-tokens/ # OAuth tokens for MCP servers
│ ├── sessions/
│ ├── memories/
│ ├── skills/
│ ├── cron/
│ └── logs/
├── home/ # Agent HOME
└── workspace/ # MESSAGING_CWD
├── SOUL.md # From documents option
└── (agent-created files)
```
### Container Mode
Same layout, mounted into the container:
| Container path | Host path | Mode | Notes |
|---|---|---|---|
| `/nix/store` | `/nix/store` | `ro` | Hermes binary + all Nix deps |
| `/data` | `/var/lib/hermes` | `rw` | All state, config, workspace |
| `/home/hermes` | `${stateDir}/home` | `rw` | Persistent agent home — `pip install --user`, tool caches |
| `/usr`, `/usr/local`, `/tmp` | (writable layer) | `rw` | `apt`/`pip`/`npm` installs — persists across restarts, lost on recreation |
---
## Updating
```bash
# Update the flake input
nix flake update hermes-agent --flake /etc/nixos
# Rebuild
sudo nixos-rebuild switch
```
In container mode, the `current-package` symlink is updated and the agent picks up the new binary on restart. No container recreation, no loss of installed packages.
---
## Troubleshooting
:::tip Podman users
All `docker` commands below work the same with `podman`. Substitute accordingly if you set `container.backend = "podman"`.
:::
### Service Logs
```bash
# Both modes use the same systemd unit
journalctl -u hermes-agent -f
# Container mode: also available directly
docker logs -f hermes-agent
```
### Container Inspection
```bash
systemctl status hermes-agent
docker ps -a --filter name=hermes-agent
docker inspect hermes-agent --format='{{.State.Status}}'
docker exec -it hermes-agent bash
docker exec hermes-agent readlink /data/current-package
docker exec hermes-agent cat /data/.container-identity
```
### Force Container Recreation
If you need to reset the writable layer (fresh Ubuntu):
```bash
sudo systemctl stop hermes-agent
docker rm -f hermes-agent
sudo rm /var/lib/hermes/.container-identity
sudo systemctl start hermes-agent
```
### Verify Secrets Are Loaded
If the agent starts but can't authenticate with the LLM provider, check that the `.env` file was merged correctly:
```bash
# Native mode
sudo -u hermes cat /var/lib/hermes/.hermes/.env
# Container mode
docker exec hermes-agent cat /data/.hermes/.env
```
### GC Root Verification
```bash
nix-store --query --roots $(docker exec hermes-agent readlink /data/current-package)
```
### Common Issues
| Symptom | Cause | Fix |
|---|---|---|
| `Cannot save configuration: managed by NixOS` | CLI guards active | Edit `configuration.nix` and `nixos-rebuild switch` |
| Container recreated unexpectedly | `extraVolumes`, `extraOptions`, or `image` changed | Expected — writable layer resets. Reinstall packages or use a custom image |
| `hermes version` shows old version | Container not restarted | `systemctl restart hermes-agent` |
| Permission denied on `/var/lib/hermes` | State dir is `0750 hermes:hermes` | Use `docker exec` or `sudo -u hermes` |
| `nix-collect-garbage` removed hermes | GC root missing | Restart the service (preStart recreates the GC root) |

View File

@@ -9,6 +9,7 @@ const sidebars: SidebarsConfig = {
items: [ items: [
'getting-started/quickstart', 'getting-started/quickstart',
'getting-started/installation', 'getting-started/installation',
'getting-started/nix-setup',
'getting-started/updating', 'getting-started/updating',
'getting-started/learning-path', 'getting-started/learning-path',
], ],