feat(nix): home-manager module, shared with the NixOS module

Hermes is an agent for one person. The credentials, the memory, the
sessions and the cron jobs all belong to that person. But the only
declarative path was a NixOS system service. Issue #9056 asks for the
user-level equivalent. 25 public Nix configurations already write one by
hand, and several of them copy nix/nixosModules.nix and edit the systemd
part.

This module is not a second copy of that file. The code that both modules
share moves into nix/moduleCommon.nix:

  - the options
  - the renderers for config.yaml, .env and the documents
  - the activation body
  - the command lines of the processes

nixosModules.nix keeps only the parts that need root. Those parts are the
service user, stateDir, addToSystemPackages, container mode and tmpfiles.
The file goes from 1008 lines to 666.

`services.hermes-agent` is now the same option set on both modules. A
NixOS example works on Home Manager without a change, and an option added
one time appears on both.

The Home Manager module is different only where it must be. It uses
systemd.user.services on Linux and launchd.agents on Darwin. It uses
home.activation and not system.activationScripts. It sets HERMES_HOME
directly, with the default ~/.hermes, so an existing directory continues
to work. It uses the modes 0600 and 0700, because the state has one user
and does not need the group-shared umask of the NixOS module. It does not
support container mode, which needs root and the Docker socket.

The change also makes four corrections that apply to both modules:

- backend.mode runs `hermes serve` or `hermes dashboard`. Both modules
  had only the gateway. But Hermes Desktop and the web dashboard connect
  to a different process, so six of the configurations in public repos
  add a second unit by hand. serve and dashboard are one entry point with
  one flag of difference, and you can run only one of them. Thus the
  option is an enum. The NixOS module asserts against container mode with
  a backend, and does not make a unit that cannot start.

- hermesHomeFiles installs files into HERMES_HOME. The `documents` option
  installs into the working directory, which is correct for AGENTS.md but
  wrong for SOUL.md and memories/. Hermes reads those files from
  HERMES_HOME, in agent/prompt_builder.py:2095. A SOUL.md in `documents`
  made a workspace file that Hermes never loaded as the identity. The
  documentation said this in prose, but two directory diagrams showed the
  opposite. This change corrects both. A key in either option can now
  contain subdirectories.

- `documents` needs an explicit `workingDirectory`. The default of that
  option is bad on both modules. It is the home directory of the user on
  Home Manager, and ${stateDir}/workspace on NixOS. A user who declares
  workspace files without a directory therefore gets a place that the
  user did not select. The place is also different on each module. The
  modules now refuse that combination.

  The test is on the priority of the option and not on its value. An
  option that nothing sets keeps the priority of its own default, and
  each definition from a user is stronger. Thus a directory with the same
  text as the default still counts as a selection, and so does a
  mkDefault. A comparison of values detects neither case.

- Each activation writes .env again from a base in the Nix store, and
  does not add to the file that exists. Thus a second activation cannot
  put the same secret in the file two times, and a removed
  environmentFile goes away. environmentFiles keeps the type `listOf
  str` and not `path`, so Nix cannot copy a sops-nix or agenix path into
  the Nix store, which all users can read.

- HERMES_MANAGED and the .managed marker now hold the name of the system
  that manages the install. Thus a refusal says "managed by home-manager"
  and not "managed by NixOS", and `hermes update` gives the Nix guidance
  for both shapes. The CLI does not print a rebuild command for each
  system. It names the owner, and the user knows their own tool. A bare
  `true` and an empty marker still mean NixOS, so this does not change an
  existing install.

Verification. Six new checks, all built:

  nixos-module           evaluates the module with evalModules and the
                         NixOS module list. It asserts both units, one
                         HERMES_HOME, and that the module refuses
                         container mode with a backend.
  home-manager-module    evaluates the module with the
                         homeManagerConfiguration function of
                         home-manager. The process assertions run against
                         systemd units on Linux and launchd agents on
                         Darwin.
  module-option-parity   asserts that each shared option is on both
                         modules, and that the two exclusion lists name
                         only options that exist.
  env-file-assembly      runs the real .env script and checks the
                         contents, the mode, that a second run gives the
                         same bytes, and that a removed file goes away.
  workspace-files-need-a-directory
                         checks that the module refuses `documents`
                         without a directory, and accepts a directory
                         that has the same text as the default.
  service-argv           runs each command line that the modules build
                         through the real parser of the CLI, with one
                         sentinel flag added, and requires that argparse
                         refuses only the sentinel.

`nix flake check` passes, with 21 checks in total.

The CLI branches that treat an install as a Nix install move to one
helper, is_nix_install_method. Four call sites in main.py, web_server.py,
update_cmd.py and doctor.py tested the literal set {"nix", "nixos"}, and
each one missed home-manager. recommended_update_command asks the managed
state before the code-scoped stamp again, because a managed install can
carry a stale stamp that names an update path the managed guard refuses.
The metrics contract gets a home-manager bucket, so a Home Manager
install does not report as unknown.

Each check was mutation-probed. 22 faults were injected, and the checks
caught all 22:

  - a lost --no-open
  - a backend that runs the gateway
  - an overwritten config.yaml
  - documents in the wrong directory
  - a different HERMES_HOME on the two processes
  - a lost HERMES_HOME export
  - a missing backend unit
  - a removed assertion
  - an .env file that grows at each activation
  - an install that reports NixOS
  - an empty .managed marker
  - an option on the NixOS module only
  - a stale entry in an exclusion list
  - a renamed subcommand
  - an unknown flag
  - the workspace-files assertion always passes
  - the assertion compares values instead of priorities
  - an off-by-one that lets an untouched default through
  - the assertion also fires for hermesHomeFiles
  - a mkDefault no longer counts as a selection
  - the Home Manager module stops wiring the assertion
  - the NixOS module stops wiring the assertion

The 16 Python tests in tests/hermes_cli/test_managed_install_shapes.py
were probed the same way. 8 faults were injected and 8 were caught.

These tests fail on this tree. They fail in the same way on the stashed
HEAD, and they have no relation to Nix:

  - test_git_probe_tree_kill.py (2 tests)
  - test_update_import_guard.py (1 test)
  - test_telegram_media_read_timeout.py (2 tests)
  - test_teams.py (a collection error)

Closes #9056

# Conflicts:
#	hermes_cli/main.py
#	hermes_cli/update_cmd.py
#	hermes_cli/web_server.py
This commit is contained in:
ethernet
2026-08-11 20:25:42 -04:00
parent 3c675019f1
commit d5a9c2ba6c
16 changed files with 2649 additions and 997 deletions
Generated
+21
View File
@@ -20,6 +20,26 @@
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1786487128,
"narHash": "sha256-ad60hrRVhH/bo3Jl1YLzO+QcS3mUNZr9LNJdzhMO2P4=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "f404edbfa4117810c96b97048299242fc50e5362",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1785318670,
@@ -105,6 +125,7 @@
"root": {
"inputs": {
"flake-parts": "flake-parts",
"home-manager": "home-manager",
"nixpkgs": "nixpkgs",
"npm-lockfile-fix": "npm-lockfile-fix",
"pyproject-build-systems": "pyproject-build-systems",
+9
View File
@@ -26,6 +26,14 @@
url = "github:jeslie0/npm-lockfile-fix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Used only by nix/checks.nix, to evaluate homeManagerModules.default
# against the real Home Manager module system rather than a stub of it.
# Consuming the module does not require this input — import it from your
# own home-manager, exactly as you would any other HM module.
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
@@ -41,6 +49,7 @@
./nix/packages.nix
./nix/overlays.nix
./nix/nixosModules.nix
./nix/homeManagerModules.nix
./nix/checks.nix
./nix/devShell.nix
];
+44 -31
View File
@@ -338,10 +338,10 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul
# =============================================================================
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
_MANAGED_SYSTEM_NAMES = {
"nix": "NixOS",
"nixos": "NixOS",
}
_NIX_MANAGED_SYSTEMS = {"nixos", "home-manager"}
# Only the NixOS module ever wrote a bare "true" or an empty marker, so both
# legacy signals name that system.
_LEGACY_MANAGED_SYSTEM = "nixos"
# The Nix store root. Used by detect_install_method to identify installs
# from `nix run` / `nix profile install` (which don't set HERMES_MANAGED).
# A module-level constant so tests can patch it without creating files
@@ -357,19 +357,31 @@ _IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"})
def get_managed_system() -> Optional[str]:
"""Return the package manager owning this install, if any."""
raw = os.getenv("HERMES_MANAGED", "").strip()
marker = None
if raw:
normalized = raw.lower()
if normalized in _IGNORED_MANAGED_VALUES:
return None
if normalized in _MANAGED_TRUE_VALUES:
return "NixOS"
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
marker = raw.lower()
else:
managed_marker = get_hermes_home() / ".managed"
# An interactive shell reads the marker, because it does not see the
# HERMES_MANAGED variable of the service. A marker with content
# names the system that manages the install.
if managed_marker.exists():
return "NixOS"
try:
marker = managed_marker.read_text(encoding="utf-8", errors="replace").strip().lower()
except OSError:
marker = ""
if marker is None:
return None
if marker in _IGNORED_MANAGED_VALUES:
return None
if marker == "" or marker in _MANAGED_TRUE_VALUES:
return _LEGACY_MANAGED_SYSTEM
return marker
def is_managed() -> bool:
"""Check if Hermes is running in package-manager-managed mode.
@@ -381,6 +393,9 @@ def is_managed() -> bool:
return get_managed_system() is not None
# Nix installs arrive by several routes (nix run, nix profile, a system flake,
# home-manager), and the running process cannot tell which one. Thus this text
# names the routes instead of one command.
_NIX_UPDATE_MSG = (
"Update Hermes through the Nix source that installed it "
"(e.g. nix profile upgrade, or update your flake input and rebuild with nixos-rebuild or home-manager switch)"
@@ -390,7 +405,7 @@ _NIX_UPDATE_MSG = (
def get_managed_update_command() -> Optional[str]:
"""Return the preferred upgrade command for a managed install."""
managed_system = get_managed_system()
if managed_system == "NixOS":
if managed_system in _NIX_MANAGED_SYSTEMS:
return _NIX_UPDATE_MSG
return None
@@ -546,9 +561,19 @@ def stamp_install_method(method: str, project_root: Optional[Path] = None) -> No
pass
def is_nix_install_method(method: str) -> bool:
"""Return True for every install method that Nix owns.
The callers that branch on the install method must treat "nix",
"nixos" and "home-manager" the same way. One helper keeps the three
names in one place, so a new Nix shape cannot miss a call site.
"""
return method == "nix" or method in _NIX_MANAGED_SYSTEMS
def recommended_update_command_for_method(method: str) -> str:
"""Return the update command or guidance for a given install method."""
if method in {"nix", "nixos"}:
if is_nix_install_method(method):
return _NIX_UPDATE_MSG
if method == "docker":
return "docker pull nousresearch/hermes-agent:latest"
@@ -561,6 +586,9 @@ def recommended_update_command_for_method(method: str) -> str:
def recommended_update_command() -> str:
"""Return the best update command for the current installation."""
# The managed state wins over the code-scoped stamp. A managed install
# can carry a stale stamp from an earlier install shape, and the stamp
# then names an update path that the managed guard refuses.
managed_cmd = get_managed_update_command()
if managed_cmd:
return managed_cmd
@@ -623,17 +651,6 @@ def format_docker_update_message() -> str:
def format_managed_message(action: str = "modify this Hermes installation") -> str:
"""Build a user-facing error for managed installs."""
managed_system = get_managed_system() or "a package manager"
raw = os.getenv("HERMES_MANAGED", "").strip().lower()
if managed_system == "NixOS":
env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true"
return (
f"Cannot {action}: this Hermes installation is managed by NixOS "
f"(HERMES_MANAGED={env_hint}).\n"
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
" sudo nixos-rebuild switch"
)
return (
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
"Use your package manager to upgrade or reinstall Hermes."
@@ -928,16 +945,12 @@ def _ensure_hermes_home_managed(home: Path):
"""Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md."""
if not home.is_dir():
raise RuntimeError(
f"HERMES_HOME {home} does not exist. "
"Run 'sudo nixos-rebuild switch' first."
f"HERMES_HOME {home} does not exist."
)
for subdir in ("cron", "sessions", "logs", "memories"):
d = home / subdir
if not d.is_dir():
raise RuntimeError(
f"{d} does not exist. "
"Run 'sudo nixos-rebuild switch' first."
)
raise RuntimeError(f"{d} does not exist.")
# Curator reports dir is a sub-path of logs/; create it if missing.
# In managed mode the activation script may not know about this subdir,
# so we mkdir it ourselves (it's inside an already-secured logs/ dir).
+2 -1
View File
@@ -16,6 +16,7 @@ from hermes_cli.config import (
get_env_path,
get_hermes_home,
get_project_root,
is_nix_install_method,
recommended_update_command_for_method,
)
from hermes_cli.env_loader import load_hermes_dotenv
@@ -90,7 +91,7 @@ def _sqlite_upgrade_hint(install_method: str | None = None) -> str:
if method == "docker":
command = recommended_update_command_for_method(method)
action = f"run `{command}`, then recreate all Hermes containers"
elif method in {"nix", "nixos"}:
elif is_nix_install_method(method):
# The Nix helper is prose guidance, not a literal shell command.
action = recommended_update_command_for_method(method)
elif method == "apt":
+2 -2
View File
@@ -7511,7 +7511,7 @@ def _gateway_command_inner(args):
# Service management commands
if subcmd == "install":
if is_managed():
managed_error("install gateway service (managed by NixOS)")
managed_error("install gateway service")
return
force = getattr(args, "force", False)
system = getattr(args, "system", False)
@@ -7623,7 +7623,7 @@ def _gateway_command_inner(args):
elif subcmd == "uninstall":
if is_managed():
managed_error("uninstall gateway service (managed by NixOS)")
managed_error("uninstall gateway service")
return
system = getattr(args, "system", False)
if is_termux():
+2 -1
View File
@@ -9905,6 +9905,7 @@ def cmd_update(args):
detect_install_method,
format_docker_update_message,
is_managed,
is_nix_install_method,
managed_error,
recommended_update_command_for_method,
)
@@ -9924,7 +9925,7 @@ def cmd_update(args):
print(format_docker_update_message())
sys.exit(1)
if install_method in {"nix", "nixos", "apt"}:
if is_nix_install_method(install_method) or install_method == "apt":
print(recommended_update_command_for_method(install_method))
sys.exit(1)
@@ -58,7 +58,7 @@
},
"install_method": {
"type": "string",
"enum": ["apt", "docker", "git", "homebrew", "nixos", "pip", "unknown"]
"enum": ["apt", "docker", "git", "home-manager", "homebrew", "nixos", "pip", "unknown"]
},
"os_family": {
"type": "string",
@@ -197,6 +197,7 @@ CLIENT_INSTALL_METHODS: frozenset[str] = frozenset({
"apt",
"docker",
"git",
"home-manager",
"homebrew",
"nixos",
"pip",
+6 -2
View File
@@ -2690,7 +2690,11 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
Installs that can't honor non-default branches (e.g. Docker) surface a
one-line notice instead of silently dropping the flag.
"""
from hermes_cli.config import detect_install_method, recommended_update_command_for_method
from hermes_cli.config import (
detect_install_method,
is_nix_install_method,
recommended_update_command_for_method,
)
method = detect_install_method(_m().PROJECT_ROOT)
if method == "docker":
# Docker can't ``git fetch`` from within the container. Surface the
@@ -2701,7 +2705,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
print(format_docker_update_message())
sys.exit(1)
if method in {"nix", "nixos", "apt"}:
if is_nix_install_method(method) or method == "apt":
print(recommended_update_command_for_method(method))
sys.exit(1)
+2 -1
View File
@@ -78,6 +78,7 @@ from hermes_cli.config import (
check_config_version,
detect_install_method,
format_docker_update_message,
is_nix_install_method,
recommended_update_command_for_method,
redact_key,
write_platform_config_field,
@@ -4776,7 +4777,7 @@ async def update_hermes():
"update_command": recommended_update_command_for_method(install_method),
}
if install_method in {"nix", "nixos", "apt"}:
if is_nix_install_method(install_method) or install_method == "apt":
message = recommended_update_command_for_method(install_method)
_record_completed_action("hermes-update", message, exit_code=1)
return {
+491 -1
View File
@@ -11,6 +11,73 @@
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
# ── How the checks evaluate the modules ───────────────────────────
# The checks evaluate both modules for real. The NixOS module goes
# through lib.evalModules with the NixOS module list. The Home Manager
# module goes through the homeManagerConfiguration function of
# home-manager. The option system rejects a wrong type, an option that
# does not exist, and a broken activation string. Each of these faults
# then stops the check, and not the rebuild of a user.
evalNixosModule =
settings:
inputs.nixpkgs.lib.evalModules {
modules = import "${inputs.nixpkgs}/nixos/modules/module-list.nix" ++ [
inputs.self.nixosModules.default
{ _module.args.lib = inputs.nixpkgs.lib; }
{ nixpkgs.hostPlatform = pkgs.stdenv.hostPlatform.system; }
{
system.stateVersion = "24.11";
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "/dev/null";
fsType = "ext4";
};
}
{ services.hermes-agent = settings; }
];
};
evalHomeModule =
settings:
inputs.home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
inputs.self.homeManagerModules.default
{
home = {
username = "hermes-check";
homeDirectory = "/home/hermes-check";
stateVersion = "24.11";
};
}
{ services.hermes-agent = settings; }
];
};
# The option names that each module defines under
# services.hermes-agent. The internal names that the module system adds
# are not in the list.
moduleOptionNames =
eval: lib.attrNames (lib.filterAttrs (n: _: !lib.hasPrefix "_" n) eval.options.services.hermes-agent);
# These options belong to one module by design. The check does not
# compare the two lists against each other, because that test only
# detects a change. The important property is that each shared option
# is on both modules.
nixosOnlyOptions = [
"addToSystemPackages"
"container"
"createUser"
"group"
"stateDir"
"user"
];
homeOnlyOptions = [
"gateway"
"hermesHome"
"installPackage"
];
# Auto-generated config key reference — always in sync with Python
configKeys = pkgs.runCommand "hermes-config-keys" {} ''
set -euo pipefail
@@ -74,7 +141,427 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
mkdir -p $out
echo "ok" > $out/result
'';
# ── The Home Manager module ──────────────────────────────────────
# This check evaluates homeManagerModules.default through the real
# module system of home-manager. It runs on each platform. The module
# supports Linux, with systemd user units, and Darwin, with launchd
# agents. Each host checks its own kind of process.
home-manager-module =
let
enabled = evalHomeModule {
enable = true;
gateway.enable = true;
backend.mode = "serve";
settings.model.default = "test/model";
environment.HERMES_TEST = "1";
environmentFiles = [ "/run/secrets/hermes-env" ];
hermesHomeFiles."SOUL.md" = "test soul";
# documents needs an explicit workingDirectory. The check
# workspace-files-need-a-directory below asserts that rule.
workingDirectory = "/home/test-user/workspace";
documents."AGENTS.md" = "test agents";
mcpServers.demo = {
command = "echo";
args = [ "hi" ];
};
};
cfg = enabled.config;
# The gateway and the backend are two processes with one
# HERMES_HOME.
processes =
if pkgs.stdenv.hostPlatform.isDarwin then
lib.mapAttrs (_: agent: {
argv = agent.config.ProgramArguments;
env = agent.config.EnvironmentVariables;
}) (lib.filterAttrs (n: _: lib.hasPrefix "hermes" n) cfg.launchd.agents)
else
lib.mapAttrs (_: unit: {
argv = [ unit.Service.ExecStart ];
env = unit.Service.Environment;
}) (lib.filterAttrs (n: _: lib.hasPrefix "hermes" n) cfg.systemd.user.services);
names = lib.attrNames processes;
argvOf = name: lib.concatStringsSep " " (lib.flatten (processes.${name}.argv));
# The systemd Environment is a list of "K=V" strings. The launchd
# equivalent is an attribute set. Make both into one "K=V K=V"
# string, so that the assertions below are the same on each host.
envOf =
name:
let
env = processes.${name}.env;
in
lib.concatStringsSep " " (
if lib.isAttrs env then lib.mapAttrsToList (k: v: "${k}=${toString v}") env else env
);
activation = cfg.home.activation.hermesAgentSetup.data;
failures =
lib.optional (names != [
"hermes-agent"
"hermes-backend"
]) "expected hermes-agent + hermes-backend processes, got: ${toString names}"
++ lib.optional (
!lib.hasInfix "bin/hermes gateway" (argvOf "hermes-agent")
) "gateway process does not run `hermes gateway`: ${argvOf "hermes-agent"}"
++ lib.optional (
!lib.hasInfix "bin/hermes serve" (argvOf "hermes-backend")
) "backend process does not run `hermes serve`: ${argvOf "hermes-backend"}"
++ lib.optional (
!lib.hasInfix "--no-open" (argvOf "hermes-backend")
) "backend must pass --no-open so a service never opens a browser"
++ lib.optional (
lib.any (n: !lib.hasInfix "/home/hermes-check/.hermes" (envOf n)) names
) "gateway and backend must share one HERMES_HOME"
++ lib.optional (
cfg.home.sessionVariables.HERMES_HOME or null != "/home/hermes-check/.hermes"
) "installPackage must export HERMES_HOME for interactive shells"
++ lib.optional (
!lib.hasInfix "hermes-config-merge" activation
) "activation must deep-merge config.yaml, not overwrite it"
++ lib.optional (
!lib.hasInfix "/home/hermes-check/.hermes/SOUL.md" activation
) "hermesHomeFiles must install into HERMES_HOME"
++ lib.optional (
!lib.hasInfix "/home/test-user/workspace/AGENTS.md" activation
) "documents must install into workingDirectory"
# The CLI reads HERMES_MANAGED to name the rebuild command when
# it refuses to write the configuration. A Home Manager install
# has no nixos-rebuild command. Thus it must not report NixOS.
++ lib.optional (
!lib.any (n: lib.hasInfix "HERMES_MANAGED=home-manager" (envOf n)) names
) "processes must report HERMES_MANAGED=home-manager"
++ lib.optional (
!lib.hasInfix "hermes-managed" activation
) "activation must write a .managed marker naming the managing system";
in
pkgs.runCommand "hermes-home-manager-module" { } (
if failures != [ ] then
throw "Home Manager module check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
else
''
echo "PASS: home-manager module evaluates (${toString (lib.length names)} processes)"
mkdir -p $out
echo "ok" > $out/result
''
);
# ── Workspace files need a chosen directory ──────────────────────
# `documents` goes into workingDirectory. The default of that option
# is bad, and it is different on each module, so the modules refuse
# the two options together.
#
# Home Manager reads the assertions while it builds `config`. Thus a
# refused case throws an error and does not return a list. tryEval
# makes the error into data again.
workspace-files-need-a-directory =
let
accepts =
settings:
let
cfg = (evalHomeModule ({ enable = true; } // settings)).config;
probe = builtins.tryEval (lib.all (a: a.assertion) cfg.assertions);
in
probe.success && probe.value;
rejects = settings: !(accepts settings);
# This directory has the same text as the default. A comparison
# of values reads it as untouched, but a comparison of priorities
# sees the definition. This row is the reason that the code tests
# the priority.
sameAsDefault = "/home/hermes-check";
cases = [
{
name = "documents without a directory is refused";
ok = rejects { documents."AGENTS.md" = "x"; };
}
{
name = "documents with a directory is accepted";
ok = accepts {
documents."AGENTS.md" = "x";
workingDirectory = "/srv/workspace";
};
}
{
name = "a directory equal to the default still counts as chosen";
ok = accepts {
documents."AGENTS.md" = "x";
workingDirectory = sameAsDefault;
};
}
{
name = "mkDefault counts as chosen";
ok = accepts {
documents."AGENTS.md" = "x";
workingDirectory = lib.mkDefault "/srv/workspace";
};
}
{
name = "hermesHomeFiles needs no directory";
ok = accepts { hermesHomeFiles."SOUL.md" = "x"; };
}
{
name = "no files at all is accepted";
ok = accepts { };
}
];
failed = lib.filter (c: !c.ok) cases;
in
pkgs.runCommand "hermes-workspace-files-need-a-directory" { } (
if failed != [ ] then
throw "workspace-files rule failed:\n${
lib.concatMapStringsSep "\n" (c: " - ${c.name}") failed
}"
else
''
${lib.concatMapStringsSep "\n" (c: ''echo "PASS: ${c.name}"'') cases}
mkdir -p $out
echo "ok" > $out/result
''
);
# ── The two modules keep the same options ────────────────────────
# The modules share one option set, in nix/moduleCommon.nix. Thus a
# NixOS example works on Home Manager without a change. This check
# asserts that relation and not the current list of names. An option
# that goes into the shared set must appear on both modules. An
# option for one module must be in that module's exclusion list.
module-option-parity =
let
nixosNames = moduleOptionNames (evalNixosModule { });
homeNames = moduleOptionNames (evalHomeModule { });
sharedFromNixos = lib.subtractLists nixosOnlyOptions nixosNames;
sharedFromHome = lib.subtractLists homeOnlyOptions homeNames;
missingInHome = lib.subtractLists homeNames sharedFromNixos;
missingInNixos = lib.subtractLists nixosNames sharedFromHome;
# These two values check the exclusion lists. An entry for an
# option that does not exist makes the check weaker, and gives no
# message.
staleNixosOnly = lib.subtractLists nixosNames nixosOnlyOptions;
staleHomeOnly = lib.subtractLists homeNames homeOnlyOptions;
failures =
lib.optional (
missingInHome != [ ]
) "shared options missing from the Home Manager module: ${toString missingInHome} (add to nix/moduleCommon.nix, or list under nixosOnlyOptions if system-scoped)"
++ lib.optional (
missingInNixos != [ ]
) "shared options missing from the NixOS module: ${toString missingInNixos} (add to nix/moduleCommon.nix, or list under homeOnlyOptions if user-scoped)"
++ lib.optional (
staleNixosOnly != [ ]
) "nixosOnlyOptions names options the NixOS module no longer defines: ${toString staleNixosOnly}"
++ lib.optional (
staleHomeOnly != [ ]
) "homeOnlyOptions names options the Home Manager module no longer defines: ${toString staleHomeOnly}";
in
pkgs.runCommand "hermes-module-option-parity" { } (
if failures != [ ] then
throw "Module option parity failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
else
''
echo "PASS: ${toString (lib.length sharedFromNixos)} shared options present on both modules"
mkdir -p $out
echo "ok" > $out/result
''
);
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
# ── The NixOS module ─────────────────────────────────────────────
# This check runs on Linux only. The evaluation of a NixOS module
# needs a Linux hostPlatform.
nixos-module =
let
cfg = (evalNixosModule {
enable = true;
backend.mode = "dashboard";
settings.model.default = "test/model";
environmentFiles = [ "/run/secrets/hermes-env" ];
hermesHomeFiles."SOUL.md" = "test soul";
}).config;
units = lib.filterAttrs (n: _: lib.hasPrefix "hermes" n) cfg.systemd.services;
names = lib.attrNames units;
execOf = name: units.${name}.serviceConfig.ExecStart;
activation = cfg.system.activationScripts."hermes-agent-setup".text;
failures =
lib.optional (names != [
"hermes-agent"
"hermes-backend"
]) "expected hermes-agent + hermes-backend units, got: ${toString names}"
++ lib.optional (
!lib.hasInfix "bin/hermes gateway" (execOf "hermes-agent")
) "gateway unit does not run `hermes gateway`: ${execOf "hermes-agent"}"
++ lib.optional (
!lib.hasInfix "bin/hermes dashboard" (execOf "hermes-backend")
) "backend unit does not run `hermes dashboard`: ${execOf "hermes-backend"}"
++ lib.optional (
units.hermes-agent.environment.HERMES_HOME != units.hermes-backend.environment.HERMES_HOME
) "gateway and backend must share one HERMES_HOME"
++ lib.optional (
!lib.hasInfix "/var/lib/hermes/.hermes/SOUL.md" activation
) "hermesHomeFiles must install into HERMES_HOME";
# You cannot use container mode and the backend together. The
# module says so with an assertion. Without the assertion it
# makes a unit that never starts.
containerConflict = builtins.tryEval (
lib.deepSeq
(evalNixosModule {
enable = true;
container.enable = true;
backend.mode = "serve";
}).config.system.build.toplevel.drvPath
true
);
in
pkgs.runCommand "hermes-nixos-module" { } (
if failures != [ ] then
throw "NixOS module check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
else if containerConflict.success then
throw "NixOS module check failed:\n - an assertion must reject backend.mode with container.enable"
else
''
echo "PASS: nixos module evaluates (${toString (lib.length names)} units)"
mkdir -p $out
echo "ok" > $out/result
''
);
# ── How .env is built ────────────────────────────────────────────
# This check runs the real script that both modules use to build
# $HERMES_HOME/.env. The important property is that a second run
# gives the same result. Activation runs at each rebuild. If the
# script added the secrets to the file that exists, the file would
# grow at each rebuild. The script writes the file again from the
# base in the Nix store, which prevents that fault. This check proves
# it.
env-file-assembly =
let
envScript = (import ./moduleCommon.nix { inherit lib; }).mkEnvScript {
inherit pkgs;
environment = {
HERMES_PUBLIC = "visible";
};
};
in
pkgs.runCommand "hermes-env-file-assembly" { } ''
set -e
workdir=$(mktemp -d)
printf 'SECRET_TOKEN=s3cret\n' > "$workdir/secret-a"
printf 'OTHER_TOKEN=t0ken\n' > "$workdir/secret-b"
echo "=== First activation ==="
${envScript} "$workdir/.env" 0600 "$workdir/secret-a" "$workdir/secret-b"
first=$(cat "$workdir/.env")
grep -qx 'HERMES_PUBLIC=visible' "$workdir/.env" || \
(echo "FAIL: non-secret environment missing"; cat "$workdir/.env"; exit 1)
grep -qx 'SECRET_TOKEN=s3cret' "$workdir/.env" || \
(echo "FAIL: secret from environmentFile missing"; cat "$workdir/.env"; exit 1)
grep -qx 'OTHER_TOKEN=t0ken' "$workdir/.env" || \
(echo "FAIL: second environmentFile missing"; cat "$workdir/.env"; exit 1)
echo "PASS: .env contains the declared environment and every secret"
test "$(stat -c %a "$workdir/.env")" = "600" || \
(echo "FAIL: .env mode is $(stat -c %a "$workdir/.env"), want 600"; exit 1)
echo "PASS: .env installed with the requested mode"
echo "=== Re-activation is idempotent ==="
${envScript} "$workdir/.env" 0600 "$workdir/secret-a" "$workdir/secret-b"
second=$(cat "$workdir/.env")
test "$first" = "$second" || \
(echo "FAIL: second run changed .env"; diff <(echo "$first") <(echo "$second") || true; exit 1)
COUNT=$(grep -c '^SECRET_TOKEN=' "$workdir/.env")
test "$COUNT" -eq 1 || \
(echo "FAIL: secret appears $COUNT times after two activations"; exit 1)
echo "PASS: secrets are not accumulated across activations"
echo "=== A removed environmentFile disappears ==="
${envScript} "$workdir/.env" 0600 "$workdir/secret-a"
if grep -q '^OTHER_TOKEN=' "$workdir/.env"; then
echo "FAIL: dropped environmentFile still present in .env"; exit 1
fi
echo "PASS: .env tracks the declared environmentFiles"
mkdir -p $out
echo "ok" > $out/result
'';
# ── The command lines of the services ────────────────────────────
# The modules build these command lines. This check runs each one
# through the real parser of the CLI. A subcommand or a flag with a
# new name then fails here, and not as a service that restarts again
# and again after a rebuild.
#
# The method: add one sentinel flag that the CLI does not know, and
# parse without --help. argparse refuses unknown arguments before it
# calls the command, so no process starts and no port is bound. The
# error names each argument that argparse did not accept. If the
# error names only the sentinel, the parser accepts each other flag.
#
# `--help` cannot do this job. It returns before argparse reads the
# remainder of the command line.
service-argv =
let
common = import ./moduleCommon.nix { inherit lib; };
cfgFor = mode: {
package = hermes-agent;
extraPythonPackages = [ ];
extraDependencyGroups = [ ];
extraArgs = [ ];
backend = {
inherit mode;
host = "127.0.0.1";
port = 9119;
extraArgs = [ ];
};
};
sentinel = "--hermes-nix-argv-probe";
probe = argv: lib.escapeShellArgs (argv ++ [ sentinel ]);
in
pkgs.runCommand "hermes-service-argv" { } ''
set -e
export HOME=$(mktemp -d)
check() {
local label="$1"
shift
local output
output=$("$@" 2>&1) && {
echo "FAIL: $label — the sentinel flag was accepted, so this probe proves nothing"
exit 1
}
case "$output" in
*"unrecognized arguments: ${sentinel}")
echo "PASS: $label — every flag but the sentinel is recognized" ;;
*"unrecognized arguments"*)
echo "FAIL: $label — the CLI also rejected flags the module passes:"
echo "$output" | tail -3
exit 1 ;;
*)
echo "FAIL: $label — argv rejected before flag parsing (bad subcommand?):"
echo "$output" | tail -3
exit 1 ;;
esac
}
check "gateway" ${probe (common.gatewayArgv (cfgFor "none"))}
check "serve" ${probe (common.backendArgv (cfgFor "serve"))}
check "dashboard" ${probe (common.backendArgv (cfgFor "dashboard"))}
mkdir -p $out
echo "ok" > $out/result
'';
# Verify binaries exist and are executable
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
set -e
@@ -288,7 +775,10 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
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)
# Case-insensitive: the message names the managing system as the
# identifier it is keyed by, and the display form is not the
# property under test here.
echo "$OUTPUT" | grep -qi "managed by nixos" || (echo "FAIL: $label not guarded"; echo "$OUTPUT"; exit 1)
echo "PASS: $label blocked in managed mode"
}
+261
View File
@@ -0,0 +1,261 @@
# nix/homeManagerModules.nix — the Home Manager module for hermes-agent
#
# This module is the user-level equivalent of nixosModules.default. Hermes is
# an agent for one person. The credentials, the memory, the sessions and the
# cron jobs all belong to that person. Thus a user-level module is correct on
# each distribution, and not only on NixOS.
#
# `services.hermes-agent` is the same option set on both modules. All of the
# options except the system-level ones come from nix/moduleCommon.nix, so an
# example from the NixOS documentation works here without a change. Only the
# necessary parts are different:
#
# removed user, group, createUser — Home Manager runs as the user
# removed container.* — it needs root and the Docker socket
# removed UMask 0007 — that mode shares state with a UNIX
# group, but this state has one user
# changed systemd.services -> systemd.user.services or
# launchd.agents
# changed system.activationScripts -> home.activation
# changed addToSystemPackages -> installPackage and
# home.sessionVariables
# changed stateDir (+ "/.hermes") -> hermesHome, set directly
#
# To use the module:
# imports = [ hermes-agent.homeManagerModules.default ];
# services.hermes-agent = {
# enable = true;
# gateway.enable = true;
# settings.model.default = "anthropic/claude-sonnet-4";
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
# };
#
# CAUTION: Enable linger for the account. Without linger, systemd stops the
# user manager at logout, and both units stop with it. Home Manager cannot
# run `loginctl enable-linger`. On NixOS, set
# users.users.<name>.linger = true;
# On other systems, run `loginctl enable-linger <name>` one time.
{ inputs, ... }:
{
flake.homeManagerModules.default =
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.services.hermes-agent;
common = import ./moduleCommon.nix { inherit lib; };
effectivePackage = common.effectivePackage cfg;
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
inherit (pkgs.stdenv.hostPlatform) isDarwin isLinux;
processEnvironment = common.processEnvironment {
inherit (cfg) hermesHome;
# The CLI reads this value and names it when it refuses a
# configuration change.
managedSystem = "home-manager";
};
unitPath = lib.makeBinPath (common.processPath { inherit pkgs cfg; });
# The systemd unit that the gateway and the backend both start from.
mkUnit =
{
description,
argv,
}:
{
Unit = {
Description = description;
# Do not use network-online.target here. That is a system target.
# A user unit that orders against it has no effect, and systemd
# gives no message.
After = [ "default.target" ];
};
Install.WantedBy = [ "default.target" ];
Service = {
Type = "simple";
Environment = (lib.mapAttrsToList (k: v: "${k}=${v}") processEnvironment) ++ [
"PATH=${unitPath}"
];
ExecStart = lib.escapeShellArgs argv;
WorkingDirectory = cfg.workingDirectory;
Restart = cfg.restart;
RestartSec = cfg.restartSec;
# This state has one user. Keep it private. The NixOS module uses
# 0007 to share the state with a UNIX group.
UMask = "0077";
NoNewPrivileges = true;
PrivateTmp = true;
};
};
mkAgent =
{ argv, logName }:
{
enable = true;
config = {
Label = "org.nix-community.home.${logName}";
ProgramArguments = argv;
EnvironmentVariables = processEnvironment // {
PATH = "${unitPath}:/usr/bin:/bin:/usr/sbin:/sbin";
};
WorkingDirectory = cfg.workingDirectory;
RunAtLoad = true;
KeepAlive =
if cfg.restart == "always" then
true
else
{
SuccessfulExit = false;
Crashed = true;
};
ThrottleInterval = cfg.restartSec;
StandardOutPath = "${config.home.homeDirectory}/Library/Logs/${logName}.log";
StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/${logName}.err.log";
ProcessType = "Background";
};
};
in
{
options.services.hermes-agent =
common.sharedOptions {
defaultPackage = hermes-agent;
defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default";
defaultWorkingDirectory = config.home.homeDirectory;
defaultWorkingDirectoryText = lib.literalExpression "config.home.homeDirectory";
}
// {
hermesHome = lib.mkOption {
type = lib.types.str;
default = "${config.home.homeDirectory}/.hermes";
defaultText = lib.literalExpression ''"''${config.home.homeDirectory}/.hermes"'';
description = ''
The value of HERMES_HOME. This state directory holds
config.yaml, .env, auth.json, the sessions, the skills, the
memory and the cron jobs.
The NixOS module takes a `stateDir` and adds `/.hermes` to it.
This module sets HERMES_HOME directly. Thus an existing
~/.hermes continues to work, and you can give the directory any
name.
'';
example = "/home/alice/.hermes-work";
};
installPackage = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Add the hermes CLI to home.packages, and export HERMES_HOME
with home.sessionVariables. Interactive shells then use the
same state as the services.
The equivalent NixOS option, `addToSystemPackages`, exports
HERMES_HOME with environment.variables. That variable applies
to the full system and replaces the HERMES_HOME of each other
user. This module exports the variable for one user session
only, which is the reason to use Home Manager.
'';
};
gateway.enable = lib.mkEnableOption "the messaging gateway service (Telegram, Discord, Slack, ...)";
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
# ── Merge MCP servers into settings ────────────────────────────
(lib.mkIf (cfg.mcpServers != { }) {
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
})
{
assertions =
common.pluginNameAssertions {
inherit cfg;
optionPath = "services.hermes-agent";
}
++ common.workspaceFilesAssertions {
inherit cfg;
opt = options.services.hermes-agent.workingDirectory;
optionPath = "services.hermes-agent";
};
}
# ── Packages and interactive-shell environment ─────────────────
(lib.mkIf cfg.installPackage {
home.packages = [ effectivePackage ] ++ cfg.extraPackages;
home.sessionVariables.HERMES_HOME = cfg.hermesHome;
})
# ── Activation: directories, config, secrets, documents ────────
{
# The activation runs after writeBoundary, when the home.file
# symlinks are in place. It also runs after linkGeneration, when
# Home Manager completes the switch. A secret that the activation
# entry of sops-nix writes exists at that point.
home.activation.hermesAgentSetup =
lib.hm.dag.entryAfter
[
"writeBoundary"
"linkGeneration"
]
(
common.mkStateScript {
inherit pkgs cfg;
inherit (cfg) hermesHome workingDirectory;
run = "$DRY_RUN_CMD ";
stateDirs = common.stateSubdirs;
managedSystem = "home-manager";
# This state has one user. No group needs access to it.
modes = {
config = "0600";
env = "0600";
managed = "0600";
auth = "0600";
document = "0600";
};
}
);
}
# ── Linux: systemd user services ───────────────────────────────
(lib.mkIf (isLinux && cfg.gateway.enable) {
systemd.user.services.hermes-agent = mkUnit {
description = "Hermes Agent Gateway";
argv = common.gatewayArgv cfg;
};
})
(lib.mkIf (isLinux && cfg.backend.mode != "none") {
systemd.user.services.hermes-backend = mkUnit {
description = common.backendDescription cfg;
argv = common.backendArgv cfg;
};
})
# ── Darwin: launchd agents ─────────────────────────────────────
(lib.mkIf (isDarwin && cfg.gateway.enable) {
launchd.agents.hermes-agent = mkAgent {
argv = common.gatewayArgv cfg;
logName = "hermes-agent";
};
})
(lib.mkIf (isDarwin && cfg.backend.mode != "none") {
launchd.agents.hermes-backend = mkAgent {
argv = common.backendArgv cfg;
logName = "hermes-backend";
};
})
]
);
};
}
+916
View File
@@ -0,0 +1,916 @@
# nix/moduleCommon.nix — the code that the NixOS and Home Manager modules share
#
# `services.hermes-agent` is the same option set on both modules. Both modules
# get their options, their renderers for config.yaml, .env and documents, and
# their state setup from this file. A NixOS example works on Home Manager
# without a change. An option added here appears on both modules at once.
#
# Each module keeps only the parts that belong to its own scope:
#
# nixosModules.nix the service user and group, stateDir,
# addToSystemPackages, container mode, tmpfiles,
# system.activationScripts, system systemd units
# homeManagerModules.nix hermesHome, installPackage, home.activation,
# systemd.user.services, launchd.agents
#
# The split is by scope, not by feature. Code that needs root or a system
# identity stays in the NixOS module. All other code is here.
{ lib }:
let
inherit (lib)
literalExpression
mkOption
types
;
# ── Configuration type ──────────────────────────────────────────────────
# More than one module can set `settings = { ... }`. recursiveUpdate joins
# all of the definitions. Without it, only the last definition applies.
deepConfigType = 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);
};
# ── MCP server submodule ────────────────────────────────────────────────
mcpServerType = 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.";
};
};
};
# Convert the mcpServers submodules into the shape that config.yaml uses.
mcpServersToConfig =
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
;
};
}
) servers;
documentsType = types.attrsOf (types.either types.str types.path);
# ── The options that both modules share ─────────────────────────────────
# `defaultPackage` and `defaultWorkingDirectory` are different on each
# module, so the caller gives them. All other options are the same.
sharedOptions =
{
defaultPackage,
defaultPackageText,
defaultWorkingDirectory,
defaultWorkingDirectoryText,
}:
{
enable = lib.mkEnableOption "Hermes Agent";
# ── Package ────────────────────────────────────────────────────────
package = mkOption {
type = types.package;
default = defaultPackage;
defaultText = defaultPackageText;
description = "The hermes-agent package to use.";
};
workingDirectory = mkOption {
type = types.str;
default = defaultWorkingDirectory;
defaultText = defaultWorkingDirectoryText;
description = ''
The working directory for the agent. The module also writes this
path to config.yaml as `terminal.cwd`. The terminal and file tools
of the agent use that value.
'';
};
# ── Declarative config ─────────────────────────────────────────────
configFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
The path to an existing config.yaml. If you set this option, it
replaces the `settings` option. The module installs the file
without a change and overwrites all runtime edits on each
activation.
'';
};
settings = mkOption {
type = deepConfigType;
default = { };
description = ''
The Hermes configuration, as an attribute set. The module joins the
definitions from all modules and writes the result to config.yaml.
The merge into the config.yaml on disk is also a deep merge. These
keys replace the keys on disk. The module keeps all other keys,
which includes the keys that `hermes config set` and the settings
panes of the TUI and the desktop app write at runtime.
'';
example = literalExpression ''
{
model.default = "anthropic/claude-sonnet-4";
terminal.backend = "local";
compression = { enabled = true; threshold = 0.85; };
}
'';
};
# ── Secrets / environment ──────────────────────────────────────────
environmentFiles = mkOption {
# The type is `str` and not `path` for a reason. A Nix path literal
# copies the secret into the Nix store, which all users can read. Use
# a runtime path from sops-nix or agenix instead, for example
# `config.sops.secrets."x".path`.
type = types.listOf types.str;
default = [ ];
description = ''
The paths to environment files that contain secrets, for example
API keys and tokens. Activation adds the contents of these files to
$HERMES_HOME/.env. Hermes reads that file at each start, with
load_hermes_dotenv().
Each activation writes .env again from the start. Thus a secret
file cannot go into .env two times.
'';
example = literalExpression ''[ config.sops.secrets."hermes/env".path ]'';
};
environment = mkOption {
type = types.attrsOf types.str;
default = { };
description = ''
Environment variables that are not secret. Activation writes them
to $HERMES_HOME/.env.
CAUTION: Do not put secrets in this option. All users can read the
Nix store. Use environmentFiles for secrets.
'';
};
authFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
The path to a file that gives the first contents of auth.json, the
OAuth credentials. The module copies the file only when auth.json
does not exist. Thus a token that Hermes refreshes at runtime stays
after an activation.
'';
};
authFileForceOverwrite = mkOption {
type = types.bool;
default = false;
description = "Always overwrite auth.json from authFile on activation.";
};
# ── Documents ──────────────────────────────────────────────────────
documents = mkOption {
type = documentsType;
default = { };
description = ''
Workspace files. The module installs them into workingDirectory.
Each key is a path relative to that directory, and the module makes
the necessary subdirectories. Each value is a string or a path.
Use this option for the project context that the agent reads from
its working directory, for example AGENTS.md, notes and checklists.
Hermes reads SOUL.md and memories/ from HERMES_HOME, so put those
files in `hermesHomeFiles`.
If you set this option, you must also set `workingDirectory`. The
default of that option is different on each module. Thus an unset
default puts these files in a directory that you did not select.
'';
example = literalExpression ''
{
"AGENTS.md" = ./AGENTS.md;
"notes/oncall.md" = "Page #infra before restarting anything.";
}
'';
};
hermesHomeFiles = mkOption {
type = documentsType;
default = { };
description = ''
Files that the module installs into HERMES_HOME. Each key is a path
relative to that directory, and the module makes the necessary
subdirectories. Each value is a string or a path.
Hermes reads SOUL.md and the memory files from HERMES_HOME and not
from the working directory. Declare those files here, or Hermes
does not load them.
'';
example = literalExpression ''
{
"SOUL.md" = "You are a helpful AI assistant.";
"memories/USER.md" = ./USER.md;
}
'';
};
# ── MCP Servers ────────────────────────────────────────────────────
mcpServers = mkOption {
type = types.attrsOf mcpServerType;
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";
};
}
'';
};
# ── Packages / plugins ─────────────────────────────────────────────
extraPackages = mkOption {
type = types.listOf types.package;
default = [ ];
description = "More packages on the PATH of the agent. The agent can run these tools.";
};
extraPlugins = mkOption {
type = types.listOf types.package;
default = [ ];
description = ''
Directory-based plugin packages to symlink into the hermes plugins
directory. Each package must contain a plugin.yaml and __init__.py
at its root. Hermes discovers these automatically on startup.
'';
example = literalExpression ''
[
(pkgs.fetchFromGitHub {
owner = "stephenschoettler";
repo = "hermes-lcm";
name = "hermes-lcm";
rev = "v0.7.0";
hash = "sha256-...";
})
]
'';
};
extraPythonPackages = mkOption {
type = types.listOf types.package;
default = [ ];
description = ''
Python packages to add to PYTHONPATH for entry-point plugin discovery.
These are pip-packaged plugins that register via the
hermes_agent.plugins entry-point group. Each package must be built
with the same Python interpreter as hermes (python312).
'';
example = literalExpression ''
[
(pkgs.python312Packages.buildPythonPackage {
pname = "rtk-hermes";
version = "1.0.0";
src = pkgs.fetchFromGitHub {
owner = "ogallotti";
repo = "rtk-hermes";
rev = "main";
hash = "sha256-...";
};
})
]
'';
};
extraDependencyGroups = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Additional pyproject.toml optional-dependency groups to include in
the sealed Python venv. These are resolved by uv alongside core
dependencies — no PYTHONPATH patching or collision risk.
Use this for optional extras already declared in hermes-agent's
pyproject.toml (e.g. "hindsight", "honcho", "voice").
Use extraPythonPackages for external packages not in pyproject.toml.
'';
example = [ "hindsight" ];
};
# ── Service behaviour ──────────────────────────────────────────────
extraArgs = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Extra command-line arguments for `hermes gateway`.";
};
restart = mkOption {
type = types.str;
default = "always";
description = "The systemd Restart= policy. Darwin does not use this option.";
};
restartSec = mkOption {
type = types.int;
default = 5;
description = "The systemd RestartSec= value. Darwin does not use this option.";
};
# ── The backend: `hermes serve` or `hermes dashboard` ──────────────
# `hermes serve` and `hermes dashboard` are the same entry point,
# hermes_cli.main:cmd_dashboard, with one flag of difference. serve runs
# without a user interface. dashboard also serves the web application.
# Both give the /api/ws and /api/pty sockets that Hermes Desktop
# connects to. They are one process, and you can run only one of them.
# Thus this option is an enum and not two booleans.
#
# The backend does not run the messaging gateway. web_server.py only
# controls an external gateway, with `hermes gateway restart`. It does
# not contain a gateway.
backend = {
mode = mkOption {
type = types.enum [
"none"
"serve"
"dashboard"
];
default = "none";
description = ''
The backend process to run with the messaging gateway.
- "none" — no backend
- "serve" — the backend without a user interface. It gives
the /api/ws and /api/pty sockets that Hermes
Desktop connects to.
- "dashboard" — all that "serve" gives, and the browser admin
panel on the same port
"dashboard" contains all of "serve".
'';
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = ''
The address that the backend binds to.
An address other than loopback starts the authentication gate of
the dashboard. You must then configure credentials, or a client
cannot connect. The server also refuses each request with a Host
header that is different from the address that the server bound
to. This is a defence against DNS rebinding. Bind to the name or
the address that your clients use.
'';
};
port = mkOption {
type = types.port;
default = 9119;
description = "The port for the backend.";
};
extraArgs = mkOption {
type = types.listOf types.str;
default = [ ];
description = "More command-line arguments for the backend command.";
};
};
};
# ── Package resolution ──────────────────────────────────────────────────
effectivePackage =
cfg:
if cfg.extraPythonPackages == [ ] && cfg.extraDependencyGroups == [ ] then
cfg.package
else
cfg.package.override { inherit (cfg) extraPythonPackages extraDependencyGroups; };
# ── The rendered config.yaml ────────────────────────────────────────────
# YAML contains JSON, so the output of toJSON is a correct config.yaml.
# terminal.cwd replaces the old MESSAGING_CWD environment variable. The
# order of the recursiveUpdate lets an explicit settings.terminal.cwd
# replace the default value.
mkConfigFiles =
{
pkgs,
cfg,
workingDirectory,
}:
let
generated = pkgs.writeText "hermes-config.yaml" (
builtins.toJSON (lib.recursiveUpdate { terminal.cwd = workingDirectory; } cfg.settings)
);
in
{
inherit generated;
effective = if cfg.configFile != null then cfg.configFile else generated;
mergeScript = pkgs.callPackage ./configMergeScript.nix { };
};
# ── Documents ───────────────────────────────────────────────────────────
# A key can contain subdirectories. The tree has the same shape, so the
# install loop can copy each entry with `install -D`.
mkDocumentTree =
{ pkgs, documents }:
pkgs.runCommand "hermes-documents" { } (
''
mkdir -p $out
''
+ lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value:
let
dir = builtins.dirOf name;
mkdir = lib.optionalString (dir != ".") "mkdir -p $out/${dir}";
in
if builtins.isPath value || lib.isStorePath value then
"${mkdir}\ncp ${value} $out/${name}"
else
"${mkdir}\ncat > $out/${name} <<'HERMES_DOC_EOF'\n${value}\nHERMES_DOC_EOF"
) documents
)
);
# ── How .env is built ───────────────────────────────────────────────────
# The values that are not secret come from the Nix store. Activation adds
# the secrets from paths outside the store. This is one command, so it is
# safe in a dry run. A second activation writes .env again and does not add
# the same secrets a second time.
mkEnvScript =
{ pkgs, environment }:
let
base = pkgs.writeText "hermes-env-base" (
lib.concatStringsSep "\n" (lib.mapAttrsToList (k: v: "${k}=${v}") environment)
+ lib.optionalString (environment != { }) "\n"
);
in
pkgs.writeShellScript "hermes-env-merge" ''
set -eu
dest="$1"
mode="$2"
shift 2
install -m "$mode" ${base} "$dest"
for file in "$@"; do
if [ -r "$file" ]; then
printf '\n' >> "$dest"
cat "$file" >> "$dest"
else
echo "hermes-agent: WARNING cannot read environmentFile $file" >&2
fi
done
'';
# ── State setup ─────────────────────────────────────────────────────────
# The activation code that both modules run. It makes the directories and
# installs config.yaml, .env, auth.json, the documents and the plugins. The
# differences between the two modules are only the install flags for the
# owner and the file modes. Thus they are arguments, and not a second copy
# of the script.
#
# run the command prefix ("" on NixOS, "$DRY_RUN_CMD " on
# Home Manager)
# owner "user:group" that owns each file, or null for the user that
# runs the activation
# modes the file mode for each kind of file
mkStateScript =
{
pkgs,
cfg,
hermesHome,
workingDirectory,
# The value to write as terminal.cwd. It is different from
# workingDirectory only in the container mode of NixOS. There the agent
# sees the directory at its mount point in the container, but
# activation writes to the path on the host.
configWorkingDirectory ? workingDirectory,
run ? "",
owner ? null,
modes,
stateDirs ? [ ],
# The module writes this value into the .managed marker. An
# interactive shell reads the marker, because it does not see the
# HERMES_MANAGED variable of the service. The value tells the shell
# which system owns the install and which rebuild command to name.
managedSystem ? "nixos",
}:
let
installFlags = lib.optionalString (owner != null) (
let
parts = lib.splitString ":" owner;
in
"-o ${lib.head parts} -g ${lib.last parts}"
);
configFiles = mkConfigFiles {
inherit pkgs cfg;
workingDirectory = configWorkingDirectory;
};
envScript = mkEnvScript {
inherit pkgs;
inherit (cfg) environment;
};
documentTree = mkDocumentTree {
inherit pkgs;
inherit (cfg) documents;
};
homeDocumentTree = mkDocumentTree {
inherit pkgs;
documents = cfg.hermesHomeFiles;
};
inst = "${run}install ${installFlags}";
installDocuments =
tree: root: docs:
lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: _value: "${inst} -m ${modes.document} -D ${tree}/${name} ${root}/${name}"
) docs
);
in
''
# Directories. The service units and Hermes make most of these
# directories when they first need them. Activation makes them here so
# that the first activation sets the correct owner and mode, and does
# not use the umask.
${run}mkdir -p ${
lib.escapeShellArgs (
[
hermesHome
workingDirectory
]
++ map (d: "${hermesHome}/${d}") stateDirs
)
}
# config.yaml: merge the Nix settings into the file on disk. Hermes
# writes this file at runtime. A read-only symlink to the Nix store
# breaks each save from the application. The Nix keys replace the keys
# on disk, and the module keeps all other keys.
${
if cfg.configFile != null then
"${inst} -m ${modes.config} -D ${configFiles.effective} ${hermesHome}/config.yaml"
else
''
${run}${configFiles.mergeScript} ${configFiles.generated} ${hermesHome}/config.yaml
${run}chmod ${modes.config} ${hermesHome}/config.yaml
''
}
# The managed-mode marker. It makes an interactive shell also refuse to
# change the configuration that Nix owns.
${inst} -m ${modes.managed} ${pkgs.writeText "hermes-managed" managedSystem} ${hermesHome}/.managed
${lib.optionalString (cfg.environment != { } || cfg.environmentFiles != [ ]) ''
${run}${envScript} ${hermesHome}/.env ${modes.env} ${lib.escapeShellArgs cfg.environmentFiles}
${lib.optionalString (owner != null) "${run}chown ${owner} ${hermesHome}/.env"}
''}
${lib.optionalString (cfg.authFile != null) (
if cfg.authFileForceOverwrite then
"${inst} -m ${modes.auth} ${cfg.authFile} ${hermesHome}/auth.json"
else
''
if [ ! -e ${hermesHome}/auth.json ]; then
${inst} -m ${modes.auth} ${cfg.authFile} ${hermesHome}/auth.json
fi
''
)}
${installDocuments documentTree workingDirectory cfg.documents}
${installDocuments homeDocumentTree hermesHome cfg.hermesHomeFiles}
# Declarative plugins. Activation first deletes the old managed
# symlinks. Thus a plugin that you remove from the configuration also
# goes away from the plugins directory.
${run}find ${hermesHome}/plugins -maxdepth 1 -type l -name 'nix-managed-*' -delete 2>/dev/null || true
${lib.concatMapStringsSep "\n" (plugin: ''
if [ ! -f ${plugin}/plugin.yaml ]; then
echo "hermes-agent: ERROR extraPlugins entry '${plugin}' has no plugin.yaml" >&2
exit 1
fi
${run}ln -sfn ${plugin} ${hermesHome}/plugins/nix-managed-${lib.getName plugin}
'') cfg.extraPlugins}
'';
# ── Process argv ────────────────────────────────────────────────────────
gatewayArgv =
cfg:
[
"${effectivePackage cfg}/bin/hermes"
"gateway"
]
++ cfg.extraArgs;
backendArgv =
cfg:
[
"${effectivePackage cfg}/bin/hermes"
cfg.backend.mode
"--host"
cfg.backend.host
"--port"
(toString cfg.backend.port)
# CAUTION: A service must not try to open a browser when it starts.
"--no-open"
]
++ cfg.backend.extraArgs;
backendDescription =
cfg:
if cfg.backend.mode == "dashboard" then
"Hermes Agent web dashboard and desktop backend"
else
"Hermes Agent backend for Hermes Desktop";
# The environment that each Hermes process needs, from either module.
#
# managedSystem gives the value of HERMES_MANAGED. The CLI reads that
# variable to refuse a configuration change that it cannot keep, and to
# name the correct rebuild command. The answer is different on each module,
# so each module gives its own value.
processEnvironment =
{
hermesHome,
managedSystem ? "true",
}:
{
HERMES_HOME = hermesHome;
HERMES_MANAGED = managedSystem;
};
processPath =
{ pkgs, cfg }:
[
(effectivePackage cfg)
pkgs.bash
pkgs.coreutils
pkgs.git
]
++ cfg.extraPackages;
# workingDirectory has a default on both modules, but a bad one. It is the
# home directory of the user on Home Manager, and ${stateDir}/workspace on
# NixOS. A user who declares files without a directory therefore gets a
# place that the user did not select. The place is also different on each
# module. The modules refuse that combination.
#
# The test is on the priority of the option and not on its value. An option
# that nothing sets keeps the priority of its own default, and each
# definition from a user is stronger. Thus a directory that has the same
# text as the default is still a selection, and so is a mkDefault. A
# comparison of values detects neither.
workspaceFilesAssertions =
{
cfg,
opt,
optionPath,
}:
let
untouched = (lib.mkOptionDefault null).priority; # 1500, derived not spelled
in
[
{
assertion = cfg.documents == { } || opt.highestPrio < untouched;
message = ''
${optionPath}.documents needs an explicit ${optionPath}.workingDirectory.
The files go into workingDirectory. The default of that option is
different on each module, so an unset default puts the files in a
directory that you did not select. Set the directory:
${optionPath}.workingDirectory = "/path/you/want";
To give Hermes an identity and a memory, use
${optionPath}.hermesHomeFiles instead. Those files go to
HERMES_HOME. Hermes reads SOUL.md and memories/ only from there.
'';
}
];
# Two plugins with the same name use one nix-managed-<name> symlink. One of
# the plugins then disappears without a message. Both modules assert
# against this condition.
pluginNameAssertions =
{ cfg, optionPath }:
let
names = map lib.getName cfg.extraPlugins;
in
[
{
assertion = (lib.length names) == (lib.length (lib.unique names));
message = "${optionPath}.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate.";
}
];
# The subdirectories of HERMES_HOME that both modules make.
stateSubdirs = [
"cron"
"sessions"
"logs"
"memories"
"plugins"
];
in
{
inherit
backendArgv
backendDescription
deepConfigType
effectivePackage
gatewayArgv
mcpServerType
mcpServersToConfig
mkConfigFiles
mkDocumentTree
mkEnvScript
mkStateScript
pluginNameAssertions
processEnvironment
processPath
sharedOptions
stateSubdirs
workspaceFilesAssertions
;
}
+221 -563
View File
@@ -1,4 +1,10 @@
# nix/nixosModules.nix — NixOS module for hermes-agent
# nix/nixosModules.nix — the NixOS module for hermes-agent
#
# This module shares its options, its renderers for config.yaml, .env and
# documents, and its state setup with the Home Manager module
# (nix/homeManagerModules.nix). The shared code is in nix/moduleCommon.nix.
# This file holds only the parts that need root: the service user, a system
# state directory, the system PATH, and container mode.
#
# Two modes:
# container.enable = false (default) → native systemd service
@@ -19,75 +25,49 @@
# Usage:
# services.hermes-agent = {
# enable = true;
# settings.model = "anthropic/claude-sonnet-4";
# settings.model.default = "anthropic/claude-sonnet-4";
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
# };
#
{ inputs, ... }: {
flake.nixosModules.default = { config, lib, pkgs, ... }:
{ inputs, ... }:
{
flake.nixosModules.default =
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.services.hermes-agent;
effectivePackage =
if cfg.extraPythonPackages == [ ] && cfg.extraDependencyGroups == [ ]
then cfg.package
else cfg.package.override { inherit (cfg) extraPythonPackages extraDependencyGroups; };
common = import ./moduleCommon.nix { inherit lib; };
effectivePackage = common.effectivePackage cfg;
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.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);
};
hermesHome = "${cfg.stateDir}/.hermes";
# Generate config.yaml from Nix attrset (YAML is a superset of JSON).
# terminal.cwd replaces the deprecated MESSAGING_CWD env var — hermes
# reads it from config.yaml and bridges it to TERMINAL_CWD internally.
# recursiveUpdate: cfg.settings wins, so an explicit
# settings.terminal.cwd overrides the workingDirectory default.
# Container mode uses the in-container mount path.
# In container mode, the agent uses the mount path in the container.
effectiveWorkDir = if cfg.container.enable then containerWorkDir else cfg.workingDirectory;
configJson = builtins.toJSON (
lib.recursiveUpdate { terminal.cwd = effectiveWorkDir; } cfg.settings
);
generatedConfigFile = pkgs.writeText "hermes-config.yaml" configJson;
configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
# config.yaml mode: group-writable (0660) when interactive users share this
# HERMES_HOME via addToSystemPackages, so they can save settings through the
# CLI/TUI without hitting EACCES; otherwise group-read-only (0640). Secrets
# (.env) stay 0640 regardless — see below.
# (.env) stay 0640 regardless.
configYamlMode = if cfg.addToSystemPackages then "0660" else "0640";
# 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";
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
@@ -199,34 +179,80 @@
# 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 {
containerIdentity = builtins.hashString "sha256" (
builtins.toJSON {
schema = 4; # bump when identity inputs change (4: Node 18→22 via NodeSource)
image = cfg.container.image;
extraVolumes = cfg.container.extraVolumes;
extraOptions = cfg.container.extraOptions;
});
}
);
identityFile = "${cfg.stateDir}/.container-identity";
# The CLI on the host reads this file, in get_container_exec_info. The
# file tells the CLI to run in the container and not on the host.
containerModeFile = pkgs.writeText "hermes-container-mode" ''
# Written by the NixOS activation script. Do not edit manually.
backend=${cfg.container.backend}
container_name=${containerName}
exec_user=${cfg.user}
hermes_bin=${containerDataDir}/current-package/bin/hermes
'';
# 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;
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";
# The hardening and the environment that the gateway unit and the
# backend unit share.
commonServiceConfig = {
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.workingDirectory;
# ── Package ──────────────────────────────────────────────────────────
package = mkOption {
type = types.package;
default = hermes-agent;
description = "The hermes-agent package to use.";
Restart = cfg.restart;
RestartSec = cfg.restartSec;
# Shared-state: files created by the service should be group-writable
# so interactive users in the hermes group can read/write them.
UMask = "0007";
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = false;
ReadWritePaths = [
cfg.stateDir
cfg.workingDirectory
];
PrivateTmp = true;
};
# ── Service identity ─────────────────────────────────────────────────
commonUnitEnvironment = {
HOME = cfg.stateDir;
}
// common.processEnvironment { inherit hermesHome; };
unitPath = common.processPath { inherit pkgs cfg; };
in
{
options.services.hermes-agent =
common.sharedOptions {
defaultPackage = hermes-agent;
defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default";
defaultWorkingDirectory = "${cfg.stateDir}/workspace";
defaultWorkingDirectoryText = lib.literalExpression ''"''${cfg.stateDir}/workspace"'';
}
// (
with lib;
{
# ── Service identity ───────────────────────────────────────────
user = mkOption {
type = types.str;
default = "hermes";
@@ -245,320 +271,13 @@
description = "Create the user/group automatically.";
};
# ── Directories ──────────────────────────────────────────────────────
# ── 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.";
};
# ── 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 to the agent — terminal commands, skills,
cron jobs, and the service process all see them.
Implemented via the hermes user's per-user profile
(`/etc/profiles/per-user/${cfg.user}/bin`), which NixOS includes
in PATH for login shells. The packages are also added to the
systemd service PATH for direct process access.
'';
};
extraPlugins = mkOption {
type = types.listOf types.package;
default = [ ];
description = ''
Directory-based plugin packages to symlink into the hermes plugins
directory. Each package should contain a plugin.yaml and __init__.py
at its root. Hermes discovers these automatically on startup.
'';
example = literalExpression ''
[
(pkgs.fetchFromGitHub {
owner = "stephenschoettler";
repo = "hermes-lcm";
name = "hermes-lcm";
rev = "v0.7.0";
hash = "sha256-...";
})
]
'';
};
extraPythonPackages = mkOption {
type = types.listOf types.package;
default = [ ];
description = ''
Python packages to add to PYTHONPATH for entry-point plugin discovery.
These are pip-packaged plugins that register via the
hermes_agent.plugins entry-point group. Each package must be built
with the same Python interpreter as hermes (python312).
'';
example = literalExpression ''
[
(pkgs.python312Packages.buildPythonPackage {
pname = "rtk-hermes";
version = "1.0.0";
src = pkgs.fetchFromGitHub {
owner = "ogallotti";
repo = "rtk-hermes";
rev = "main";
hash = "sha256-...";
};
})
]
'';
};
extraDependencyGroups = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Additional pyproject.toml optional-dependency groups to include in
the sealed Python venv. These are resolved by uv alongside core
dependencies — no PYTHONPATH patching or collision risk.
Use this for optional extras already declared in hermes-agent's
pyproject.toml (e.g. "hindsight", "honcho", "voice").
Use extraPythonPackages for external packages not in pyproject.toml.
'';
example = [ "hindsight" ];
};
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;
@@ -569,12 +288,15 @@
'';
};
# ── OCI Container (opt-in) ──────────────────────────────────────────
# ── OCI Container (opt-in) ────────────────────────────────────
container = {
enable = mkEnableOption "OCI container mode (Ubuntu base, full self-modification support)";
backend = mkOption {
type = types.enum [ "docker" "podman" ];
type = types.enum [
"docker"
"podman"
];
default = "docker";
description = "Container runtime.";
};
@@ -608,40 +330,15 @@
example = [ "sidbin" ];
};
};
};
}
);
config = lib.mkIf cfg.enable (lib.mkMerge [
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;
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
})
# ── User / group ──────────────────────────────────────────────────
@@ -662,39 +359,54 @@
# gateway service instead of creating a separate ~/.hermes/.
(lib.mkIf cfg.addToSystemPackages {
environment.systemPackages = [ effectivePackage ];
environment.variables.HERMES_HOME = "${cfg.stateDir}/.hermes";
environment.variables.HERMES_HOME = hermesHome;
})
# ── Host user group membership ─────────────────────────────────────
(lib.mkIf (cfg.container.enable && cfg.container.hostUsers != []) {
users.users = lib.genAttrs cfg.container.hostUsers (user: {
(lib.mkIf (cfg.container.enable && cfg.container.hostUsers != [ ]) {
users.users = lib.genAttrs cfg.container.hostUsers (_user: {
extraGroups = [ cfg.group ];
});
})
# ── Assertions ─────────────────────────────────────────────────────
{
assertions = let
names = map lib.getName cfg.extraPlugins;
in [{
assertion = (lib.length names) == (lib.length (lib.unique names));
message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate.";
}];
assertions =
common.pluginNameAssertions {
inherit cfg;
optionPath = "services.hermes-agent";
}
++ common.workspaceFilesAssertions {
inherit cfg;
opt = options.services.hermes-agent.workingDirectory;
optionPath = "services.hermes-agent";
}
++ [
{
# Container mode runs one command in one container. A second
# process needs its own container and its own ports. This
# module does not do that.
assertion = !(cfg.container.enable && cfg.backend.mode != "none");
message = "services.hermes-agent: backend.mode is not supported together with container.enable — the container runs the gateway only.";
}
];
}
# ── Warnings ──────────────────────────────────────────────────────
# ── Per-user profile for extraPackages ───────────────────────────
# Wire extraPackages into the hermes user's per-user profile so the
# login-shell snapshot (which rebuilds PATH from NixOS profiles) sees
# them. The systemd service PATH also includes them for direct access.
(lib.mkIf (cfg.extraPackages != []) {
(lib.mkIf (cfg.extraPackages != [ ]) {
# listOf options are merged by the NixOS module system — this appends to
# any packages the operator assigned to this user externally (e.g. when
# createUser = false and the user definition lives elsewhere in the config).
users.users.${cfg.user}.packages = cfg.extraPackages;
})
(lib.mkIf (cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != []) {
# ── Warnings ──────────────────────────────────────────────────────
(lib.mkIf
(cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != [ ])
{
warnings = [
''
services.hermes-agent: container.enable is true and container.hostUsers
@@ -703,106 +415,111 @@
Set addToSystemPackages = true or ensure hermes is on PATH.
''
];
})
}
)
# ── Directories ───────────────────────────────────────────────────
{
systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes 2770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes/cron 2770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes/sessions 2770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes/logs 2770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes/memories 2770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes/plugins 2770 ${cfg.user} ${cfg.group} - -"
"d ${hermesHome} 2770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -"
"d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -"
];
]
++ map (d: "d ${hermesHome}/${d} 2770 ${cfg.user} ${cfg.group} - -") common.stateSubdirs;
}
# ── Activation: link config + auth + documents ────────────────────
{
system.activationScripts."hermes-agent-setup" = lib.stringAfter ([ "users" ] ++ lib.optional (config.system.activationScripts ? setupSecrets) "setupSecrets") ''
system.activationScripts."hermes-agent-setup" =
lib.stringAfter
(
[ "users" ] ++ lib.optional (config.system.activationScripts ? setupSecrets) "setupSecrets"
)
''
# Ensure directories exist (activation runs before tmpfiles)
mkdir -p ${cfg.stateDir}/.hermes
mkdir -p ${hermesHome}
mkdir -p ${cfg.stateDir}/home
mkdir -p ${cfg.workingDirectory}
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory}
chmod 2770 ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.workingDirectory}
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${hermesHome} ${cfg.stateDir}/home ${cfg.workingDirectory}
chmod 2770 ${cfg.stateDir} ${hermesHome} ${cfg.workingDirectory}
chmod 0750 ${cfg.stateDir}/home
# Create subdirs, set setgid + group-writable, migrate existing files.
# Nix-managed .env/.managed stay 0640/0644; config.yaml uses
# configYamlMode (0660 under addToSystemPackages, else 0640).
find ${cfg.stateDir}/.hermes -maxdepth 1 \
find ${hermesHome} -maxdepth 1 \
\( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \
-exec chmod g+rw {} + 2>/dev/null || true
for _subdir in cron sessions logs memories plugins; do
mkdir -p "${cfg.stateDir}/.hermes/$_subdir"
chown ${cfg.user}:${cfg.group} "${cfg.stateDir}/.hermes/$_subdir"
chmod 2770 "${cfg.stateDir}/.hermes/$_subdir"
find "${cfg.stateDir}/.hermes/$_subdir" -type f \
for _subdir in ${lib.concatStringsSep " " common.stateSubdirs}; do
mkdir -p "${hermesHome}/$_subdir"
chown ${cfg.user}:${cfg.group} "${hermesHome}/$_subdir"
chmod 2770 "${hermesHome}/$_subdir"
find "${hermesHome}/$_subdir" -type f \
-exec chmod g+rw {} + 2>/dev/null || true
done
# 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.
# Mode is configYamlMode (0660 under addToSystemPackages so interactive
# hermes-group users can save settings via the CLI/TUI, else 0640).
${if cfg.configFile != null then ''
install -o ${cfg.user} -g ${cfg.group} -m ${configYamlMode} -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 ${configYamlMode} ${cfg.stateDir}/.hermes/config.yaml
''}
${common.mkStateScript {
inherit pkgs cfg hermesHome;
workingDirectory = cfg.workingDirectory;
configWorkingDirectory = effectiveWorkDir;
owner = "${cfg.user}:${cfg.group}";
stateDirs = common.stateSubdirs;
modes = {
config = configYamlMode;
env = "0640";
managed = "0644";
auth = "0600";
document = "0640";
};
}}
# Managed mode marker (so interactive shells also detect NixOS management)
touch ${cfg.stateDir}/.hermes/.managed
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.managed
chmod 0644 ${cfg.stateDir}/.hermes/.managed
chown -h ${cfg.user}:${cfg.group} ${hermesHome}/plugins/nix-managed-* 2>/dev/null || true
# Container mode metadata — tells the host CLI to exec into the
# container instead of running locally. Removed when container mode
# is disabled so the host CLI falls back to native execution.
${if cfg.container.enable then ''
cat > ${cfg.stateDir}/.hermes/.container-mode <<'HERMES_CONTAINER_MODE_EOF'
# Written by NixOS activation script. Do not edit manually.
backend=${cfg.container.backend}
container_name=${containerName}
exec_user=${cfg.user}
hermes_bin=${containerDataDir}/current-package/bin/hermes
HERMES_CONTAINER_MODE_EOF
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.container-mode
chmod 0644 ${cfg.stateDir}/.hermes/.container-mode
'' else ''
rm -f ${cfg.stateDir}/.hermes/.container-mode
${
if cfg.container.enable then
''
install -o ${cfg.user} -g ${cfg.group} -m 0644 ${containerModeFile} ${hermesHome}/.container-mode
''
else
''
rm -f ${hermesHome}/.container-mode
# Remove symlink bridge for hostUsers
${lib.concatStringsSep "\n" (map (user:
${lib.concatStringsSep "\n" (
map (
user:
let
userHome = config.users.users.${user}.home;
symlinkPath = "${userHome}/.hermes";
in ''
if [ -L "${symlinkPath}" ] && [ "$(readlink "${symlinkPath}")" = "${cfg.stateDir}/.hermes" ]; then
in
''
if [ -L "${symlinkPath}" ] && [ "$(readlink "${symlinkPath}")" = "${hermesHome}" ]; then
rm -f "${symlinkPath}"
echo "hermes-agent: removed symlink ${symlinkPath}"
fi
'') cfg.container.hostUsers)}
''}
''
) cfg.container.hostUsers
)}
''
}
# ── Symlink bridge for interactive users ───────────────────────
# Create ~/.hermes -> stateDir/.hermes for each hostUser so the
# host CLI shares state with the container service.
# Only runs when container mode is enabled.
${lib.optionalString cfg.container.enable
(lib.concatStringsSep "\n" (map (user:
${lib.optionalString cfg.container.enable (
lib.concatStringsSep "\n" (
map (
user:
let
userHome = config.users.users.${user}.home;
symlinkPath = "${userHome}/.hermes";
target = "${cfg.stateDir}/.hermes";
in ''
in
''
if [ -d "${symlinkPath}" ] && [ ! -L "${symlinkPath}" ]; then
# Real directory — back it up, then create symlink.
# (ln -sfn cannot atomically replace a directory.)
@@ -812,58 +529,12 @@
fi
# For everything else (existing symlink, doesn't exist, etc.)
# ln -sfn handles it: replaces symlinks, creates new ones.
ln -sfn "${target}" "${symlinkPath}"
ln -sfn "${hermesHome}" "${symlinkPath}"
chown -h ${user}:${cfg.group} "${symlinkPath}"
'') cfg.container.hostUsers))}
# 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 0640 /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 0640 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
'') cfg.documents)}
# ── Declarative plugins ─────────────────────────────────────────
# Remove stale managed symlinks (plugins removed from config)
find ${cfg.stateDir}/.hermes/plugins -maxdepth 1 -type l -name 'nix-managed-*' -delete 2>/dev/null || true
${lib.concatStringsSep "\n" (map (plugin:
let
name = lib.getName plugin;
in ''
if [ ! -f "${plugin}/plugin.yaml" ]; then
echo "ERROR: extraPlugins entry '${plugin}' has no plugin.yaml" >&2
exit 1
fi
ln -sfn ${plugin} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name}
chown -h ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name}
'') cfg.extraPlugins)}
''
) cfg.container.hostUsers
)
)}
'';
}
@@ -877,52 +548,36 @@
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
HOME = cfg.stateDir;
HERMES_HOME = "${cfg.stateDir}/.hermes";
HERMES_MANAGED = "true";
# Working directory is declared via terminal.cwd in the merged
# config.yaml (see configJson above) — MESSAGING_CWD is deprecated.
};
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.
environment = commonUnitEnvironment;
ExecStart = lib.concatStringsSep " " ([
"${effectivePackage}/bin/hermes"
"gateway"
] ++ cfg.extraArgs);
Restart = cfg.restart;
RestartSec = cfg.restartSec;
# Shared-state: files created by the gateway should be group-writable
# so interactive users in the hermes group can read/write them.
UMask = "0007";
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = false;
ReadWritePaths = [
cfg.stateDir
cfg.workingDirectory
];
PrivateTmp = true;
serviceConfig = commonServiceConfig // {
ExecStart = lib.escapeShellArgs (common.gatewayArgv cfg);
};
path = [
effectivePackage
pkgs.bash
pkgs.coreutils
pkgs.git
] ++ cfg.extraPackages;
path = unitPath;
};
})
# ── The backend: hermes serve or hermes dashboard ─────────────────
# This is a different process from the gateway. Both use one
# HERMES_HOME.
(lib.mkIf (!cfg.container.enable && cfg.backend.mode != "none") {
systemd.services.hermes-backend = {
description = common.backendDescription cfg;
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = commonUnitEnvironment;
serviceConfig = commonServiceConfig // {
ExecStart = lib.escapeShellArgs (common.backendArgv cfg);
};
path = unitPath;
};
})
@@ -936,7 +591,9 @@
systemd.services.hermes-agent = {
description = "Hermes Agent Gateway (container)";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.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";
@@ -1003,6 +660,7 @@
};
};
})
]);
]
);
};
}
@@ -0,0 +1,112 @@
"""Managed-mode detection across the Nix install shapes.
The NixOS module and the Home Manager module both mark the install as
managed, and the CLI then refuses a configuration change that it cannot
keep. The two modules write different values, and an install from an
earlier version writes an empty marker, so detection must handle all three.
"""
import os
import pytest
from hermes_cli import config as config_mod
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_MANAGED", raising=False)
return home
@pytest.mark.parametrize(
("env_value", "expected"),
[
("nixos", "nixos"),
("home-manager", "home-manager"),
("HOME-MANAGER", "home-manager"),
# An install from an earlier version sets a bare "true". Only the
# NixOS module did that.
("true", "nixos"),
("1", "nixos"),
# Homebrew is not a distribution method, so these must not block a
# configuration change.
("brew", None),
("homebrew", None),
(None, None),
],
)
def test_env_var_names_the_managing_system(hermes_home, monkeypatch, env_value, expected):
if env_value is None:
monkeypatch.delenv("HERMES_MANAGED", raising=False)
else:
monkeypatch.setenv("HERMES_MANAGED", env_value)
assert config_mod.get_managed_system() == expected
assert config_mod.is_managed() is (expected is not None)
@pytest.mark.parametrize(
("marker_text", "expected"),
[
("home-manager", "home-manager"),
("nixos", "nixos"),
# An install from an earlier version has an empty marker. Only the
# NixOS module wrote one.
("", "nixos"),
],
)
def test_marker_file_names_the_managing_system(
hermes_home, monkeypatch, marker_text, expected
):
"""An interactive shell reads .managed, not the HERMES_MANAGED of the service."""
(hermes_home / ".managed").write_text(marker_text, encoding="utf-8")
monkeypatch.delenv("HERMES_MANAGED", raising=False)
assert config_mod.get_managed_system() == expected
assert config_mod.is_managed() is True
def test_env_var_wins_over_the_marker(hermes_home, monkeypatch):
(hermes_home / ".managed").write_text("nixos", encoding="utf-8")
monkeypatch.setenv("HERMES_MANAGED", "home-manager")
assert config_mod.get_managed_system() == "home-manager"
@pytest.mark.parametrize("managed_value", ["nixos", "home-manager"])
def test_managed_install_names_its_system_and_offers_an_update(
hermes_home, monkeypatch, managed_value
):
"""The message names the system, so the user knows what owns the install."""
monkeypatch.setenv("HERMES_MANAGED", managed_value)
assert managed_value in config_mod.format_managed_message("set model")
assert "set model" in config_mod.format_managed_message("set model")
assert config_mod.get_managed_update_command()
assert config_mod.detect_install_method(config_mod.get_project_root()) == managed_value
# `hermes update` cannot run on a managed install, so the advice must not
# name it.
assert config_mod.recommended_update_command() != "hermes update"
def test_unmanaged_install_offers_no_update_command(hermes_home, monkeypatch):
monkeypatch.delenv("HERMES_MANAGED", raising=False)
assert config_mod.get_managed_update_command() is None
def test_unreadable_marker_still_reports_managed(hermes_home, monkeypatch):
"""A marker we cannot read is still a marker. Fail closed, not open."""
marker = hermes_home / ".managed"
marker.write_text("home-manager", encoding="utf-8")
marker.chmod(0o000)
monkeypatch.delenv("HERMES_MANAGED", raising=False)
try:
if os.access(marker, os.R_OK):
pytest.skip("running as a user that ignores file modes (for example root)")
assert config_mod.is_managed() is True
finally:
marker.chmod(0o600)
+187 -23
View File
@@ -12,11 +12,12 @@ Nix and NixOS are [Tier 2 platforms](./platform-support.md#tier-2). The flake an
For a supported setup, use one of the standard [installation](./installation.md) paths - either Docker or an FHS environment.
:::
Hermes Agent ships a Nix flake & a NixOS module.
Hermes Agent ships a Nix flake, a NixOS module, and a Home Manager module.
| Level | Who it's for | What you get |
|-------|-------------|--------------|
| **`nix run` / `nix profile install`** | Any Nix user (macOS, Linux) | Pre-built binary with all deps — then use the standard CLI workflow |
| **Home Manager module** | An agent for one person, on any distribution or on macOS | Declarative configuration and a user service, without root |
| **NixOS module (native)** | NixOS server deployments | Declarative config, hardened systemd service, managed secrets |
| **NixOS module (container)** | Agents that need self-modification | Everything above, plus a persistent Ubuntu container where the agent can `apt`/`pip`/`npm install` |
@@ -84,7 +85,7 @@ hermes setup
The flake exports `nixosModules.default` — a full NixOS service module that declaratively manages user creation, directories, config generation, secrets, documents, and service lifecycle.
:::note
This module requires NixOS. For non-NixOS systems (macOS, other Linux distros), use `nix profile install` and the standard CLI workflow above.
This module needs NixOS. Hermes is an agent for one person. If you want an agent for one person and not a system service, use the [Home Manager module](#home-manager-module). That module runs on NixOS and on each other system that Home Manager supports.
:::
### Add the Flake Input
@@ -287,8 +288,10 @@ Run `nix build .#configKeys && cat result` to see every leaf config key extracte
environmentFiles = [ config.sops.secrets."hermes-env".path ];
# ── Documents ──────────────────────────────────────────────────────
documents = {
"USER.md" = ./documents/USER.md;
# USER.md is memory, so it goes to HERMES_HOME. Workspace files use
# `documents`, and that option needs an explicit `workingDirectory`.
hermesHomeFiles = {
"memories/USER.md" = ./documents/USER.md;
};
# ── MCP Servers ────────────────────────────────────────────────────
@@ -336,7 +339,9 @@ Quick reference for the most common things Nix users want to customize:
| Change the LLM model | `settings.model.default` | `"anthropic/claude-sonnet-4"` |
| Use a different provider endpoint | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` |
| Add API keys | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` |
| Give the agent a personality | `${services.hermes-agent.stateDir}/.hermes/SOUL.md` | manage the file directly |
| Give the agent an identity | `hermesHomeFiles."SOUL.md"` | `"You are a terse ops assistant."` |
| Add project context to the workspace | `documents."AGENTS.md"` | `./documents/AGENTS.md` |
| Run the backend for the desktop app or the dashboard | `backend.mode` | `"serve"` or `"dashboard"` |
| Add MCP tool servers | `mcpServers.<name>` | See [MCP Servers](#mcp-servers) |
| Enable Discord/Telegram/Slack | `extraDependencyGroups` | `[ "messaging" ]` |
| Mount host directories into container | `container.extraVolumes` | `[ "/data:/data:rw" ]` |
@@ -416,22 +421,45 @@ The file is only copied if `auth.json` doesn't already exist (unless `authFileFo
## Documents
The `documents` option installs files into the agent's working directory (the `workingDirectory`, which the agent reads as its workspace). Hermes looks for specific filenames by convention:
Hermes reads files from two directories. Thus there are two options. Use the option for the directory that the file must go into.
- **`USER.md`** — context about the user the agent is interacting with.
- Any other files you place here are visible to the agent as workspace files.
The agent identity file is separate: Hermes loads its primary `SOUL.md` from `$HERMES_HOME/SOUL.md`, which in the NixOS module is `${services.hermes-agent.stateDir}/.hermes/SOUL.md`. Putting `SOUL.md` in `documents` only creates a workspace file and will not replace the main persona file.
`documents` installs into the **working directory** of the agent, which is `workingDirectory`. The agent reads its project context from that workspace:
```nix
{
services.hermes-agent.documents = {
"USER.md" = ./documents/USER.md; # path reference, copied from Nix store
services.hermes-agent = {
# documents needs this option. Read the note below.
workingDirectory = "/var/lib/hermes/workspace";
documents = {
"AGENTS.md" = ./documents/AGENTS.md; # path reference, copied from Nix store
"notes/oncall.md" = "Page #infra before restarting anything.";
};
};
}
```
Values can be inline strings or path references. Files are installed on every `nixos-rebuild switch`.
:::warning documents needs an explicit workingDirectory
The module refuses `documents` until you set `workingDirectory`. The default of
that option is different on each module. It is your home directory on Home
Manager, and `${stateDir}/workspace` on NixOS. Thus an unset default puts the
files in a directory that you did not select. A directory with the same path as
the default is a correct selection, and it satisfies the rule.
:::
`hermesHomeFiles` installs into **`HERMES_HOME`**. Hermes reads the identity file and the memory files of the agent from that directory. `SOUL.md` and `memories/` work only from there. A `SOUL.md` in `documents` makes a workspace file. Hermes does not load that file as the identity:
```nix
{
services.hermes-agent.hermesHomeFiles = {
"SOUL.md" = "You are a helpful AI assistant.";
"memories/USER.md" = ./documents/USER.md;
};
}
```
Each value is a string or a path. A key in either option can contain subdirectories, and the module makes the parent directories. Each activation installs the files again.
`hermesHomeFiles` needs no `workingDirectory`, because the module owns the `HERMES_HOME` directory. Most users want `hermesHomeFiles`.
---
@@ -554,10 +582,108 @@ When hermes runs via the NixOS module, the following CLI commands are **blocked*
This prevents drift between what Nix declares and what's on disk. Detection uses two signals:
1. **`HERMES_MANAGED=true`** environment variable — set by the systemd service, visible to the gateway process
2. **`.managed` marker file** in `HERMES_HOME` — set by the activation script, visible to interactive shells (e.g., `docker exec -it hermes-agent hermes config set ...` is also blocked)
1. **The `HERMES_MANAGED` environment variable.** The service sets it, and the gateway process reads it.
2. **The `.managed` marker file** in `HERMES_HOME`. The activation script writes it, and an interactive shell reads it. Thus the CLI also blocks a command such as `docker exec -it hermes-agent hermes config set ...`.
To change configuration, edit your Nix config and run `sudo nixos-rebuild switch`.
Both signals hold the name of the system that manages the install. Thus the refusal names the correct rebuild command. The NixOS module gives `sudo nixos-rebuild switch`. The Home Manager module gives `home-manager switch`.
---
## Home Manager Module
The flake also exports `homeManagerModules.default`. Hermes is an agent for one person. The credentials, the memory, the sessions and the cron jobs all belong to that person. Thus a user service is the correct shape on a personal machine. It runs on each distribution that Home Manager supports, and not only on NixOS.
The option set is the same set that the NixOS module uses. It is `services.hermes-agent`, with the same `settings`, `environmentFiles`, `documents`, `mcpServers`, `extraPlugins` and `backend` options. Each example above works here without a change. Only the necessary parts are different:
| | NixOS module | Home Manager module |
|---|---|---|
| Runs as | a system user that you declare, with `user`, `group` and `createUser` | you |
| State directory | `stateDir` and `/.hermes` | `hermesHome`, set directly. The default is `~/.hermes`. |
| Service | `systemd.services` | `systemd.user.services` on Linux, `launchd.agents` on macOS |
| CLI on the PATH | `addToSystemPackages`, which exports `HERMES_HOME` for the full system | `installPackage`, which exports it for your session only |
| Container mode | supported | not supported, because it needs root and the Docker socket |
### Add the Flake Input
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager.url = "github:nix-community/home-manager";
home-manager.inputs.nixpkgs.follows = "nixpkgs";
hermes-agent.url = "github:NousResearch/hermes-agent";
};
}
```
Then import the module into your Home Manager configuration. The configuration can be standalone. It can also be under `home-manager.users.<name>` in a NixOS or nix-darwin configuration:
```nix
{
imports = [ hermes-agent.homeManagerModules.default ];
services.hermes-agent = {
enable = true;
gateway.enable = true;
settings.model.default = "anthropic/claude-sonnet-4";
environmentFiles = [ config.sops.secrets."hermes-env".path ];
};
}
```
`home-manager switch` makes `~/.hermes`, writes `config.yaml`, builds `.env` and starts the gateway as a user service.
:::warning Enable linger, or the service stops at logout
CAUTION: Enable linger for your account. Without linger, systemd stops the user manager when your last session ends, and the gateway stops with it. Home Manager cannot set linger, because linger is a property of the account:
```nix
# NixOS
users.users.your-username.linger = true;
```
```bash
# anywhere else
sudo loginctl enable-linger your-username
```
macOS has no equivalent option. A `launchd` agent with `RunAtLoad` starts at login and continues to run.
:::
### Running the Desktop / Dashboard Backend
`gateway.enable` runs the messaging gateway for Telegram, Discord, Slack and the other platforms. Hermes Desktop and the web dashboard connect to a *different* process, which is `hermes serve` or `hermes dashboard`. `backend.mode` runs that process with the gateway:
```nix
{
services.hermes-agent = {
enable = true;
gateway.enable = true; # messaging platforms
backend.mode = "dashboard"; # + the browser dashboard on 127.0.0.1:9119
backend.port = 9119;
};
}
```
`serve` runs without a user interface. It gives the `/api/ws` and `/api/pty` sockets that Hermes Desktop connects to, and it does not build the web application. `dashboard` gives all of that, and also serves the browser admin panel. Both processes use one `HERMES_HOME` with the gateway. Thus the sessions, the skills, the memory and the cron jobs are the same for all of them. `backend.mode` works in the same way on the NixOS module, but not in container mode.
:::warning Binding to an address other than loopback
The default address is `127.0.0.1`. Each other address starts the authentication gate of the dashboard. The server also refuses each request with a `Host` header that is different from the address that the server bound to. This is a defence against DNS rebinding. Bind to the name or the address that your client uses.
:::
### Verify It Works
```bash
# Linux
systemctl --user status hermes-agent
journalctl --user -u hermes-agent -f
# macOS
launchctl list | grep hermes
tail -f ~/Library/Logs/hermes-agent.log
hermes version
hermes config # shows the configuration that Nix wrote
```
---
@@ -587,7 +713,7 @@ Host Container
│ └── mcp-tokens/ (OAuth tokens for MCP servers)
├── home/ ──► /home/hermes (rw)
└── workspace/ (agent working directory)
├── SOUL.md (from documents option)
├── AGENTS.md (from the documents option)
└── (agent-created files)
Container writable layer (apt/pip/npm): /usr, /usr/local, /tmp
@@ -857,7 +983,8 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use
| Option | Type | Default | Description |
|---|---|---|---|
| `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Keys are filenames, values are inline strings or paths. Installed into `workingDirectory` on activation |
| `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Each key is a path relative to `workingDirectory`. You must set that option to use this one. |
| `hermesHomeFiles` | `attrsOf (either str path)` | `{}` | Files that go into `HERMES_HOME`. `SOUL.md` and `memories/` must be here, or Hermes does not load them. |
### MCP Servers
@@ -885,10 +1012,29 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use
| `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` |
| `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Build with `python312Packages` |
| `extraDependencyGroups` | `listOf str` | `[]` | pyproject.toml optional extras to include in the sealed venv (e.g. `["hindsight"]`). Resolved by uv — no collisions |
| `restart` | `str` | `"always"` | systemd `Restart=` policy |
| `restartSec` | `int` | `5` | systemd `RestartSec=` value |
| `restart` | `str` | `"always"` | The systemd `Restart=` policy. macOS does not use it. |
| `restartSec` | `int` | `5` | The systemd `RestartSec=` value. macOS does not use it. |
### Container
### Backend (`hermes serve` / `hermes dashboard`)
This option runs the process that Hermes Desktop and the web dashboard connect to, with the gateway. You cannot use it with `container.enable`.
| Option | Type | Default | Description |
|---|---|---|---|
| `backend.mode` | `enum ["none" "serve" "dashboard"]` | `"none"` | `serve` runs without a user interface and gives `/api/ws` and `/api/pty`. `dashboard` also serves the browser panel. |
| `backend.host` | `str` | `"127.0.0.1"` | The address to bind to. Each address other than loopback starts the authentication gate. |
| `backend.port` | `port` | `9119` | The port to bind to |
| `backend.extraArgs` | `listOf str` | `[]` | More arguments for the backend command |
### Home Manager only
| Option | Type | Default | Description |
|---|---|---|---|
| `hermesHome` | `str` | `"${config.home.homeDirectory}/.hermes"` | `HERMES_HOME` directly. The NixOS module builds it from `stateDir`. |
| `installPackage` | `bool` | `true` | Add the `hermes` CLI to `home.packages`, and export `HERMES_HOME` for your shells |
| `gateway.enable` | `bool` | `false` | Run the messaging gateway. On the NixOS module the gateway is the service, so that module has no such option. |
### Container (NixOS only)
| Option | Type | Default | Description |
|---|---|---|---|
@@ -908,6 +1054,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use
```
/var/lib/hermes/ # stateDir (owned by hermes:hermes, 0750)
├── .hermes/ # HERMES_HOME
│ ├── SOUL.md # from hermesHomeFiles: the agent identity
│ ├── config.yaml # Nix-generated (deep-merged each rebuild)
│ ├── .managed # Marker: CLI config mutation blocked
│ ├── .env # Merged from environment + environmentFiles
@@ -922,10 +1069,26 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use
│ └── logs/
├── home/ # Agent HOME
└── workspace/ # Agent working directory
├── SOUL.md # From documents option
├── AGENTS.md # from the documents option
└── (agent-created files)
```
### Home Manager
```
~/.hermes/ # hermesHome (HERMES_HOME), 0700
├── SOUL.md # from hermesHomeFiles
├── config.yaml # written by Nix, merged at each activation
├── .managed # marker: names the system that manages this
├── .env # written again from environment + environmentFiles
├── auth.json # OAuth credentials: seeded, then Hermes owns it
├── memories/ sessions/ skills/ cron/ logs/ plugins/
└── (runtime state)
~/ # workingDirectory, your home by default
└── AGENTS.md # from the documents option
```
### Container Mode
Same layout, mounted into the container:
@@ -946,7 +1109,8 @@ Same layout, mounted into the container:
cd /etc/nixos && nix flake update hermes-agent
# Rebuild
sudo nixos-rebuild switch
sudo nixos-rebuild switch # for the NixOS module
home-manager switch # for the Home Manager module
```
In container mode, the `current-package` symlink is updated and the agent picks up the new binary on restart. No container recreation, no loss of installed packages.