Files
aiturk-hermes-ide/nix/moduleCommon.nix
T

1166 lines
43 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 (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.
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
;
}