Files
hermes-agent/nix/homeManagerModules.nix
ethernet 76f6ba3706 feat(nix): give Home Manager a programs module and the desktop app
Home Manager separates an installation from a daemon. This module put
both under `services.hermes-agent`, and `installPackage` added a program
to the PATH from a service module.

`programs.hermes-agent` now installs the command line application and
the desktop application. `services.hermes-agent` keeps the state, the
configuration and the daemons, and stays the authority: the new module
reads `hermesHome` and the backend address from it. A person can enable
one without the other, which is a machine with an application and no
gateway, or a headless gateway with no display.

The desktop application needs this split to work correctly. A launcher
that starts from the desktop menu reads no shell profile, thus the
HERMES_HOME that `home.sessionVariables` exports reaches an interactive
shell only. Home Manager writes `systemd.user.sessionVariables` to
environment.d, and this module puts no HERMES_HOME there, because that
file applies to each user unit. The application then opens ~/.hermes
while the services use `hermesHome`, and the person sees no sessions and
no keys. Thus the launcher carries the value itself, through a new
`extraEnv` argument on the desktop package.

The application also gets the Nix agent package, with
HERMES_DESKTOP_HERMES. The usual distribution of the Electron
application carries its own Hermes runtime and downloads more at the
first start. `hermesDesktop` is a passthru of the agent and pins
`finalAttrs.finalPackage`, so an override of `extraPythonPackages` or
`extraDependencyGroups` reaches both. One machine thus has one runtime.

`backend.sessionTokenFile` connects the application to the backend of
the service. Without it the module runs `hermes serve` and the
application starts a backend of its own, which gives two backends on one
HERMES_HOME. The backend reads the file into
HERMES_DASHBOARD_SESSION_TOKEN. The launcher reads the same file into
HERMES_DESKTOP_REMOTE_TOKEN, beside a HERMES_DESKTOP_REMOTE_URL that
names the address of the service.

Measurements against a live `hermes serve` on loopback show why that
shape is the correct one:

- `_resolve_session_token()` reads HERMES_DASHBOARD_SESSION_TOKEN, and
  `_has_valid_session_token` accepts that value as a Bearer credential.
  A request without it gets 401, and a request with the wrong value
  gets 401.
- The /api/ws socket accepts a query parameter only. A header gets 403,
  and `?token=` connects. Hermes Desktop builds exactly that URL, in
  `apps/desktop/electron/connection-config.ts`. Thus a test of the HTTP
  leg alone is a false positive.
- `resolveDesktopRemoteRoute` throws when the URL is set and the token
  is not. Thus the two variables travel together or not at all.

The token enters no Nix store path. `makeWrapper --set` and a systemd
`Environment=` value both write a literal into the store, which all
users can read. Thus each side reads the file at start time. The
launcher does it through a new `extraRun` argument on the desktop
package, and the backend through the launcher script that
`backend.waitFor` already uses. launchd has no EnvironmentFile, so a
script is the one shape that works on Linux and on Darwin.
`backendArgv` gives the plain argv only when nothing must run before
the backend.

`services.hermes-agent.installPackage` is removed. It defaulted to true,
so a person who never named it still got the command line. A silent
removal thus gives them a machine with no `hermes` and no message. The
module refuses a configuration that sets it, and the text names the
exact replacement for the value they gave.

Checks:

- the launcher carries HERMES_HOME
- the launcher reports HERMES_MANAGED only when the services own the
  configuration, because no activation writes a marker without them
- the launcher pins the agent package that `programs.enable` installs
- the launcher names the backend of the service, and gives a token
  beside the URL
- the backend reads the session token
- each side reads the file at start time, and the token is no `--set`
  value
- `programs.enable` alone starts no service
- `installPackage` is refused, with a message that names the
  replacement, and its absence evaluates

Each check reads the wrapper of the real package, and not an option
value. Each one was tested with a mutation that breaks the behavior it
asserts.
2026-08-21 17:00:30 -04:00

429 lines
18 KiB
Nix

# nix/homeManagerModules.nix — the Home Manager module for hermes-agent
#
# This module is the user-level equivalent of nixosModules.default. Hermes is
# an agent for one person. The credentials, the memory, the sessions and the
# cron jobs all belong to that person. Thus a user-level module is correct on
# each distribution, and not only on NixOS.
#
# `services.hermes-agent` is the same option set on both modules. All of the
# options except the system-level ones come from nix/moduleCommon.nix, so an
# example from the NixOS documentation works here without a change. Only the
# necessary parts are different:
#
# removed user, group, createUser — Home Manager runs as the user
# removed container.* — it needs root and the Docker socket
# removed UMask 0007 — that mode shares state with a UNIX
# group, but this state has one user
# changed systemd.services -> systemd.user.services or
# launchd.agents
# changed system.activationScripts -> home.activation
# changed addToSystemPackages -> programs.hermes-agent.enable and
# home.sessionVariables
# added programs.hermes-agent the CLI and the desktop application,
# because Home Manager separates an
# installation from a daemon
# changed stateDir (+ "/.hermes") -> hermesHome, set directly
#
# To use the module:
# imports = [ hermes-agent.homeManagerModules.default ];
# programs.hermes-agent = {
# enable = true; # the hermes CLI on your PATH
# desktop.enable = true; # the Electron application and a launcher
# };
# services.hermes-agent = {
# enable = true;
# gateway.enable = true;
# settings.model.default = "anthropic/claude-sonnet-4";
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
# };
#
# CAUTION: Enable linger for the account. Without linger, systemd stops the
# user manager at logout, and both units stop with it. Home Manager cannot
# run `loginctl enable-linger`. On NixOS, set
# users.users.<name>.linger = true;
# On other systems, run `loginctl enable-linger <name>` one time.
{ inputs, ... }:
{
flake.homeManagerModules.default =
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.services.hermes-agent;
cfgPrograms = config.programs.hermes-agent;
common = import ./moduleCommon.nix { inherit lib; };
effectivePackage = common.effectivePackage cfg;
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
inherit (pkgs.stdenv.hostPlatform) isDarwin isLinux;
processEnvironment = common.processEnvironment {
inherit (cfg) hermesHome;
# The CLI reads this value and names it when it refuses a
# configuration change.
managedSystem = "home-manager";
};
unitPath = lib.makeBinPath (common.processPath { inherit pkgs cfg; });
# ── The desktop launcher ───────────────────────────────────────────
# A GUI launcher reads no shell profile, so home.sessionVariables does
# not reach it, and the application would open ~/.hermes while the
# services use hermesHome. Thus the launcher carries the value itself.
#
# HERMES_MANAGED rides along only when the services are enabled. That
# variable makes the CLI refuse a configuration change and name the
# rebuild command. A person who enables `programs.` alone has no
# activation and no managed configuration, so the application must not
# claim one and refuse an edit that nothing else owns.
desktopEnvironment = {
HERMES_HOME = cfg.hermesHome;
}
// lib.optionalAttrs cfg.enable {
inherit (processEnvironment) HERMES_MANAGED;
}
// lib.optionalAttrs desktopUsesService {
HERMES_DESKTOP_REMOTE_URL = "http://${cfg.backend.host}:${toString cfg.backend.port}";
};
# The application reaches the backend of the service only when there is
# a backend to reach AND a shared token to present with. Without the
# token the desktop resolver throws ("HERMES_DESKTOP_REMOTE_URL is set
# but HERMES_DESKTOP_REMOTE_TOKEN is not"), so the two variables travel
# together or not at all.
desktopUsesService = cfg.enable && cfg.backend.mode != "none" && cfg.backend.sessionTokenFile != null;
# The token is read at start time and never with `--set`. makeWrapper
# writes a --set value into the Nix store, which all users can read.
desktopRun = lib.optional desktopUsesService ''
if [ -r ${lib.escapeShellArg cfg.backend.sessionTokenFile} ]; then
HERMES_DESKTOP_REMOTE_TOKEN="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.backend.sessionTokenFile})"
export HERMES_DESKTOP_REMOTE_TOKEN
else
echo "hermes-desktop: cannot read the session token at ${cfg.backend.sessionTokenFile}." >&2
echo "hermes-desktop: the application starts its own backend instead of the one of the service." >&2
fi
'';
# `override`, and not `overrideAttrs`: the values go into the wrapper
# that the installPhase writes, and not into a derivation attribute.
desktopPackage = cfgPrograms.desktop.package.override {
extraEnv = desktopEnvironment;
extraRun = desktopRun;
};
# The systemd unit that the gateway and the backend both start from.
mkUnit =
{
description,
argv,
}:
{
Unit = {
Description = description;
# Do not use network-online.target here. That is a system target.
# A user unit that orders against it has no effect, and systemd
# gives no message.
After = [ "default.target" ];
};
Install.WantedBy = [ "default.target" ];
Service = {
Type = "simple";
Environment = (lib.mapAttrsToList (k: v: "${k}=${v}") processEnvironment) ++ [
"PATH=${unitPath}"
];
ExecStart = lib.escapeShellArgs argv;
WorkingDirectory = cfg.workingDirectory;
Restart = cfg.restart;
RestartSec = cfg.restartSec;
# This state has one user. Keep it private. The NixOS module uses
# 0007 to share the state with a UNIX group.
UMask = "0077";
NoNewPrivileges = true;
PrivateTmp = true;
};
};
mkAgent =
{ argv, logName }:
{
enable = true;
config = {
Label = "org.nix-community.home.${logName}";
ProgramArguments = argv;
EnvironmentVariables = processEnvironment // {
PATH = "${unitPath}:/usr/bin:/bin:/usr/sbin:/sbin";
};
WorkingDirectory = cfg.workingDirectory;
RunAtLoad = true;
KeepAlive =
if cfg.restart == "always" then
true
else
{
SuccessfulExit = false;
Crashed = true;
};
ThrottleInterval = cfg.restartSec;
StandardOutPath = "${config.home.homeDirectory}/Library/Logs/${logName}.log";
StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/${logName}.err.log";
ProcessType = "Background";
};
};
in
{
# ── programs.hermes-agent — the installation ───────────────────────
# Home Manager separates "install this application for me" from "run
# this daemon". Hermes needs both, and a person can want one without
# the other: an application with no gateway, or a headless gateway on
# a machine with no display.
#
# `services.hermes-agent` stays the authority for the state and the
# configuration. This module reads hermesHome and the backend address
# from it, and never the reverse.
options.programs.hermes-agent = {
enable = lib.mkEnableOption ''
the Hermes Agent command line application.
This adds `hermes` to home.packages, and exports HERMES_HOME with
home.sessionVariables. An interactive shell then uses the same
state as `services.hermes-agent`
'';
package = lib.mkOption {
type = lib.types.package;
default = effectivePackage;
defaultText = lib.literalExpression "config.services.hermes-agent.package";
description = ''
The hermes-agent package to install.
The default follows `services.hermes-agent.package`, and applies
`extraPythonPackages` and `extraDependencyGroups` from that
module. Thus the command line and the services are one build,
and a plugin that the services can load is a plugin that your
shell can load.
'';
};
desktop = {
enable = lib.mkEnableOption ''
the Hermes Desktop application (Electron).
This adds `hermes-desktop` to home.packages, with an XDG
launcher entry on Linux. The launcher starts the same Hermes
runtime that `package` gives, and reads the HERMES_HOME of
`services.hermes-agent`. Thus the application, the interactive
shell and the services share one state directory.
The Electron application carries its own Hermes runtime with
the usual distribution. This module gives it the Nix package
instead, with HERMES_DESKTOP_HERMES. It installs no second copy
of Hermes, and it downloads nothing on the first start
'';
package = lib.mkOption {
type = lib.types.package;
default = cfgPrograms.package.hermesDesktop;
defaultText = lib.literalExpression "config.programs.hermes-agent.package.hermesDesktop";
description = ''
The hermes-desktop package to use.
The default follows `package`, and thus also
`services.hermes-agent.extraPythonPackages` and
`extraDependencyGroups`, because the desktop application is a
passthru of the agent package. A package that you set here
carries its own Hermes runtime, and this module cannot make
it agree with the services.
'';
};
};
};
options.services.hermes-agent =
common.sharedOptions {
defaultPackage = hermes-agent;
defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default";
defaultWorkingDirectory = config.home.homeDirectory;
defaultWorkingDirectoryText = lib.literalExpression "config.home.homeDirectory";
}
// {
hermesHome = lib.mkOption {
type = lib.types.str;
default = "${config.home.homeDirectory}/.hermes";
defaultText = lib.literalExpression ''"''${config.home.homeDirectory}/.hermes"'';
description = ''
The value of HERMES_HOME. This state directory holds
config.yaml, .env, auth.json, the sessions, the skills, the
memory and the cron jobs.
The NixOS module takes a `stateDir` and adds `/.hermes` to it.
This module sets HERMES_HOME directly. Thus an existing
~/.hermes continues to work, and you can give the directory any
name.
'';
example = "/home/alice/.hermes-work";
};
# `installPackage` moved to `programs.hermes-agent.enable`. The
# option is dead, but it must not be silent: it defaulted to true,
# so a person who never named it still got the command line, and a
# quiet removal gives them a machine with no `hermes` and no
# message. mkOption with an assertion, and not
# mkRemovedOptionModule, because the message must name the exact
# replacement for the value they set.
installPackage = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
visible = false;
description = ''
Removed. Use `programs.hermes-agent.enable` instead.
'';
};
gateway.enable = lib.mkEnableOption "the messaging gateway service (Telegram, Discord, Slack, ...)";
};
config = lib.mkMerge [
# ── programs.hermes-agent — the installation ──────────────────────
# Outside the `services.enable` guard on purpose. A person can want
# the command line or the application on a machine that runs no
# daemon at all.
(lib.mkIf cfgPrograms.enable {
home.packages = [ cfgPrograms.package ];
home.sessionVariables.HERMES_HOME = cfg.hermesHome;
})
# A launcher from the desktop menu reads no shell profile, so the
# HERMES_HOME that `programs.enable` exports does not reach it. Home
# Manager writes only systemd.user.sessionVariables into
# environment.d, and this module does not put HERMES_HOME there,
# because that file applies to each user unit. Thus the launcher
# carries the value itself. See desktopEnvironment above.
(lib.mkIf cfgPrograms.desktop.enable {
home.packages = [ desktopPackage ];
})
{
assertions = [
{
# `installPackage` was removed in favour of the programs/services
# split. It defaulted to true, so a quiet removal leaves a person
# with no `hermes` on the PATH and no message.
assertion = cfg.installPackage == null;
message = common.installPackageRemovedMessage cfg.installPackage;
}
];
}
(lib.mkIf cfg.enable (
lib.mkMerge [
# ── Merge MCP servers into settings ────────────────────────────
(lib.mkIf (cfg.mcpServers != { }) {
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
})
{
assertions =
common.pluginNameAssertions {
inherit cfg;
optionPath = "services.hermes-agent";
}
++ common.workspaceFilesAssertions {
inherit cfg;
opt = options.services.hermes-agent.workingDirectory;
optionPath = "services.hermes-agent";
}
++ common.backendBindAssertions {
inherit cfg;
optionPath = "services.hermes-agent";
}
++ [
{
# The interface poll reads `ip`, which iproute2 supplies on
# Linux only.
assertion = !isDarwin || cfg.backend.waitFor != "interface";
message = "services.hermes-agent.backend.waitFor = \"interface\" works on Linux only. Use \"hostname\" on Darwin.";
}
];
}
# The agent runs these tools, so they belong on the PATH of the
# person as well as in the unit.
(lib.mkIf cfgPrograms.enable {
home.packages = cfg.extraPackages;
})
# ── Activation: directories, config, secrets, documents ────────
{
# The activation runs after writeBoundary, when the home.file
# symlinks are in place. It also runs after linkGeneration, when
# Home Manager completes the switch. A secret that the activation
# entry of sops-nix writes exists at that point.
home.activation.hermesAgentSetup =
lib.hm.dag.entryAfter
[
"writeBoundary"
"linkGeneration"
]
(
common.mkStateScript {
inherit pkgs cfg;
inherit (cfg) hermesHome workingDirectory;
run = "$DRY_RUN_CMD ";
stateDirs = common.stateSubdirs;
managedSystem = "home-manager";
# This state has one user. No group needs access to it.
modes = {
config = "0600";
env = "0600";
managed = "0600";
auth = "0600";
document = "0600";
};
}
);
}
# ── Linux: systemd user services ───────────────────────────────
(lib.mkIf (isLinux && cfg.gateway.enable) {
systemd.user.services.hermes-agent = mkUnit {
description = "Hermes Agent Gateway";
argv = common.gatewayArgv cfg;
};
})
(lib.mkIf (isLinux && cfg.backend.mode != "none") {
systemd.user.services.hermes-backend = mkUnit {
description = common.backendDescription cfg;
argv = common.backendArgv { inherit pkgs cfg; };
};
})
# ── Darwin: launchd agents ─────────────────────────────────────
(lib.mkIf (isDarwin && cfg.gateway.enable) {
launchd.agents.hermes-agent = mkAgent {
argv = common.gatewayArgv cfg;
logName = "hermes-agent";
};
})
(lib.mkIf (isDarwin && cfg.backend.mode != "none") {
launchd.agents.hermes-backend = mkAgent {
argv = common.backendArgv { inherit pkgs cfg; };
logName = "hermes-backend";
};
})
]
))
];
};
}