diff --git a/tests/tools/test_local_env_relative_cwd.py b/tests/tools/test_local_env_relative_cwd.py new file mode 100644 index 000000000000..88bfec085da3 --- /dev/null +++ b/tests/tools/test_local_env_relative_cwd.py @@ -0,0 +1,52 @@ +"""Regression tests for local terminal initial cwd normalization.""" + +from pathlib import Path + +from tools.environments.local import LocalEnvironment, _resolve_local_initial_cwd + + +def test_relative_initial_cwd_resolves_from_parent(tmp_path, monkeypatch): + project = tmp_path / "hermes-agent" + project.mkdir() + monkeypatch.chdir(tmp_path) + + assert _resolve_local_initial_cwd("hermes-agent") == str(project) + + +def test_relative_initial_cwd_matching_current_dir_uses_current_dir(tmp_path, monkeypatch): + project = tmp_path / "hermes-agent" + project.mkdir() + monkeypatch.chdir(project) + + assert _resolve_local_initial_cwd("hermes-agent") == str(project) + + +def test_local_environment_does_not_cd_into_nested_matching_relative_cwd(tmp_path, monkeypatch): + project = tmp_path / "hermes-agent" + project.mkdir() + monkeypatch.chdir(project) + + env = LocalEnvironment(cwd="hermes-agent", timeout=5) + try: + result = env.execute("pwd", timeout=5) + finally: + env.cleanup() + + assert result["returncode"] == 0 + assert result["output"].strip() == str(project) + assert "cd: hermes-agent" not in result["output"] + + +def test_local_environment_keeps_existing_relative_child_cwd(tmp_path, monkeypatch): + project = tmp_path / "hermes-agent" + project.mkdir() + monkeypatch.chdir(tmp_path) + + env = LocalEnvironment(cwd="hermes-agent", timeout=5) + try: + result = env.execute("pwd", timeout=5) + finally: + env.cleanup() + + assert result["returncode"] == 0 + assert result["output"].strip() == str(project) diff --git a/tools/environments/local.py b/tools/environments/local.py index 0a867fa984bd..acb390df4d9b 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -48,6 +48,40 @@ def _msys_to_windows_path(cwd: str) -> str: return f"{drive}:{tail or chr(92)}" # chr(92) = backslash, avoid raw-string escape +def _resolve_local_initial_cwd(cwd: str) -> str: + """Resolve the local backend's initial cwd to an absolute host path. + + ``TERMINAL_CWD`` can be populated from config.yaml before the terminal + backend is created. If that value is relative and happens to match the + directory Hermes was already launched from (for example ``hermes-agent`` + while the process cwd is ``~/.hermes/hermes-agent``), passing it through + unchanged makes the wrapper run ``cd hermes-agent`` *inside* the project + and fail with a confusing nested-path error. Anchor relative local cwd + values once, up front, so both ``subprocess.Popen(cwd=...)`` and the + in-shell ``cd`` use the same absolute directory. + """ + expanded = os.path.expanduser(cwd) if cwd else os.getcwd() + if _IS_WINDOWS: + expanded = _msys_to_windows_path(expanded) + if os.path.isabs(expanded): + return expanded + + candidate = os.path.abspath(expanded) + current = os.getcwd() + + # Common recovery for config values like ``hermes-agent`` when Hermes was + # launched from that directory already. ``os.path.abspath`` would point at + # a nonexistent nested ``./hermes-agent``; use the current directory instead. + if not os.path.isdir(candidate): + wanted_parts = Path(expanded).parts + current_parts = Path(current).parts + if wanted_parts and len(wanted_parts) <= len(current_parts): + if current_parts[-len(wanted_parts):] == wanted_parts: + return current + + return candidate + + def _windows_to_msys_path(cwd: str) -> str: """Translate a native Windows path (``C:\\Users\\x``) to Git Bash / MSYS form (``/c/Users/x``) so ``builtin cd`` resolves it reliably. @@ -1102,9 +1136,8 @@ class LocalEnvironment(BaseEnvironment): """ def __init__(self, cwd: str = "", timeout: int = 60, env: dict = None): - if cwd: - cwd = os.path.expanduser(cwd) - super().__init__(cwd=cwd or os.getcwd(), timeout=timeout, env=env) + cwd = _resolve_local_initial_cwd(cwd) + super().__init__(cwd=cwd, timeout=timeout, env=env) self.init_session() def get_temp_dir(self) -> str: