diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 5629da03fe38b..961a7368a1eed 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -6,6 +6,7 @@ import os import sys +import shutil import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions import importlib.util from pathlib import Path @@ -84,6 +85,34 @@ def _effective_provider_label() -> str: return provider_label(effective) +def _sudo_status(terminal_backend: str = "local") -> tuple[bool, str]: + """Return whether sudo is available along with a human-readable label.""" + if terminal_backend != "local": + return False, "unknown (remote backend)" + + sudo_password = os.getenv("SUDO_PASSWORD", "") + if sudo_password: + return True, "enabled (SUDO_PASSWORD)" + + if not shutil.which("sudo"): + return False, "disabled" + + try: + result = subprocess.run( + ["sudo", "-n", "true"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + check=False, + ) + except Exception: + return False, "disabled" + + if result.returncode == 0: + return True, "enabled (non-interactive)" + return False, "disabled" + + from hermes_constants import is_termux as _is_termux @@ -398,8 +427,8 @@ def _resolve_env(env_ref) -> str: print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}") print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation") - sudo_password = os.getenv("SUDO_PASSWORD", "") - print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}") + sudo_enabled, sudo_label = _sudo_status(terminal_env) + print(f" Sudo: {check_mark(sudo_enabled)} {sudo_label}") # ========================================================================= # Messaging Platforms diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py index 3cee9ab10ba7f..0eda16a030af2 100644 --- a/tests/hermes_cli/test_status.py +++ b/tests/hermes_cli/test_status.py @@ -31,6 +31,7 @@ def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys, monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False) monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False) monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False) + monkeypatch.setattr(status_mod.shutil, "which", lambda name: None) def _unexpected_systemctl(*args, **kwargs): raise AssertionError("systemctl should not be called in the Termux status view") @@ -115,7 +116,7 @@ def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_pa # --------------------------------------------------------------------------- -# Helpers shared by xAI OAuth status tests +# Helpers shared by xAI OAuth and sudo status tests # --------------------------------------------------------------------------- def _base_xai_mocks(monkeypatch, tmp_path): @@ -138,8 +139,132 @@ def _base_xai_mocks(monkeypatch, tmp_path): return status_mod +def _minimal_status_test_setup(monkeypatch, tmp_path): + import hermes_cli.gateway as gateway_mod + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr( + gateway_mod, + "get_gateway_runtime_snapshot", + lambda: (_ for _ in ()).throw(RuntimeError("skip gateway snapshot")), + raising=False, + ) + return status_mod + + +def test_show_status_reports_passwordless_sudo(monkeypatch, capsys, tmp_path): + status_mod = _minimal_status_test_setup(monkeypatch, tmp_path) + + class _Result: + returncode = 0 + + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.setattr(status_mod.shutil, "which", lambda name: "/usr/bin/sudo" if name == "sudo" else None) + monkeypatch.setattr(status_mod.subprocess, "run", lambda *args, **kwargs: _Result()) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "Sudo: ✓ enabled (non-interactive)" in output + + +def test_show_status_reports_sudo_password(monkeypatch, capsys, tmp_path): + status_mod = _minimal_status_test_setup(monkeypatch, tmp_path) + + monkeypatch.setenv("SUDO_PASSWORD", "secret") + + def _unexpected_subprocess(*args, **kwargs): + raise AssertionError("subprocess.run should not be called when SUDO_PASSWORD is set") + + monkeypatch.setattr(status_mod.subprocess, "run", _unexpected_subprocess) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "Sudo: ✓ enabled (SUDO_PASSWORD)" in output + + +def test_show_status_reports_disabled_when_passwordless_probe_fails(monkeypatch, capsys, tmp_path): + status_mod = _minimal_status_test_setup(monkeypatch, tmp_path) + + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.setattr(status_mod.shutil, "which", lambda name: "/usr/bin/sudo" if name == "sudo" else None) + + def _raise_probe_failure(*args, **kwargs): + raise OSError("sudo probe failed") + + monkeypatch.setattr(status_mod.subprocess, "run", _raise_probe_failure) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "Sudo: ✗ disabled" in output + + +def test_show_status_reports_disabled_when_passwordless_probe_returns_nonzero(monkeypatch, capsys, tmp_path): + status_mod = _minimal_status_test_setup(monkeypatch, tmp_path) + + class _Result: + returncode = 1 + + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.setattr(status_mod.shutil, "which", lambda name: "/usr/bin/sudo" if name == "sudo" else None) + monkeypatch.setattr(status_mod.subprocess, "run", lambda *args, **kwargs: _Result()) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "Sudo: ✗ disabled" in output + + +def test_show_status_reports_disabled_when_sudo_is_missing(monkeypatch, capsys, tmp_path): + status_mod = _minimal_status_test_setup(monkeypatch, tmp_path) + + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.setattr(status_mod.shutil, "which", lambda name: None) + + def _unexpected_probe(*args, **kwargs): + raise AssertionError("subprocess.run should not be called when sudo is missing") + + monkeypatch.setattr(status_mod.subprocess, "run", _unexpected_probe) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "Sudo: ✗ disabled" in output + + +def test_show_status_reports_remote_backend_sudo_as_unknown_without_host_probe(monkeypatch, capsys, tmp_path): + status_mod = _minimal_status_test_setup(monkeypatch, tmp_path) + + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.setenv("SUDO_PASSWORD", "secret") + monkeypatch.setattr( + status_mod, + "load_config", + lambda: {"model": "gpt-5.4", "terminal": {"backend": "docker"}}, + raising=False, + ) + + def _unexpected_which(*args, **kwargs): + raise AssertionError("remote backend sudo status must not inspect host sudo") + + def _unexpected_probe(*args, **kwargs): + raise AssertionError("remote backend sudo status must not probe host sudo") + + monkeypatch.setattr(status_mod.shutil, "which", _unexpected_which) + monkeypatch.setattr(status_mod.subprocess, "run", _unexpected_probe) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + output = capsys.readouterr().out + assert "Backend: docker" in output + assert "Sudo: ✗ unknown (remote backend)" in output + + class TestShowStatusXaiOAuth: - """xAI OAuth row in hermes status.""" + # ------------------------------------------------------------------ # Logged-in branch