Files
hermes-agent/tests/agent/lsp/test_root_policy.py
kshitijk4poor e929702021 fix(lsp): trust the workspace the session was opened in, not only the process cwd
The trust anchor was os.getcwd(), which is the user's project only for a
plain `hermes` launched inside a repo. `hermes -w` sets TERMINAL_CWD to its
new worktree without a chdir, the Desktop/TUI backend runs from the install
tree or $HOME with the project bound as the session cwd, the gateway runs
from HERMES_HOME, and cron binds a per-job workdir. All of them lost
pyright's project venv, rust-analyzer/gopls/jdtls/... and the .ts/.rs lint
fallbacks inside the user's own repository.

operator_workspace_roots() now adds the worktree of resolve_agent_cwd()
(session cwd, then TERMINAL_CWD) to the launch dir's; only surfaces set
those, the agent's `cd` moves the terminal env's cwd, not them. $HOME is
never an anchor: a dotfiles repo there would trust every directory below it.
The service remembers every anchor it has seen, because the loop thread
that spawns servers has no session context of its own.

- The lint gate checks only the linter's cwd: npx and rustup resolve the
  toolchain from there (subprocess cwd=env.cwd), not from the file's dir.
  The try/except that wrapped code which cannot fail is gone.
- Trust is computed once per spawn and handed to ServerContext.
- Multi-root servers (pyright) share one process across trusted roots
  only; an untrusted root gets its own, so whichever root spawned first no
  longer decides the interpreter for the others.
2026-09-29 03:28:46 +05:30

122 lines
4.6 KiB
Python

"""Per-root policy knobs of :class:`LSPService` (#116446): ``lsp.exclude_roots`` gates a workspace off
without disabling the server fleet-wide, and ``lsp.warmup_timeout`` gives a cold root one long wait
instead of the steady-state budget. Both are driven through ``create_from_config`` and the production
pre-write / post-write entry points, never through the helpers.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from agent.lsp import eventlog
from agent.lsp.manager import LSPService, _client_key
from agent.lsp.servers import find_server_for_file
from agent.lsp.workspace import clear_cache
@pytest.fixture(autouse=True)
def _clear_caches():
clear_cache()
eventlog.reset_announce_caches()
yield
clear_cache()
def _git_repo(tmp_path: Path, name: str) -> Path:
repo = tmp_path / name
repo.mkdir()
(repo / ".git").mkdir()
(repo / "pyproject.toml").write_text("[project]\nname='t'\n", encoding="utf-8")
(repo / "x.py").write_text("", encoding="utf-8")
return repo
def _service_from(lsp_cfg: dict) -> LSPService:
with patch("hermes_cli.config.load_config_readonly", return_value={"lsp": lsp_cfg}):
svc = LSPService.create_from_config()
assert svc is not None
return svc
def test_exclude_roots_skips_matching_root_only(tmp_path, monkeypatch, caplog):
"""A root matching ``lsp.exclude_roots`` never reaches the server — the pre-write snapshot does not
spawn or wait — while a sibling workspace keeps its diagnostics. A non-list value fails closed
(every root skipped) with a WARNING naming the expected shape instead of silently excluding nothing."""
big = _git_repo(tmp_path, "big")
small = _git_repo(tmp_path, "small")
monkeypatch.chdir(str(tmp_path))
svc = _service_from({"wait_timeout": 1.0, "install_strategy": "manual",
"exclude_roots": [str(tmp_path / "bi*")]})
try:
async def must_not_run(*_a, **_k):
raise AssertionError("excluded root reached the server")
with patch.object(svc, "_snapshot_async", must_not_run), caplog.at_level("INFO", logger=eventlog.event_log.name):
svc.snapshot_baseline(str(big / "x.py"))
assert svc.enabled_for(str(big / "x.py")) is False
assert svc.enabled_for(str(small / "x.py")) is True
assert any("exclude_roots" in r.getMessage() and str(big) in r.getMessage() for r in caplog.records)
finally:
svc.shutdown()
with caplog.at_level("WARNING", logger=eventlog.event_log.name):
bad = _service_from({"install_strategy": "manual", "exclude_roots": str(big)})
try:
assert bad.enabled_for(str(small / "x.py")) is False
warned = [r for r in caplog.records if r.levelno >= 30 and "exclude_roots" in r.getMessage()]
assert warned and "list" in warned[0].getMessage() and "str" in warned[0].getMessage()
finally:
bad.shutdown()
def test_warmup_timeout_applies_to_cold_root_only(tmp_path, monkeypatch):
"""The first request against a root with no running client waits up to ``lsp.warmup_timeout``;
once the client is up the steady-state ``wait_timeout`` applies again (#116446: a 55 s cold build
was being cut off at the 5 s steady-state budget, then the root was poisoned)."""
repo = _git_repo(tmp_path, "repo")
src = repo / "x.py"
monkeypatch.chdir(str(repo))
svc = _service_from({"wait_timeout": 1.0, "warmup_timeout": 30.0, "install_strategy": "manual"})
budgets: list = []
class FakeClient:
server_id = "pyright"
workspace_root = str(repo)
workspace_folders = (str(repo),)
state = "running"
is_running = True
async def open_file(self, *_a, **_k):
return 1
async def save_file(self, *_a, **_k):
return None
async def wait_for_diagnostics(self, *_a, timeout=None, **_k):
budgets.append(timeout)
return True
def diagnostics_for(self, *_a, **_k):
return []
async def shutdown(self):
return None
fake = FakeClient()
async def spawn(_path):
return fake
try:
with patch.object(svc, "_get_or_spawn", spawn):
svc.snapshot_baseline(str(src)) # cold: no client registered for the root yet
key = _client_key(find_server_for_file(str(src)), str(repo), svc._trusted(str(repo)))
with svc._state_lock:
svc._clients[key] = fake
assert svc.get_diagnostics_sync(str(src)) == [] # warm
finally:
svc.shutdown()
assert budgets == [30.0, 1.0]