Compare commits

...

1 Commits

Author SHA1 Message Date
alt-glitch
12e817700c fix(gateway): persist Nix wrapper env vars in generated systemd units
`hermes gateway install` generates a systemd unit that execs Python
directly, bypassing the Nix wrapper. The wrapper sets HERMES_BUNDLED_SKILLS,
HERMES_BUNDLED_PLUGINS, HERMES_WEB_DIST, HERMES_TUI_DIR, HERMES_PYTHON,
HERMES_NODE, LD_LIBRARY_PATH, and PYTHONPATH — all absent from the generated
unit. Result: the gateway service runs without skills, plugins, or native
libs, silently breaking platform adapters (discord, etc.) even when the
correct deps are installed.

Capture these env vars at `gateway install` time and persist them as
Environment= lines in the unit file. System-mode units remap paths from
the calling user's home to the target user's home.

Based on qmx's patch: https://gist.github.com/qmx/63356d87f40048565bc0f3e62d869b1f
2026-05-28 10:14:44 +05:30
2 changed files with 112 additions and 0 deletions

View File

@@ -2075,6 +2075,60 @@ def _build_wsl_interop_paths(path_entries: list[str]) -> list[str]:
return result
_PACKAGED_RUNTIME_ENV_VARS: tuple[str, ...] = (
# Nix/packaged wrappers point Hermes at read-only bundled assets that are
# intentionally not importable from the sealed Python environment alone.
"HERMES_BUNDLED_SKILLS",
"HERMES_BUNDLED_PLUGINS",
"HERMES_WEB_DIST",
"HERMES_TUI_DIR",
"HERMES_PYTHON",
"HERMES_NODE",
# Wrappers may also expose native libraries (e.g. libopus for Discord
# voice) or extra Python plugin paths. A systemd unit that bypasses the
# wrapper still needs those paths.
"LD_LIBRARY_PATH",
"PYTHONPATH",
)
def _escape_systemd_env_value(value: str) -> str:
"""Escape a value for a quoted systemd ``Environment=`` assignment."""
return value.replace("\\", "\\\\").replace('"', '\\"')
def _remap_colon_separated_paths_for_user(value: str, target_home_dir: str) -> str:
"""Remap each path component in a colon-separated environment value."""
return os.pathsep.join(
_remap_path_for_user(part, target_home_dir) if part else part
for part in value.split(os.pathsep)
)
def _packaged_runtime_environment_lines(target_home_dir: str | None = None) -> str:
"""Return systemd Environment= lines for wrapper-provided runtime paths.
``hermes gateway install`` writes a service unit that launches the Python
module directly. Packaged launchers (notably Nix wrappers) set environment
variables that locate bundled plugins/skills/web assets and native library
paths before execing that Python module. Persist those variables into the
unit so the background gateway sees the same runtime layout as the CLI that
generated it.
"""
lines: list[str] = []
for key in _PACKAGED_RUNTIME_ENV_VARS:
value = os.environ.get(key)
if not value:
continue
if target_home_dir:
if key in {"LD_LIBRARY_PATH", "PYTHONPATH"}:
value = _remap_colon_separated_paths_for_user(value, target_home_dir)
elif value.startswith(("/", "~")):
value = _remap_path_for_user(value, target_home_dir)
lines.append(f'Environment="{key}={_escape_systemd_env_value(value)}"')
return "\n".join(lines)
def _remap_path_for_user(path: str, target_home_dir: str) -> str:
"""Remap *path* from the current user's home to *target_home_dir*.
@@ -2199,6 +2253,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
path_entries.extend(_build_wsl_interop_paths(path_entries))
path_entries.extend(common_bin_paths)
sane_path = ":".join(path_entries)
packaged_runtime_env = _packaged_runtime_environment_lines(home_dir)
return f"""[Unit]
Description={SERVICE_DESCRIPTION}
After=network-online.target
@@ -2217,6 +2272,7 @@ Environment="LOGNAME={username}"
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
{packaged_runtime_env}
Restart=always
RestartSec=5
RestartMaxDelaySec=300
@@ -2239,6 +2295,7 @@ WantedBy=multi-user.target
path_entries.extend(_build_wsl_interop_paths(path_entries))
path_entries.extend(common_bin_paths)
sane_path = ":".join(path_entries)
packaged_runtime_env = _packaged_runtime_environment_lines()
return f"""[Unit]
Description={SERVICE_DESCRIPTION}
After=network-online.target
@@ -2252,6 +2309,7 @@ WorkingDirectory={working_dir}
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
{packaged_runtime_env}
Restart=always
RestartSec=5
RestartMaxDelaySec=300

View File

@@ -394,6 +394,60 @@ class TestGeneratedSystemdUnits:
assert self._expected_timeout_stop_sec() in unit
assert "WantedBy=multi-user.target" in unit
def test_user_unit_preserves_packaged_runtime_environment(self, monkeypatch):
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", "/nix/store/hermes/share/plugins")
monkeypatch.setenv("HERMES_BUNDLED_SKILLS", "/nix/store/hermes/share/skills")
monkeypatch.setenv("HERMES_WEB_DIST", "/nix/store/hermes/share/web_dist")
monkeypatch.setenv("LD_LIBRARY_PATH", "/nix/store/libopus/lib")
unit = gateway_cli.generate_systemd_unit(system=False)
assert (
'Environment="HERMES_BUNDLED_PLUGINS=/nix/store/hermes/share/plugins"'
in unit
)
assert (
'Environment="HERMES_BUNDLED_SKILLS=/nix/store/hermes/share/skills"'
in unit
)
assert 'Environment="HERMES_WEB_DIST=/nix/store/hermes/share/web_dist"' in unit
assert 'Environment="LD_LIBRARY_PATH=/nix/store/libopus/lib"' in unit
def test_system_unit_remaps_packaged_runtime_environment_for_target_user(
self, monkeypatch
):
monkeypatch.setattr(Path, "home", lambda: Path("/root"))
monkeypatch.setattr(
gateway_cli,
"_system_service_identity",
lambda run_as_user=None: ("alice", "alice", "/home/alice"),
)
monkeypatch.setattr(
gateway_cli,
"_hermes_home_for_target_user",
lambda home: "/home/alice/.hermes",
)
monkeypatch.setenv(
"HERMES_BUNDLED_PLUGINS",
"/root/.nix-profile/share/hermes/plugins",
)
monkeypatch.setenv(
"LD_LIBRARY_PATH",
"/root/.nix-profile/lib:/nix/store/libopus/lib",
)
unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice")
assert (
'Environment="HERMES_BUNDLED_PLUGINS='
'/home/alice/.nix-profile/share/hermes/plugins"' in unit
)
assert (
'Environment="LD_LIBRARY_PATH='
'/home/alice/.nix-profile/lib:/nix/store/libopus/lib"' in unit
)
assert "/root/.nix-profile" not in unit
class TestGatewayStopCleanup:
def test_stop_only_kills_current_profile_by_default(self, tmp_path, monkeypatch):