diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 00000000..004f8236 --- /dev/null +++ b/.github/workflows/nix.yml @@ -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 diff --git a/.gitignore b/.gitignore index 77ca54f3..baa31a54 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ environments/benchmarks/evals/ # Release script temp files .release_notes.md mini-swe-agent/ + +# Nix +.direnv/ +result diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..628e492f --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..87be89c8 --- /dev/null +++ b/flake.nix @@ -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 + ]; + }; +} diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f3c6073a..d73edc6a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -46,6 +46,32 @@ from hermes_cli.colors import Colors, color 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 # ============================================================================= @@ -1342,6 +1368,9 @@ _COMMENTED_SECTIONS = """ def save_config(config: Dict[str, Any]): """Save configuration to ~/.hermes/config.yaml.""" + if is_managed(): + managed_error("save configuration") + return from utils import atomic_yaml_write ensure_hermes_home() @@ -1483,6 +1512,9 @@ def sanitize_env_file() -> int: def save_env_value(key: str, value: str): """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): raise ValueError(f"Invalid environment variable name: {key!r}") value = value.replace("\n", "").replace("\r", "") @@ -1739,6 +1771,9 @@ def show_config(): def edit_config(): """Open config file in user's editor.""" + if is_managed(): + managed_error("edit configuration") + return config_path = get_config_path() # Ensure config exists @@ -1768,6 +1803,9 @@ def edit_config(): def set_config_value(key: str, value: str): """Set a configuration value.""" + if is_managed(): + managed_error("set configuration values") + return # Check if it's an API key (goes to .env) api_keys = [ 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b156c75e..d1d79867 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -14,7 +14,7 @@ from pathlib import Path 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 ( print_header, print_info, print_success, print_warning, print_error, prompt, prompt_choice, prompt_yes_no, @@ -1562,6 +1562,9 @@ def _setup_signal(): def gateway_setup(): """Interactive setup for messaging platforms + gateway service.""" + if is_managed(): + managed_error("run gateway setup") + return print() print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) @@ -1716,6 +1719,9 @@ def gateway_command(args): # Service management commands if subcmd == "install": + if is_managed(): + managed_error("install gateway service (managed by NixOS)") + return force = getattr(args, 'force', False) system = getattr(args, 'system', False) run_as_user = getattr(args, 'run_as_user', None) @@ -1729,6 +1735,9 @@ def gateway_command(args): sys.exit(1) elif subcmd == "uninstall": + if is_managed(): + managed_error("uninstall gateway service (managed by NixOS)") + return system = getattr(args, 'system', False) if is_linux(): systemd_uninstall(system=system) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ff460ebc..ddb86529 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -3106,6 +3106,10 @@ def run_setup_wizard(args): hermes setup tools — just tool configuration 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() config = load_config() diff --git a/nix/checks.nix b/nix/checks.nix new file mode 100644 index 00000000..4af5d552 --- /dev/null +++ b/nix/checks.nix @@ -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 + ''; + }; + }; +} diff --git a/nix/config-keys.json b/nix/config-keys.json new file mode 100644 index 00000000..327958fb --- /dev/null +++ b/nix/config-keys.json @@ -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" +] diff --git a/nix/configMergeScript.nix b/nix/configMergeScript.nix new file mode 100644 index 00000000..bea2d616 --- /dev/null +++ b/nix/configMergeScript.nix @@ -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) +'' diff --git a/nix/devShell.nix b/nix/devShell.nix new file mode 100644 index 00000000..7f8b5a1b --- /dev/null +++ b/nix/devShell.nix @@ -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." + ''; + }; + }; +} diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix new file mode 100644 index 00000000..178305a2 --- /dev/null +++ b/nix/nixosModules.nix @@ -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; + }; + }; + }) + ]); + }; +} diff --git a/nix/packages.nix b/nix/packages.nix new file mode 100644 index 00000000..8c2b7cbd --- /dev/null +++ b/nix/packages.nix @@ -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; + }; + }; + }; +} diff --git a/nix/python.nix b/nix/python.nix new file mode 100644 index 00000000..406e7aee --- /dev/null +++ b/nix/python.nix @@ -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" ]; +} diff --git a/tests/tools/test_skills_sync.py b/tests/tools/test_skills_sync.py index 1549d517..e3469c80 100644 --- a/tests/tools/test_skills_sync.py +++ b/tests/tools/test_skills_sync.py @@ -4,6 +4,7 @@ from pathlib import Path from unittest.mock import patch from tools.skills_sync import ( + _get_bundled_dir, _read_manifest, _write_manifest, _discover_bundled_skills, @@ -467,3 +468,24 @@ class TestSyncSkills: new_bundled_hash = _dir_hash(bundled / "old-skill") assert manifest["old-skill"] == new_bundled_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" diff --git a/tools/skills_sync.py b/tools/skills_sync.py index b89e4599..f76fcced 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -37,7 +37,14 @@ MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest" 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" diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 83ed9555..e3282fa8 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -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`). ::: +:::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 diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md new file mode 100644 index 00000000..2adec8b4 --- /dev/null +++ b/website/docs/getting-started/nix-setup.md @@ -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/`. + +
+Building from a local clone + +```bash +git clone https://github.com/NousResearch/hermes-agent.git +cd hermes-agent +nix build +./result/bin/hermes setup +``` + +
+ +--- + +## 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. +::: + +
+Full example: all commonly customized settings + +```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; + }; +} +``` + +
+ +### 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.` | 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/.json` and persist across restarts and rebuilds. + +
+Initial OAuth authorization on headless servers + +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 +``` + +
+ +### 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 ` | 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 +``` + +
+What each check verifies + +| 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 | + +
+ +--- + +## 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..command` | `null` or `str` | `null` | Server command (stdio transport) | +| `mcpServers..args` | `listOf str` | `[]` | Command arguments | +| `mcpServers..env` | `attrsOf str` | `{}` | Environment variables for the server process | +| `mcpServers..url` | `null` or `str` | `null` | Server endpoint URL (HTTP/StreamableHTTP transport) | +| `mcpServers..headers` | `attrsOf str` | `{}` | HTTP headers, e.g. `Authorization` | +| `mcpServers..auth` | `null` or `"oauth"` | `null` | Authentication method. `"oauth"` enables OAuth 2.1 PKCE | +| `mcpServers..enabled` | `bool` | `true` | Enable or disable this server | +| `mcpServers..timeout` | `null` or `int` | `null` | Tool call timeout in seconds (default: 120) | +| `mcpServers..connect_timeout` | `null` or `int` | `null` | Connection timeout in seconds (default: 60) | +| `mcpServers..tools` | `null` or `submodule` | `null` | Tool filtering (`include`/`exclude` lists) | +| `mcpServers..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) | diff --git a/website/sidebars.ts b/website/sidebars.ts index 92a56bcc..0665662d 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -9,6 +9,7 @@ const sidebars: SidebarsConfig = { items: [ 'getting-started/quickstart', 'getting-started/installation', + 'getting-started/nix-setup', 'getting-started/updating', 'getting-started/learning-path', ],