From ac855bba0ed282ed6a1a94b8471689b2b6bf9fe4 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Thu, 23 Apr 2026 21:01:45 +1000 Subject: [PATCH] fix(cli): respect terminal.cwd config in local terminal backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init_session() runs a login shell bootstrap that sources profile scripts (.bashrc, .bash_profile, etc.) before capturing pwd. If any profile script changes the working directory, the captured cwd overwrites the configured terminal.cwd value — so terminal commands run in the wrong directory despite the TUI banner showing the configured path. Add an explicit 'builtin cd' to the configured cwd in the bootstrap script, after profile sourcing but before pwd capture, ensuring the configured terminal.cwd is always what gets recorded. Fixes #14044 --- tests/tools/test_init_session_cwd_respect.py | 148 +++++++++++++++++++ tools/environments/base.py | 5 + 2 files changed, 153 insertions(+) create mode 100644 tests/tools/test_init_session_cwd_respect.py 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 0000000000..2adce4b74e --- /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 9ca26405cf..2f565fe5f8 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" )