Files
hermes-agent/nix/moduleCommon.nix
ethernet 712734436e fix(pm): make bootstrap and bundle ownership explicit
Finish bootstrap uv before PM replaces its store entry. Keep failure
receipts stdlib-only and align the cryptography requirement and override
with the locked version.

Let bundle builders declare launch paths and update ownership. Remove
payload discovery, Store probing, and the unused develop command.
Derive Nix Python from the PM lock and share its provenance stamp.

Document setup, activation, optional dependencies, and distribution
ownership. Targeted Windows tests, relocated runtime launches, Electron
bundling, and bilingual docs builds pass. Native Nix and signed-package
acceptance remain CI gates.
2026-09-08 00:24:51 -04:00

1169 lines
44 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, programs.hermes-agent (the CLI and
# the desktop application), 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. The interpreter
major.minor is derived from pm/lock.json by nix/pythonLock.nix —
take packages from config.services.hermes-agent.package.python.pkgs so the set always
matches the interpreter hermes was built with.
'';
example = literalExpression ''
[
(config.services.hermes-agent.package.python.pkgs.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.
If the name or the address is not available when the unit starts,
set `waitFor` as well.
'';
};
waitFor = mkOption {
type = types.nullOr (
types.enum [
"hostname"
"interface"
]
);
default = null;
description = ''
Wait for the bind target before the backend starts.
The backend binds to `host` immediately by default. The bind fails
when the target is not ready, because uvicorn cannot bind a name
that does not resolve, or an address that no interface holds. A
unit that starts at boot can lose this race against the daemon
that supplies the target, such as tailscaled or a VPN client.
A systemd user unit cannot order itself after a system unit.
`After=` and `Requires=` are silent no-ops across that boundary.
Thus the wait is a poll, and not a dependency.
The values are:
- `null` — bind immediately. `Restart=on-failure` retries the unit
until the target is ready.
- `"hostname"` — poll until `host` resolves, then bind to `host`.
Use this for a name, such as a Tailscale MagicDNS name.
- `"interface"` — poll until `interfaceName` has an IPv4 address,
then bind to that address. Use this when the address changes,
and a name for it does not exist.
CAUTION: The `"interface"` value ignores `host`. The unit binds to
the address of the interface.
'';
example = "hostname";
};
interfaceName = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The interface to take the bind address from.
This option is necessary when `waitFor` is `"interface"`, and it
has no effect for the other values.
'';
example = "tailscale0";
};
waitTimeout = mkOption {
type = types.ints.positive;
default = 120;
description = ''
The time in seconds to wait for the bind target.
The unit stops with an error after this time. It does not bind to
a different address, because a fallback address can expose the
backend more widely than you intend.
'';
};
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.";
};
sessionTokenFile = mkOption {
# The type is `str` and not `path` for the same reason that
# environmentFiles uses `str`. 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.
type = types.nullOr types.str;
default = null;
description = ''
The path to a file that holds the session token of the backend,
on one line.
The backend reads the file at each start and gives the value to
HERMES_DASHBOARD_SESSION_TOKEN. That token authorizes the /api
routes and the /api/ws socket. Hermes Desktop presents the same
value, so the application reaches this backend and starts no
second one.
Without this option the backend makes a new token at each start,
which no other process can know.
CAUTION: The file must hold the raw token and nothing else. Give
it mode 0600. Do not use a Nix path literal, because that copies
the secret into the Nix store.
'';
example = literalExpression ''config.sops.secrets."hermes/desktop-token".path'';
};
};
};
# ── The removal of installPackage ───────────────────────────────────────
# The programs./services. split replaced this option. It defaulted to true,
# so a person who never named it still got the command line, and a silent
# removal leaves them with no `hermes` on the PATH and no message. The
# module refuses the configuration with this text.
#
# A function, and not a literal in the module, so a check can call the same
# code and read the real message. A check that matched the source text of
# the module would pass while the message was wrong.
installPackageRemovedMessage =
value:
''
services.hermes-agent.installPackage was removed. Hermes now
separates the installation from the services, which is the
Home Manager convention:
programs.hermes-agent.enable = ${lib.boolToString (value != false)}; # the hermes CLI, and HERMES_HOME for your shells
programs.hermes-agent.desktop.enable = true; # the desktop application
`services.hermes-agent` keeps the state, the configuration and
the daemons. Remove `installPackage` and add the line above.
'';
# ── 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;
# The command line of the backend, without the wait.
backendCommand =
cfg: host:
[
"${effectivePackage cfg}/bin/hermes"
cfg.backend.mode
"--host"
host
"--port"
(toString cfg.backend.port)
# CAUTION: A service must not try to open a browser when it starts.
"--no-open"
]
++ cfg.backend.extraArgs;
# The launcher that reads the session token, waits for the bind target,
# then starts the backend.
#
# The token cannot go in the unit environment. A systemd `Environment=`
# value and a launchd EnvironmentVariables value both land in the Nix
# store, which all users can read. Thus the launcher reads the file at
# start time. launchd has no EnvironmentFile, so a script is the one shape
# that works on both hosts.
#
# `exec` on the last line keeps hermes as the MainPID of the unit. No shell
# stays in the cgroup, and the restart logic of systemd sees the real
# process.
backendLauncher =
{ pkgs, cfg }:
# The bind address is known only at start time, but escapeShellArgs quotes
# each argument. Thus the command line is built with a placeholder, and the
# placeholder becomes the shell variable after the quoting.
pkgs.writeShellScript "hermes-backend-launch" (
builtins.replaceStrings [ "@HOST@" ] [ ''"$_target"'' ] ''
set -euo pipefail
_timeout=${toString cfg.backend.waitTimeout}
_waited=0
${lib.optionalString (cfg.backend.sessionTokenFile != null) ''
# Read the token, and never put it on a command line. A command
# line is visible to each process on the host.
_token_file=${lib.escapeShellArg cfg.backend.sessionTokenFile}
if [ ! -r "$_token_file" ]; then
echo "hermes-backend: cannot read the session token file '$_token_file'. The unit stops." >&2
echo "hermes-backend: backend.sessionTokenFile must name a runtime path that this user can read." >&2
exit 1
fi
HERMES_DASHBOARD_SESSION_TOKEN="$(${pkgs.coreutils}/bin/tr -d '\r\n' < "$_token_file")"
export HERMES_DASHBOARD_SESSION_TOKEN
if [ -z "$HERMES_DASHBOARD_SESSION_TOKEN" ]; then
echo "hermes-backend: the session token file '$_token_file' is empty. The unit stops." >&2
exit 1
fi
''}
${
if cfg.backend.waitFor == null then
''
_target=${lib.escapeShellArg cfg.backend.host}
_how="the configured address"
''
else if cfg.backend.waitFor == "hostname" then
''
_target=${lib.escapeShellArg cfg.backend.host}
_how="hostname"
while :; do
if ${pkgs.getent}/bin/getent hosts "$_target" >/dev/null 2>&1; then
break
fi
if [ "$_waited" -ge "$_timeout" ]; then
echo "hermes-backend: '$_target' did not resolve after ''${_timeout}s. The unit stops." >&2
exit 1
fi
if [ "$_waited" = 0 ]; then
echo "hermes-backend: waits for '$_target' to resolve..." >&2
fi
${pkgs.coreutils}/bin/sleep 2
_waited=$(( _waited + 2 ))
done
''
else
''
_iface=${lib.escapeShellArg cfg.backend.interfaceName}
_how="interface $_iface"
while :; do
_target="$(${pkgs.iproute2}/bin/ip -4 -oneline addr show dev "$_iface" 2>/dev/null \
| ${pkgs.gawk}/bin/awk '{print $4}' \
| ${pkgs.coreutils}/bin/cut -d/ -f1 \
| ${pkgs.coreutils}/bin/head -n1 || true)"
if [ -n "''${_target:-}" ]; then
break
fi
if [ "$_waited" -ge "$_timeout" ]; then
echo "hermes-backend: interface '$_iface' had no IPv4 address after ''${_timeout}s. The unit stops." >&2
echo "hermes-backend: a fallback address can expose the backend more widely than you intend." >&2
exit 1
fi
if [ "$_waited" = 0 ]; then
echo "hermes-backend: waits for an IPv4 address on '$_iface'..." >&2
fi
${pkgs.coreutils}/bin/sleep 2
_waited=$(( _waited + 2 ))
done
''
}
echo "hermes-backend: binds to $_target:${toString cfg.backend.port} (from $_how)" >&2
exec ${lib.escapeShellArgs (backendCommand cfg "@HOST@")}
''
);
backendArgv =
{ pkgs, cfg }:
# A plain argv is enough only when nothing must run before the backend.
# A wait needs the address at start time, and a token must be read from
# a file that the store must never hold. Either one needs the launcher.
if cfg.backend.waitFor == null && cfg.backend.sessionTokenFile == null then
backendCommand cfg cfg.backend.host
else
[ "${backendLauncher { inherit pkgs cfg; }}" ];
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 backend wait needs an interface name when it polls an interface.
backendBindAssertions =
{ cfg, optionPath }:
[
{
assertion = cfg.backend.waitFor != "interface" || cfg.backend.interfaceName != null;
message = "${optionPath}.backend.interfaceName must be set when backend.waitFor is \"interface\".";
}
{
assertion = cfg.backend.waitFor == "interface" || cfg.backend.interfaceName == null;
message = "${optionPath}.backend.interfaceName has no effect unless backend.waitFor is \"interface\".";
}
];
# The subdirectories of HERMES_HOME that both modules make.
stateSubdirs = [
"cron"
"sessions"
"logs"
"memories"
"plugins"
];
in
{
inherit
backendArgv
backendBindAssertions
backendDescription
deepConfigType
effectivePackage
gatewayArgv
installPackageRemovedMessage
mcpServerType
mcpServersToConfig
mkConfigFiles
mkDocumentTree
mkEnvScript
mkStateScript
pluginNameAssertions
processEnvironment
processPath
sharedOptions
stateSubdirs
workspaceFilesAssertions
;
}