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 <you>' 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.
This commit is contained in:
Teknium 2026-06-27 21:24:08 -07:00 committed by GitHub
parent b304023fc6
commit 4626ceb747
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 56 additions and 16 deletions

View file

@ -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 <your-user>"
)
print_info(" Then start it with: sudo hermes gateway start --system")
return scope, False
if not run_as_user:

View file

@ -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

View file

@ -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):