diff --git a/nix/checks.nix b/nix/checks.nix index f353b6942b..227f57d5e5 100644 --- a/nix/checks.nix +++ b/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"; diff --git a/nix/desktop.nix b/nix/desktop.nix index fa76993d27..7f7bcfdb0e 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -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 diff --git a/nix/homeManagerModules.nix b/nix/homeManagerModules.nix index efaf9d7af4..cbffaffce7 100644 --- a/nix/homeManagerModules.nix +++ b/nix/homeManagerModules.nix @@ -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"; + }; + }) + ] + )) + ]; }; } diff --git a/nix/moduleCommon.nix b/nix/moduleCommon.nix index 5ea5b50e2c..cb0da9444a 100644 --- a/nix/moduleCommon.nix +++ b/nix/moduleCommon.nix @@ -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 diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index a663721a91..3041696e5d 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -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 |