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:
308
nix/checks.nix
308
nix/checks.nix
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user