From 4626ceb747272b4580293240bc1ec4936fa22241 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:24:08 -0700 Subject: [PATCH] fix(gateway): only offer system-scope gateway install to root sessions (#53975) Non-root users picking 'System service' in the setup wizard were handed a 'sudo hermes gateway install --system --run-as-user ' recipe that fails on most distros: sudo's secure_path strips ~/.local/bin (pipx/uv installs), so 'sudo hermes' is command-not-found. Worse, it funnels a non-root user toward a system install they shouldn't be doing from a user session. Now prompt_linux_gateway_install_scope() only offers system scope when os.geteuid()==0. Non-root sessions get user-service or skip, with a tip to re-run as root for a boot service. The non-root branch in install_linux_gateway_from_setup becomes a defensive guard that refuses without printing any self-elevation recipe. Gated the matching deferral hint in setup.py behind root too. --- hermes_cli/gateway.py | 39 +++++++++++++++++++++++--------- hermes_cli/setup.py | 4 ++-- tests/hermes_cli/test_gateway.py | 29 +++++++++++++++++++++--- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 652c93c7496..1f0735ed025 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2265,11 +2265,33 @@ def _default_system_service_user() -> str | None: def prompt_linux_gateway_install_scope() -> str | None: + # A boot-time system service has to be created by root (writing the unit to + # /etc/systemd/system). We only offer that scope when the session is already + # root — a non-root user is never handed a "re-run yourself under sudo" + # recipe, since that just funnels them into a system install they can't + # actually perform from here. Non-root sessions get the user service. + is_root = os.geteuid() == 0 # windows-footgun: ok — Linux systemd install wizard, never invoked on Windows + if not is_root: + choice = prompt_choice( + " Choose how the gateway should run in the background:", + [ + "User service (no sudo; best for laptops/dev boxes; may need linger after logout)", + "Skip service install for now", + ], + default=0, + ) + if choice == 0: + print_info( + " Tip: for a boot-time system service, re-run setup as root " + "(e.g. from a root shell or `sudo -i`)." + ) + return {0: "user", 1: None}[choice] + choice = prompt_choice( " Choose how the gateway should run in the background:", [ "User service (no sudo; best for laptops/dev boxes; may need linger after logout)", - "System service (starts on boot; requires sudo; still runs as your user)", + "System service (starts on boot; runs as your chosen user)", "Skip service install for now", ], default=0, @@ -2285,18 +2307,13 @@ def install_linux_gateway_from_setup(force: bool = False, enable_on_startup: boo if scope == "system": run_as_user = _default_system_service_user() if os.geteuid() != 0: # windows-footgun: ok — Linux systemd install wizard, never invoked on Windows + # Unreachable from the wizard: prompt_linux_gateway_install_scope() + # only offers "system" to root sessions. Defensive guard for any + # direct caller — we do NOT print a self-elevation recipe. print_warning( - " System service install requires sudo, so Hermes can't create it from this user session." + " System service install requires root. Re-run setup from a " + "root shell, or install a user service instead: hermes gateway install" ) - if run_as_user: - print_info( - f" After setup, run: sudo hermes gateway install --system --run-as-user {run_as_user}" - ) - else: - print_info( - " After setup, run: sudo hermes gateway install --system --run-as-user " - ) - print_info(" Then start it with: sudo hermes gateway start --system") return scope, False if not run_as_user: diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 8eea7248d47..a178c0b5ca9 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2171,8 +2171,8 @@ def setup_gateway(config: dict): print_info(" You can try manually: hermes gateway install") else: print_info(" You can install later: hermes gateway install") - if supports_systemd: - print_info(" Or as a boot-time service: sudo hermes gateway install --system") + if supports_systemd and os.geteuid() == 0: # windows-footgun: ok — guarded by supports_systemd (Linux only) + print_info(" Or as a boot-time service: hermes gateway install --system") print_info(" Or run in foreground: hermes gateway") else: from hermes_constants import is_container diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 9fb3e99caca..e28c57e273e 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -720,7 +720,30 @@ def test_conflicting_systemd_units_warning(monkeypatch, tmp_path, capsys): assert "--system" in out -def test_install_linux_gateway_from_setup_system_choice_without_root_prints_followup(monkeypatch, capsys): +def test_install_linux_gateway_from_setup_non_root_never_offers_system(monkeypatch, capsys): + # Non-root sessions must not be offered system scope, and must never be + # handed a `sudo hermes …` self-elevation recipe. + captured = {} + + def fake_prompt_choice(_msg, options, default=0): + captured["options"] = options + return 0 # pick "user" + + monkeypatch.setattr(gateway.os, "geteuid", lambda: 1000) + monkeypatch.setattr(gateway, "prompt_choice", fake_prompt_choice) + monkeypatch.setattr(gateway, "systemd_install", lambda *a, **k: None) + + scope = gateway.prompt_linux_gateway_install_scope() + out = capsys.readouterr().out + + assert scope == "user" + assert not any("System service" in opt for opt in captured["options"]) + assert "sudo hermes" not in out + + +def test_install_linux_gateway_from_setup_system_choice_without_root_no_sudo_recipe(monkeypatch, capsys): + # Defensive guard: if "system" is forced non-root (not reachable via wizard), + # we refuse and do NOT print a self-elevation recipe. monkeypatch.setattr(gateway, "prompt_linux_gateway_install_scope", lambda: "system") monkeypatch.setattr(gateway.os, "geteuid", lambda: 1000) monkeypatch.setattr(gateway, "_default_system_service_user", lambda: "alice") @@ -730,8 +753,8 @@ def test_install_linux_gateway_from_setup_system_choice_without_root_prints_foll out = capsys.readouterr().out assert (scope, did_install) == ("system", False) - assert "sudo hermes gateway install --system --run-as-user alice" in out - assert "sudo hermes gateway start --system" in out + assert "sudo hermes" not in out + assert "requires root" in out def test_install_linux_gateway_from_setup_system_choice_as_root_installs(monkeypatch):