mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(learn): treat unreadable candidate paths as absent in project decode (#2446)
## Description `headroom learn` crashes with an uncaught `PermissionError` when the current user's username contains a dash. `_decode_project_path` (in `headroom/learn/plugins/claude.py`) probes speculative candidate paths when reconstructing an original filesystem path from a Claude Code encoded project directory name. When the username is e.g. `marco-rocha`, one candidate becomes `/home/marco/rocha`, which can collide with another user's home directory whose parent isn't stat-able. `Path.exists()` calls `os.stat` internally, raising `PermissionError` instead of returning `False`, so the whole `learn` command crashes before returning any recommendations. Fixes #2443 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add `_path_exists()` to `headroom/learn/plugins/claude.py` — a thin wrapper around `Path.exists()` that returns `False` on any `OSError` (including `PermissionError`), mirroring the existing `OSError` handling already used in `_greedy_path_decode`. - Route every speculative candidate-path existence check in the decode path through `_path_exists()`: the Windows drive/path probes in `_decode_windows_path`, the `simple` POSIX candidate and greedy-branch bases in `_decode_project_path`/`_greedy_path_decode`, and the decoded `project_path`/`CLAUDE.md` checks in `discover_projects`. - Add regression tests covering the exact issue shape (`PermissionError` on `/home/marco/rocha`) and the `_path_exists` helper directly. - Leave `CHANGELOG.md` untouched — release-please generates it from conventional commits. ## Testing - [x] Unit tests pass (`python -m pytest tests/test_learn/test_scanner.py::TestDecodePermissionError -q`) - [x] Linting passes (`ruff check`, `ruff format --check` on the two changed files) - [x] New tests added for new functionality ### Test Output ```text $ python -m pytest tests/test_learn/test_scanner.py::TestDecodePermissionError -q collected 2 items tests\test_learn\test_scanner.py .. [100%] 2 passed in 1.86s $ ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.13, local dev checkout of headroom on branch off upstream/main - Exact command / steps: Simulated the issue by monkeypatching `Path.exists` to raise `PermissionError` for the colliding candidate `/home/marco/rocha`, then calling `_decode_project_path("-home-marco-rocha-butterfly-sylphina")` - Observed result: Before the fix the call propagates `PermissionError` (crash, matching the reported traceback); after the fix it returns without raising and the unreadable candidate is treated as non-existent. Both regression tests pass. - Not tested: End-to-end `headroom learn --apply` on a real Linux multi-user box with an actually unreadable `/home/<prefix>` — reproduced via the documented minimal logic instead. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3e976712e7
commit
a09ba6c087
2 changed files with 63 additions and 7 deletions
|
|
@ -82,9 +82,9 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner):
|
|||
name = _project_display_name(project_path, entry.name)
|
||||
|
||||
context_file = None
|
||||
if project_path.exists():
|
||||
if _path_exists(project_path):
|
||||
claude_md = project_path / "CLAUDE.md"
|
||||
if claude_md.exists():
|
||||
if _path_exists(claude_md):
|
||||
context_file = claude_md
|
||||
|
||||
memory_dir = entry / "memory"
|
||||
|
|
@ -368,6 +368,23 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner):
|
|||
# =============================================================================
|
||||
|
||||
|
||||
def _path_exists(path: Path) -> bool:
|
||||
"""Like ``Path.exists()`` but treats an unreadable path as absent.
|
||||
|
||||
``_decode_project_path`` probes speculative candidate paths (e.g.
|
||||
``/home/marco/rocha`` when reconstructing ``/home/marco-rocha/...``). A
|
||||
candidate can collide with another user's directory whose parent isn't
|
||||
stat-able, and ``Path.exists()`` calls ``os.stat`` which then raises
|
||||
``PermissionError`` instead of returning ``False`` — crashing the whole
|
||||
``learn`` command (issue #2443). Match ``_greedy_path_decode``'s existing
|
||||
``OSError`` handling and treat any such error as "does not exist".
|
||||
"""
|
||||
try:
|
||||
return path.exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _decode_windows_path(drive: str, parts: list[str]) -> Path | None:
|
||||
"""Reconstruct a Windows path from drive letter + dash-split tokens.
|
||||
|
||||
|
|
@ -378,10 +395,10 @@ def _decode_windows_path(drive: str, parts: list[str]) -> Path | None:
|
|||
if not tokens:
|
||||
return None
|
||||
win_path = Path(f"{drive}:\\" + "\\".join(tokens))
|
||||
if win_path.exists():
|
||||
if _path_exists(win_path):
|
||||
return win_path
|
||||
drive_root = Path(f"{drive}:\\")
|
||||
if drive_root.exists():
|
||||
if _path_exists(drive_root):
|
||||
result = _greedy_path_decode(drive_root, tokens)
|
||||
if result:
|
||||
return result
|
||||
|
|
@ -411,7 +428,7 @@ def _decode_project_path(escaped_name: str) -> Path | None:
|
|||
return None
|
||||
|
||||
simple = Path("/" + escaped_name[1:].replace("-", "/"))
|
||||
if simple.exists():
|
||||
if _path_exists(simple):
|
||||
return simple
|
||||
|
||||
if len(parts) < 3:
|
||||
|
|
@ -445,9 +462,9 @@ def _project_display_name(project_path: Path, fallback: str) -> str:
|
|||
def _greedy_path_decode(base: Path, parts: list[str]) -> Path | None:
|
||||
"""Greedily decode remaining path parts using real child directories."""
|
||||
if not parts:
|
||||
return base if base.exists() else None
|
||||
return base if _path_exists(base) else None
|
||||
|
||||
if not base.exists() or not base.is_dir():
|
||||
if not _path_exists(base) or not base.is_dir():
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -573,3 +573,42 @@ class TestDecodeProjectPath:
|
|||
assert result == project
|
||||
# The home component is reconstructed whole, never split on a separator.
|
||||
assert home.name in result.parts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PermissionError on speculative candidate paths (issue #2443)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDecodePermissionError:
|
||||
"""A candidate path that raises PermissionError must not crash decode.
|
||||
|
||||
When the username contains a dash (``marco-rocha``), decoding
|
||||
``-home-marco-rocha-butterfly-sylphina`` probes candidates like
|
||||
``/home/marco/rocha``. If ``/home/marco`` is another user's unreadable
|
||||
directory, ``Path.exists()`` raises ``PermissionError`` from ``os.stat``
|
||||
rather than returning ``False`` — this used to crash ``headroom learn``.
|
||||
"""
|
||||
|
||||
def test_permission_error_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
real_exists = Path.exists
|
||||
|
||||
def fake_exists(self: Path, *args: object, **kwargs: object) -> bool:
|
||||
if str(self) == "/home/marco/rocha":
|
||||
raise PermissionError(13, "Permission denied", str(self))
|
||||
return real_exists(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "exists", fake_exists)
|
||||
|
||||
# Must not raise; the unreadable candidate is treated as non-existent.
|
||||
result = _decode_project_path("-home-marco-rocha-butterfly-sylphina")
|
||||
assert result is None or isinstance(result, Path)
|
||||
|
||||
def test_path_exists_swallows_oserror(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from headroom.learn.plugins.claude import _path_exists
|
||||
|
||||
def boom(self: Path, *args: object, **kwargs: object) -> bool:
|
||||
raise PermissionError(13, "Permission denied", str(self))
|
||||
|
||||
monkeypatch.setattr(Path, "exists", boom)
|
||||
assert _path_exists(Path("/home/marco")) is False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue