diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index a865bcaf8be2e..9881447d3a8b3 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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: @@ -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 diff --git a/tests/hermes_cli/test_gateway_service_paths.py b/tests/hermes_cli/test_gateway_service_paths.py index 71abc4aef2400..8fb112badcfe7 100644 --- a/tests/hermes_cli/test_gateway_service_paths.py +++ b/tests/hermes_cli/test_gateway_service_paths.py @@ -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) + + 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