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.
This commit is contained in:
ethernet
2026-08-21 16:19:17 -04:00
parent 0287dfb0c2
commit 76f6ba3706
5 changed files with 708 additions and 128 deletions

View File

@@ -54,6 +54,31 @@
];
};
# The programs./services. split means a check often needs both halves.
# This takes each one as its own attribute set.
evalHomeSplit =
{
programs ? { },
services ? { },
}:
inputs.home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
inputs.self.homeManagerModules.default
{
home = {
username = "hermes-check";
homeDirectory = "/home/hermes-check";
stateVersion = "24.11";
};
}
{
programs.hermes-agent = programs;
services.hermes-agent = services;
}
];
};
# The option names that each module defines under
# services.hermes-agent. The internal names that the module system adds
# are not in the list.
@@ -149,21 +174,24 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
# agents. Each host checks its own kind of process.
home-manager-module =
let
enabled = evalHomeModule {
enable = true;
gateway.enable = true;
backend.mode = "serve";
settings.model.default = "test/model";
environment.HERMES_TEST = "1";
environmentFiles = [ "/run/secrets/hermes-env" ];
hermesHomeFiles."SOUL.md" = "test soul";
# documents needs an explicit workingDirectory. The check
# workspace-files-need-a-directory below asserts that rule.
workingDirectory = "/home/test-user/workspace";
documents."AGENTS.md" = "test agents";
mcpServers.demo = {
command = "echo";
args = [ "hi" ];
enabled = evalHomeSplit {
programs.enable = true;
services = {
enable = true;
gateway.enable = true;
backend.mode = "serve";
settings.model.default = "test/model";
environment.HERMES_TEST = "1";
environmentFiles = [ "/run/secrets/hermes-env" ];
hermesHomeFiles."SOUL.md" = "test soul";
# documents needs an explicit workingDirectory. The check
# workspace-files-need-a-directory below asserts that rule.
workingDirectory = "/home/test-user/workspace";
documents."AGENTS.md" = "test agents";
mcpServers.demo = {
command = "echo";
args = [ "hi" ];
};
};
};
cfg = enabled.config;
@@ -217,7 +245,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
) "gateway and backend must share one HERMES_HOME"
++ lib.optional (
cfg.home.sessionVariables.HERMES_HOME or null != "/home/hermes-check/.hermes"
) "installPackage must export HERMES_HOME for interactive shells"
) "programs.hermes-agent.enable must export HERMES_HOME for interactive shells"
++ lib.optional (
!lib.hasInfix "hermes-config-merge" activation
) "activation must deep-merge config.yaml, not overwrite it"
@@ -325,6 +353,250 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
''
);
# ── The desktop application shares one HERMES_HOME ───────────────
# `programs.enable` exports HERMES_HOME with home.sessionVariables,
# which reaches an interactive shell only. Home Manager writes that
# file to etc/profile.d, and a launcher from the desktop menu reads
# no shell profile. Thus the desktop application would open ~/.hermes
# while the services use the HERMES_HOME of the module, and the user
# would see an empty application with no sessions and no keys.
#
# The launcher must therefore carry the value itself. This check
# reads the real wrapper text of the package that the module
# installs, and not an option value.
home-manager-desktop =
let
tokenFile = "/run/secrets/hermes-desktop-token";
enabled = evalHomeSplit {
programs = {
enable = true;
desktop.enable = true;
};
services = {
enable = true;
hermesHome = "/home/hermes-check/.hermes-work";
# An override on purpose. Without one the effective package
# IS the default package, so a launcher that pinned the plain
# default would look correct while it shipped a second
# runtime to anyone who customises theirs.
extraDependencyGroups = [ "hindsight" ];
backend = {
mode = "serve";
port = 9231;
sessionTokenFile = tokenFile;
};
};
};
cfg = enabled.config;
desktopPackages = builtins.filter (p: (p.pname or "") == "hermes-desktop") cfg.home.packages;
desktop = lib.head desktopPackages;
wrapper = desktop.installPhase;
# Read the value that each --set flag gives the launcher. The
# quotes are not part of the test: escapeShellArg adds them only
# when the value needs them, and a path with no special character
# arrives bare.
setValue =
name:
let
m = builtins.match ".*--set ${name} ['\"]?([^'\"\n ]*)['\"]?.*" wrapper;
in
if m == null then null else lib.head m;
# The agent package that the module installs, and the runtime
# that the launcher pins. These must be the same store path: a
# second Hermes runtime beside the services is the fault that
# `programs.enable` plus a plain desktop package would give.
agentPackages = builtins.filter (p: (p.pname or "") == "hermes-agent") cfg.home.packages;
# The backend of the service, as the unit or the agent runs it.
backendScript =
let
argv =
if pkgs.stdenv.hostPlatform.isDarwin then
cfg.launchd.agents.hermes-backend.config.ProgramArguments
else
[ cfg.systemd.user.services.hermes-backend.Service.ExecStart ];
first = lib.head (lib.flatten argv);
# writeShellScript gives a store path. Read the real text, so
# the check tests the script and not the option that made it.
path = lib.head (lib.splitString " " first);
in
builtins.readFile path;
failures =
lib.optional (
lib.length desktopPackages != 1
) "programs.desktop.enable must install exactly one hermes-desktop package, got ${toString (lib.length desktopPackages)}"
++ lib.optional (
setValue "HERMES_HOME" != "/home/hermes-check/.hermes-work"
) "the launcher must carry HERMES_HOME: a GUI launcher reads no shell profile, so home.sessionVariables never reaches it (got: ${toString (setValue "HERMES_HOME")})"
++ lib.optional (
setValue "HERMES_MANAGED" != "home-manager"
) "the launcher must report HERMES_MANAGED=home-manager while the services own the configuration (got: ${toString (setValue "HERMES_MANAGED")})"
++ lib.optional (
lib.length agentPackages == 1
&& setValue "HERMES_DESKTOP_HERMES" != "${lib.head agentPackages}/bin/hermes"
) "the launcher must pin the agent package that programs.enable installs, and not a second runtime: ${toString (setValue "HERMES_DESKTOP_HERMES")}"
# ── The application reaches the backend of the service ──────
++ lib.optional (
setValue "HERMES_DESKTOP_REMOTE_URL" != "http://127.0.0.1:9231"
) "the launcher must name the backend of the service, or the application starts a second one (got: ${toString (setValue "HERMES_DESKTOP_REMOTE_URL")})"
++ lib.optional (
!lib.hasInfix "HERMES_DESKTOP_REMOTE_TOKEN" wrapper
) "the launcher must give a token with the URL: the desktop resolver throws when the URL is set alone"
++ lib.optional (
!lib.hasInfix "HERMES_DASHBOARD_SESSION_TOKEN" backendScript
) "the backend must read the session token, or it makes a new one that the application cannot know"
# ── The token never enters the Nix store ────────────────────
# Each side must read the file at start time. A --set flag or
# an Environment= value writes the literal into a store path
# that all users can read.
++ lib.optional (
!lib.hasInfix tokenFile wrapper || !lib.hasInfix "--run" wrapper
) "the launcher must read the token from ${tokenFile} at start time, with --run"
++ lib.optional (
!lib.hasInfix tokenFile backendScript
) "the backend must read the token from ${tokenFile} at start time"
++ lib.optional (
setValue "HERMES_DESKTOP_REMOTE_TOKEN" != null
) "the token must never be a --set value: makeWrapper writes it into the world-readable Nix store";
in
pkgs.runCommand "hermes-home-manager-desktop" { } (
if failures != [ ] then
throw "Home Manager desktop check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
else
''
echo "PASS: the desktop launcher shares HERMES_HOME, the runtime and the backend of the service"
mkdir -p $out
echo "ok" > $out/result
''
);
# ── The desktop application without the services ─────────────────
# A person can want the application on a machine that runs no daemon.
# Then nothing writes config.yaml or the .managed marker, so the
# launcher must not claim a managed install: the CLI would refuse an
# edit that nothing else owns. It must also not name a backend, since
# there is none.
home-manager-desktop-standalone =
let
enabled = evalHomeSplit {
programs = {
enable = true;
desktop.enable = true;
};
};
cfg = enabled.config;
desktopPackages = builtins.filter (p: (p.pname or "") == "hermes-desktop") cfg.home.packages;
wrapper = (lib.head desktopPackages).installPhase;
failures =
lib.optional (
lib.length desktopPackages != 1
) "programs.desktop.enable must install the application with no services enabled"
++ lib.optional (
!lib.hasInfix "--set HERMES_HOME" wrapper
) "the launcher must carry HERMES_HOME even with no services"
++ lib.optional (
lib.hasInfix "HERMES_MANAGED" wrapper
) "the launcher must not claim a managed install when no activation writes one"
++ lib.optional (
lib.hasInfix "HERMES_DESKTOP_REMOTE_URL" wrapper
) "the launcher must not name a backend when the services run none"
++ lib.optional (
cfg.systemd.user.services ? hermes-backend || cfg.launchd.agents ? hermes-backend
) "programs.enable alone must start no service";
in
pkgs.runCommand "hermes-home-manager-desktop-standalone" { } (
if failures != [ ] then
throw "Home Manager standalone desktop check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
else
''
echo "PASS: the application runs with no services, and claims nothing that no activation wrote"
mkdir -p $out
echo "ok" > $out/result
''
);
# ── installPackage names its replacement ─────────────────────────
# The option was removed by the programs./services. split. It
# defaulted to true, so a person who never named it still got the
# command line. A silent removal thus leaves them with no `hermes`
# and no message. The module must refuse the configuration and name
# the replacement.
home-manager-install-package-removed =
let
common = import ./moduleCommon.nix { inherit lib; };
# `builtins.length` is enough to force the assertion, because
# Home Manager wraps the whole `config` in its assertion check.
# `lib.deepSeq` would walk each package of the closure instead,
# and overflow the stack before it reached an answer.
refuses =
value:
!(builtins.tryEval (
builtins.length
(evalHomeSplit {
services = {
enable = true;
installPackage = value;
};
}).config.home.packages
)).success;
# The check calls the same function the module calls, so it reads
# the real message. Matching the source text of the module instead
# would pass while the message was wrong.
messageFor = common.installPackageRemovedMessage;
cases = [
{
value = true;
expect = "programs.hermes-agent.enable = true;";
}
{
value = false;
expect = "programs.hermes-agent.enable = false;";
}
];
failures =
lib.concatMap (
case:
lib.optional (
!refuses case.value
) "installPackage = ${lib.boolToString case.value} must be refused"
++ lib.optional (
!lib.hasInfix case.expect (messageFor case.value)
) "the message for installPackage = ${lib.boolToString case.value} must name `${case.expect}`"
++ lib.optional (
!lib.hasInfix "installPackage was removed" (messageFor case.value)
) "the message must say that the option was removed"
) cases
++ lib.optional (
# A configuration that never names the option must still work.
# An assertion that fires on the default value would break each
# existing user at once.
refuses null
) "a configuration that never names installPackage must evaluate";
in
pkgs.runCommand "hermes-home-manager-install-package-removed" { } (
if failures != [ ] then
throw "installPackage removal check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}"
else
''
echo "PASS: installPackage is refused with guidance, and its absence evaluates"
mkdir -p $out
echo "ok" > $out/result
''
);
# ── The two modules keep the same options ────────────────────────
# The modules share one option set, in nix/moduleCommon.nix. Thus a
# NixOS example works on Home Manager without a change. This check
@@ -629,6 +901,10 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
waitFor = null;
interfaceName = null;
waitTimeout = 120;
# No token here: this case asserts the plain argv, which the
# module builds only when nothing must run before the
# backend. A token needs the launcher script instead.
sessionTokenFile = null;
};
};
sentinel = "--hermes-nix-argv-probe";

View File

@@ -15,9 +15,30 @@
electron,
hermesAgent,
python3,
# Environment to bake into the launcher. A GUI launcher reads none of the
# shell profile, so a variable that an interactive shell exports does not
# reach an app that the desktop menu starts. The Home Manager module passes
# HERMES_HOME and HERMES_MANAGED here, which gives the app the same state
# directory as the services.
extraEnv ? { },
# Shell lines to run before the app starts. A secret belongs here and never
# in extraEnv: makeWrapper writes a --set value into the Nix store, which
# all users can read. A --run line reads the value from a runtime path at
# each start instead.
extraRun ? [ ],
...
}:
let
# Each flag goes on its own continued line, and the leading backslash is
# inside the generated string. An empty attribute set then adds no text at
# all, and cannot leave a backslash above a blank line. That fault ends the
# makeWrapper command early, and the next flag runs as a shell command.
extraEnvFlags = lib.concatMapStrings (
name: " \\\n --set ${name} ${lib.escapeShellArg (toString extraEnv.${name})}"
) (lib.attrNames extraEnv);
extraRunFlags = lib.concatMapStrings (line: " \\\n --run ${lib.escapeShellArg line}") extraRun;
electronHeaders = pkgs.fetchurl {
url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz";
sha256 = "sha256-f8bSbLRmtbP93CJAvEBs+sHWDZ1xP2bcpLhC1EnOmZU=";
@@ -168,7 +189,7 @@ stdenv.mkDerivation {
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
--add-flags "$out/share/hermes-desktop" \
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
--set ELECTRON_IS_DEV 0
--set ELECTRON_IS_DEV 0${extraEnvFlags}${extraRunFlags}
# XDG launcher entry
mkdir -p $out/share/applications $out/share/icons/hicolor/1024x1024/apps

View File

@@ -17,12 +17,19 @@
# changed systemd.services -> systemd.user.services or
# launchd.agents
# changed system.activationScripts -> home.activation
# changed addToSystemPackages -> installPackage and
# 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;
@@ -48,6 +55,7 @@
let
cfg = config.services.hermes-agent;
cfgPrograms = config.programs.hermes-agent;
common = import ./moduleCommon.nix { inherit lib; };
effectivePackage = common.effectivePackage cfg;
@@ -63,6 +71,52 @@
};
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 =
{
@@ -124,6 +178,73 @@
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;
@@ -149,125 +270,159 @@
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.bool;
default = true;
type = lib.types.nullOr lib.types.bool;
default = null;
visible = false;
description = ''
Add the hermes CLI to home.packages, and export HERMES_HOME
with home.sessionVariables. Interactive shells then use the
same state as the services.
The equivalent NixOS option, `addToSystemPackages`, exports
HERMES_HOME with environment.variables. That variable applies
to the full system and replaces the HERMES_HOME of each other
user. This module exports the variable for one user session
only, which is the reason to use Home Manager.
Removed. Use `programs.hermes-agent.enable` instead.
'';
};
gateway.enable = lib.mkEnableOption "the messaging gateway service (Telegram, Discord, Slack, ...)";
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
config = lib.mkMerge [
# ── Merge MCP servers into settings ────────────────────────────
(lib.mkIf (cfg.mcpServers != { }) {
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
})
# ── 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;
})
{
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.";
# 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";
}
];
}
# ── Packages and interactive-shell environment ─────────────────
(lib.mkIf cfg.installPackage {
home.packages = [ effectivePackage ] ++ cfg.extraPackages;
home.sessionVariables.HERMES_HOME = cfg.hermesHome;
})
# ── Activation: directories, config, secrets, documents ────────
{
# The activation runs after writeBoundary, when the home.file
# symlinks are in place. It also runs after linkGeneration, when
# Home Manager completes the switch. A secret that the activation
# entry of sops-nix writes exists at that point.
home.activation.hermesAgentSetup =
lib.hm.dag.entryAfter
[
"writeBoundary"
"linkGeneration"
]
(
common.mkStateScript {
inherit pkgs cfg;
inherit (cfg) hermesHome workingDirectory;
run = "$DRY_RUN_CMD ";
stateDirs = common.stateSubdirs;
managedSystem = "home-manager";
# This state has one user. No group needs access to it.
modes = {
config = "0600";
env = "0600";
managed = "0600";
auth = "0600";
document = "0600";
};
++ 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.";
}
);
}
];
}
# ── Linux: systemd user services ───────────────────────────────
(lib.mkIf (isLinux && cfg.gateway.enable) {
systemd.user.services.hermes-agent = mkUnit {
description = "Hermes Agent Gateway";
argv = common.gatewayArgv cfg;
};
})
# 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;
})
(lib.mkIf (isLinux && cfg.backend.mode != "none") {
systemd.user.services.hermes-backend = mkUnit {
description = common.backendDescription cfg;
argv = common.backendArgv { inherit pkgs cfg; };
};
})
# ── 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";
};
}
);
}
# ── Darwin: launchd agents ─────────────────────────────────────
(lib.mkIf (isDarwin && cfg.gateway.enable) {
launchd.agents.hermes-agent = mkAgent {
argv = common.gatewayArgv cfg;
logName = "hermes-agent";
};
})
# ── 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 (isDarwin && cfg.backend.mode != "none") {
launchd.agents.hermes-backend = mkAgent {
argv = common.backendArgv { inherit pkgs cfg; };
logName = "hermes-backend";
};
})
]
);
(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";
};
})
]
))
];
};
}

View File

@@ -10,7 +10,8 @@
# nixosModules.nix the service user and group, stateDir,
# addToSystemPackages, container mode, tmpfiles,
# system.activationScripts, system systemd units
# homeManagerModules.nix hermesHome, installPackage, home.activation,
# 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
@@ -618,9 +619,59 @@ let
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:
@@ -862,7 +913,14 @@ let
]
++ cfg.backend.extraArgs;
# The launcher that waits for the bind target, then starts the backend.
# 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
@@ -879,8 +937,32 @@ let
_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 == "hostname" then
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"
@@ -940,7 +1022,10 @@ let
backendArgv =
{ pkgs, cfg }:
if cfg.backend.waitFor == null then
# 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; }}" ];
@@ -1063,6 +1148,7 @@ in
deepConfigType
effectivePackage
gatewayArgv
installPackageRemovedMessage
mcpServerType
mcpServersToConfig
mkConfigFiles

View File

@@ -600,7 +600,8 @@ The option set is the same set that the NixOS module uses. It is `services.herme
| Runs as | a system user that you declare, with `user`, `group` and `createUser` | you |
| State directory | `stateDir` and `/.hermes` | `hermesHome`, set directly. The default is `~/.hermes`. |
| Service | `systemd.services` | `systemd.user.services` on Linux, `launchd.agents` on macOS |
| CLI on the PATH | `addToSystemPackages`, which exports `HERMES_HOME` for the full system | `installPackage`, which exports it for your session only |
| CLI on the PATH | `addToSystemPackages`, which exports `HERMES_HOME` for the full system | `programs.hermes-agent.enable`, which exports it for your session only |
| Desktop application | not supported, because a system service cannot own a user session | `programs.hermes-agent.desktop.enable` |
| Container mode | supported | not supported, because it needs root and the Docker socket |
### Add the Flake Input
@@ -1031,9 +1032,50 @@ This option runs the process that Hermes Desktop and the web dashboard connect t
| Option | Type | Default | Description |
|---|---|---|---|
| `hermesHome` | `str` | `"${config.home.homeDirectory}/.hermes"` | `HERMES_HOME` directly. The NixOS module builds it from `stateDir`. |
| `installPackage` | `bool` | `true` | Add the `hermes` CLI to `home.packages`, and export `HERMES_HOME` for your shells |
| `gateway.enable` | `bool` | `false` | Run the messaging gateway. On the NixOS module the gateway is the service, so that module has no such option. |
### `programs.hermes-agent` (Home Manager only)
Home Manager separates "install this application for me" from "run this
daemon". `services.hermes-agent` keeps the state, the configuration and the
daemons. `programs.hermes-agent` installs what you use, and reads
`hermesHome` and the backend address from the services.
| Option | Type | Default | Description |
|---|---|---|---|
| `enable` | `bool` | `false` | Add the `hermes` CLI to `home.packages`, and export `HERMES_HOME` for your shells |
| `package` | `package` | `services.hermes-agent.package` | The package to install. The default applies `extraPythonPackages` and `extraDependencyGroups` from the services, so both are one build. |
| `desktop.enable` | `bool` | `false` | Add the Hermes Desktop application, with a launcher entry on Linux |
| `desktop.package` | `package` | `package.hermesDesktop` | The desktop package. The default follows `package`, so the application and the services run one Hermes runtime. |
```nix
programs.hermes-agent = {
enable = true;
desktop.enable = true;
};
services.hermes-agent = {
enable = true;
backend.mode = "serve";
backend.sessionTokenFile = config.sops.secrets."hermes/desktop-token".path;
};
```
The launcher carries `HERMES_HOME` itself. A desktop menu reads no shell
profile, so the value that `programs.hermes-agent.enable` exports with
`home.sessionVariables` reaches an interactive shell only. Without the
value in the launcher, the application opens `~/.hermes` while the
services use `hermesHome`, and you see no sessions and no keys.
With `backend.sessionTokenFile`, the application connects to the backend
of the service instead of starting one of its own. Both sides read the
file at start time, so the token enters no Nix store path. Without the
option, each side runs its own backend.
`services.hermes-agent.installPackage` was removed by this split. A
configuration that still sets it gets an error that names the
replacement.
### Container (NixOS only)
| Option | Type | Default | Description |