From 9621a30ce499a8c6194687be789ad3382cf2edb8 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sat, 13 Jun 2026 13:46:23 +0800 Subject: [PATCH] fix(agent): catch RuntimeError from Path.expanduser/home when $HOME is unset Python 3.11+ raises RuntimeError (not OSError/ValueError) from Path.expanduser() and Path.home() when the $HOME environment variable is not set. The except clauses in SubdirectoryHintTracker only caught (OSError, ValueError), so the RuntimeError propagated up and crashed the agent conversation loop. Add RuntimeError to all four except clauses: - _add_path_candidate: expanduser() call (line 147) - _is_valid_subdir: is_relative_to() call (line 191) - _load_hints_for_directory: is_relative_to() call (line 213) - relative-path fallback: Path.home() call (line 248) Fixes #45401 --- agent/subdirectory_hints.py | 8 +- .../test_subdirectory_hints_runtime_error.py | 112 ++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/agent/test_subdirectory_hints_runtime_error.py diff --git a/agent/subdirectory_hints.py b/agent/subdirectory_hints.py index 858807aba2d4..4ee52cd263c7 100644 --- a/agent/subdirectory_hints.py +++ b/agent/subdirectory_hints.py @@ -144,7 +144,7 @@ def _add_path_candidate(self, raw_path: str, candidates: Set[Path]): if parent == p: break # filesystem root p = parent - except (OSError, ValueError): + except (OSError, ValueError, RuntimeError): pass def _extract_paths_from_command(self, cmd: str, candidates: Set[Path]): @@ -188,7 +188,7 @@ def _is_valid_subdir(self, path: Path) -> bool: try: if not path.is_relative_to(self.working_dir): return False - except (OSError, ValueError): + except (OSError, ValueError, RuntimeError): # Older Python or path resolution error — fall back to parent # check as a best-effort safeguard. if not _is_ancestor_or_same(self.working_dir, path): @@ -210,7 +210,7 @@ def _load_hints_for_directory(self, directory: Path) -> Optional[str]: directory, self.working_dir, ) return None - except (OSError, ValueError): + except (OSError, ValueError, RuntimeError): if not _is_ancestor_or_same(self.working_dir, directory): logger.debug( "Skipping hint files in %s — outside working_dir %s", @@ -245,7 +245,7 @@ def _load_hints_for_directory(self, directory: Path) -> Optional[str]: try: rel_path = str(hint_path.relative_to(Path.home())) rel_path = "~/" + rel_path - except ValueError: + except (ValueError, RuntimeError): pass # keep absolute found_hints.append((rel_path, content)) # First match wins per directory (like startup loading) diff --git a/tests/agent/test_subdirectory_hints_runtime_error.py b/tests/agent/test_subdirectory_hints_runtime_error.py new file mode 100644 index 000000000000..97eca24c6e05 --- /dev/null +++ b/tests/agent/test_subdirectory_hints_runtime_error.py @@ -0,0 +1,112 @@ +"""Tests for agent.subdirectory_hints RuntimeError guards. + +Python 3.11+ raises RuntimeError (not OSError/ValueError) from +Path.expanduser() and Path.home() when $HOME is unset. These tests +verify that the hint tracker handles the missing-$HOME case gracefully +instead of crashing the agent conversation loop. + +Regression test for https://github.com/NousResearch/hermes-agent/issues/45401 +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from agent.subdirectory_hints import SubdirectoryHintTracker + + +@pytest.fixture() +def tracker(tmp_path: Path) -> SubdirectoryHintTracker: + """Create a tracker rooted in a temporary directory.""" + return SubdirectoryHintTracker(working_dir=tmp_path) + + +# --------------------------------------------------------------------------- +# _add_path_candidate — expanduser() RuntimeError +# --------------------------------------------------------------------------- + +class TestAddPathCandidateRuntimeError: + """expanduser() raises RuntimeError when $HOME is unset (Python 3.11+).""" + + def test_expanduser_runtime_error_gracefully_skipped( + self, tracker: SubdirectoryHintTracker, tmp_path: Path + ) -> None: + """_add_path_candidate must not crash when expanduser() raises RuntimeError.""" + candidates: set[Path] = set() + + with patch("pathlib.Path.expanduser", side_effect=RuntimeError("Could not determine home directory.")): + # Should not raise — RuntimeError is caught + tracker._add_path_candidate("~/some/path", candidates) + + # Candidate set should be empty (path was skipped) + assert candidates == set() + + +# --------------------------------------------------------------------------- +# _is_valid_subdir — is_relative_to() RuntimeError +# --------------------------------------------------------------------------- + +class TestIsValidSubdirRuntimeError: + """is_relative_to() may raise RuntimeError on some Python builds.""" + + def test_is_relative_to_runtime_error_returns_false( + self, tracker: SubdirectoryHintTracker, tmp_path: Path + ) -> None: + """_is_valid_subdir must return False (not crash) when is_relative_to raises RuntimeError.""" + # Use a path outside the working directory so _is_ancestor_or_same + # also returns False after the RuntimeError fallback. + outside = Path("/tmp/outside-workdir-subdir") + outside.mkdir(exist_ok=True) + try: + with patch.object(Path, "is_relative_to", side_effect=RuntimeError("Could not determine home directory.")): + result = tracker._is_valid_subdir(outside) + finally: + outside.rmdir() + + assert result is False + + +# --------------------------------------------------------------------------- +# _load_hints_for_directory — is_relative_to() + Path.home() RuntimeError +# --------------------------------------------------------------------------- + +class TestLoadHintsForDirectoryRuntimeError: + """_load_hints_for_directory must not crash when $HOME is unset.""" + + def test_is_relative_to_runtime_error_returns_none( + self, tracker: SubdirectoryHintTracker, tmp_path: Path + ) -> None: + """When is_relative_to raises RuntimeError, returns None (skip directory).""" + subdir = tmp_path / "project" + subdir.mkdir() + + with patch.object(Path, "is_relative_to", side_effect=RuntimeError("Could not determine home directory.")): + result = tracker._load_hints_for_directory(subdir) + + assert result is None + + def test_path_home_runtime_error_in_relative_path_fallback( + self, tracker: SubdirectoryHintTracker, tmp_path: Path + ) -> None: + """When Path.home() raises RuntimeError in the relative-path fallback, hint is still returned.""" + subdir = tmp_path / "project" + subdir.mkdir() + hint_file = subdir / "AGENTS.md" + hint_file.write_text("# Test hint\n") + + original_home = Path.home + + def _no_home(): + raise RuntimeError("Could not determine home directory.") + + with patch.object(Path, "home", side_effect=_no_home): + result = tracker._load_hints_for_directory(subdir) + + # Hint should still be loaded — the RuntimeError only affects the + # display path fallback, not the actual content. + assert result is not None + assert "Test hint" in result