diff --git a/tests/tools/test_init_session_cwd_respect.py b/tests/tools/test_init_session_cwd_respect.py new file mode 100644 index 00000000000..2adce4b74e3 --- /dev/null +++ b/tests/tools/test_init_session_cwd_respect.py @@ -0,0 +1,148 @@ +"""Tests that init_session() respects the configured cwd. + +The bug: when terminal.cwd is set in config.yaml, the configured path was +displayed in the TUI banner but actual terminal commands ran in os.getcwd() +(the directory where ``hermes chat`` was started). + +Root cause: init_session() captures the login shell environment by running +``pwd -P`` inside a ``bash -l -c`` bootstrap. Profile scripts (.bashrc, +.bash_profile, etc.) can change the working directory before ``pwd -P`` +runs, so _update_cwd() overwrites self.cwd with the wrong directory. + +Fix: the bootstrap now includes an explicit ``cd`` back to self.cwd before +running ``pwd -P``, so the configured cwd is always what gets recorded. +""" + +from tempfile import TemporaryFile +from unittest.mock import MagicMock + +from tools.environments.base import BaseEnvironment + + +class _TestableEnv(BaseEnvironment): + """Concrete subclass for testing base class methods.""" + + def __init__(self, cwd="/tmp", timeout=10): + super().__init__(cwd=cwd, timeout=timeout) + + def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None): + raise NotImplementedError("Use mock") + + def cleanup(self): + pass + + +class TestInitSessionCwdRespect: + """init_session() must preserve the configured cwd.""" + + def test_bootstrap_contains_cd_to_configured_cwd(self): + """The bootstrap script must cd to self.cwd before running pwd.""" + env = _TestableEnv(cwd="/my/project") + + # Capture the bootstrap script that init_session would pass to _run_bash + captured = {} + + def mock_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): + captured["cmd"] = cmd_string + mock = MagicMock() + mock.poll.return_value = 0 + mock.returncode = 0 + stdout = TemporaryFile(mode="w+b") + stdout.seek(0) + mock.stdout = stdout + return mock + + env._run_bash = mock_run_bash + env.init_session() + + assert "cmd" in captured, "init_session did not call _run_bash" + bootstrap = captured["cmd"] + + # The cd must appear before pwd -P so the configured cwd is recorded + cd_pos = bootstrap.find("builtin cd") + pwd_pos = bootstrap.find("pwd -P") + assert cd_pos != -1, "bootstrap must contain 'builtin cd'" + assert pwd_pos != -1, "bootstrap must contain 'pwd -P'" + assert cd_pos < pwd_pos, ( + "builtin cd must appear before pwd -P in the bootstrap so " + "the configured cwd is what gets recorded" + ) + + # The cd target must be the configured path (shlex.quote only adds + # quotes when the path contains shell-special characters) + assert "/my/project" in bootstrap, ( + "bootstrap cd must target the configured cwd (/my/project)" + ) + + def test_configured_cwd_survives_init_session(self): + """self.cwd must be the configured path after init_session completes.""" + configured_cwd = "/my/project" + env = _TestableEnv(cwd=configured_cwd) + + marker = env._cwd_marker + + def mock_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): + mock = MagicMock() + mock.poll.return_value = 0 + mock.returncode = 0 + # Simulate output where pwd reports the configured cwd + output = f"snapshot output\n{marker}{configured_cwd}{marker}\n" + stdout = TemporaryFile(mode="w+b") + stdout.write(output.encode("utf-8")) + stdout.seek(0) + mock.stdout = stdout + return mock + + env._run_bash = mock_run_bash + env.init_session() + + assert env.cwd == configured_cwd, ( + f"Expected cwd={configured_cwd!r} after init_session, got {env.cwd!r}" + ) + + def test_default_cwd_still_works(self): + """When no custom cwd is configured, default /tmp behavior is preserved.""" + env = _TestableEnv() # default cwd="/tmp" + + marker = env._cwd_marker + + def mock_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): + mock = MagicMock() + mock.poll.return_value = 0 + mock.returncode = 0 + output = f"snapshot output\n{marker}/tmp{marker}\n" + stdout = TemporaryFile(mode="w+b") + stdout.write(output.encode("utf-8")) + stdout.seek(0) + mock.stdout = stdout + return mock + + env._run_bash = mock_run_bash + env.init_session() + + assert env.cwd == "/tmp" + + def test_bootstrap_cd_uses_shlex_quote(self): + """Paths with spaces must be properly quoted in the bootstrap cd.""" + env = _TestableEnv(cwd="/my project/with spaces") + + captured = {} + + def mock_run_bash(cmd_string, *, login=False, timeout=120, stdin_data=None): + captured["cmd"] = cmd_string + mock = MagicMock() + mock.poll.return_value = 0 + mock.returncode = 0 + stdout = TemporaryFile(mode="w+b") + stdout.seek(0) + mock.stdout = stdout + return mock + + env._run_bash = mock_run_bash + env.init_session() + + bootstrap = captured["cmd"] + # shlex.quote wraps paths with spaces in single quotes + assert "'/my project/with spaces'" in bootstrap, ( + "bootstrap cd must properly quote paths with spaces" + ) diff --git a/tools/environments/base.py b/tools/environments/base.py index 9ca26405cf5..2f565fe5f87 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -335,6 +335,10 @@ class BaseEnvironment(ABC): instead of running with ``bash -l``. """ # Full capture: env vars, functions (filtered), aliases, shell options. + # Restore configured cwd after login shell profile scripts, which may + # change the working directory (e.g. bashrc `cd ~`). Without this, + # pwd -P captures the profile's directory, not terminal.cwd. + _quoted_cwd = shlex.quote(self.cwd) bootstrap = ( f"export -p > {self._snapshot_path}\n" f"declare -f | grep -vE '^_[^_]' >> {self._snapshot_path}\n" @@ -342,6 +346,7 @@ class BaseEnvironment(ABC): f"echo 'shopt -s expand_aliases' >> {self._snapshot_path}\n" f"echo 'set +e' >> {self._snapshot_path}\n" f"echo 'set +u' >> {self._snapshot_path}\n" + f"builtin cd {_quoted_cwd} 2>/dev/null || true\n" f"pwd -P > {self._cwd_file} 2>/dev/null || true\n" f"printf '\\n{self._cwd_marker}%s{self._cwd_marker}\\n' \"$(pwd -P)\"\n" )