mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
8f6ef042c1
commit
b6461903ff
20 changed files with 2557 additions and 2 deletions
376
nix/checks.nix
Normal file
376
nix/checks.nix
Normal 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
129
nix/config-keys.json
Normal 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
33
nix/configMergeScript.nix
Normal 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
51
nix/devShell.nix
Normal 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
716
nix/nixosModules.nix
Normal 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
54
nix/packages.nix
Normal 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
28
nix/python.nix
Normal 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" ];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue