Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d265b1c
fix(cli): report passwordless sudo in status
rylena May 1, 2026
5cf1093
Merge remote-tracking branch 'origin/main' into pr18315-merge-refresh
rylena May 2, 2026
47a9d65
Merge remote-tracking branch 'origin/main' into pr18315-merge-refresh
rylena May 5, 2026
1f575e3
test(status): cover nonzero passwordless sudo probe
rylena May 5, 2026
cdabdd0
Merge remote-tracking branch 'origin/main' into fix/issue-18110-statu…
rylena May 9, 2026
29e2340
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 10, 2026
dcf4a85
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 14, 2026
d78b36b
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 15, 2026
394ddc1
fix(status): label sudo -n probe as non-interactive
rylena May 15, 2026
490a6fa
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 16, 2026
ecd075f
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 18, 2026
1cbd300
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 19, 2026
0b0bbc1
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 20, 2026
523b891
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 22, 2026
84394b4
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 23, 2026
1738426
Merge remote-tracking branch 'upstream/main' into fix/issue-18110-sta…
rylena May 24, 2026
4ec8098
fix(cli): avoid host sudo probe for remote status
rylena Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions hermes_cli/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This host subprocess probe needs a TERMINAL_ENV == "local" guard. tools/terminal_tool.py:680-703 deliberately forbids inheriting host sudo state for Docker/SSH/Modal/etc.; otherwise this status row can describe the host rather than the configured execution backend.

if result.returncode == 0:
return True, "enabled (non-interactive)"
return False, "disabled"


from hermes_constants import is_termux as _is_termux


Expand Down Expand Up @@ -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
Expand Down
129 changes: 127 additions & 2 deletions tests/hermes_cli/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down
Loading