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";