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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2557 additions and 2 deletions

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