Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 21 additions & 4 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -2103,6 +2103,23 @@ def _hermes_home_for_target_user(target_home_dir: str) -> str:
return str(current_hermes)


def _safe_is_dir(path: Path) -> bool:
"""Return ``True`` iff ``path`` is a directory we can stat.

Wraps :meth:`pathlib.Path.is_dir` to treat unreachable paths
(``PermissionError``, broken symlinks, network mounts that error out
on ``stat``) as "not a directory" rather than crashing the caller.
``generate_systemd_unit`` runs at install / setup time when the
target host may have a partially-locked-down ``~/.hermes`` (CI
sandboxes, multi-user systemd setups), so a stat failure must not
prevent unit generation.
"""
try:
return path.is_dir()
except OSError:
return False


def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
"""Build PATH directory list for service units, excluding non-existent dirs."""
if project_root is None:
Expand All @@ -2111,21 +2128,21 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
candidates = []

venv_bin = project_root / "venv" / "bin"
if venv_bin.is_dir():
if _safe_is_dir(venv_bin):
candidates.append(str(venv_bin))
elif sys.prefix != sys.base_prefix:
candidates.append(str(Path(sys.prefix) / "bin"))

node_bin = project_root / "node_modules" / ".bin"
if node_bin.is_dir():
if _safe_is_dir(node_bin):
candidates.append(str(node_bin))

hermes_home = get_hermes_home()
hermes_node = hermes_home / "node" / "bin"
if hermes_node.is_dir():
if _safe_is_dir(hermes_node):
candidates.append(str(hermes_node))
hermes_nm = hermes_home / "node_modules" / ".bin"
if hermes_nm.is_dir():
if _safe_is_dir(hermes_nm):
candidates.append(str(hermes_nm))

return candidates
Expand Down
52 changes: 52 additions & 0 deletions tests/hermes_cli/test_gateway_service_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,55 @@ def test_service_path_includes_hermes_home_node_modules(tmp_path):
with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"):
dirs = _build_service_path_dirs(project_root=tmp_path)
assert str(hermes_nm) in dirs


def test_service_path_treats_permission_error_as_missing(tmp_path):
"""A ``PermissionError`` from ``is_dir()`` must not crash unit
generation — treat the path as missing and continue.

Reproduces the CI baseline (#26622 audit): on locked-down Ubuntu
runners, ``stat('/root/.hermes/node/bin')`` returns ``EACCES`` from
the sandboxed filesystem layer even when the path is otherwise
reachable. Before the guard, ``generate_systemd_unit()`` and
``generate_launchd_plist()`` propagated the ``OSError`` and refused
to produce any unit at all.
"""
from hermes_cli.gateway import _build_service_path_dirs

real_is_dir = Path.is_dir
target = tmp_path / ".hermes" / "node" / "bin"

def fake_is_dir(self):
# Only the hermes_home node/bin probe trips EACCES; other paths
# must keep their real behavior so the rest of the function is
# exercised normally.
if self == target:
raise PermissionError(13, "Permission denied", str(self))
return real_is_dir(self)
Comment on lines +50 to +56

with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"), \
patch.object(Path, "is_dir", fake_is_dir):
dirs = _build_service_path_dirs(project_root=tmp_path)

assert str(target) not in dirs


def test_service_path_treats_oserror_as_missing(tmp_path):
"""Broken-symlink / unreachable network mount probes (``OSError``)
must also be treated as "directory not present" rather than crashing.
"""
from hermes_cli.gateway import _build_service_path_dirs

real_is_dir = Path.is_dir
target = tmp_path / ".hermes" / "node_modules" / ".bin"

def fake_is_dir(self):
if self == target:
raise OSError(5, "Input/output error", str(self))
return real_is_dir(self)

with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"), \
patch.object(Path, "is_dir", fake_is_dir):
dirs = _build_service_path_dirs(project_root=tmp_path)

assert str(target) not in dirs
Loading