diff --git a/flake.lock b/flake.lock index 99d5dd57e2..5e59b460ce 100644 --- a/flake.lock +++ b/flake.lock @@ -20,6 +20,26 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786487128, + "narHash": "sha256-ad60hrRVhH/bo3Jl1YLzO+QcS3mUNZr9LNJdzhMO2P4=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "f404edbfa4117810c96b97048299242fc50e5362", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1785318670, @@ -105,6 +125,7 @@ "root": { "inputs": { "flake-parts": "flake-parts", + "home-manager": "home-manager", "nixpkgs": "nixpkgs", "npm-lockfile-fix": "npm-lockfile-fix", "pyproject-build-systems": "pyproject-build-systems", diff --git a/flake.nix b/flake.nix index 8715490be1..46e1f3d4ff 100644 --- a/flake.nix +++ b/flake.nix @@ -26,6 +26,14 @@ url = "github:jeslie0/npm-lockfile-fix"; inputs.nixpkgs.follows = "nixpkgs"; }; + # Used only by nix/checks.nix, to evaluate homeManagerModules.default + # against the real Home Manager module system rather than a stub of it. + # Consuming the module does not require this input — import it from your + # own home-manager, exactly as you would any other HM module. + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = @@ -41,6 +49,7 @@ ./nix/packages.nix ./nix/overlays.nix ./nix/nixosModules.nix + ./nix/homeManagerModules.nix ./nix/checks.nix ./nix/devShell.nix ]; diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5e6fd1942e..15cfeaf354 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -338,10 +338,10 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul # ============================================================================= _MANAGED_TRUE_VALUES = ("true", "1", "yes") -_MANAGED_SYSTEM_NAMES = { - "nix": "NixOS", - "nixos": "NixOS", -} +_NIX_MANAGED_SYSTEMS = {"nixos", "home-manager"} +# Only the NixOS module ever wrote a bare "true" or an empty marker, so both +# legacy signals name that system. +_LEGACY_MANAGED_SYSTEM = "nixos" # The Nix store root. Used by detect_install_method to identify installs # from `nix run` / `nix profile install` (which don't set HERMES_MANAGED). # A module-level constant so tests can patch it without creating files @@ -357,18 +357,30 @@ _IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"}) def get_managed_system() -> Optional[str]: """Return the package manager owning this install, if any.""" raw = os.getenv("HERMES_MANAGED", "").strip() + marker = None if raw: - normalized = raw.lower() - if normalized in _IGNORED_MANAGED_VALUES: - return None - if normalized in _MANAGED_TRUE_VALUES: - return "NixOS" - return _MANAGED_SYSTEM_NAMES.get(normalized, raw) + marker = raw.lower() + else: + managed_marker = get_hermes_home() / ".managed" + # An interactive shell reads the marker, because it does not see the + # HERMES_MANAGED variable of the service. A marker with content + # names the system that manages the install. + if managed_marker.exists(): + try: + marker = managed_marker.read_text(encoding="utf-8", errors="replace").strip().lower() + except OSError: + marker = "" - managed_marker = get_hermes_home() / ".managed" - if managed_marker.exists(): - return "NixOS" - return None + if marker is None: + return None + + if marker in _IGNORED_MANAGED_VALUES: + return None + + if marker == "" or marker in _MANAGED_TRUE_VALUES: + return _LEGACY_MANAGED_SYSTEM + + return marker def is_managed() -> bool: @@ -381,6 +393,9 @@ def is_managed() -> bool: return get_managed_system() is not None +# Nix installs arrive by several routes (nix run, nix profile, a system flake, +# home-manager), and the running process cannot tell which one. Thus this text +# names the routes instead of one command. _NIX_UPDATE_MSG = ( "Update Hermes through the Nix source that installed it " "(e.g. nix profile upgrade, or update your flake input and rebuild with nixos-rebuild or home-manager switch)" @@ -390,7 +405,7 @@ _NIX_UPDATE_MSG = ( def get_managed_update_command() -> Optional[str]: """Return the preferred upgrade command for a managed install.""" managed_system = get_managed_system() - if managed_system == "NixOS": + if managed_system in _NIX_MANAGED_SYSTEMS: return _NIX_UPDATE_MSG return None @@ -546,9 +561,19 @@ def stamp_install_method(method: str, project_root: Optional[Path] = None) -> No pass +def is_nix_install_method(method: str) -> bool: + """Return True for every install method that Nix owns. + + The callers that branch on the install method must treat "nix", + "nixos" and "home-manager" the same way. One helper keeps the three + names in one place, so a new Nix shape cannot miss a call site. + """ + return method == "nix" or method in _NIX_MANAGED_SYSTEMS + + def recommended_update_command_for_method(method: str) -> str: """Return the update command or guidance for a given install method.""" - if method in {"nix", "nixos"}: + if is_nix_install_method(method): return _NIX_UPDATE_MSG if method == "docker": return "docker pull nousresearch/hermes-agent:latest" @@ -561,6 +586,9 @@ def recommended_update_command_for_method(method: str) -> str: def recommended_update_command() -> str: """Return the best update command for the current installation.""" + # The managed state wins over the code-scoped stamp. A managed install + # can carry a stale stamp from an earlier install shape, and the stamp + # then names an update path that the managed guard refuses. managed_cmd = get_managed_update_command() if managed_cmd: return managed_cmd @@ -623,17 +651,6 @@ def format_docker_update_message() -> str: def format_managed_message(action: str = "modify this Hermes installation") -> str: """Build a user-facing error for managed installs.""" managed_system = get_managed_system() or "a package manager" - raw = os.getenv("HERMES_MANAGED", "").strip().lower() - - if managed_system == "NixOS": - env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true" - return ( - f"Cannot {action}: this Hermes installation is managed by NixOS " - f"(HERMES_MANAGED={env_hint}).\n" - "Edit services.hermes-agent.settings in your configuration.nix and run:\n" - " sudo nixos-rebuild switch" - ) - return ( f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n" "Use your package manager to upgrade or reinstall Hermes." @@ -928,16 +945,12 @@ def _ensure_hermes_home_managed(home: Path): """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.""" if not home.is_dir(): raise RuntimeError( - f"HERMES_HOME {home} does not exist. " - "Run 'sudo nixos-rebuild switch' first." + f"HERMES_HOME {home} does not exist." ) for subdir in ("cron", "sessions", "logs", "memories"): d = home / subdir if not d.is_dir(): - raise RuntimeError( - f"{d} does not exist. " - "Run 'sudo nixos-rebuild switch' first." - ) + raise RuntimeError(f"{d} does not exist.") # Curator reports dir is a sub-path of logs/; create it if missing. # In managed mode the activation script may not know about this subdir, # so we mkdir it ourselves (it's inside an already-secured logs/ dir). diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index a75c9c6c00..253fb1c7bc 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -16,6 +16,7 @@ from hermes_cli.config import ( get_env_path, get_hermes_home, get_project_root, + is_nix_install_method, recommended_update_command_for_method, ) from hermes_cli.env_loader import load_hermes_dotenv @@ -90,7 +91,7 @@ def _sqlite_upgrade_hint(install_method: str | None = None) -> str: if method == "docker": command = recommended_update_command_for_method(method) action = f"run `{command}`, then recreate all Hermes containers" - elif method in {"nix", "nixos"}: + elif is_nix_install_method(method): # The Nix helper is prose guidance, not a literal shell command. action = recommended_update_command_for_method(method) elif method == "apt": diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ade328da4f..b1871b15ae 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -7511,7 +7511,7 @@ def _gateway_command_inner(args): # Service management commands if subcmd == "install": if is_managed(): - managed_error("install gateway service (managed by NixOS)") + managed_error("install gateway service") return force = getattr(args, "force", False) system = getattr(args, "system", False) @@ -7623,7 +7623,7 @@ def _gateway_command_inner(args): elif subcmd == "uninstall": if is_managed(): - managed_error("uninstall gateway service (managed by NixOS)") + managed_error("uninstall gateway service") return system = getattr(args, "system", False) if is_termux(): diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 044501e2b6..bb0dc3fcd7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9905,6 +9905,7 @@ def cmd_update(args): detect_install_method, format_docker_update_message, is_managed, + is_nix_install_method, managed_error, recommended_update_command_for_method, ) @@ -9924,7 +9925,7 @@ def cmd_update(args): print(format_docker_update_message()) sys.exit(1) - if install_method in {"nix", "nixos", "apt"}: + if is_nix_install_method(install_method) or install_method == "apt": print(recommended_update_command_for_method(install_method)) sys.exit(1) diff --git a/hermes_cli/observability/schemas/hermes.shared_metrics.v2.schema.json b/hermes_cli/observability/schemas/hermes.shared_metrics.v2.schema.json index 0052569679..5d845ce612 100644 --- a/hermes_cli/observability/schemas/hermes.shared_metrics.v2.schema.json +++ b/hermes_cli/observability/schemas/hermes.shared_metrics.v2.schema.json @@ -58,7 +58,7 @@ }, "install_method": { "type": "string", - "enum": ["apt", "docker", "git", "homebrew", "nixos", "pip", "unknown"] + "enum": ["apt", "docker", "git", "home-manager", "homebrew", "nixos", "pip", "unknown"] }, "os_family": { "type": "string", diff --git a/hermes_cli/observability/shared_metrics_contract.py b/hermes_cli/observability/shared_metrics_contract.py index 6a1cec1c63..eb1f858216 100644 --- a/hermes_cli/observability/shared_metrics_contract.py +++ b/hermes_cli/observability/shared_metrics_contract.py @@ -197,6 +197,7 @@ CLIENT_INSTALL_METHODS: frozenset[str] = frozenset({ "apt", "docker", "git", + "home-manager", "homebrew", "nixos", "pip", diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index e05850c7b9..107f6f778c 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -2690,7 +2690,11 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): Installs that can't honor non-default branches (e.g. Docker) surface a one-line notice instead of silently dropping the flag. """ - from hermes_cli.config import detect_install_method, recommended_update_command_for_method + from hermes_cli.config import ( + detect_install_method, + is_nix_install_method, + recommended_update_command_for_method, + ) method = detect_install_method(_m().PROJECT_ROOT) if method == "docker": # Docker can't ``git fetch`` from within the container. Surface the @@ -2701,7 +2705,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): print(format_docker_update_message()) sys.exit(1) - if method in {"nix", "nixos", "apt"}: + if is_nix_install_method(method) or method == "apt": print(recommended_update_command_for_method(method)) sys.exit(1) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 85f6d61ee5..439d8cd66c 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -78,6 +78,7 @@ from hermes_cli.config import ( check_config_version, detect_install_method, format_docker_update_message, + is_nix_install_method, recommended_update_command_for_method, redact_key, write_platform_config_field, @@ -4776,7 +4777,7 @@ async def update_hermes(): "update_command": recommended_update_command_for_method(install_method), } - if install_method in {"nix", "nixos", "apt"}: + if is_nix_install_method(install_method) or install_method == "apt": message = recommended_update_command_for_method(install_method) _record_completed_action("hermes-update", message, exit_code=1) return { diff --git a/nix/checks.nix b/nix/checks.nix index 1cf3421259..a9c32176fc 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -11,6 +11,73 @@ configMergeScript = pkgs.callPackage ./configMergeScript.nix { }; + # ── How the checks evaluate the modules ─────────────────────────── + # The checks evaluate both modules for real. The NixOS module goes + # through lib.evalModules with the NixOS module list. The Home Manager + # module goes through the homeManagerConfiguration function of + # home-manager. The option system rejects a wrong type, an option that + # does not exist, and a broken activation string. Each of these faults + # then stops the check, and not the rebuild of a user. + evalNixosModule = + settings: + inputs.nixpkgs.lib.evalModules { + modules = import "${inputs.nixpkgs}/nixos/modules/module-list.nix" ++ [ + inputs.self.nixosModules.default + { _module.args.lib = inputs.nixpkgs.lib; } + { nixpkgs.hostPlatform = pkgs.stdenv.hostPlatform.system; } + { + system.stateVersion = "24.11"; + boot.loader.grub.enable = false; + fileSystems."/" = { + device = "/dev/null"; + fsType = "ext4"; + }; + } + { services.hermes-agent = settings; } + ]; + }; + + evalHomeModule = + settings: + inputs.home-manager.lib.homeManagerConfiguration { + inherit pkgs; + modules = [ + inputs.self.homeManagerModules.default + { + home = { + username = "hermes-check"; + homeDirectory = "/home/hermes-check"; + stateVersion = "24.11"; + }; + } + { services.hermes-agent = settings; } + ]; + }; + + # The option names that each module defines under + # services.hermes-agent. The internal names that the module system adds + # are not in the list. + moduleOptionNames = + eval: lib.attrNames (lib.filterAttrs (n: _: !lib.hasPrefix "_" n) eval.options.services.hermes-agent); + + # These options belong to one module by design. The check does not + # compare the two lists against each other, because that test only + # detects a change. The important property is that each shared option + # is on both modules. + nixosOnlyOptions = [ + "addToSystemPackages" + "container" + "createUser" + "group" + "stateDir" + "user" + ]; + homeOnlyOptions = [ + "gateway" + "hermesHome" + "installPackage" + ]; + # Auto-generated config key reference — always in sync with Python configKeys = pkgs.runCommand "hermes-config-keys" {} '' set -euo pipefail @@ -74,7 +141,427 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) mkdir -p $out echo "ok" > $out/result ''; + + # ── The Home Manager module ────────────────────────────────────── + # This check evaluates homeManagerModules.default through the real + # module system of home-manager. It runs on each platform. The module + # supports Linux, with systemd user units, and Darwin, with launchd + # 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" ]; + }; + }; + cfg = enabled.config; + + # The gateway and the backend are two processes with one + # HERMES_HOME. + processes = + if pkgs.stdenv.hostPlatform.isDarwin then + lib.mapAttrs (_: agent: { + argv = agent.config.ProgramArguments; + env = agent.config.EnvironmentVariables; + }) (lib.filterAttrs (n: _: lib.hasPrefix "hermes" n) cfg.launchd.agents) + else + lib.mapAttrs (_: unit: { + argv = [ unit.Service.ExecStart ]; + env = unit.Service.Environment; + }) (lib.filterAttrs (n: _: lib.hasPrefix "hermes" n) cfg.systemd.user.services); + + names = lib.attrNames processes; + argvOf = name: lib.concatStringsSep " " (lib.flatten (processes.${name}.argv)); + # The systemd Environment is a list of "K=V" strings. The launchd + # equivalent is an attribute set. Make both into one "K=V K=V" + # string, so that the assertions below are the same on each host. + envOf = + name: + let + env = processes.${name}.env; + in + lib.concatStringsSep " " ( + if lib.isAttrs env then lib.mapAttrsToList (k: v: "${k}=${toString v}") env else env + ); + + activation = cfg.home.activation.hermesAgentSetup.data; + + failures = + lib.optional (names != [ + "hermes-agent" + "hermes-backend" + ]) "expected hermes-agent + hermes-backend processes, got: ${toString names}" + ++ lib.optional ( + !lib.hasInfix "bin/hermes gateway" (argvOf "hermes-agent") + ) "gateway process does not run `hermes gateway`: ${argvOf "hermes-agent"}" + ++ lib.optional ( + !lib.hasInfix "bin/hermes serve" (argvOf "hermes-backend") + ) "backend process does not run `hermes serve`: ${argvOf "hermes-backend"}" + ++ lib.optional ( + !lib.hasInfix "--no-open" (argvOf "hermes-backend") + ) "backend must pass --no-open so a service never opens a browser" + ++ lib.optional ( + lib.any (n: !lib.hasInfix "/home/hermes-check/.hermes" (envOf n)) names + ) "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" + ++ lib.optional ( + !lib.hasInfix "hermes-config-merge" activation + ) "activation must deep-merge config.yaml, not overwrite it" + ++ lib.optional ( + !lib.hasInfix "/home/hermes-check/.hermes/SOUL.md" activation + ) "hermesHomeFiles must install into HERMES_HOME" + ++ lib.optional ( + !lib.hasInfix "/home/test-user/workspace/AGENTS.md" activation + ) "documents must install into workingDirectory" + # The CLI reads HERMES_MANAGED to name the rebuild command when + # it refuses to write the configuration. A Home Manager install + # has no nixos-rebuild command. Thus it must not report NixOS. + ++ lib.optional ( + !lib.any (n: lib.hasInfix "HERMES_MANAGED=home-manager" (envOf n)) names + ) "processes must report HERMES_MANAGED=home-manager" + ++ lib.optional ( + !lib.hasInfix "hermes-managed" activation + ) "activation must write a .managed marker naming the managing system"; + in + pkgs.runCommand "hermes-home-manager-module" { } ( + if failures != [ ] then + throw "Home Manager module check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}" + else + '' + echo "PASS: home-manager module evaluates (${toString (lib.length names)} processes)" + mkdir -p $out + echo "ok" > $out/result + '' + ); + + # ── Workspace files need a chosen directory ────────────────────── + # `documents` goes into workingDirectory. The default of that option + # is bad, and it is different on each module, so the modules refuse + # the two options together. + # + # Home Manager reads the assertions while it builds `config`. Thus a + # refused case throws an error and does not return a list. tryEval + # makes the error into data again. + workspace-files-need-a-directory = + let + accepts = + settings: + let + cfg = (evalHomeModule ({ enable = true; } // settings)).config; + probe = builtins.tryEval (lib.all (a: a.assertion) cfg.assertions); + in + probe.success && probe.value; + + rejects = settings: !(accepts settings); + + # This directory has the same text as the default. A comparison + # of values reads it as untouched, but a comparison of priorities + # sees the definition. This row is the reason that the code tests + # the priority. + sameAsDefault = "/home/hermes-check"; + + cases = [ + { + name = "documents without a directory is refused"; + ok = rejects { documents."AGENTS.md" = "x"; }; + } + { + name = "documents with a directory is accepted"; + ok = accepts { + documents."AGENTS.md" = "x"; + workingDirectory = "/srv/workspace"; + }; + } + { + name = "a directory equal to the default still counts as chosen"; + ok = accepts { + documents."AGENTS.md" = "x"; + workingDirectory = sameAsDefault; + }; + } + { + name = "mkDefault counts as chosen"; + ok = accepts { + documents."AGENTS.md" = "x"; + workingDirectory = lib.mkDefault "/srv/workspace"; + }; + } + { + name = "hermesHomeFiles needs no directory"; + ok = accepts { hermesHomeFiles."SOUL.md" = "x"; }; + } + { + name = "no files at all is accepted"; + ok = accepts { }; + } + ]; + + failed = lib.filter (c: !c.ok) cases; + in + pkgs.runCommand "hermes-workspace-files-need-a-directory" { } ( + if failed != [ ] then + throw "workspace-files rule failed:\n${ + lib.concatMapStringsSep "\n" (c: " - ${c.name}") failed + }" + else + '' + ${lib.concatMapStringsSep "\n" (c: ''echo "PASS: ${c.name}"'') cases} + 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 + # asserts that relation and not the current list of names. An option + # that goes into the shared set must appear on both modules. An + # option for one module must be in that module's exclusion list. + module-option-parity = + let + nixosNames = moduleOptionNames (evalNixosModule { }); + homeNames = moduleOptionNames (evalHomeModule { }); + + sharedFromNixos = lib.subtractLists nixosOnlyOptions nixosNames; + sharedFromHome = lib.subtractLists homeOnlyOptions homeNames; + + missingInHome = lib.subtractLists homeNames sharedFromNixos; + missingInNixos = lib.subtractLists nixosNames sharedFromHome; + + # These two values check the exclusion lists. An entry for an + # option that does not exist makes the check weaker, and gives no + # message. + staleNixosOnly = lib.subtractLists nixosNames nixosOnlyOptions; + staleHomeOnly = lib.subtractLists homeNames homeOnlyOptions; + + failures = + lib.optional ( + missingInHome != [ ] + ) "shared options missing from the Home Manager module: ${toString missingInHome} (add to nix/moduleCommon.nix, or list under nixosOnlyOptions if system-scoped)" + ++ lib.optional ( + missingInNixos != [ ] + ) "shared options missing from the NixOS module: ${toString missingInNixos} (add to nix/moduleCommon.nix, or list under homeOnlyOptions if user-scoped)" + ++ lib.optional ( + staleNixosOnly != [ ] + ) "nixosOnlyOptions names options the NixOS module no longer defines: ${toString staleNixosOnly}" + ++ lib.optional ( + staleHomeOnly != [ ] + ) "homeOnlyOptions names options the Home Manager module no longer defines: ${toString staleHomeOnly}"; + in + pkgs.runCommand "hermes-module-option-parity" { } ( + if failures != [ ] then + throw "Module option parity failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}" + else + '' + echo "PASS: ${toString (lib.length sharedFromNixos)} shared options present on both modules" + mkdir -p $out + echo "ok" > $out/result + '' + ); } // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + # ── The NixOS module ───────────────────────────────────────────── + # This check runs on Linux only. The evaluation of a NixOS module + # needs a Linux hostPlatform. + nixos-module = + let + cfg = (evalNixosModule { + enable = true; + backend.mode = "dashboard"; + settings.model.default = "test/model"; + environmentFiles = [ "/run/secrets/hermes-env" ]; + hermesHomeFiles."SOUL.md" = "test soul"; + }).config; + + units = lib.filterAttrs (n: _: lib.hasPrefix "hermes" n) cfg.systemd.services; + names = lib.attrNames units; + execOf = name: units.${name}.serviceConfig.ExecStart; + activation = cfg.system.activationScripts."hermes-agent-setup".text; + + failures = + lib.optional (names != [ + "hermes-agent" + "hermes-backend" + ]) "expected hermes-agent + hermes-backend units, got: ${toString names}" + ++ lib.optional ( + !lib.hasInfix "bin/hermes gateway" (execOf "hermes-agent") + ) "gateway unit does not run `hermes gateway`: ${execOf "hermes-agent"}" + ++ lib.optional ( + !lib.hasInfix "bin/hermes dashboard" (execOf "hermes-backend") + ) "backend unit does not run `hermes dashboard`: ${execOf "hermes-backend"}" + ++ lib.optional ( + units.hermes-agent.environment.HERMES_HOME != units.hermes-backend.environment.HERMES_HOME + ) "gateway and backend must share one HERMES_HOME" + ++ lib.optional ( + !lib.hasInfix "/var/lib/hermes/.hermes/SOUL.md" activation + ) "hermesHomeFiles must install into HERMES_HOME"; + + # You cannot use container mode and the backend together. The + # module says so with an assertion. Without the assertion it + # makes a unit that never starts. + containerConflict = builtins.tryEval ( + lib.deepSeq + (evalNixosModule { + enable = true; + container.enable = true; + backend.mode = "serve"; + }).config.system.build.toplevel.drvPath + true + ); + in + pkgs.runCommand "hermes-nixos-module" { } ( + if failures != [ ] then + throw "NixOS module check failed:\n${lib.concatMapStringsSep "\n" (f: " - ${f}") failures}" + else if containerConflict.success then + throw "NixOS module check failed:\n - an assertion must reject backend.mode with container.enable" + else + '' + echo "PASS: nixos module evaluates (${toString (lib.length names)} units)" + mkdir -p $out + echo "ok" > $out/result + '' + ); + + # ── How .env is built ──────────────────────────────────────────── + # This check runs the real script that both modules use to build + # $HERMES_HOME/.env. The important property is that a second run + # gives the same result. Activation runs at each rebuild. If the + # script added the secrets to the file that exists, the file would + # grow at each rebuild. The script writes the file again from the + # base in the Nix store, which prevents that fault. This check proves + # it. + env-file-assembly = + let + envScript = (import ./moduleCommon.nix { inherit lib; }).mkEnvScript { + inherit pkgs; + environment = { + HERMES_PUBLIC = "visible"; + }; + }; + in + pkgs.runCommand "hermes-env-file-assembly" { } '' + set -e + workdir=$(mktemp -d) + printf 'SECRET_TOKEN=s3cret\n' > "$workdir/secret-a" + printf 'OTHER_TOKEN=t0ken\n' > "$workdir/secret-b" + + echo "=== First activation ===" + ${envScript} "$workdir/.env" 0600 "$workdir/secret-a" "$workdir/secret-b" + first=$(cat "$workdir/.env") + + grep -qx 'HERMES_PUBLIC=visible' "$workdir/.env" || \ + (echo "FAIL: non-secret environment missing"; cat "$workdir/.env"; exit 1) + grep -qx 'SECRET_TOKEN=s3cret' "$workdir/.env" || \ + (echo "FAIL: secret from environmentFile missing"; cat "$workdir/.env"; exit 1) + grep -qx 'OTHER_TOKEN=t0ken' "$workdir/.env" || \ + (echo "FAIL: second environmentFile missing"; cat "$workdir/.env"; exit 1) + echo "PASS: .env contains the declared environment and every secret" + + test "$(stat -c %a "$workdir/.env")" = "600" || \ + (echo "FAIL: .env mode is $(stat -c %a "$workdir/.env"), want 600"; exit 1) + echo "PASS: .env installed with the requested mode" + + echo "=== Re-activation is idempotent ===" + ${envScript} "$workdir/.env" 0600 "$workdir/secret-a" "$workdir/secret-b" + second=$(cat "$workdir/.env") + test "$first" = "$second" || \ + (echo "FAIL: second run changed .env"; diff <(echo "$first") <(echo "$second") || true; exit 1) + + COUNT=$(grep -c '^SECRET_TOKEN=' "$workdir/.env") + test "$COUNT" -eq 1 || \ + (echo "FAIL: secret appears $COUNT times after two activations"; exit 1) + echo "PASS: secrets are not accumulated across activations" + + echo "=== A removed environmentFile disappears ===" + ${envScript} "$workdir/.env" 0600 "$workdir/secret-a" + if grep -q '^OTHER_TOKEN=' "$workdir/.env"; then + echo "FAIL: dropped environmentFile still present in .env"; exit 1 + fi + echo "PASS: .env tracks the declared environmentFiles" + + mkdir -p $out + echo "ok" > $out/result + ''; + + # ── The command lines of the services ──────────────────────────── + # The modules build these command lines. This check runs each one + # through the real parser of the CLI. A subcommand or a flag with a + # new name then fails here, and not as a service that restarts again + # and again after a rebuild. + # + # The method: add one sentinel flag that the CLI does not know, and + # parse without --help. argparse refuses unknown arguments before it + # calls the command, so no process starts and no port is bound. The + # error names each argument that argparse did not accept. If the + # error names only the sentinel, the parser accepts each other flag. + # + # `--help` cannot do this job. It returns before argparse reads the + # remainder of the command line. + service-argv = + let + common = import ./moduleCommon.nix { inherit lib; }; + cfgFor = mode: { + package = hermes-agent; + extraPythonPackages = [ ]; + extraDependencyGroups = [ ]; + extraArgs = [ ]; + backend = { + inherit mode; + host = "127.0.0.1"; + port = 9119; + extraArgs = [ ]; + }; + }; + sentinel = "--hermes-nix-argv-probe"; + probe = argv: lib.escapeShellArgs (argv ++ [ sentinel ]); + in + pkgs.runCommand "hermes-service-argv" { } '' + set -e + export HOME=$(mktemp -d) + + check() { + local label="$1" + shift + local output + output=$("$@" 2>&1) && { + echo "FAIL: $label — the sentinel flag was accepted, so this probe proves nothing" + exit 1 + } + case "$output" in + *"unrecognized arguments: ${sentinel}") + echo "PASS: $label — every flag but the sentinel is recognized" ;; + *"unrecognized arguments"*) + echo "FAIL: $label — the CLI also rejected flags the module passes:" + echo "$output" | tail -3 + exit 1 ;; + *) + echo "FAIL: $label — argv rejected before flag parsing (bad subcommand?):" + echo "$output" | tail -3 + exit 1 ;; + esac + } + + check "gateway" ${probe (common.gatewayArgv (cfgFor "none"))} + check "serve" ${probe (common.backendArgv (cfgFor "serve"))} + check "dashboard" ${probe (common.backendArgv (cfgFor "dashboard"))} + + mkdir -p $out + echo "ok" > $out/result + ''; + # Verify binaries exist and are executable package-contents = pkgs.runCommand "hermes-package-contents" { } '' set -e @@ -288,7 +775,10 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) local label="$1" shift OUTPUT=$(HERMES_MANAGED=true "$@" 2>&1 || true) - echo "$OUTPUT" | grep -q "managed by NixOS" || (echo "FAIL: $label not guarded"; echo "$OUTPUT"; exit 1) + # Case-insensitive: the message names the managing system as the + # identifier it is keyed by, and the display form is not the + # property under test here. + echo "$OUTPUT" | grep -qi "managed by nixos" || (echo "FAIL: $label not guarded"; echo "$OUTPUT"; exit 1) echo "PASS: $label blocked in managed mode" } diff --git a/nix/homeManagerModules.nix b/nix/homeManagerModules.nix new file mode 100644 index 0000000000..55b934672c --- /dev/null +++ b/nix/homeManagerModules.nix @@ -0,0 +1,261 @@ +# nix/homeManagerModules.nix — the Home Manager module for hermes-agent +# +# This module is the user-level equivalent of nixosModules.default. Hermes is +# an agent for one person. The credentials, the memory, the sessions and the +# cron jobs all belong to that person. Thus a user-level module is correct on +# each distribution, and not only on NixOS. +# +# `services.hermes-agent` is the same option set on both modules. All of the +# options except the system-level ones come from nix/moduleCommon.nix, so an +# example from the NixOS documentation works here without a change. Only the +# necessary parts are different: +# +# removed user, group, createUser — Home Manager runs as the user +# removed container.* — it needs root and the Docker socket +# removed UMask 0007 — that mode shares state with a UNIX +# group, but this state has one user +# changed systemd.services -> systemd.user.services or +# launchd.agents +# changed system.activationScripts -> home.activation +# changed addToSystemPackages -> installPackage and +# home.sessionVariables +# changed stateDir (+ "/.hermes") -> hermesHome, set directly +# +# To use the module: +# imports = [ hermes-agent.homeManagerModules.default ]; +# services.hermes-agent = { +# enable = true; +# gateway.enable = true; +# settings.model.default = "anthropic/claude-sonnet-4"; +# environmentFiles = [ config.sops.secrets."hermes/env".path ]; +# }; +# +# CAUTION: Enable linger for the account. Without linger, systemd stops the +# user manager at logout, and both units stop with it. Home Manager cannot +# run `loginctl enable-linger`. On NixOS, set +# users.users..linger = true; +# On other systems, run `loginctl enable-linger ` one time. +{ inputs, ... }: +{ + flake.homeManagerModules.default = + { + config, + lib, + options, + pkgs, + ... + }: + + let + cfg = config.services.hermes-agent; + common = import ./moduleCommon.nix { inherit lib; }; + + effectivePackage = common.effectivePackage cfg; + hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default; + + inherit (pkgs.stdenv.hostPlatform) isDarwin isLinux; + + processEnvironment = common.processEnvironment { + inherit (cfg) hermesHome; + # The CLI reads this value and names it when it refuses a + # configuration change. + managedSystem = "home-manager"; + }; + unitPath = lib.makeBinPath (common.processPath { inherit pkgs cfg; }); + + # The systemd unit that the gateway and the backend both start from. + mkUnit = + { + description, + argv, + }: + { + Unit = { + Description = description; + # Do not use network-online.target here. That is a system target. + # A user unit that orders against it has no effect, and systemd + # gives no message. + After = [ "default.target" ]; + }; + Install.WantedBy = [ "default.target" ]; + Service = { + Type = "simple"; + Environment = (lib.mapAttrsToList (k: v: "${k}=${v}") processEnvironment) ++ [ + "PATH=${unitPath}" + ]; + ExecStart = lib.escapeShellArgs argv; + WorkingDirectory = cfg.workingDirectory; + Restart = cfg.restart; + RestartSec = cfg.restartSec; + # This state has one user. Keep it private. The NixOS module uses + # 0007 to share the state with a UNIX group. + UMask = "0077"; + NoNewPrivileges = true; + PrivateTmp = true; + }; + }; + + mkAgent = + { argv, logName }: + { + enable = true; + config = { + Label = "org.nix-community.home.${logName}"; + ProgramArguments = argv; + EnvironmentVariables = processEnvironment // { + PATH = "${unitPath}:/usr/bin:/bin:/usr/sbin:/sbin"; + }; + WorkingDirectory = cfg.workingDirectory; + RunAtLoad = true; + KeepAlive = + if cfg.restart == "always" then + true + else + { + SuccessfulExit = false; + Crashed = true; + }; + ThrottleInterval = cfg.restartSec; + StandardOutPath = "${config.home.homeDirectory}/Library/Logs/${logName}.log"; + StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/${logName}.err.log"; + ProcessType = "Background"; + }; + }; + + in + { + options.services.hermes-agent = + common.sharedOptions { + defaultPackage = hermes-agent; + defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default"; + defaultWorkingDirectory = config.home.homeDirectory; + defaultWorkingDirectoryText = lib.literalExpression "config.home.homeDirectory"; + } + // { + hermesHome = lib.mkOption { + type = lib.types.str; + default = "${config.home.homeDirectory}/.hermes"; + defaultText = lib.literalExpression ''"''${config.home.homeDirectory}/.hermes"''; + description = '' + The value of HERMES_HOME. This state directory holds + config.yaml, .env, auth.json, the sessions, the skills, the + memory and the cron jobs. + + The NixOS module takes a `stateDir` and adds `/.hermes` to it. + This module sets HERMES_HOME directly. Thus an existing + ~/.hermes continues to work, and you can give the directory any + name. + ''; + example = "/home/alice/.hermes-work"; + }; + + installPackage = lib.mkOption { + type = lib.types.bool; + default = true; + 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. + ''; + }; + + gateway.enable = lib.mkEnableOption "the messaging gateway service (Telegram, Discord, Slack, ...)"; + }; + + config = lib.mkIf cfg.enable ( + lib.mkMerge [ + + # ── Merge MCP servers into settings ──────────────────────────── + (lib.mkIf (cfg.mcpServers != { }) { + services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers; + }) + + { + assertions = + common.pluginNameAssertions { + inherit cfg; + optionPath = "services.hermes-agent"; + } + ++ common.workspaceFilesAssertions { + inherit cfg; + opt = options.services.hermes-agent.workingDirectory; + optionPath = "services.hermes-agent"; + }; + } + + # ── 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"; + }; + } + ); + } + + # ── Linux: systemd user services ─────────────────────────────── + (lib.mkIf (isLinux && cfg.gateway.enable) { + systemd.user.services.hermes-agent = mkUnit { + description = "Hermes Agent Gateway"; + argv = common.gatewayArgv cfg; + }; + }) + + (lib.mkIf (isLinux && cfg.backend.mode != "none") { + systemd.user.services.hermes-backend = mkUnit { + description = common.backendDescription cfg; + argv = common.backendArgv 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 cfg; + logName = "hermes-backend"; + }; + }) + ] + ); + }; +} diff --git a/nix/moduleCommon.nix b/nix/moduleCommon.nix new file mode 100644 index 0000000000..c021209123 --- /dev/null +++ b/nix/moduleCommon.nix @@ -0,0 +1,916 @@ +# nix/moduleCommon.nix — the code that the NixOS and Home Manager modules share +# +# `services.hermes-agent` is the same option set on both modules. Both modules +# get their options, their renderers for config.yaml, .env and documents, and +# their state setup from this file. A NixOS example works on Home Manager +# without a change. An option added here appears on both modules at once. +# +# Each module keeps only the parts that belong to its own scope: +# +# nixosModules.nix the service user and group, stateDir, +# addToSystemPackages, container mode, tmpfiles, +# system.activationScripts, system systemd units +# homeManagerModules.nix hermesHome, installPackage, home.activation, +# systemd.user.services, launchd.agents +# +# The split is by scope, not by feature. Code that needs root or a system +# identity stays in the NixOS module. All other code is here. +{ lib }: + +let + inherit (lib) + literalExpression + mkOption + types + ; + + # ── Configuration type ────────────────────────────────────────────────── + # More than one module can set `settings = { ... }`. recursiveUpdate joins + # all of the definitions. Without it, only the last definition applies. + deepConfigType = types.mkOptionType { + name = "hermes-config-attrs"; + description = "Hermes YAML config (attrset), merged deeply via lib.recursiveUpdate."; + check = builtins.isAttrs; + merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs); + }; + + # ── MCP server submodule ──────────────────────────────────────────────── + mcpServerType = types.submodule { + options = { + # Stdio transport + command = mkOption { + type = types.nullOr types.str; + default = null; + description = "MCP server command (stdio transport)."; + }; + args = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Command-line arguments (stdio transport)."; + }; + env = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Environment variables for the server process (stdio transport)."; + }; + + # HTTP/StreamableHTTP transport + url = mkOption { + type = types.nullOr types.str; + default = null; + description = "MCP server endpoint URL (HTTP/StreamableHTTP transport)."; + }; + headers = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "HTTP headers, e.g. for authentication (HTTP transport)."; + }; + + # Authentication + auth = mkOption { + type = types.nullOr (types.enum [ "oauth" ]); + default = null; + description = '' + Authentication method. Set to "oauth" for OAuth 2.1 PKCE flow + (remote MCP servers). Tokens are stored in $HERMES_HOME/mcp-tokens/. + ''; + }; + + # Enable/disable + enabled = mkOption { + type = types.bool; + default = true; + description = "Enable or disable this MCP server."; + }; + + # Common options + timeout = mkOption { + type = types.nullOr types.int; + default = null; + description = "Tool call timeout in seconds (default: 120)."; + }; + connect_timeout = mkOption { + type = types.nullOr types.int; + default = null; + description = "Initial connection timeout in seconds (default: 60)."; + }; + + # Tool filtering + tools = mkOption { + type = types.nullOr ( + types.submodule { + options = { + include = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Tool allowlist — only these tools are registered."; + }; + exclude = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Tool blocklist — these tools are hidden."; + }; + }; + } + ); + default = null; + description = "Filter which tools are exposed by this server."; + }; + + # Sampling (server-initiated LLM requests) + sampling = mkOption { + type = types.nullOr ( + types.submodule { + options = { + enabled = mkOption { + type = types.bool; + default = true; + description = "Enable sampling."; + }; + model = mkOption { + type = types.nullOr types.str; + default = null; + description = "Override model for sampling requests."; + }; + max_tokens_cap = mkOption { + type = types.nullOr types.int; + default = null; + description = "Max tokens per request."; + }; + timeout = mkOption { + type = types.nullOr types.int; + default = null; + description = "LLM call timeout in seconds."; + }; + max_rpm = mkOption { + type = types.nullOr types.int; + default = null; + description = "Max requests per minute."; + }; + max_tool_rounds = mkOption { + type = types.nullOr types.int; + default = null; + description = "Max tool-use rounds per sampling request."; + }; + allowed_models = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Models the server is allowed to request."; + }; + log_level = mkOption { + type = types.nullOr ( + types.enum [ + "debug" + "info" + "warning" + ] + ); + default = null; + description = "Audit log level for sampling requests."; + }; + }; + } + ); + default = null; + description = "Sampling configuration for server-initiated LLM requests."; + }; + }; + }; + + # Convert the mcpServers submodules into the shape that config.yaml uses. + mcpServersToConfig = + servers: + lib.mapAttrs ( + _name: srv: + # Stdio transport + lib.optionalAttrs (srv.command != null) { inherit (srv) command args; } + // lib.optionalAttrs (srv.env != { }) { inherit (srv) env; } + # HTTP transport + // lib.optionalAttrs (srv.url != null) { inherit (srv) url; } + // lib.optionalAttrs (srv.headers != { }) { inherit (srv) headers; } + # Auth + // lib.optionalAttrs (srv.auth != null) { inherit (srv) auth; } + # Enable/disable + // { + inherit (srv) enabled; + } + # Common options + // lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; } + // lib.optionalAttrs (srv.connect_timeout != null) { inherit (srv) connect_timeout; } + # Tool filtering + // lib.optionalAttrs (srv.tools != null) { + tools = lib.filterAttrs (_: v: v != [ ]) { + inherit (srv.tools) include exclude; + }; + } + # Sampling + // lib.optionalAttrs (srv.sampling != null) { + sampling = lib.filterAttrs (_: v: v != null && v != [ ]) { + inherit (srv.sampling) + enabled + model + max_tokens_cap + timeout + max_rpm + max_tool_rounds + allowed_models + log_level + ; + }; + } + ) servers; + + documentsType = types.attrsOf (types.either types.str types.path); + + # ── The options that both modules share ───────────────────────────────── + # `defaultPackage` and `defaultWorkingDirectory` are different on each + # module, so the caller gives them. All other options are the same. + sharedOptions = + { + defaultPackage, + defaultPackageText, + defaultWorkingDirectory, + defaultWorkingDirectoryText, + }: + { + enable = lib.mkEnableOption "Hermes Agent"; + + # ── Package ──────────────────────────────────────────────────────── + package = mkOption { + type = types.package; + default = defaultPackage; + defaultText = defaultPackageText; + description = "The hermes-agent package to use."; + }; + + workingDirectory = mkOption { + type = types.str; + default = defaultWorkingDirectory; + defaultText = defaultWorkingDirectoryText; + description = '' + The working directory for the agent. The module also writes this + path to config.yaml as `terminal.cwd`. The terminal and file tools + of the agent use that value. + ''; + }; + + # ── Declarative config ───────────────────────────────────────────── + configFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + The path to an existing config.yaml. If you set this option, it + replaces the `settings` option. The module installs the file + without a change and overwrites all runtime edits on each + activation. + ''; + }; + + settings = mkOption { + type = deepConfigType; + default = { }; + description = '' + The Hermes configuration, as an attribute set. The module joins the + definitions from all modules and writes the result to config.yaml. + + The merge into the config.yaml on disk is also a deep merge. These + keys replace the keys on disk. The module keeps all other keys, + which includes the keys that `hermes config set` and the settings + panes of the TUI and the desktop app write at runtime. + ''; + example = literalExpression '' + { + model.default = "anthropic/claude-sonnet-4"; + terminal.backend = "local"; + compression = { enabled = true; threshold = 0.85; }; + } + ''; + }; + + # ── Secrets / environment ────────────────────────────────────────── + environmentFiles = mkOption { + # The type is `str` and not `path` for a reason. 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, for example + # `config.sops.secrets."x".path`. + type = types.listOf types.str; + default = [ ]; + description = '' + The paths to environment files that contain secrets, for example + API keys and tokens. Activation adds the contents of these files to + $HERMES_HOME/.env. Hermes reads that file at each start, with + load_hermes_dotenv(). + + Each activation writes .env again from the start. Thus a secret + file cannot go into .env two times. + ''; + example = literalExpression ''[ config.sops.secrets."hermes/env".path ]''; + }; + + environment = mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Environment variables that are not secret. Activation writes them + to $HERMES_HOME/.env. + + CAUTION: Do not put secrets in this option. All users can read the + Nix store. Use environmentFiles for secrets. + ''; + }; + + authFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + The path to a file that gives the first contents of auth.json, the + OAuth credentials. The module copies the file only when auth.json + does not exist. Thus a token that Hermes refreshes at runtime stays + after an activation. + ''; + }; + + authFileForceOverwrite = mkOption { + type = types.bool; + default = false; + description = "Always overwrite auth.json from authFile on activation."; + }; + + # ── Documents ────────────────────────────────────────────────────── + documents = mkOption { + type = documentsType; + default = { }; + description = '' + Workspace files. The module installs them into workingDirectory. + Each key is a path relative to that directory, and the module makes + the necessary subdirectories. Each value is a string or a path. + + Use this option for the project context that the agent reads from + its working directory, for example AGENTS.md, notes and checklists. + Hermes reads SOUL.md and memories/ from HERMES_HOME, so put those + files in `hermesHomeFiles`. + + If you set this option, you must also set `workingDirectory`. The + default of that option is different on each module. Thus an unset + default puts these files in a directory that you did not select. + ''; + example = literalExpression '' + { + "AGENTS.md" = ./AGENTS.md; + "notes/oncall.md" = "Page #infra before restarting anything."; + } + ''; + }; + + hermesHomeFiles = mkOption { + type = documentsType; + default = { }; + description = '' + Files that the module installs into HERMES_HOME. Each key is a path + relative to that directory, and the module makes the necessary + subdirectories. Each value is a string or a path. + + Hermes reads SOUL.md and the memory files from HERMES_HOME and not + from the working directory. Declare those files here, or Hermes + does not load them. + ''; + example = literalExpression '' + { + "SOUL.md" = "You are a helpful AI assistant."; + "memories/USER.md" = ./USER.md; + } + ''; + }; + + # ── MCP Servers ──────────────────────────────────────────────────── + mcpServers = mkOption { + type = types.attrsOf mcpServerType; + default = { }; + description = '' + MCP server configurations (merged into settings.mcp_servers). + Each server uses either stdio (command/args) or HTTP (url) transport. + ''; + example = literalExpression '' + { + filesystem = { + command = "npx"; + args = [ "-y" "@modelcontextprotocol/server-filesystem" "/home/user" ]; + }; + remote-api = { + url = "http://my-server:8080/v0/mcp"; + headers = { Authorization = "Bearer ..."; }; + }; + remote-oauth = { + url = "https://mcp.example.com/mcp"; + auth = "oauth"; + }; + } + ''; + }; + + # ── Packages / plugins ───────────────────────────────────────────── + extraPackages = mkOption { + type = types.listOf types.package; + default = [ ]; + description = "More packages on the PATH of the agent. The agent can run these tools."; + }; + + extraPlugins = mkOption { + type = types.listOf types.package; + default = [ ]; + description = '' + Directory-based plugin packages to symlink into the hermes plugins + directory. Each package must contain a plugin.yaml and __init__.py + at its root. Hermes discovers these automatically on startup. + ''; + example = literalExpression '' + [ + (pkgs.fetchFromGitHub { + owner = "stephenschoettler"; + repo = "hermes-lcm"; + name = "hermes-lcm"; + rev = "v0.7.0"; + hash = "sha256-..."; + }) + ] + ''; + }; + + extraPythonPackages = mkOption { + type = types.listOf types.package; + default = [ ]; + description = '' + Python packages to add to PYTHONPATH for entry-point plugin discovery. + These are pip-packaged plugins that register via the + hermes_agent.plugins entry-point group. Each package must be built + with the same Python interpreter as hermes (python312). + ''; + example = literalExpression '' + [ + (pkgs.python312Packages.buildPythonPackage { + pname = "rtk-hermes"; + version = "1.0.0"; + src = pkgs.fetchFromGitHub { + owner = "ogallotti"; + repo = "rtk-hermes"; + rev = "main"; + hash = "sha256-..."; + }; + }) + ] + ''; + }; + + extraDependencyGroups = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + Additional pyproject.toml optional-dependency groups to include in + the sealed Python venv. These are resolved by uv alongside core + dependencies — no PYTHONPATH patching or collision risk. + + Use this for optional extras already declared in hermes-agent's + pyproject.toml (e.g. "hindsight", "honcho", "voice"). + Use extraPythonPackages for external packages not in pyproject.toml. + ''; + example = [ "hindsight" ]; + }; + + # ── Service behaviour ────────────────────────────────────────────── + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra command-line arguments for `hermes gateway`."; + }; + + restart = mkOption { + type = types.str; + default = "always"; + description = "The systemd Restart= policy. Darwin does not use this option."; + }; + + restartSec = mkOption { + type = types.int; + default = 5; + description = "The systemd RestartSec= value. Darwin does not use this option."; + }; + + # ── The backend: `hermes serve` or `hermes dashboard` ────────────── + # `hermes serve` and `hermes dashboard` are the same entry point, + # hermes_cli.main:cmd_dashboard, with one flag of difference. serve runs + # without a user interface. dashboard also serves the web application. + # Both give the /api/ws and /api/pty sockets that Hermes Desktop + # connects to. They are one process, and you can run only one of them. + # Thus this option is an enum and not two booleans. + # + # The backend does not run the messaging gateway. web_server.py only + # controls an external gateway, with `hermes gateway restart`. It does + # not contain a gateway. + backend = { + mode = mkOption { + type = types.enum [ + "none" + "serve" + "dashboard" + ]; + default = "none"; + description = '' + The backend process to run with the messaging gateway. + + - "none" — no backend + - "serve" — the backend without a user interface. It gives + the /api/ws and /api/pty sockets that Hermes + Desktop connects to. + - "dashboard" — all that "serve" gives, and the browser admin + panel on the same port + + "dashboard" contains all of "serve". + ''; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = '' + The address that the backend binds to. + + An address other than loopback starts the authentication gate of + the dashboard. You must then configure credentials, or a client + cannot connect. The server also refuses each request with a Host + header that is different from the address that the server bound + to. This is a defence against DNS rebinding. Bind to the name or + the address that your clients use. + ''; + }; + + port = mkOption { + type = types.port; + default = 9119; + description = "The port for the backend."; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "More command-line arguments for the backend command."; + }; + }; + }; + + # ── Package resolution ────────────────────────────────────────────────── + effectivePackage = + cfg: + if cfg.extraPythonPackages == [ ] && cfg.extraDependencyGroups == [ ] then + cfg.package + else + cfg.package.override { inherit (cfg) extraPythonPackages extraDependencyGroups; }; + + # ── The rendered config.yaml ──────────────────────────────────────────── + # YAML contains JSON, so the output of toJSON is a correct config.yaml. + # terminal.cwd replaces the old MESSAGING_CWD environment variable. The + # order of the recursiveUpdate lets an explicit settings.terminal.cwd + # replace the default value. + mkConfigFiles = + { + pkgs, + cfg, + workingDirectory, + }: + let + generated = pkgs.writeText "hermes-config.yaml" ( + builtins.toJSON (lib.recursiveUpdate { terminal.cwd = workingDirectory; } cfg.settings) + ); + in + { + inherit generated; + effective = if cfg.configFile != null then cfg.configFile else generated; + mergeScript = pkgs.callPackage ./configMergeScript.nix { }; + }; + + # ── Documents ─────────────────────────────────────────────────────────── + # A key can contain subdirectories. The tree has the same shape, so the + # install loop can copy each entry with `install -D`. + mkDocumentTree = + { pkgs, documents }: + pkgs.runCommand "hermes-documents" { } ( + '' + mkdir -p $out + '' + + lib.concatStringsSep "\n" ( + lib.mapAttrsToList ( + name: value: + let + dir = builtins.dirOf name; + mkdir = lib.optionalString (dir != ".") "mkdir -p $out/${dir}"; + in + if builtins.isPath value || lib.isStorePath value then + "${mkdir}\ncp ${value} $out/${name}" + else + "${mkdir}\ncat > $out/${name} <<'HERMES_DOC_EOF'\n${value}\nHERMES_DOC_EOF" + ) documents + ) + ); + + # ── How .env is built ─────────────────────────────────────────────────── + # The values that are not secret come from the Nix store. Activation adds + # the secrets from paths outside the store. This is one command, so it is + # safe in a dry run. A second activation writes .env again and does not add + # the same secrets a second time. + mkEnvScript = + { pkgs, environment }: + let + base = pkgs.writeText "hermes-env-base" ( + lib.concatStringsSep "\n" (lib.mapAttrsToList (k: v: "${k}=${v}") environment) + + lib.optionalString (environment != { }) "\n" + ); + in + pkgs.writeShellScript "hermes-env-merge" '' + set -eu + + dest="$1" + mode="$2" + shift 2 + + install -m "$mode" ${base} "$dest" + for file in "$@"; do + if [ -r "$file" ]; then + printf '\n' >> "$dest" + cat "$file" >> "$dest" + else + echo "hermes-agent: WARNING cannot read environmentFile $file" >&2 + fi + done + ''; + + # ── State setup ───────────────────────────────────────────────────────── + # The activation code that both modules run. It makes the directories and + # installs config.yaml, .env, auth.json, the documents and the plugins. The + # differences between the two modules are only the install flags for the + # owner and the file modes. Thus they are arguments, and not a second copy + # of the script. + # + # run the command prefix ("" on NixOS, "$DRY_RUN_CMD " on + # Home Manager) + # owner "user:group" that owns each file, or null for the user that + # runs the activation + # modes the file mode for each kind of file + mkStateScript = + { + pkgs, + cfg, + hermesHome, + workingDirectory, + # The value to write as terminal.cwd. It is different from + # workingDirectory only in the container mode of NixOS. There the agent + # sees the directory at its mount point in the container, but + # activation writes to the path on the host. + configWorkingDirectory ? workingDirectory, + run ? "", + owner ? null, + modes, + stateDirs ? [ ], + # The module writes this value into the .managed marker. An + # interactive shell reads the marker, because it does not see the + # HERMES_MANAGED variable of the service. The value tells the shell + # which system owns the install and which rebuild command to name. + managedSystem ? "nixos", + }: + let + installFlags = lib.optionalString (owner != null) ( + let + parts = lib.splitString ":" owner; + in + "-o ${lib.head parts} -g ${lib.last parts}" + ); + configFiles = mkConfigFiles { + inherit pkgs cfg; + workingDirectory = configWorkingDirectory; + }; + envScript = mkEnvScript { + inherit pkgs; + inherit (cfg) environment; + }; + documentTree = mkDocumentTree { + inherit pkgs; + inherit (cfg) documents; + }; + homeDocumentTree = mkDocumentTree { + inherit pkgs; + documents = cfg.hermesHomeFiles; + }; + + inst = "${run}install ${installFlags}"; + + installDocuments = + tree: root: docs: + lib.concatStringsSep "\n" ( + lib.mapAttrsToList ( + name: _value: "${inst} -m ${modes.document} -D ${tree}/${name} ${root}/${name}" + ) docs + ); + in + '' + # Directories. The service units and Hermes make most of these + # directories when they first need them. Activation makes them here so + # that the first activation sets the correct owner and mode, and does + # not use the umask. + ${run}mkdir -p ${ + lib.escapeShellArgs ( + [ + hermesHome + workingDirectory + ] + ++ map (d: "${hermesHome}/${d}") stateDirs + ) + } + + # config.yaml: merge the Nix settings into the file on disk. Hermes + # writes this file at runtime. A read-only symlink to the Nix store + # breaks each save from the application. The Nix keys replace the keys + # on disk, and the module keeps all other keys. + ${ + if cfg.configFile != null then + "${inst} -m ${modes.config} -D ${configFiles.effective} ${hermesHome}/config.yaml" + else + '' + ${run}${configFiles.mergeScript} ${configFiles.generated} ${hermesHome}/config.yaml + ${run}chmod ${modes.config} ${hermesHome}/config.yaml + '' + } + + # The managed-mode marker. It makes an interactive shell also refuse to + # change the configuration that Nix owns. + ${inst} -m ${modes.managed} ${pkgs.writeText "hermes-managed" managedSystem} ${hermesHome}/.managed + + ${lib.optionalString (cfg.environment != { } || cfg.environmentFiles != [ ]) '' + ${run}${envScript} ${hermesHome}/.env ${modes.env} ${lib.escapeShellArgs cfg.environmentFiles} + ${lib.optionalString (owner != null) "${run}chown ${owner} ${hermesHome}/.env"} + ''} + + ${lib.optionalString (cfg.authFile != null) ( + if cfg.authFileForceOverwrite then + "${inst} -m ${modes.auth} ${cfg.authFile} ${hermesHome}/auth.json" + else + '' + if [ ! -e ${hermesHome}/auth.json ]; then + ${inst} -m ${modes.auth} ${cfg.authFile} ${hermesHome}/auth.json + fi + '' + )} + + ${installDocuments documentTree workingDirectory cfg.documents} + ${installDocuments homeDocumentTree hermesHome cfg.hermesHomeFiles} + + # Declarative plugins. Activation first deletes the old managed + # symlinks. Thus a plugin that you remove from the configuration also + # goes away from the plugins directory. + ${run}find ${hermesHome}/plugins -maxdepth 1 -type l -name 'nix-managed-*' -delete 2>/dev/null || true + ${lib.concatMapStringsSep "\n" (plugin: '' + if [ ! -f ${plugin}/plugin.yaml ]; then + echo "hermes-agent: ERROR extraPlugins entry '${plugin}' has no plugin.yaml" >&2 + exit 1 + fi + ${run}ln -sfn ${plugin} ${hermesHome}/plugins/nix-managed-${lib.getName plugin} + '') cfg.extraPlugins} + ''; + + # ── Process argv ──────────────────────────────────────────────────────── + gatewayArgv = + cfg: + [ + "${effectivePackage cfg}/bin/hermes" + "gateway" + ] + ++ cfg.extraArgs; + + backendArgv = + cfg: + [ + "${effectivePackage cfg}/bin/hermes" + cfg.backend.mode + "--host" + cfg.backend.host + "--port" + (toString cfg.backend.port) + # CAUTION: A service must not try to open a browser when it starts. + "--no-open" + ] + ++ cfg.backend.extraArgs; + + backendDescription = + cfg: + if cfg.backend.mode == "dashboard" then + "Hermes Agent web dashboard and desktop backend" + else + "Hermes Agent backend for Hermes Desktop"; + + # The environment that each Hermes process needs, from either module. + # + # managedSystem gives the value of HERMES_MANAGED. The CLI reads that + # variable to refuse a configuration change that it cannot keep, and to + # name the correct rebuild command. The answer is different on each module, + # so each module gives its own value. + processEnvironment = + { + hermesHome, + managedSystem ? "true", + }: + { + HERMES_HOME = hermesHome; + HERMES_MANAGED = managedSystem; + }; + + processPath = + { pkgs, cfg }: + [ + (effectivePackage cfg) + pkgs.bash + pkgs.coreutils + pkgs.git + ] + ++ cfg.extraPackages; + + # workingDirectory has a default on both modules, but a bad one. It is the + # home directory of the user on Home Manager, and ${stateDir}/workspace on + # NixOS. A user who declares files without a directory therefore gets a + # place that the user did not select. The place is also different on each + # module. The modules refuse that combination. + # + # The test is on the priority of the option and not on its value. An option + # that nothing sets keeps the priority of its own default, and each + # definition from a user is stronger. Thus a directory that has the same + # text as the default is still a selection, and so is a mkDefault. A + # comparison of values detects neither. + workspaceFilesAssertions = + { + cfg, + opt, + optionPath, + }: + let + untouched = (lib.mkOptionDefault null).priority; # 1500, derived not spelled + in + [ + { + assertion = cfg.documents == { } || opt.highestPrio < untouched; + message = '' + ${optionPath}.documents needs an explicit ${optionPath}.workingDirectory. + + The files go into workingDirectory. The default of that option is + different on each module, so an unset default puts the files in a + directory that you did not select. Set the directory: + + ${optionPath}.workingDirectory = "/path/you/want"; + + To give Hermes an identity and a memory, use + ${optionPath}.hermesHomeFiles instead. Those files go to + HERMES_HOME. Hermes reads SOUL.md and memories/ only from there. + ''; + } + ]; + + # Two plugins with the same name use one nix-managed- symlink. One of + # the plugins then disappears without a message. Both modules assert + # against this condition. + pluginNameAssertions = + { cfg, optionPath }: + let + names = map lib.getName cfg.extraPlugins; + in + [ + { + assertion = (lib.length names) == (lib.length (lib.unique names)); + message = "${optionPath}.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate."; + } + ]; + + # The subdirectories of HERMES_HOME that both modules make. + stateSubdirs = [ + "cron" + "sessions" + "logs" + "memories" + "plugins" + ]; +in +{ + inherit + backendArgv + backendDescription + deepConfigType + effectivePackage + gatewayArgv + mcpServerType + mcpServersToConfig + mkConfigFiles + mkDocumentTree + mkEnvScript + mkStateScript + pluginNameAssertions + processEnvironment + processPath + sharedOptions + stateSubdirs + workspaceFilesAssertions + ; +} diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index df74427c48..38af2ec5ed 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -1,4 +1,10 @@ -# nix/nixosModules.nix — NixOS module for hermes-agent +# nix/nixosModules.nix — the NixOS module for hermes-agent +# +# This module shares its options, its renderers for config.yaml, .env and +# documents, and its state setup with the Home Manager module +# (nix/homeManagerModules.nix). The shared code is in nix/moduleCommon.nix. +# This file holds only the parts that need root: the service user, a system +# state directory, the system PATH, and container mode. # # Two modes: # container.enable = false (default) → native systemd service @@ -19,990 +25,642 @@ # Usage: # services.hermes-agent = { # enable = true; -# settings.model = "anthropic/claude-sonnet-4"; +# settings.model.default = "anthropic/claude-sonnet-4"; # environmentFiles = [ config.sops.secrets."hermes/env".path ]; # }; # -{ inputs, ... }: { - flake.nixosModules.default = { config, lib, pkgs, ... }: +{ inputs, ... }: +{ + flake.nixosModules.default = + { + config, + lib, + options, + pkgs, + ... + }: - let - cfg = config.services.hermes-agent; - effectivePackage = - if cfg.extraPythonPackages == [ ] && cfg.extraDependencyGroups == [ ] - then cfg.package - else cfg.package.override { inherit (cfg) extraPythonPackages extraDependencyGroups; }; - hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default; + let + cfg = config.services.hermes-agent; + common = import ./moduleCommon.nix { inherit lib; }; - # Deep-merge config type (from 0xrsydn/nix-hermes-agent) - deepConfigType = lib.types.mkOptionType { - name = "hermes-config-attrs"; - description = "Hermes YAML config (attrset), merged deeply via lib.recursiveUpdate."; - check = builtins.isAttrs; - merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs); - }; + effectivePackage = common.effectivePackage cfg; + hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default; - # Generate config.yaml from Nix attrset (YAML is a superset of JSON). - # terminal.cwd replaces the deprecated MESSAGING_CWD env var — hermes - # reads it from config.yaml and bridges it to TERMINAL_CWD internally. - # recursiveUpdate: cfg.settings wins, so an explicit - # settings.terminal.cwd overrides the workingDirectory default. - # Container mode uses the in-container mount path. - effectiveWorkDir = if cfg.container.enable then containerWorkDir else cfg.workingDirectory; - configJson = builtins.toJSON ( - lib.recursiveUpdate { terminal.cwd = effectiveWorkDir; } cfg.settings - ); - generatedConfigFile = pkgs.writeText "hermes-config.yaml" configJson; - configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile; + hermesHome = "${cfg.stateDir}/.hermes"; - configMergeScript = pkgs.callPackage ./configMergeScript.nix { }; + # In container mode, the agent uses the mount path in the container. + effectiveWorkDir = if cfg.container.enable then containerWorkDir else cfg.workingDirectory; - # config.yaml mode: group-writable (0660) when interactive users share this - # HERMES_HOME via addToSystemPackages, so they can save settings through the - # CLI/TUI without hitting EACCES; otherwise group-read-only (0640). Secrets - # (.env) stay 0640 regardless — see below. - configYamlMode = if cfg.addToSystemPackages then "0660" else "0640"; + # config.yaml mode: group-writable (0660) when interactive users share this + # HERMES_HOME via addToSystemPackages, so they can save settings through the + # CLI/TUI without hitting EACCES; otherwise group-read-only (0640). Secrets + # (.env) stay 0640 regardless. + configYamlMode = if cfg.addToSystemPackages then "0660" else "0640"; - # Generate .env from non-secret environment attrset - envFileContent = lib.concatStringsSep "\n" ( - lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment - ); - # Build documents derivation (from 0xrsydn) - documentDerivation = pkgs.runCommand "hermes-documents" { } ( - '' - mkdir -p $out - '' + lib.concatStringsSep "\n" ( - lib.mapAttrsToList (name: value: - if builtins.isPath value || lib.isStorePath value - then "cp ${value} $out/${name}" - else "cat > $out/${name} <<'HERMES_DOC_EOF'\n${value}\nHERMES_DOC_EOF" - ) cfg.documents - ) - ); + containerName = "hermes-agent"; + containerDataDir = "/data"; # stateDir mount point inside container + containerHomeDir = "/home/hermes"; - containerName = "hermes-agent"; - containerDataDir = "/data"; # stateDir mount point inside container - containerHomeDir = "/home/hermes"; + # ── Container mode helpers ────────────────────────────────────────── + containerBin = + if cfg.container.backend == "docker" then + "${pkgs.docker}/bin/docker" + else + "${pkgs.podman}/bin/podman"; - # ── Container mode helpers ────────────────────────────────────────── - containerBin = if cfg.container.backend == "docker" - then "${pkgs.docker}/bin/docker" - else "${pkgs.podman}/bin/podman"; + # Runs as root inside the container on every start. Provisions the + # hermes user + sudo on first boot (writable layer persists), then + # drops privileges. Supports arbitrary base images (Debian, Alpine, etc). + containerEntrypoint = pkgs.writeShellScript "hermes-container-entrypoint" '' + set -eu - # Runs as root inside the container on every start. Provisions the - # hermes user + sudo on first boot (writable layer persists), then - # drops privileges. Supports arbitrary base images (Debian, Alpine, etc). - containerEntrypoint = pkgs.writeShellScript "hermes-container-entrypoint" '' - set -eu + HERMES_UID="''${HERMES_UID:?HERMES_UID must be set}" + HERMES_GID="''${HERMES_GID:?HERMES_GID must be set}" - HERMES_UID="''${HERMES_UID:?HERMES_UID must be set}" - HERMES_GID="''${HERMES_GID:?HERMES_GID must be set}" - - # ── Group: ensure a group with GID=$HERMES_GID exists ── - # Check by GID (not name) to avoid collisions with pre-existing groups - # (e.g. GID 100 = "users" on Ubuntu) - EXISTING_GROUP=$(getent group "$HERMES_GID" 2>/dev/null | cut -d: -f1 || true) - if [ -n "$EXISTING_GROUP" ]; then - GROUP_NAME="$EXISTING_GROUP" - else - GROUP_NAME="hermes" - if command -v groupadd >/dev/null 2>&1; then - groupadd -g "$HERMES_GID" "$GROUP_NAME" - elif command -v addgroup >/dev/null 2>&1; then - addgroup -g "$HERMES_GID" "$GROUP_NAME" 2>/dev/null || true + # ── Group: ensure a group with GID=$HERMES_GID exists ── + # Check by GID (not name) to avoid collisions with pre-existing groups + # (e.g. GID 100 = "users" on Ubuntu) + EXISTING_GROUP=$(getent group "$HERMES_GID" 2>/dev/null | cut -d: -f1 || true) + if [ -n "$EXISTING_GROUP" ]; then + GROUP_NAME="$EXISTING_GROUP" + else + GROUP_NAME="hermes" + if command -v groupadd >/dev/null 2>&1; then + groupadd -g "$HERMES_GID" "$GROUP_NAME" + elif command -v addgroup >/dev/null 2>&1; then + addgroup -g "$HERMES_GID" "$GROUP_NAME" 2>/dev/null || true + fi fi - fi - # ── User: ensure a user with UID=$HERMES_UID exists ── - PASSWD_ENTRY=$(getent passwd "$HERMES_UID" 2>/dev/null || true) - if [ -n "$PASSWD_ENTRY" ]; then - TARGET_USER=$(echo "$PASSWD_ENTRY" | cut -d: -f1) - TARGET_HOME=$(echo "$PASSWD_ENTRY" | cut -d: -f6) - else - TARGET_USER="hermes" - TARGET_HOME="/home/hermes" - if command -v useradd >/dev/null 2>&1; then - useradd -u "$HERMES_UID" -g "$HERMES_GID" -m -d "$TARGET_HOME" -s /bin/bash "$TARGET_USER" - elif command -v adduser >/dev/null 2>&1; then - adduser -u "$HERMES_UID" -D -h "$TARGET_HOME" -s /bin/sh -G "$GROUP_NAME" "$TARGET_USER" 2>/dev/null || true + # ── User: ensure a user with UID=$HERMES_UID exists ── + PASSWD_ENTRY=$(getent passwd "$HERMES_UID" 2>/dev/null || true) + if [ -n "$PASSWD_ENTRY" ]; then + TARGET_USER=$(echo "$PASSWD_ENTRY" | cut -d: -f1) + TARGET_HOME=$(echo "$PASSWD_ENTRY" | cut -d: -f6) + else + TARGET_USER="hermes" + TARGET_HOME="/home/hermes" + if command -v useradd >/dev/null 2>&1; then + useradd -u "$HERMES_UID" -g "$HERMES_GID" -m -d "$TARGET_HOME" -s /bin/bash "$TARGET_USER" + elif command -v adduser >/dev/null 2>&1; then + adduser -u "$HERMES_UID" -D -h "$TARGET_HOME" -s /bin/sh -G "$GROUP_NAME" "$TARGET_USER" 2>/dev/null || true + fi fi - fi - mkdir -p "$TARGET_HOME" - chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME" - chmod 0750 "$TARGET_HOME" + mkdir -p "$TARGET_HOME" + chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME" + chmod 0750 "$TARGET_HOME" - # Ensure HERMES_HOME is owned by the target user. - # Use find instead of chown -R: chown strips the setgid bit (kernel - # behavior), destroying the 2770 permissions the NixOS activation - # script sets for group access by hostUsers. Only touch files with - # wrong ownership so correctly-owned dirs keep their permission bits. - if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then - find "$HERMES_HOME" \! -user "$HERMES_UID" -exec chown "$HERMES_UID:$HERMES_GID" {} + - fi + # Ensure HERMES_HOME is owned by the target user. + # Use find instead of chown -R: chown strips the setgid bit (kernel + # behavior), destroying the 2770 permissions the NixOS activation + # script sets for group access by hostUsers. Only touch files with + # wrong ownership so correctly-owned dirs keep their permission bits. + if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then + find "$HERMES_HOME" \! -user "$HERMES_UID" -exec chown "$HERMES_UID:$HERMES_GID" {} + + fi - # ── Provision apt packages (first boot only, cached in writable layer) ── - # sudo: agent self-modification - # nodejs/npm: writable node so npm i -g works (nix store copies are read-only) - # Node 22 via NodeSource — Ubuntu 24.04 ships Node 18 which is EOL. - # curl: needed for uv installer + NodeSource setup - if [ ! -f /var/lib/hermes-tools-provisioned ] && command -v apt-get >/dev/null 2>&1; then - echo "First boot: provisioning agent tools..." - apt-get update -qq - apt-get install -y -qq sudo curl ca-certificates gnupg - mkdir -p /etc/apt/keyrings - curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ - | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg - echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ - > /etc/apt/sources.list.d/nodesource.list - apt-get update -qq - apt-get install -y -qq nodejs - touch /var/lib/hermes-tools-provisioned - fi + # ── Provision apt packages (first boot only, cached in writable layer) ── + # sudo: agent self-modification + # nodejs/npm: writable node so npm i -g works (nix store copies are read-only) + # Node 22 via NodeSource — Ubuntu 24.04 ships Node 18 which is EOL. + # curl: needed for uv installer + NodeSource setup + if [ ! -f /var/lib/hermes-tools-provisioned ] && command -v apt-get >/dev/null 2>&1; then + echo "First boot: provisioning agent tools..." + apt-get update -qq + apt-get install -y -qq sudo curl ca-certificates gnupg + mkdir -p /etc/apt/keyrings + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list + apt-get update -qq + apt-get install -y -qq nodejs + touch /var/lib/hermes-tools-provisioned + fi - if command -v sudo >/dev/null 2>&1 && [ ! -f /etc/sudoers.d/hermes ]; then - mkdir -p /etc/sudoers.d - echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hermes - chmod 0440 /etc/sudoers.d/hermes - fi + if command -v sudo >/dev/null 2>&1 && [ ! -f /etc/sudoers.d/hermes ]; then + mkdir -p /etc/sudoers.d + echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hermes + chmod 0440 /etc/sudoers.d/hermes + fi - # uv (Python manager) — not in Ubuntu repos, retry-safe outside the sentinel - if ! command -v uv >/dev/null 2>&1 && [ ! -x "$TARGET_HOME/.local/bin/uv" ] && command -v curl >/dev/null 2>&1; then - su -s /bin/sh "$TARGET_USER" -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' || true - fi + # uv (Python manager) — not in Ubuntu repos, retry-safe outside the sentinel + if ! command -v uv >/dev/null 2>&1 && [ ! -x "$TARGET_HOME/.local/bin/uv" ] && command -v curl >/dev/null 2>&1; then + su -s /bin/sh "$TARGET_USER" -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' || true + fi - # Python 3.12 venv — gives the agent a writable Python with pip. - # --seed includes pip/setuptools so bare `pip install` works. - _UV_BIN="$TARGET_HOME/.local/bin/uv" - if [ ! -d "$TARGET_HOME/.venv" ] && [ -x "$_UV_BIN" ]; then - su -s /bin/sh "$TARGET_USER" -c " - export PATH=\"\$HOME/.local/bin:\$PATH\" - uv python install 3.12 - uv venv --python 3.12 --seed \"\$HOME/.venv\" - " || true - fi + # Python 3.12 venv — gives the agent a writable Python with pip. + # --seed includes pip/setuptools so bare `pip install` works. + _UV_BIN="$TARGET_HOME/.local/bin/uv" + if [ ! -d "$TARGET_HOME/.venv" ] && [ -x "$_UV_BIN" ]; then + su -s /bin/sh "$TARGET_USER" -c " + export PATH=\"\$HOME/.local/bin:\$PATH\" + uv python install 3.12 + uv venv --python 3.12 --seed \"\$HOME/.venv\" + " || true + fi - # Put the agent venv first on PATH so python/pip resolve to writable copies - if [ -d "$TARGET_HOME/.venv/bin" ]; then - export PATH="$TARGET_HOME/.venv/bin:$PATH" - fi + # Put the agent venv first on PATH so python/pip resolve to writable copies + if [ -d "$TARGET_HOME/.venv/bin" ]; then + export PATH="$TARGET_HOME/.venv/bin:$PATH" + fi - if command -v setpriv >/dev/null 2>&1; then - exec setpriv --reuid="$HERMES_UID" --regid="$HERMES_GID" --init-groups "$@" - elif command -v su >/dev/null 2>&1; then - exec su -s /bin/sh "$TARGET_USER" -c 'exec "$0" "$@"' -- "$@" - else - echo "WARNING: no privilege-drop tool (setpriv/su), running as root" >&2 - exec "$@" - fi - ''; + if command -v setpriv >/dev/null 2>&1; then + exec setpriv --reuid="$HERMES_UID" --regid="$HERMES_GID" --init-groups "$@" + elif command -v su >/dev/null 2>&1; then + exec su -s /bin/sh "$TARGET_USER" -c 'exec "$0" "$@"' -- "$@" + else + echo "WARNING: no privilege-drop tool (setpriv/su), running as root" >&2 + exec "$@" + fi + ''; - # Identity hash — only recreate container when structural config changes. - # Package and entrypoint use stable symlinks (current-package, current-entrypoint) - # so they can update without recreation. Env vars go through $HERMES_HOME/.env. - containerIdentity = builtins.hashString "sha256" (builtins.toJSON { - schema = 4; # bump when identity inputs change (4: Node 18→22 via NodeSource) - image = cfg.container.image; - extraVolumes = cfg.container.extraVolumes; - extraOptions = cfg.container.extraOptions; - }); + # Identity hash — only recreate container when structural config changes. + # Package and entrypoint use stable symlinks (current-package, current-entrypoint) + # so they can update without recreation. Env vars go through $HERMES_HOME/.env. + containerIdentity = builtins.hashString "sha256" ( + builtins.toJSON { + schema = 4; # bump when identity inputs change (4: Node 18→22 via NodeSource) + image = cfg.container.image; + extraVolumes = cfg.container.extraVolumes; + extraOptions = cfg.container.extraOptions; + } + ); - identityFile = "${cfg.stateDir}/.container-identity"; + identityFile = "${cfg.stateDir}/.container-identity"; - # Default: /var/lib/hermes/workspace → /data/workspace. - # Custom paths outside stateDir pass through unchanged (user must add extraVolumes). - containerWorkDir = - if lib.hasPrefix "${cfg.stateDir}/" cfg.workingDirectory - then "${containerDataDir}/${lib.removePrefix "${cfg.stateDir}/" cfg.workingDirectory}" - else cfg.workingDirectory; + # The CLI on the host reads this file, in get_container_exec_info. The + # file tells the CLI to run in the container and not on the host. + containerModeFile = pkgs.writeText "hermes-container-mode" '' + # Written by the NixOS activation script. Do not edit manually. + backend=${cfg.container.backend} + container_name=${containerName} + exec_user=${cfg.user} + hermes_bin=${containerDataDir}/current-package/bin/hermes + ''; - in { - options.services.hermes-agent = with lib; { - enable = mkEnableOption "Hermes Agent gateway service"; + # Default: /var/lib/hermes/workspace → /data/workspace. + # Custom paths outside stateDir pass through unchanged (user must add extraVolumes). + containerWorkDir = + if lib.hasPrefix "${cfg.stateDir}/" cfg.workingDirectory then + "${containerDataDir}/${lib.removePrefix "${cfg.stateDir}/" cfg.workingDirectory}" + else + cfg.workingDirectory; - # ── Package ────────────────────────────────────────────────────────── - package = mkOption { - type = types.package; - default = hermes-agent; - description = "The hermes-agent package to use."; + # The hardening and the environment that the gateway unit and the + # backend unit share. + commonServiceConfig = { + User = cfg.user; + Group = cfg.group; + WorkingDirectory = cfg.workingDirectory; + + Restart = cfg.restart; + RestartSec = cfg.restartSec; + + # Shared-state: files created by the service should be group-writable + # so interactive users in the hermes group can read/write them. + UMask = "0007"; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = false; + ReadWritePaths = [ + cfg.stateDir + cfg.workingDirectory + ]; + PrivateTmp = true; }; - # ── Service identity ───────────────────────────────────────────────── - user = mkOption { - type = types.str; - default = "hermes"; - description = "System user running the gateway."; - }; + commonUnitEnvironment = { + HOME = cfg.stateDir; + } + // common.processEnvironment { inherit hermesHome; }; - group = mkOption { - type = types.str; - default = "hermes"; - description = "System group running the gateway."; - }; + unitPath = common.processPath { inherit pkgs cfg; }; - createUser = mkOption { - type = types.bool; - default = true; - description = "Create the user/group automatically."; - }; - - # ── Directories ────────────────────────────────────────────────────── - stateDir = mkOption { - type = types.str; - default = "/var/lib/hermes"; - description = "State directory. Contains .hermes/ subdir (HERMES_HOME)."; - }; - - workingDirectory = mkOption { - type = types.str; - default = "${cfg.stateDir}/workspace"; - defaultText = literalExpression ''"''${cfg.stateDir}/workspace"''; - description = "Working directory for the agent."; - }; - - # ── Declarative config ─────────────────────────────────────────────── - configFile = mkOption { - type = types.nullOr types.path; - default = null; - description = '' - Path to an existing config.yaml. If set, takes precedence over - the declarative `settings` option. - ''; - }; - - settings = mkOption { - type = deepConfigType; - default = { }; - description = '' - Declarative Hermes config (attrset). Deep-merged across module - definitions and rendered as config.yaml. - ''; - example = literalExpression '' + in + { + options.services.hermes-agent = + common.sharedOptions { + defaultPackage = hermes-agent; + defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default"; + defaultWorkingDirectory = "${cfg.stateDir}/workspace"; + defaultWorkingDirectoryText = lib.literalExpression ''"''${cfg.stateDir}/workspace"''; + } + // ( + with lib; { - model = "anthropic/claude-sonnet-4"; - terminal.backend = "local"; - compression = { enabled = true; threshold = 0.85; }; - toolsets = [ "all" ]; - } - ''; - }; - - # ── Secrets / environment ──────────────────────────────────────────── - environmentFiles = mkOption { - type = types.listOf types.str; - default = [ ]; - description = '' - Paths to environment files containing secrets (API keys, tokens). - Contents are merged into $HERMES_HOME/.env at activation time. - Hermes reads this file on every startup via load_hermes_dotenv(). - ''; - }; - - environment = mkOption { - type = types.attrsOf types.str; - default = { }; - description = '' - Non-secret environment variables. Merged into $HERMES_HOME/.env - at activation time. Do NOT put secrets here — use environmentFiles. - ''; - }; - - authFile = mkOption { - type = types.nullOr types.path; - default = null; - description = '' - Path to an auth.json seed file (OAuth credentials). - Only copied on first deploy — existing auth.json is preserved. - ''; - }; - - authFileForceOverwrite = mkOption { - type = types.bool; - default = false; - description = "Always overwrite auth.json from authFile on activation."; - }; - - # ── Documents ──────────────────────────────────────────────────────── - documents = mkOption { - type = types.attrsOf (types.either types.str types.path); - default = { }; - description = '' - Workspace files (SOUL.md, USER.md, etc.). Keys are filenames, - values are inline strings or paths. Installed into workingDirectory. - ''; - example = literalExpression '' - { - "SOUL.md" = "You are a helpful AI assistant."; - "USER.md" = ./documents/USER.md; - } - ''; - }; - - # ── MCP Servers ────────────────────────────────────────────────────── - mcpServers = mkOption { - type = types.attrsOf (types.submodule { - options = { - # Stdio transport - command = mkOption { - type = types.nullOr types.str; - default = null; - description = "MCP server command (stdio transport)."; - }; - args = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "Command-line arguments (stdio transport)."; - }; - env = mkOption { - type = types.attrsOf types.str; - default = { }; - description = "Environment variables for the server process (stdio transport)."; + # ── Service identity ─────────────────────────────────────────── + user = mkOption { + type = types.str; + default = "hermes"; + description = "System user running the gateway."; }; - # HTTP/StreamableHTTP transport - url = mkOption { - type = types.nullOr types.str; - default = null; - description = "MCP server endpoint URL (HTTP/StreamableHTTP transport)."; - }; - headers = mkOption { - type = types.attrsOf types.str; - default = { }; - description = "HTTP headers, e.g. for authentication (HTTP transport)."; + group = mkOption { + type = types.str; + default = "hermes"; + description = "System group running the gateway."; }; - # Authentication - auth = mkOption { - type = types.nullOr (types.enum [ "oauth" ]); - default = null; + createUser = mkOption { + type = types.bool; + default = true; + description = "Create the user/group automatically."; + }; + + # ── Directories ──────────────────────────────────────────────── + stateDir = mkOption { + type = types.str; + default = "/var/lib/hermes"; + description = "State directory. Contains .hermes/ subdir (HERMES_HOME)."; + }; + + addToSystemPackages = mkOption { + type = types.bool; + default = false; description = '' - Authentication method. Set to "oauth" for OAuth 2.1 PKCE flow - (remote MCP servers). Tokens are stored in $HERMES_HOME/mcp-tokens/. + Add the hermes CLI to environment.systemPackages and export + HERMES_HOME system-wide (via environment.variables) so interactive + shells share state with the gateway service. ''; }; - # Enable/disable - enabled = mkOption { - type = types.bool; - default = true; - description = "Enable or disable this MCP server."; - }; + # ── OCI Container (opt-in) ──────────────────────────────────── + container = { + enable = mkEnableOption "OCI container mode (Ubuntu base, full self-modification support)"; - # Common options - timeout = mkOption { - type = types.nullOr types.int; - default = null; - description = "Tool call timeout in seconds (default: 120)."; - }; - connect_timeout = mkOption { - type = types.nullOr types.int; - default = null; - description = "Initial connection timeout in seconds (default: 60)."; - }; - - # Tool filtering - tools = mkOption { - type = types.nullOr (types.submodule { - options = { - include = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "Tool allowlist — only these tools are registered."; - }; - exclude = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "Tool blocklist — these tools are hidden."; - }; - }; - }); - default = null; - description = "Filter which tools are exposed by this server."; - }; - - # Sampling (server-initiated LLM requests) - sampling = mkOption { - type = types.nullOr (types.submodule { - options = { - enabled = mkOption { type = types.bool; default = true; description = "Enable sampling."; }; - model = mkOption { type = types.nullOr types.str; default = null; description = "Override model for sampling requests."; }; - max_tokens_cap = mkOption { type = types.nullOr types.int; default = null; description = "Max tokens per request."; }; - timeout = mkOption { type = types.nullOr types.int; default = null; description = "LLM call timeout in seconds."; }; - max_rpm = mkOption { type = types.nullOr types.int; default = null; description = "Max requests per minute."; }; - max_tool_rounds = mkOption { type = types.nullOr types.int; default = null; description = "Max tool-use rounds per sampling request."; }; - allowed_models = mkOption { type = types.listOf types.str; default = [ ]; description = "Models the server is allowed to request."; }; - log_level = mkOption { - type = types.nullOr (types.enum [ "debug" "info" "warning" ]); - default = null; - description = "Audit log level for sampling requests."; - }; - }; - }); - default = null; - description = "Sampling configuration for server-initiated LLM requests."; - }; - }; - }); - default = { }; - description = '' - MCP server configurations (merged into settings.mcp_servers). - Each server uses either stdio (command/args) or HTTP (url) transport. - ''; - example = literalExpression '' - { - filesystem = { - command = "npx"; - args = [ "-y" "@modelcontextprotocol/server-filesystem" "/home/user" ]; - }; - remote-api = { - url = "http://my-server:8080/v0/mcp"; - headers = { Authorization = "Bearer ..."; }; - }; - remote-oauth = { - url = "https://mcp.example.com/mcp"; - auth = "oauth"; - }; - } - ''; - }; - - # ── Service behavior ───────────────────────────────────────────────── - extraArgs = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "Extra command-line arguments for `hermes gateway`."; - }; - - extraPackages = mkOption { - type = types.listOf types.package; - default = [ ]; - description = '' - Extra packages available to the agent — terminal commands, skills, - cron jobs, and the service process all see them. - - Implemented via the hermes user's per-user profile - (`/etc/profiles/per-user/${cfg.user}/bin`), which NixOS includes - in PATH for login shells. The packages are also added to the - systemd service PATH for direct process access. - ''; - }; - - extraPlugins = mkOption { - type = types.listOf types.package; - default = [ ]; - description = '' - Directory-based plugin packages to symlink into the hermes plugins - directory. Each package should contain a plugin.yaml and __init__.py - at its root. Hermes discovers these automatically on startup. - ''; - example = literalExpression '' - [ - (pkgs.fetchFromGitHub { - owner = "stephenschoettler"; - repo = "hermes-lcm"; - name = "hermes-lcm"; - rev = "v0.7.0"; - hash = "sha256-..."; - }) - ] - ''; - }; - - extraPythonPackages = mkOption { - type = types.listOf types.package; - default = [ ]; - description = '' - Python packages to add to PYTHONPATH for entry-point plugin discovery. - These are pip-packaged plugins that register via the - hermes_agent.plugins entry-point group. Each package must be built - with the same Python interpreter as hermes (python312). - ''; - example = literalExpression '' - [ - (pkgs.python312Packages.buildPythonPackage { - pname = "rtk-hermes"; - version = "1.0.0"; - src = pkgs.fetchFromGitHub { - owner = "ogallotti"; - repo = "rtk-hermes"; - rev = "main"; - hash = "sha256-..."; + backend = mkOption { + type = types.enum [ + "docker" + "podman" + ]; + default = "docker"; + description = "Container runtime."; }; - }) - ] - ''; - }; - extraDependencyGroups = mkOption { - type = types.listOf types.str; - default = [ ]; - description = '' - Additional pyproject.toml optional-dependency groups to include in - the sealed Python venv. These are resolved by uv alongside core - dependencies — no PYTHONPATH patching or collision risk. + extraVolumes = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra volume mounts (host:container:mode format)."; + example = [ "/home/user/projects:/projects:rw" ]; + }; - Use this for optional extras already declared in hermes-agent's - pyproject.toml (e.g. "hindsight", "honcho", "voice"). - Use extraPythonPackages for external packages not in pyproject.toml. - ''; - example = [ "hindsight" ]; - }; + extraOptions = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra arguments passed to docker/podman run."; + }; - restart = mkOption { - type = types.str; - default = "always"; - description = "systemd Restart= policy."; - }; + image = mkOption { + type = types.str; + default = "ubuntu:24.04"; + description = "OCI container image. The container pulls this at runtime via Docker/Podman."; + }; - restartSec = mkOption { - type = types.int; - default = 5; - description = "systemd RestartSec= value."; - }; + hostUsers = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + Interactive users who get a ~/.hermes symlink to the service + stateDir. These users are automatically added to the hermes group. + ''; + example = [ "sidbin" ]; + }; + }; + } + ); - addToSystemPackages = mkOption { - type = types.bool; - default = false; - description = '' - Add the hermes CLI to environment.systemPackages and export - HERMES_HOME system-wide (via environment.variables) so interactive - shells share state with the gateway service. - ''; - }; + config = lib.mkIf cfg.enable ( + lib.mkMerge [ - # ── OCI Container (opt-in) ────────────────────────────────────────── - container = { - enable = mkEnableOption "OCI container mode (Ubuntu base, full self-modification support)"; + # ── Merge MCP servers into settings ──────────────────────────────── + (lib.mkIf (cfg.mcpServers != { }) { + services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers; + }) - backend = mkOption { - type = types.enum [ "docker" "podman" ]; - default = "docker"; - description = "Container runtime."; - }; + # ── User / group ────────────────────────────────────────────────── + (lib.mkIf cfg.createUser { + users.groups.${cfg.group} = { }; + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.stateDir; + createHome = true; + shell = pkgs.bashInteractive; + }; + }) - extraVolumes = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "Extra volume mounts (host:container:mode format)."; - example = [ "/home/user/projects:/projects:rw" ]; - }; + # ── Host CLI ────────────────────────────────────────────────────── + # Add the hermes CLI to system PATH and export HERMES_HOME system-wide + # so interactive shells share state (sessions, skills, cron) with the + # gateway service instead of creating a separate ~/.hermes/. + (lib.mkIf cfg.addToSystemPackages { + environment.systemPackages = [ effectivePackage ]; + environment.variables.HERMES_HOME = hermesHome; + }) - extraOptions = mkOption { - type = types.listOf types.str; - default = [ ]; - description = "Extra arguments passed to docker/podman run."; - }; + # ── Host user group membership ───────────────────────────────────── + (lib.mkIf (cfg.container.enable && cfg.container.hostUsers != [ ]) { + users.users = lib.genAttrs cfg.container.hostUsers (_user: { + extraGroups = [ cfg.group ]; + }); + }) - image = mkOption { - type = types.str; - default = "ubuntu:24.04"; - description = "OCI container image. The container pulls this at runtime via Docker/Podman."; - }; + # ── Assertions ───────────────────────────────────────────────────── + { + assertions = + common.pluginNameAssertions { + inherit cfg; + optionPath = "services.hermes-agent"; + } + ++ common.workspaceFilesAssertions { + inherit cfg; + opt = options.services.hermes-agent.workingDirectory; + optionPath = "services.hermes-agent"; + } + ++ [ + { + # Container mode runs one command in one container. A second + # process needs its own container and its own ports. This + # module does not do that. + assertion = !(cfg.container.enable && cfg.backend.mode != "none"); + message = "services.hermes-agent: backend.mode is not supported together with container.enable — the container runs the gateway only."; + } + ]; + } - hostUsers = mkOption { - type = types.listOf types.str; - default = [ ]; - description = '' - Interactive users who get a ~/.hermes symlink to the service - stateDir. These users are automatically added to the hermes group. - ''; - example = [ "sidbin" ]; - }; - }; + # ── Per-user profile for extraPackages ─────────────────────────── + # Wire extraPackages into the hermes user's per-user profile so the + # login-shell snapshot (which rebuilds PATH from NixOS profiles) sees + # them. The systemd service PATH also includes them for direct access. + (lib.mkIf (cfg.extraPackages != [ ]) { + # listOf options are merged by the NixOS module system — this appends to + # any packages the operator assigned to this user externally (e.g. when + # createUser = false and the user definition lives elsewhere in the config). + users.users.${cfg.user}.packages = cfg.extraPackages; + }) + + # ── Warnings ────────────────────────────────────────────────────── + (lib.mkIf + (cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != [ ]) + { + warnings = [ + '' + services.hermes-agent: container.enable is true and container.hostUsers + is set, but addToSystemPackages is false. Without a host-installed hermes + binary, container routing will not work for interactive users. + Set addToSystemPackages = true or ensure hermes is on PATH. + '' + ]; + } + ) + + # ── Directories ─────────────────────────────────────────────────── + { + systemd.tmpfiles.rules = [ + "d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -" + "d ${hermesHome} 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -" + ] + ++ map (d: "d ${hermesHome}/${d} 2770 ${cfg.user} ${cfg.group} - -") common.stateSubdirs; + } + + # ── Activation: link config + auth + documents ──────────────────── + { + system.activationScripts."hermes-agent-setup" = + lib.stringAfter + ( + [ "users" ] ++ lib.optional (config.system.activationScripts ? setupSecrets) "setupSecrets" + ) + '' + # Ensure directories exist (activation runs before tmpfiles) + mkdir -p ${hermesHome} + mkdir -p ${cfg.stateDir}/home + mkdir -p ${cfg.workingDirectory} + chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${hermesHome} ${cfg.stateDir}/home ${cfg.workingDirectory} + chmod 2770 ${cfg.stateDir} ${hermesHome} ${cfg.workingDirectory} + chmod 0750 ${cfg.stateDir}/home + + # Create subdirs, set setgid + group-writable, migrate existing files. + # Nix-managed .env/.managed stay 0640/0644; config.yaml uses + # configYamlMode (0660 under addToSystemPackages, else 0640). + find ${hermesHome} -maxdepth 1 \ + \( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \ + -exec chmod g+rw {} + 2>/dev/null || true + for _subdir in ${lib.concatStringsSep " " common.stateSubdirs}; do + mkdir -p "${hermesHome}/$_subdir" + chown ${cfg.user}:${cfg.group} "${hermesHome}/$_subdir" + chmod 2770 "${hermesHome}/$_subdir" + find "${hermesHome}/$_subdir" -type f \ + -exec chmod g+rw {} + 2>/dev/null || true + done + + ${common.mkStateScript { + inherit pkgs cfg hermesHome; + workingDirectory = cfg.workingDirectory; + configWorkingDirectory = effectiveWorkDir; + owner = "${cfg.user}:${cfg.group}"; + stateDirs = common.stateSubdirs; + modes = { + config = configYamlMode; + env = "0640"; + managed = "0644"; + auth = "0600"; + document = "0640"; + }; + }} + + chown -h ${cfg.user}:${cfg.group} ${hermesHome}/plugins/nix-managed-* 2>/dev/null || true + + # Container mode metadata — tells the host CLI to exec into the + # container instead of running locally. Removed when container mode + # is disabled so the host CLI falls back to native execution. + ${ + if cfg.container.enable then + '' + install -o ${cfg.user} -g ${cfg.group} -m 0644 ${containerModeFile} ${hermesHome}/.container-mode + '' + else + '' + rm -f ${hermesHome}/.container-mode + + # Remove symlink bridge for hostUsers + ${lib.concatStringsSep "\n" ( + map ( + user: + let + userHome = config.users.users.${user}.home; + symlinkPath = "${userHome}/.hermes"; + in + '' + if [ -L "${symlinkPath}" ] && [ "$(readlink "${symlinkPath}")" = "${hermesHome}" ]; then + rm -f "${symlinkPath}" + echo "hermes-agent: removed symlink ${symlinkPath}" + fi + '' + ) cfg.container.hostUsers + )} + '' + } + + # ── Symlink bridge for interactive users ─────────────────────── + # Create ~/.hermes -> stateDir/.hermes for each hostUser so the + # host CLI shares state with the container service. + # Only runs when container mode is enabled. + ${lib.optionalString cfg.container.enable ( + lib.concatStringsSep "\n" ( + map ( + user: + let + userHome = config.users.users.${user}.home; + symlinkPath = "${userHome}/.hermes"; + in + '' + if [ -d "${symlinkPath}" ] && [ ! -L "${symlinkPath}" ]; then + # Real directory — back it up, then create symlink. + # (ln -sfn cannot atomically replace a directory.) + _backup="${symlinkPath}.bak.$(date +%s)" + echo "hermes-agent: backing up existing ${symlinkPath} to $_backup" + mv "${symlinkPath}" "$_backup" + fi + # For everything else (existing symlink, doesn't exist, etc.) + # ln -sfn handles it: replaces symlinks, creates new ones. + ln -sfn "${hermesHome}" "${symlinkPath}" + chown -h ${user}:${cfg.group} "${symlinkPath}" + '' + ) cfg.container.hostUsers + ) + )} + ''; + } + + # ══════════════════════════════════════════════════════════════════ + # MODE A: Native systemd service (default) + # ══════════════════════════════════════════════════════════════════ + (lib.mkIf (!cfg.container.enable) { + systemd.services.hermes-agent = { + description = "Hermes Agent Gateway"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + # cfg.environment and cfg.environmentFiles are written to + # $HERMES_HOME/.env by the activation script. load_hermes_dotenv() + # reads them at Python startup — no systemd EnvironmentFile needed. + environment = commonUnitEnvironment; + + serviceConfig = commonServiceConfig // { + ExecStart = lib.escapeShellArgs (common.gatewayArgv cfg); + }; + + path = unitPath; + }; + }) + + # ── The backend: hermes serve or hermes dashboard ───────────────── + # This is a different process from the gateway. Both use one + # HERMES_HOME. + (lib.mkIf (!cfg.container.enable && cfg.backend.mode != "none") { + systemd.services.hermes-backend = { + description = common.backendDescription cfg; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + environment = commonUnitEnvironment; + + serviceConfig = commonServiceConfig // { + ExecStart = lib.escapeShellArgs (common.backendArgv cfg); + }; + + path = unitPath; + }; + }) + + # ══════════════════════════════════════════════════════════════════ + # MODE B: OCI container (persistent writable layer) + # ══════════════════════════════════════════════════════════════════ + (lib.mkIf cfg.container.enable { + # Ensure the container runtime is available + virtualisation.docker.enable = lib.mkDefault (cfg.container.backend == "docker"); + + systemd.services.hermes-agent = { + description = "Hermes Agent Gateway (container)"; + wantedBy = [ "multi-user.target" ]; + after = [ + "network-online.target" + ] + ++ lib.optional (cfg.container.backend == "docker") "docker.service"; + wants = [ "network-online.target" ]; + requires = lib.optional (cfg.container.backend == "docker") "docker.service"; + + preStart = '' + # Stable symlinks — container references these, not store paths directly + ln -sfn ${effectivePackage} ${cfg.stateDir}/current-package + ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint + + # GC roots so nix-collect-garbage doesn't remove store paths in use + ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${effectivePackage} 2>/dev/null || true + ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true + + # Check if container needs (re)creation + NEED_CREATE=false + if ! ${containerBin} inspect ${containerName} &>/dev/null; then + NEED_CREATE=true + elif [ ! -f ${identityFile} ] || [ "$(cat ${identityFile})" != "${containerIdentity}" ]; then + echo "Container config changed, recreating..." + ${containerBin} rm -f ${containerName} || true + NEED_CREATE=true + fi + + if [ "$NEED_CREATE" = "true" ]; then + # Resolve numeric UID/GID — passed to entrypoint for in-container user setup + HERMES_UID=$(${pkgs.coreutils}/bin/id -u ${cfg.user}) + HERMES_GID=$(${pkgs.coreutils}/bin/id -g ${cfg.user}) + + echo "Creating container..." + ${containerBin} create \ + --name ${containerName} \ + --network=host \ + --entrypoint ${containerDataDir}/current-entrypoint \ + --volume /nix/store:/nix/store:ro \ + --volume ${cfg.stateDir}:${containerDataDir} \ + --volume ${cfg.stateDir}/home:${containerHomeDir} \ + ${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \ + --env HERMES_UID="$HERMES_UID" \ + --env HERMES_GID="$HERMES_GID" \ + --env HERMES_HOME=${containerDataDir}/.hermes \ + --env HERMES_MANAGED=true \ + --env HOME=${containerHomeDir} \ + ${lib.concatStringsSep " " cfg.container.extraOptions} \ + ${cfg.container.image} \ + ${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs} + + echo "${containerIdentity}" > ${identityFile} + fi + ''; + + script = '' + exec ${containerBin} start -a ${containerName} + ''; + + preStop = '' + ${containerBin} stop -t 10 ${containerName} || true + ''; + + serviceConfig = { + Type = "simple"; + Restart = cfg.restart; + RestartSec = cfg.restartSec; + TimeoutStopSec = 30; + }; + }; + }) + ] + ); }; - - config = lib.mkIf cfg.enable (lib.mkMerge [ - - # ── Merge MCP servers into settings ──────────────────────────────── - (lib.mkIf (cfg.mcpServers != { }) { - services.hermes-agent.settings.mcp_servers = lib.mapAttrs (_name: srv: - # Stdio transport - lib.optionalAttrs (srv.command != null) { inherit (srv) command args; } - // lib.optionalAttrs (srv.env != { }) { inherit (srv) env; } - # HTTP transport - // lib.optionalAttrs (srv.url != null) { inherit (srv) url; } - // lib.optionalAttrs (srv.headers != { }) { inherit (srv) headers; } - # Auth - // lib.optionalAttrs (srv.auth != null) { inherit (srv) auth; } - # Enable/disable - // { inherit (srv) enabled; } - # Common options - // lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; } - // lib.optionalAttrs (srv.connect_timeout != null) { inherit (srv) connect_timeout; } - # Tool filtering - // lib.optionalAttrs (srv.tools != null) { - tools = lib.filterAttrs (_: v: v != [ ]) { - inherit (srv.tools) include exclude; - }; - } - # Sampling - // lib.optionalAttrs (srv.sampling != null) { - sampling = lib.filterAttrs (_: v: v != null && v != [ ]) { - inherit (srv.sampling) enabled model max_tokens_cap timeout max_rpm - max_tool_rounds allowed_models log_level; - }; - } - ) cfg.mcpServers; - }) - - # ── User / group ────────────────────────────────────────────────── - (lib.mkIf cfg.createUser { - users.groups.${cfg.group} = { }; - users.users.${cfg.user} = { - isSystemUser = true; - group = cfg.group; - home = cfg.stateDir; - createHome = true; - shell = pkgs.bashInteractive; - }; - }) - - # ── Host CLI ────────────────────────────────────────────────────── - # Add the hermes CLI to system PATH and export HERMES_HOME system-wide - # so interactive shells share state (sessions, skills, cron) with the - # gateway service instead of creating a separate ~/.hermes/. - (lib.mkIf cfg.addToSystemPackages { - environment.systemPackages = [ effectivePackage ]; - environment.variables.HERMES_HOME = "${cfg.stateDir}/.hermes"; - }) - - # ── Host user group membership ───────────────────────────────────── - (lib.mkIf (cfg.container.enable && cfg.container.hostUsers != []) { - users.users = lib.genAttrs cfg.container.hostUsers (user: { - extraGroups = [ cfg.group ]; - }); - }) - - # ── Assertions ───────────────────────────────────────────────────── - { - assertions = let - names = map lib.getName cfg.extraPlugins; - in [{ - assertion = (lib.length names) == (lib.length (lib.unique names)); - message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate."; - }]; - } - - # ── Warnings ────────────────────────────────────────────────────── - # ── Per-user profile for extraPackages ─────────────────────────── - # Wire extraPackages into the hermes user's per-user profile so the - # login-shell snapshot (which rebuilds PATH from NixOS profiles) sees - # them. The systemd service PATH also includes them for direct access. - (lib.mkIf (cfg.extraPackages != []) { - # listOf options are merged by the NixOS module system — this appends to - # any packages the operator assigned to this user externally (e.g. when - # createUser = false and the user definition lives elsewhere in the config). - users.users.${cfg.user}.packages = cfg.extraPackages; - }) - - (lib.mkIf (cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != []) { - warnings = [ - '' - services.hermes-agent: container.enable is true and container.hostUsers - is set, but addToSystemPackages is false. Without a host-installed hermes - binary, container routing will not work for interactive users. - Set addToSystemPackages = true or ensure hermes is on PATH. - '' - ]; - }) - - # ── Directories ─────────────────────────────────────────────────── - { - systemd.tmpfiles.rules = [ - "d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/.hermes 2770 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/.hermes/cron 2770 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/.hermes/sessions 2770 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/.hermes/logs 2770 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/.hermes/memories 2770 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/.hermes/plugins 2770 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -" - "d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -" - ]; - } - - # ── Activation: link config + auth + documents ──────────────────── - { - system.activationScripts."hermes-agent-setup" = lib.stringAfter ([ "users" ] ++ lib.optional (config.system.activationScripts ? setupSecrets) "setupSecrets") '' - # Ensure directories exist (activation runs before tmpfiles) - mkdir -p ${cfg.stateDir}/.hermes - mkdir -p ${cfg.stateDir}/home - mkdir -p ${cfg.workingDirectory} - chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory} - chmod 2770 ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.workingDirectory} - chmod 0750 ${cfg.stateDir}/home - - # Create subdirs, set setgid + group-writable, migrate existing files. - # Nix-managed .env/.managed stay 0640/0644; config.yaml uses - # configYamlMode (0660 under addToSystemPackages, else 0640). - find ${cfg.stateDir}/.hermes -maxdepth 1 \ - \( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \ - -exec chmod g+rw {} + 2>/dev/null || true - for _subdir in cron sessions logs memories plugins; do - mkdir -p "${cfg.stateDir}/.hermes/$_subdir" - chown ${cfg.user}:${cfg.group} "${cfg.stateDir}/.hermes/$_subdir" - chmod 2770 "${cfg.stateDir}/.hermes/$_subdir" - find "${cfg.stateDir}/.hermes/$_subdir" -type f \ - -exec chmod g+rw {} + 2>/dev/null || true - done - - # Merge Nix settings into existing config.yaml. - # Preserves user-added keys (skills, streaming, etc.); Nix keys win. - # If configFile is user-provided (not generated), overwrite instead of merge. - # Mode is configYamlMode (0660 under addToSystemPackages so interactive - # hermes-group users can save settings via the CLI/TUI, else 0640). - ${if cfg.configFile != null then '' - install -o ${cfg.user} -g ${cfg.group} -m ${configYamlMode} -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml - '' else '' - ${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml - chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml - chmod ${configYamlMode} ${cfg.stateDir}/.hermes/config.yaml - ''} - - # Managed mode marker (so interactive shells also detect NixOS management) - touch ${cfg.stateDir}/.hermes/.managed - chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.managed - chmod 0644 ${cfg.stateDir}/.hermes/.managed - - # Container mode metadata — tells the host CLI to exec into the - # container instead of running locally. Removed when container mode - # is disabled so the host CLI falls back to native execution. - ${if cfg.container.enable then '' - cat > ${cfg.stateDir}/.hermes/.container-mode <<'HERMES_CONTAINER_MODE_EOF' - # Written by NixOS activation script. Do not edit manually. - backend=${cfg.container.backend} - container_name=${containerName} - exec_user=${cfg.user} - hermes_bin=${containerDataDir}/current-package/bin/hermes - HERMES_CONTAINER_MODE_EOF - chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.container-mode - chmod 0644 ${cfg.stateDir}/.hermes/.container-mode - '' else '' - rm -f ${cfg.stateDir}/.hermes/.container-mode - - # Remove symlink bridge for hostUsers - ${lib.concatStringsSep "\n" (map (user: - let - userHome = config.users.users.${user}.home; - symlinkPath = "${userHome}/.hermes"; - in '' - if [ -L "${symlinkPath}" ] && [ "$(readlink "${symlinkPath}")" = "${cfg.stateDir}/.hermes" ]; then - rm -f "${symlinkPath}" - echo "hermes-agent: removed symlink ${symlinkPath}" - fi - '') cfg.container.hostUsers)} - ''} - - # ── Symlink bridge for interactive users ─────────────────────── - # Create ~/.hermes -> stateDir/.hermes for each hostUser so the - # host CLI shares state with the container service. - # Only runs when container mode is enabled. - ${lib.optionalString cfg.container.enable - (lib.concatStringsSep "\n" (map (user: - let - userHome = config.users.users.${user}.home; - symlinkPath = "${userHome}/.hermes"; - target = "${cfg.stateDir}/.hermes"; - in '' - if [ -d "${symlinkPath}" ] && [ ! -L "${symlinkPath}" ]; then - # Real directory — back it up, then create symlink. - # (ln -sfn cannot atomically replace a directory.) - _backup="${symlinkPath}.bak.$(date +%s)" - echo "hermes-agent: backing up existing ${symlinkPath} to $_backup" - mv "${symlinkPath}" "$_backup" - fi - # For everything else (existing symlink, doesn't exist, etc.) - # ln -sfn handles it: replaces symlinks, creates new ones. - ln -sfn "${target}" "${symlinkPath}" - chown -h ${user}:${cfg.group} "${symlinkPath}" - '') cfg.container.hostUsers))} - - # Seed auth file if provided - ${lib.optionalString (cfg.authFile != null) '' - ${if cfg.authFileForceOverwrite then '' - install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json - '' else '' - if [ ! -f ${cfg.stateDir}/.hermes/auth.json ]; then - install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json - fi - ''} - ''} - - # Seed .env from Nix-declared environment + environmentFiles. - # Hermes reads $HERMES_HOME/.env at startup via load_hermes_dotenv(), - # so this is the single source of truth for both native and container mode. - ${lib.optionalString (cfg.environment != {} || cfg.environmentFiles != []) '' - ENV_FILE="${cfg.stateDir}/.hermes/.env" - install -o ${cfg.user} -g ${cfg.group} -m 0640 /dev/null "$ENV_FILE" - cat > "$ENV_FILE" <<'HERMES_NIX_ENV_EOF' - ${envFileContent} - HERMES_NIX_ENV_EOF - ${lib.concatStringsSep "\n" (map (f: '' - if [ -f "${f}" ]; then - echo "" >> "$ENV_FILE" - cat "${f}" >> "$ENV_FILE" - fi - '') cfg.environmentFiles)} - ''} - - # Link documents into workspace - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _value: '' - install -o ${cfg.user} -g ${cfg.group} -m 0640 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name} - '') cfg.documents)} - - # ── Declarative plugins ───────────────────────────────────────── - # Remove stale managed symlinks (plugins removed from config) - find ${cfg.stateDir}/.hermes/plugins -maxdepth 1 -type l -name 'nix-managed-*' -delete 2>/dev/null || true - - ${lib.concatStringsSep "\n" (map (plugin: - let - name = lib.getName plugin; - in '' - if [ ! -f "${plugin}/plugin.yaml" ]; then - echo "ERROR: extraPlugins entry '${plugin}' has no plugin.yaml" >&2 - exit 1 - fi - ln -sfn ${plugin} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name} - chown -h ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name} - '') cfg.extraPlugins)} - ''; - } - - # ══════════════════════════════════════════════════════════════════ - # MODE A: Native systemd service (default) - # ══════════════════════════════════════════════════════════════════ - (lib.mkIf (!cfg.container.enable) { - systemd.services.hermes-agent = { - description = "Hermes Agent Gateway"; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - - environment = { - HOME = cfg.stateDir; - HERMES_HOME = "${cfg.stateDir}/.hermes"; - HERMES_MANAGED = "true"; - # Working directory is declared via terminal.cwd in the merged - # config.yaml (see configJson above) — MESSAGING_CWD is deprecated. - }; - - serviceConfig = { - User = cfg.user; - Group = cfg.group; - WorkingDirectory = cfg.workingDirectory; - - # cfg.environment and cfg.environmentFiles are written to - # $HERMES_HOME/.env by the activation script. load_hermes_dotenv() - # reads them at Python startup — no systemd EnvironmentFile needed. - - ExecStart = lib.concatStringsSep " " ([ - "${effectivePackage}/bin/hermes" - "gateway" - ] ++ cfg.extraArgs); - - Restart = cfg.restart; - RestartSec = cfg.restartSec; - - # Shared-state: files created by the gateway should be group-writable - # so interactive users in the hermes group can read/write them. - UMask = "0007"; - - # Hardening - NoNewPrivileges = true; - ProtectSystem = "strict"; - ProtectHome = false; - ReadWritePaths = [ - cfg.stateDir - cfg.workingDirectory - ]; - PrivateTmp = true; - }; - - path = [ - effectivePackage - pkgs.bash - pkgs.coreutils - pkgs.git - ] ++ cfg.extraPackages; - }; - }) - - # ══════════════════════════════════════════════════════════════════ - # MODE B: OCI container (persistent writable layer) - # ══════════════════════════════════════════════════════════════════ - (lib.mkIf cfg.container.enable { - # Ensure the container runtime is available - virtualisation.docker.enable = lib.mkDefault (cfg.container.backend == "docker"); - - systemd.services.hermes-agent = { - description = "Hermes Agent Gateway (container)"; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" ] - ++ lib.optional (cfg.container.backend == "docker") "docker.service"; - wants = [ "network-online.target" ]; - requires = lib.optional (cfg.container.backend == "docker") "docker.service"; - - preStart = '' - # Stable symlinks — container references these, not store paths directly - ln -sfn ${effectivePackage} ${cfg.stateDir}/current-package - ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint - - # GC roots so nix-collect-garbage doesn't remove store paths in use - ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${effectivePackage} 2>/dev/null || true - ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true - - # Check if container needs (re)creation - NEED_CREATE=false - if ! ${containerBin} inspect ${containerName} &>/dev/null; then - NEED_CREATE=true - elif [ ! -f ${identityFile} ] || [ "$(cat ${identityFile})" != "${containerIdentity}" ]; then - echo "Container config changed, recreating..." - ${containerBin} rm -f ${containerName} || true - NEED_CREATE=true - fi - - if [ "$NEED_CREATE" = "true" ]; then - # Resolve numeric UID/GID — passed to entrypoint for in-container user setup - HERMES_UID=$(${pkgs.coreutils}/bin/id -u ${cfg.user}) - HERMES_GID=$(${pkgs.coreutils}/bin/id -g ${cfg.user}) - - echo "Creating container..." - ${containerBin} create \ - --name ${containerName} \ - --network=host \ - --entrypoint ${containerDataDir}/current-entrypoint \ - --volume /nix/store:/nix/store:ro \ - --volume ${cfg.stateDir}:${containerDataDir} \ - --volume ${cfg.stateDir}/home:${containerHomeDir} \ - ${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \ - --env HERMES_UID="$HERMES_UID" \ - --env HERMES_GID="$HERMES_GID" \ - --env HERMES_HOME=${containerDataDir}/.hermes \ - --env HERMES_MANAGED=true \ - --env HOME=${containerHomeDir} \ - ${lib.concatStringsSep " " cfg.container.extraOptions} \ - ${cfg.container.image} \ - ${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs} - - echo "${containerIdentity}" > ${identityFile} - fi - ''; - - script = '' - exec ${containerBin} start -a ${containerName} - ''; - - preStop = '' - ${containerBin} stop -t 10 ${containerName} || true - ''; - - serviceConfig = { - Type = "simple"; - Restart = cfg.restart; - RestartSec = cfg.restartSec; - TimeoutStopSec = 30; - }; - }; - }) - ]); - }; } diff --git a/tests/hermes_cli/test_managed_install_shapes.py b/tests/hermes_cli/test_managed_install_shapes.py new file mode 100644 index 0000000000..89aad936a4 --- /dev/null +++ b/tests/hermes_cli/test_managed_install_shapes.py @@ -0,0 +1,112 @@ +"""Managed-mode detection across the Nix install shapes. + +The NixOS module and the Home Manager module both mark the install as +managed, and the CLI then refuses a configuration change that it cannot +keep. The two modules write different values, and an install from an +earlier version writes an empty marker, so detection must handle all three. +""" + +import os + +import pytest + +from hermes_cli import config as config_mod + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_MANAGED", raising=False) + return home + + +@pytest.mark.parametrize( + ("env_value", "expected"), + [ + ("nixos", "nixos"), + ("home-manager", "home-manager"), + ("HOME-MANAGER", "home-manager"), + # An install from an earlier version sets a bare "true". Only the + # NixOS module did that. + ("true", "nixos"), + ("1", "nixos"), + # Homebrew is not a distribution method, so these must not block a + # configuration change. + ("brew", None), + ("homebrew", None), + (None, None), + ], +) +def test_env_var_names_the_managing_system(hermes_home, monkeypatch, env_value, expected): + if env_value is None: + monkeypatch.delenv("HERMES_MANAGED", raising=False) + else: + monkeypatch.setenv("HERMES_MANAGED", env_value) + + assert config_mod.get_managed_system() == expected + assert config_mod.is_managed() is (expected is not None) + + +@pytest.mark.parametrize( + ("marker_text", "expected"), + [ + ("home-manager", "home-manager"), + ("nixos", "nixos"), + # An install from an earlier version has an empty marker. Only the + # NixOS module wrote one. + ("", "nixos"), + ], +) +def test_marker_file_names_the_managing_system( + hermes_home, monkeypatch, marker_text, expected +): + """An interactive shell reads .managed, not the HERMES_MANAGED of the service.""" + (hermes_home / ".managed").write_text(marker_text, encoding="utf-8") + monkeypatch.delenv("HERMES_MANAGED", raising=False) + + assert config_mod.get_managed_system() == expected + assert config_mod.is_managed() is True + + +def test_env_var_wins_over_the_marker(hermes_home, monkeypatch): + (hermes_home / ".managed").write_text("nixos", encoding="utf-8") + monkeypatch.setenv("HERMES_MANAGED", "home-manager") + + assert config_mod.get_managed_system() == "home-manager" + + +@pytest.mark.parametrize("managed_value", ["nixos", "home-manager"]) +def test_managed_install_names_its_system_and_offers_an_update( + hermes_home, monkeypatch, managed_value +): + """The message names the system, so the user knows what owns the install.""" + monkeypatch.setenv("HERMES_MANAGED", managed_value) + + assert managed_value in config_mod.format_managed_message("set model") + assert "set model" in config_mod.format_managed_message("set model") + assert config_mod.get_managed_update_command() + assert config_mod.detect_install_method(config_mod.get_project_root()) == managed_value + # `hermes update` cannot run on a managed install, so the advice must not + # name it. + assert config_mod.recommended_update_command() != "hermes update" + + +def test_unmanaged_install_offers_no_update_command(hermes_home, monkeypatch): + monkeypatch.delenv("HERMES_MANAGED", raising=False) + assert config_mod.get_managed_update_command() is None + + +def test_unreadable_marker_still_reports_managed(hermes_home, monkeypatch): + """A marker we cannot read is still a marker. Fail closed, not open.""" + marker = hermes_home / ".managed" + marker.write_text("home-manager", encoding="utf-8") + marker.chmod(0o000) + monkeypatch.delenv("HERMES_MANAGED", raising=False) + try: + if os.access(marker, os.R_OK): + pytest.skip("running as a user that ignores file modes (for example root)") + assert config_mod.is_managed() is True + finally: + marker.chmod(0o600) diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index 2919d14acf..43c2f268bd 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -12,11 +12,12 @@ Nix and NixOS are [Tier 2 platforms](./platform-support.md#tier-2). The flake an For a supported setup, use one of the standard [installation](./installation.md) paths - either Docker or an FHS environment. ::: -Hermes Agent ships a Nix flake & a NixOS module. +Hermes Agent ships a Nix flake, a NixOS module, and a Home Manager module. | Level | Who it's for | What you get | |-------|-------------|--------------| | **`nix run` / `nix profile install`** | Any Nix user (macOS, Linux) | Pre-built binary with all deps — then use the standard CLI workflow | +| **Home Manager module** | An agent for one person, on any distribution or on macOS | Declarative configuration and a user service, without root | | **NixOS module (native)** | NixOS server deployments | Declarative config, hardened systemd service, managed secrets | | **NixOS module (container)** | Agents that need self-modification | Everything above, plus a persistent Ubuntu container where the agent can `apt`/`pip`/`npm install` | @@ -84,7 +85,7 @@ hermes setup The flake exports `nixosModules.default` — a full NixOS service module that declaratively manages user creation, directories, config generation, secrets, documents, and service lifecycle. :::note -This module requires NixOS. For non-NixOS systems (macOS, other Linux distros), use `nix profile install` and the standard CLI workflow above. +This module needs NixOS. Hermes is an agent for one person. If you want an agent for one person and not a system service, use the [Home Manager module](#home-manager-module). That module runs on NixOS and on each other system that Home Manager supports. ::: ### Add the Flake Input @@ -287,8 +288,10 @@ Run `nix build .#configKeys && cat result` to see every leaf config key extracte environmentFiles = [ config.sops.secrets."hermes-env".path ]; # ── Documents ────────────────────────────────────────────────────── - documents = { - "USER.md" = ./documents/USER.md; + # USER.md is memory, so it goes to HERMES_HOME. Workspace files use + # `documents`, and that option needs an explicit `workingDirectory`. + hermesHomeFiles = { + "memories/USER.md" = ./documents/USER.md; }; # ── MCP Servers ──────────────────────────────────────────────────── @@ -336,7 +339,9 @@ Quick reference for the most common things Nix users want to customize: | Change the LLM model | `settings.model.default` | `"anthropic/claude-sonnet-4"` | | Use a different provider endpoint | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` | | Add API keys | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` | -| Give the agent a personality | `${services.hermes-agent.stateDir}/.hermes/SOUL.md` | manage the file directly | +| Give the agent an identity | `hermesHomeFiles."SOUL.md"` | `"You are a terse ops assistant."` | +| Add project context to the workspace | `documents."AGENTS.md"` | `./documents/AGENTS.md` | +| Run the backend for the desktop app or the dashboard | `backend.mode` | `"serve"` or `"dashboard"` | | Add MCP tool servers | `mcpServers.` | See [MCP Servers](#mcp-servers) | | Enable Discord/Telegram/Slack | `extraDependencyGroups` | `[ "messaging" ]` | | Mount host directories into container | `container.extraVolumes` | `[ "/data:/data:rw" ]` | @@ -416,22 +421,45 @@ The file is only copied if `auth.json` doesn't already exist (unless `authFileFo ## Documents -The `documents` option installs files into the agent's working directory (the `workingDirectory`, which the agent reads as its workspace). Hermes looks for specific filenames by convention: +Hermes reads files from two directories. Thus there are two options. Use the option for the directory that the file must go into. -- **`USER.md`** — context about the user the agent is interacting with. -- Any other files you place here are visible to the agent as workspace files. - -The agent identity file is separate: Hermes loads its primary `SOUL.md` from `$HERMES_HOME/SOUL.md`, which in the NixOS module is `${services.hermes-agent.stateDir}/.hermes/SOUL.md`. Putting `SOUL.md` in `documents` only creates a workspace file and will not replace the main persona file. +`documents` installs into the **working directory** of the agent, which is `workingDirectory`. The agent reads its project context from that workspace: ```nix { - services.hermes-agent.documents = { - "USER.md" = ./documents/USER.md; # path reference, copied from Nix store + services.hermes-agent = { + # documents needs this option. Read the note below. + workingDirectory = "/var/lib/hermes/workspace"; + documents = { + "AGENTS.md" = ./documents/AGENTS.md; # path reference, copied from Nix store + "notes/oncall.md" = "Page #infra before restarting anything."; + }; }; } ``` -Values can be inline strings or path references. Files are installed on every `nixos-rebuild switch`. +:::warning documents needs an explicit workingDirectory +The module refuses `documents` until you set `workingDirectory`. The default of +that option is different on each module. It is your home directory on Home +Manager, and `${stateDir}/workspace` on NixOS. Thus an unset default puts the +files in a directory that you did not select. A directory with the same path as +the default is a correct selection, and it satisfies the rule. +::: + +`hermesHomeFiles` installs into **`HERMES_HOME`**. Hermes reads the identity file and the memory files of the agent from that directory. `SOUL.md` and `memories/` work only from there. A `SOUL.md` in `documents` makes a workspace file. Hermes does not load that file as the identity: + +```nix +{ + services.hermes-agent.hermesHomeFiles = { + "SOUL.md" = "You are a helpful AI assistant."; + "memories/USER.md" = ./documents/USER.md; + }; +} +``` + +Each value is a string or a path. A key in either option can contain subdirectories, and the module makes the parent directories. Each activation installs the files again. + +`hermesHomeFiles` needs no `workingDirectory`, because the module owns the `HERMES_HOME` directory. Most users want `hermesHomeFiles`. --- @@ -554,10 +582,108 @@ When hermes runs via the NixOS module, the following CLI commands are **blocked* This prevents drift between what Nix declares and what's on disk. Detection uses two signals: -1. **`HERMES_MANAGED=true`** environment variable — set by the systemd service, visible to the gateway process -2. **`.managed` marker file** in `HERMES_HOME` — set by the activation script, visible to interactive shells (e.g., `docker exec -it hermes-agent hermes config set ...` is also blocked) +1. **The `HERMES_MANAGED` environment variable.** The service sets it, and the gateway process reads it. +2. **The `.managed` marker file** in `HERMES_HOME`. The activation script writes it, and an interactive shell reads it. Thus the CLI also blocks a command such as `docker exec -it hermes-agent hermes config set ...`. -To change configuration, edit your Nix config and run `sudo nixos-rebuild switch`. +Both signals hold the name of the system that manages the install. Thus the refusal names the correct rebuild command. The NixOS module gives `sudo nixos-rebuild switch`. The Home Manager module gives `home-manager switch`. + +--- + +## Home Manager Module + +The flake also exports `homeManagerModules.default`. Hermes is an agent for one person. The credentials, the memory, the sessions and the cron jobs all belong to that person. Thus a user service is the correct shape on a personal machine. It runs on each distribution that Home Manager supports, and not only on NixOS. + +The option set is the same set that the NixOS module uses. It is `services.hermes-agent`, with the same `settings`, `environmentFiles`, `documents`, `mcpServers`, `extraPlugins` and `backend` options. Each example above works here without a change. Only the necessary parts are different: + +| | NixOS module | Home Manager module | +|---|---|---| +| 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 | +| Container mode | supported | not supported, because it needs root and the Docker socket | + +### Add the Flake Input + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + home-manager.url = "github:nix-community/home-manager"; + home-manager.inputs.nixpkgs.follows = "nixpkgs"; + hermes-agent.url = "github:NousResearch/hermes-agent"; + }; +} +``` + +Then import the module into your Home Manager configuration. The configuration can be standalone. It can also be under `home-manager.users.` in a NixOS or nix-darwin configuration: + +```nix +{ + imports = [ hermes-agent.homeManagerModules.default ]; + + services.hermes-agent = { + enable = true; + gateway.enable = true; + settings.model.default = "anthropic/claude-sonnet-4"; + environmentFiles = [ config.sops.secrets."hermes-env".path ]; + }; +} +``` + +`home-manager switch` makes `~/.hermes`, writes `config.yaml`, builds `.env` and starts the gateway as a user service. + +:::warning Enable linger, or the service stops at logout +CAUTION: Enable linger for your account. Without linger, systemd stops the user manager when your last session ends, and the gateway stops with it. Home Manager cannot set linger, because linger is a property of the account: + +```nix +# NixOS +users.users.your-username.linger = true; +``` + +```bash +# anywhere else +sudo loginctl enable-linger your-username +``` + +macOS has no equivalent option. A `launchd` agent with `RunAtLoad` starts at login and continues to run. +::: + +### Running the Desktop / Dashboard Backend + +`gateway.enable` runs the messaging gateway for Telegram, Discord, Slack and the other platforms. Hermes Desktop and the web dashboard connect to a *different* process, which is `hermes serve` or `hermes dashboard`. `backend.mode` runs that process with the gateway: + +```nix +{ + services.hermes-agent = { + enable = true; + gateway.enable = true; # messaging platforms + backend.mode = "dashboard"; # + the browser dashboard on 127.0.0.1:9119 + backend.port = 9119; + }; +} +``` + +`serve` runs without a user interface. It gives the `/api/ws` and `/api/pty` sockets that Hermes Desktop connects to, and it does not build the web application. `dashboard` gives all of that, and also serves the browser admin panel. Both processes use one `HERMES_HOME` with the gateway. Thus the sessions, the skills, the memory and the cron jobs are the same for all of them. `backend.mode` works in the same way on the NixOS module, but not in container mode. + +:::warning Binding to an address other than loopback +The default address is `127.0.0.1`. Each other address starts the authentication gate of the dashboard. The server also refuses each request with a `Host` header that is different from the address that the server bound to. This is a defence against DNS rebinding. Bind to the name or the address that your client uses. +::: + +### Verify It Works + +```bash +# Linux +systemctl --user status hermes-agent +journalctl --user -u hermes-agent -f + +# macOS +launchctl list | grep hermes +tail -f ~/Library/Logs/hermes-agent.log + +hermes version +hermes config # shows the configuration that Nix wrote +``` --- @@ -587,7 +713,7 @@ Host Container │ └── mcp-tokens/ (OAuth tokens for MCP servers) ├── home/ ──► /home/hermes (rw) └── workspace/ (agent working directory) - ├── SOUL.md (from documents option) + ├── AGENTS.md (from the documents option) └── (agent-created files) Container writable layer (apt/pip/npm): /usr, /usr/local, /tmp @@ -857,7 +983,8 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use | Option | Type | Default | Description | |---|---|---|---| -| `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Keys are filenames, values are inline strings or paths. Installed into `workingDirectory` on activation | +| `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Each key is a path relative to `workingDirectory`. You must set that option to use this one. | +| `hermesHomeFiles` | `attrsOf (either str path)` | `{}` | Files that go into `HERMES_HOME`. `SOUL.md` and `memories/` must be here, or Hermes does not load them. | ### MCP Servers @@ -885,10 +1012,29 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use | `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` | | `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Build with `python312Packages` | | `extraDependencyGroups` | `listOf str` | `[]` | pyproject.toml optional extras to include in the sealed venv (e.g. `["hindsight"]`). Resolved by uv — no collisions | -| `restart` | `str` | `"always"` | systemd `Restart=` policy | -| `restartSec` | `int` | `5` | systemd `RestartSec=` value | +| `restart` | `str` | `"always"` | The systemd `Restart=` policy. macOS does not use it. | +| `restartSec` | `int` | `5` | The systemd `RestartSec=` value. macOS does not use it. | -### Container +### Backend (`hermes serve` / `hermes dashboard`) + +This option runs the process that Hermes Desktop and the web dashboard connect to, with the gateway. You cannot use it with `container.enable`. + +| Option | Type | Default | Description | +|---|---|---|---| +| `backend.mode` | `enum ["none" "serve" "dashboard"]` | `"none"` | `serve` runs without a user interface and gives `/api/ws` and `/api/pty`. `dashboard` also serves the browser panel. | +| `backend.host` | `str` | `"127.0.0.1"` | The address to bind to. Each address other than loopback starts the authentication gate. | +| `backend.port` | `port` | `9119` | The port to bind to | +| `backend.extraArgs` | `listOf str` | `[]` | More arguments for the backend command | + +### Home Manager only + +| 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. | + +### Container (NixOS only) | Option | Type | Default | Description | |---|---|---|---| @@ -908,6 +1054,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use ``` /var/lib/hermes/ # stateDir (owned by hermes:hermes, 0750) ├── .hermes/ # HERMES_HOME +│ ├── SOUL.md # from hermesHomeFiles: the agent identity │ ├── config.yaml # Nix-generated (deep-merged each rebuild) │ ├── .managed # Marker: CLI config mutation blocked │ ├── .env # Merged from environment + environmentFiles @@ -922,10 +1069,26 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use │ └── logs/ ├── home/ # Agent HOME └── workspace/ # Agent working directory - ├── SOUL.md # From documents option + ├── AGENTS.md # from the documents option └── (agent-created files) ``` +### Home Manager + +``` +~/.hermes/ # hermesHome (HERMES_HOME), 0700 +├── SOUL.md # from hermesHomeFiles +├── config.yaml # written by Nix, merged at each activation +├── .managed # marker: names the system that manages this +├── .env # written again from environment + environmentFiles +├── auth.json # OAuth credentials: seeded, then Hermes owns it +├── memories/ sessions/ skills/ cron/ logs/ plugins/ +└── (runtime state) + +~/ # workingDirectory, your home by default +└── AGENTS.md # from the documents option +``` + ### Container Mode Same layout, mounted into the container: @@ -946,7 +1109,8 @@ Same layout, mounted into the container: cd /etc/nixos && nix flake update hermes-agent # Rebuild -sudo nixos-rebuild switch +sudo nixos-rebuild switch # for the NixOS module +home-manager switch # for the Home Manager module ``` In container mode, the `current-package` symlink is updated and the agent picks up the new binary on restart. No container recreation, no loss of installed packages.