mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
Follow-up on the salvaged #64611 commit: the original guard blocked the install tree unconditionally, which would have broken the legitimate 'developing Hermes from a source clone' CLI flow (launching hermes inside the repo and getting its AGENTS.md as project context). Refined policy: - resolve_context_cwd(): validates configured paths (missing dir -> None + warning) but honors an EXPLICIT install-tree cwd verbatim — deliberate user choice. - build_context_files_prompt(): blocks only the cwd=None -> os.getcwd() FALLBACK into the install tree, with a new allow_install_tree_fallback param. system_prompt.py passes it for platform cli/tui (launch dir is the user's real shell cwd there); desktop/gateway surfaces keep the guard (their fallback dir is self-spawned, never user-picked). - Warning log names the resolved dir and the terminal.cwd remedy. E2E-verified all five scenarios: desktop fallback blocked, in-tree CLI dev keeps AGENTS.md, explicit install-tree cwd honored, invalid TERMINAL_CWD falls to None then blocked, normal workspace loads.
138 lines
5.7 KiB
Python
138 lines
5.7 KiB
Python
"""Tests for agent/runtime_cwd.py — the single source of truth for the agent working directory."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import agent.runtime_cwd as rt
|
|
from agent.runtime_cwd import (
|
|
clear_session_cwd,
|
|
resolve_agent_cwd,
|
|
resolve_context_cwd,
|
|
set_session_cwd,
|
|
)
|
|
|
|
|
|
def _raise_oserror(*args, **kwargs):
|
|
raise OSError("cwd gone")
|
|
|
|
|
|
class TestResolveAgentCwd:
|
|
def test_prefers_terminal_cwd_over_getcwd(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
monkeypatch.chdir(os.path.expanduser("~"))
|
|
assert resolve_agent_cwd() == tmp_path
|
|
|
|
def test_falls_back_to_getcwd_when_unset(self, monkeypatch, tmp_path):
|
|
# The #19242 local-CLI contract: TERMINAL_CWD is unset, so the launch dir wins.
|
|
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
assert resolve_agent_cwd() == tmp_path
|
|
|
|
def test_skips_nonexistent_terminal_cwd(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "gone"))
|
|
monkeypatch.chdir(tmp_path)
|
|
assert resolve_agent_cwd() == tmp_path
|
|
|
|
def test_expands_leading_tilde(self, monkeypatch):
|
|
monkeypatch.setenv("TERMINAL_CWD", "~")
|
|
assert resolve_agent_cwd() == Path(os.path.expanduser("~"))
|
|
|
|
def test_whitespace_only_terminal_cwd_falls_back_to_getcwd(self, monkeypatch, tmp_path):
|
|
# " ".strip() → "" → falsy, so the launch dir wins (not a " " path).
|
|
monkeypatch.setenv("TERMINAL_CWD", " ")
|
|
monkeypatch.chdir(tmp_path)
|
|
assert resolve_agent_cwd() == tmp_path
|
|
|
|
def test_propagates_oserror_from_getcwd(self, monkeypatch):
|
|
# The fallback arm calls os.getcwd(), which can raise OSError (deleted cwd).
|
|
# The resolver must NOT swallow it — build_environment_hints owns the
|
|
# try/except OSError guard at the call site (prompt_builder.py:805).
|
|
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
|
monkeypatch.setattr(rt.os, "getcwd", _raise_oserror)
|
|
with pytest.raises(OSError):
|
|
resolve_agent_cwd()
|
|
|
|
|
|
class TestResolveContextCwd:
|
|
def test_returns_dir_when_set(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
assert resolve_context_cwd() == tmp_path
|
|
|
|
def test_returns_none_when_unset(self, monkeypatch):
|
|
# Unset → None; the caller (build_context_files_prompt) then getcwds —
|
|
# the local-CLI #19242 contract. Discovery still runs; it is NOT skipped.
|
|
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
|
assert resolve_context_cwd() is None
|
|
|
|
def test_returns_none_for_nonexistent_dir(self, monkeypatch, tmp_path):
|
|
# A configured but missing dir must not be returned. It previously was,
|
|
# which diverged from resolve_agent_cwd and let an invalid cwd steer
|
|
# context discovery. Now it is validated and drops to None.
|
|
missing = tmp_path / "gone"
|
|
monkeypatch.setenv("TERMINAL_CWD", str(missing))
|
|
assert resolve_context_cwd() is None
|
|
|
|
def test_returns_install_tree_when_explicitly_configured(self, monkeypatch):
|
|
# An EXPLICITLY configured install-tree cwd is honored verbatim — the
|
|
# Hermes source tree is a legitimate workspace when the user is
|
|
# developing Hermes. Only the fallback path (cwd=None → os.getcwd())
|
|
# is policed, in build_context_files_prompt (#64590).
|
|
monkeypatch.setenv("TERMINAL_CWD", str(rt._PACKAGE_ROOT))
|
|
assert resolve_context_cwd() == rt._PACKAGE_ROOT
|
|
|
|
def test_expands_leading_tilde(self, monkeypatch):
|
|
monkeypatch.setenv("TERMINAL_CWD", "~")
|
|
assert resolve_context_cwd() == Path(os.path.expanduser("~"))
|
|
|
|
def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch):
|
|
# " ".strip() → "" → None, so the caller getcwds for discovery rather
|
|
# than building Path(" ") and resolving garbage under the launch dir.
|
|
monkeypatch.setenv("TERMINAL_CWD", " ")
|
|
assert resolve_context_cwd() is None
|
|
|
|
|
|
class TestSessionCwdOverride:
|
|
"""The #29531 per-session arm: a contextvar cwd wins over TERMINAL_CWD so a
|
|
multi-session gateway can pin each session to its own folder."""
|
|
|
|
def test_session_cwd_overrides_terminal_cwd(self, monkeypatch, tmp_path):
|
|
other = tmp_path / "other"
|
|
other.mkdir()
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
token = set_session_cwd(str(other))
|
|
try:
|
|
assert resolve_agent_cwd() == other
|
|
assert resolve_context_cwd() == other
|
|
finally:
|
|
rt._SESSION_CWD.reset(token)
|
|
|
|
def test_empty_session_cwd_falls_back_to_terminal_cwd(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
token = set_session_cwd("")
|
|
try:
|
|
assert resolve_agent_cwd() == tmp_path
|
|
assert resolve_context_cwd() == tmp_path
|
|
finally:
|
|
rt._SESSION_CWD.reset(token)
|
|
|
|
def test_clear_session_cwd_restores_terminal_cwd(self, monkeypatch, tmp_path):
|
|
other = tmp_path / "other"
|
|
other.mkdir()
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
token = set_session_cwd(str(other))
|
|
try:
|
|
clear_session_cwd()
|
|
assert resolve_agent_cwd() == tmp_path
|
|
finally:
|
|
rt._SESSION_CWD.reset(token)
|
|
|
|
def test_nonexistent_session_cwd_falls_back(self, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
|
token = set_session_cwd(str(tmp_path / "gone"))
|
|
try:
|
|
# resolve_agent_cwd guards on isdir; a missing session cwd must not win.
|
|
assert resolve_agent_cwd() == tmp_path
|
|
finally:
|
|
rt._SESSION_CWD.reset(token)
|