fix(#2422): harden NixOS packaged runtime

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-07-14 01:06:49 +00:00
1 parent 1f505aad88
commit 70514549c4
7 files changed
+152 -32

No files matched your search

+5 -3
View File
@@ -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 `<pkg>/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`.
+7
View File
@@ -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"
+15 -2
View File
@@ -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
{ };
+45 -25
View File
@@ -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;
+3 -2
View File
@@ -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
+18
View File
@@ -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.
+59
View File
@@ -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