diff --git a/README.md b/README.md index cd6f9a88..6be60866 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ Service example: ```nix services.hermes-webui = { enable = true; - host = "0.0.0.0"; + host = "127.0.0.1"; port = 8787; stateDir = "/var/lib/hermes-webui"; agent.dir = "/var/lib/hermes/hermes-agent"; @@ -427,9 +427,11 @@ services.hermes-webui = { }; ``` -The module defaults to `0.0.0.0` for a network-reachable system service. Pair that with auth, for example `HERMES_WEBUI_PASSWORD` via `environmentFiles`, or override `host = "127.0.0.1"` when you want loopback-only access. +The module defaults to `127.0.0.1`. Set `host = "0.0.0.0"` and `openFirewall = true` only when you want direct network access, and pair that with auth, for example `HERMES_WEBUI_PASSWORD` via `environmentFiles`. -You can also set `agent.package` instead of `agent.dir` when you are using a compatible Hermes Agent package layout. The module derives `HERMES_WEBUI_AGENT_DIR` from `/share/hermes-agent` and sets `HERMES_WEBUI_PYTHON` from the package's `passthru.hermesVenv` interpreter so bootstrap can use agent dependencies without creating a local `.venv`. +You can also set `agent.package` instead of `agent.dir` when you are using a compatible Hermes Agent package layout. When the package exposes `passthru.hermesVenv`, the module derives `HERMES_WEBUI_AGENT_DIR` from that venv's site-packages path and sets `HERMES_WEBUI_PYTHON` from the same venv interpreter so bootstrap can use agent dependencies without creating a local `.venv`. If the package does not expose that venv metadata, set `agent.dir` and `agent.python` explicitly. + +When WebUI reads shared Hermes Agent state, run the service as a user that can already read that state. For a co-located Hermes Agent service, set `user` and `group` to the agent service account; the module only creates the default `hermes-webui` account and never changes ownership of an existing `hermesHome`. The module maps directly onto existing WebUI environment variables, including: `HERMES_WEBUI_HOST`, `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_HOME`, `HERMES_WEBUI_AGENT_DIR`, and `HERMES_WEBUI_PYTHON`. diff --git a/bootstrap.py b/bootstrap.py index 424f2de9..5dbf38ca 100755 --- a/bootstrap.py +++ b/bootstrap.py @@ -270,6 +270,13 @@ def ensure_python_has_webui_deps(python_exe: str, agent_dir: Path | None = None) if _python_can_run_webui_and_agent(str(candidate), agent_dir): return str(candidate) + if (os.getenv("HERMES_WEBUI_DISABLE_LOCAL_VENV") or "").strip().lower() in {"1", "true", "yes", "on"}: + raise RuntimeError( + "Python environment cannot import both WebUI dependencies and Hermes Agent, " + "and local .venv creation is disabled for this packaged launch. Set " + "HERMES_WEBUI_PYTHON to an interpreter with Hermes Agent dependencies." + ) + venv_dir = REPO_ROOT / ".venv" venv_python = venv_dir / ( "Scripts/python.exe" if platform.system() == "Windows" else "bin/python" diff --git a/flake.nix b/flake.nix index 1af10634..1a60a20a 100644 --- a/flake.nix +++ b/flake.nix @@ -32,7 +32,7 @@ services.hermes-webui = { enable = true; package = package; - host = "0.0.0.0"; + host = "127.0.0.1"; port = 8787; stateDir = "/var/lib/hermes-webui"; agent.dir = "/var/lib/hermes-agent"; @@ -47,12 +47,25 @@ module-env-mapping = pkgs.runCommand "hermes-webui-nixos-module-${system}" { nativeBuildInputs = [ pkgs.coreutils ]; } '' - grep -q 'HERMES_WEBUI_HOST=0.0.0.0' ${envProbe} + grep -q 'HERMES_WEBUI_HOST=127.0.0.1' ${envProbe} grep -q 'HERMES_WEBUI_PORT=8787' ${envProbe} grep -q 'HERMES_WEBUI_STATE_DIR=/var/lib/hermes-webui' ${envProbe} grep -q 'HERMES_WEBUI_AGENT_DIR=/var/lib/hermes-agent' ${envProbe} touch "$out" ''; + runtime-layout = pkgs.runCommand "hermes-webui-runtime-layout-${system}" { + nativeBuildInputs = [ pkgs.coreutils ]; + } '' + test -f ${package}/hermes-webui/bootstrap.py + test -f ${package}/hermes-webui/server.py + test -d ${package}/hermes-webui/api + test -d ${package}/hermes-webui/static + cd ${package}/hermes-webui + ${package}/bin/hermes-webui --help >/dev/null + ${package}/bin/hermes-webui --help 2>&1 | grep -q -- '--foreground' + PYTHONPATH=${package}/hermes-webui ${pkgs.python3.withPackages (ps: with ps; [ pyyaml cryptography ])}/bin/python3 -c 'import api.config, server; print("runtime imports ok")' + touch "$out" + ''; } else { }; diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index 58b9ea70..a41309b8 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -15,6 +15,9 @@ let "HERMES_WEBUI_PYTHON" ]; + defaultUser = "hermes-webui"; + defaultGroup = "hermes-webui"; + protectedEnvironmentFileCheck = pkgs.writeShellScript "hermes-webui-protected-envfile-check" '' set -eu for env_file in "$@"; do @@ -47,8 +50,16 @@ let inferredAgentDir = if cfg.agent.package == null then null + else if (cfg.agent.package ? passthru) && (cfg.agent.package.passthru ? hermesVenv) then + "${cfg.agent.package.passthru.hermesVenv}/lib/python3.12/site-packages" else - "${cfg.agent.package}/share/hermes-agent"; + null; + + configuredAgentPython = + if cfg.agent.python != null then + cfg.agent.python + else + inferredAgentPython; mappedEnvironment = (lib.mapAttrsToList (name: value: "${name}=${value}") @@ -66,27 +77,18 @@ let // lib.optionalAttrs (cfg.agent.dir == null && inferredAgentDir != null) { HERMES_WEBUI_AGENT_DIR = inferredAgentDir; } - // lib.optionalAttrs (cfg.agent.dir == null && inferredAgentPython != null) { - HERMES_WEBUI_PYTHON = inferredAgentPython; + // lib.optionalAttrs (configuredAgentPython != null) { + HERMES_WEBUI_PYTHON = configuredAgentPython; } // lib.filterAttrs (name: _: !(lib.elem name protectedEnvironment)) cfg.extraEnvironment)); needsWritableStateDir = cfg.stateDir != defaultStateDir; - writableServiceDirs = - lib.optionals needsWritableStateDir [ cfg.stateDir ] - ++ lib.optionals (cfg.hermesHome != null) [ cfg.hermesHome ]; - - userspaceDirs = lib.optionals (cfg.hermesHome != null) [ - "d ${cfg.hermesHome} 2770 ${cfg.user} ${cfg.group} - -" + writableServiceDirs = lib.optionals needsWritableStateDir [ cfg.stateDir ]; + tmpfilesRules = lib.optionals needsWritableStateDir [ + "d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -" ]; - - tmpfilesRules = - (lib.optionals needsWritableStateDir [ - "d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -" - ]) - ++ userspaceDirs; in { options.services.hermes-webui = { @@ -101,19 +103,19 @@ in user = lib.mkOption { type = lib.types.str; - default = "hermes-webui"; + default = defaultUser; description = "User that runs the Hermes WebUI service."; }; group = lib.mkOption { type = lib.types.str; - default = "hermes-webui"; + default = defaultGroup; description = "Group that runs the Hermes WebUI service."; }; host = lib.mkOption { type = lib.types.str; - default = "0.0.0.0"; + default = "127.0.0.1"; description = "Value for HERMES_WEBUI_HOST."; }; @@ -123,6 +125,12 @@ in description = "Value for HERMES_WEBUI_PORT."; }; + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Open the configured TCP port in the NixOS firewall."; + }; + stateDir = lib.mkOption { type = lib.types.strMatching "^/.+"; default = defaultStateDir; @@ -140,7 +148,7 @@ in package = lib.mkOption { type = lib.types.nullOr lib.types.package; default = null; - description = "Package to derive HERMES_WEBUI_AGENT_DIR from when `agent.dir` is unset."; + description = "Package to derive HERMES_WEBUI_AGENT_DIR and HERMES_WEBUI_PYTHON from when it exposes passthru.hermesVenv."; }; dir = lib.mkOption { @@ -148,6 +156,12 @@ in default = null; description = "Explicit path for HERMES_WEBUI_AGENT_DIR."; }; + + python = lib.mkOption { + type = lib.types.nullOr (lib.types.strMatching "^/.+"); + default = null; + description = "Explicit path for HERMES_WEBUI_PYTHON when the service must run with agent dependencies from a separately managed environment."; + }; }; environmentFiles = lib.mkOption { @@ -187,13 +201,19 @@ in }; }; - users.groups.${cfg.group} = { }; + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; - users.users.${cfg.user} = { - isSystemUser = true; - group = cfg.group; - createHome = true; - home = cfg.stateDir; + users.groups = lib.mkIf (cfg.group == defaultGroup) { + ${cfg.group} = { }; + }; + + users.users = lib.mkIf (cfg.user == defaultUser) { + ${cfg.user} = { + isSystemUser = true; + group = cfg.group; + createHome = true; + home = cfg.stateDir; + }; }; systemd.tmpfiles.rules = tmpfilesRules; diff --git a/nix/packages.nix b/nix/packages.nix index 1fbd74ea..ee7e5619 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -27,10 +27,11 @@ pkgs.stdenv.mkDerivation { cp "${./../server.py}" "$out/${runtimeDir}/server.py" cp "${./../mcp_server.py}" "$out/${runtimeDir}/mcp_server.py" cp "${./../requirements.txt}" "$out/${runtimeDir}/requirements.txt" - cp -r "${./../api}" "$out/${runtimeDir}/" - cp -r "${./../static}" "$out/${runtimeDir}/" + cp -r "${./../api}" "$out/${runtimeDir}/api" + cp -r "${./../static}" "$out/${runtimeDir}/static" makeWrapper ${pythonEnv}/bin/python3 "$out/bin/hermes-webui" \ + --set HERMES_WEBUI_DISABLE_LOCAL_VENV 1 \ --add-flags "$out/${runtimeDir}/bootstrap.py --foreground --no-browser --skip-agent-install" runHook postInstall diff --git a/tests/test_bootstrap_python_selection.py b/tests/test_bootstrap_python_selection.py index 9bbcc8d8..920427e0 100644 --- a/tests/test_bootstrap_python_selection.py +++ b/tests/test_bootstrap_python_selection.py @@ -72,6 +72,24 @@ def test_ensure_python_fails_loudly_when_no_interpreter_can_import_agent(monkeyp raise AssertionError("expected RuntimeError") +def test_packaged_launch_disables_repo_local_venv_creation(monkeypatch, tmp_path): + local_python = tmp_path / "webui" / ".venv" / "bin" / "python" + monkeypatch.setattr(bootstrap, "REPO_ROOT", tmp_path) + monkeypatch.setenv("HERMES_WEBUI_DISABLE_LOCAL_VENV", "1") + monkeypatch.setattr(bootstrap, "_python_can_run_webui_and_agent", lambda *a, **k: False) + + with patch.object(bootstrap.venv, "EnvBuilder") as mock_builder: + try: + bootstrap.ensure_python_has_webui_deps(str(local_python), tmp_path / "agent") + except RuntimeError as exc: + assert "local .venv creation is disabled" in str(exc) + assert "HERMES_WEBUI_PYTHON" in str(exc) + else: + raise AssertionError("expected RuntimeError") + + mock_builder.assert_not_called() + + def test_local_venv_is_created_with_symlinks(monkeypatch, tmp_path): """Regression: mise/asdf macOS Pythons need symlinks=True to avoid SIGABRT. diff --git a/tests/test_nix_flake_module_source.py b/tests/test_nix_flake_module_source.py new file mode 100644 index 00000000..397bf3ae --- /dev/null +++ b/tests/test_nix_flake_module_source.py @@ -0,0 +1,59 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FLAKE_NIX = (ROOT / "flake.nix").read_text(encoding="utf-8") +PACKAGES_NIX = (ROOT / "nix" / "packages.nix").read_text(encoding="utf-8") +MODULE_NIX = (ROOT / "nix" / "nixosModules.nix").read_text(encoding="utf-8") +README = (ROOT / "README.md").read_text(encoding="utf-8") + + +def test_nix_package_installs_runtime_directories_with_stable_names(): + assert 'cp -r "${./../api}" "$out/${runtimeDir}/api"' in PACKAGES_NIX + assert 'cp -r "${./../static}" "$out/${runtimeDir}/static"' in PACKAGES_NIX + + +def test_flake_check_covers_runtime_layout_and_wrapper_help(): + assert "runtime-layout" in FLAKE_NIX + assert "test -d ${package}/hermes-webui/api" in FLAKE_NIX + assert "test -d ${package}/hermes-webui/static" in FLAKE_NIX + assert "${package}/bin/hermes-webui --help >/dev/null" in FLAKE_NIX + assert "import api.config, server" in FLAKE_NIX + + +def test_nix_package_disables_store_local_venv_fallback(): + assert "--set HERMES_WEBUI_DISABLE_LOCAL_VENV 1" in PACKAGES_NIX + + +def test_nixos_module_decouples_agent_dir_from_python_inference(): + assert "agent.python" in MODULE_NIX + assert "configuredAgentPython" in MODULE_NIX + assert "configuredAgentPython != null" in MODULE_NIX + assert "cfg.agent.dir == null && configuredAgentPython" not in MODULE_NIX + + +def test_nixos_module_uses_agent_venv_site_packages_when_available(): + assert "${cfg.agent.package.passthru.hermesVenv}/lib/python3.12/site-packages" in MODULE_NIX + assert "${cfg.agent.package}/share/hermes-agent" not in MODULE_NIX + + +def test_nixos_module_does_not_chown_existing_hermes_home(): + assert "d ${cfg.hermesHome}" not in MODULE_NIX + + +def test_nixos_module_only_creates_default_service_identity(): + assert "cfg.group == defaultGroup" in MODULE_NIX + assert "cfg.user == defaultUser" in MODULE_NIX + assert "users.groups.${cfg.group}" not in MODULE_NIX + assert "users.users.${cfg.user}" not in MODULE_NIX + + +def test_nixos_module_defaults_to_loopback_with_explicit_firewall_opt_in(): + nix_section = README.split("### Nix flake and NixOS module", 1)[1].split( + "### Remote access", 1 + )[0] + assert 'default = "127.0.0.1";' in MODULE_NIX + assert "openFirewall" in MODULE_NIX + assert "networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];" in MODULE_NIX + assert 'host = "127.0.0.1";' in nix_section + assert 'Set `host = "0.0.0.0"` and `openFirewall = true`' in nix_section