Files
hermes-agent/nix/moduleCommon.nix
T
ethernet d5a9c2ba6c 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
2026-08-18 20:42:06 -04:00

917 lines
33 KiB
Nix

# 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
;
}