Session-scoped working directory tracking and fixes
NousResearch/hermes-agent · cluster 10309 · agent
Data as of
9 open items. Fix merged in #65213. Check it covers your case.
The short answer
- Known?
- Yes. 23 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #65213. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #65487. That is separate from the fix event. - Still open
- 5 issues and 4 PRs.
Next step: read #65213, then check whether your report is one of the 5 still open.
Read #65213Pick up a Lab taskThese issues and PRs center on per-session working directory management: fixing stale CWD values in CLI, TUI, runtime footer, and code execution, and adding features to set or switch the working directory per session across CLI, desktop, and gateway contexts. A small number of members touch unrelated skills/asset lookup behavior.
Open issues5
- Issue#10309Feature: session-scoped repo pinning to preserve repository identity across compression/resume
- Issue#29531Per-session working directory for gateway (OpenAI-compatible API) sessions
- Issue#35745[Bug]: Runtime footer displays stale TERMINAL_CWD instead of the agent's live working directory
- Issue#37277[Feature]: Add session-scoped workspace binding for gateway chats
- Issue#50195feat: Add ability to switch working directory during session
Open PRs4
Fixes on record
-
#65213 refactor(terminal): per-session cwd records replace shared-env cwd tracking
files:
tests/agent/test_file_safety_credentials.py,tests/tools/test_container_cwd_sanitize.py,tests/tools/test_file_staleness.py,tests/tools/test_file_tools.py,tests/tools/test_file_tools_cwd_resolution.py,tests/tools/test_file_tools_tilde_profile.py,tests/tools/test_resolve_path.py,tests/tools/test_session_cwd_store.py,tests/tools/test_terminal_task_cwd.py,tools/delegate_tool.py,tools/file_tools.py,tools/terminal_tool.pyShow the diff
-
tests/agent/test_file_safety_credentials.py@@ -144,6 +144,7 @@ def test_read_file_tool_blocks_relative_path_under_terminal_cwd( import json import tools.file_tools as ft + import tools.terminal_tool as terminal_tool _create(fake_home, "auth.json") # Force the file_tools resolver to anchor relative paths at HERMES_HOME @@ -151,7 +152,7 @@ def test_read_file_tool_blocks_relative_path_under_terminal_cwd( monkeypatch.setenv("TERMINAL_CWD", str(fake_home)) monkeypatch.chdir(tmp_path) monkeypatch.setattr( - ft, "_get_live_tracking_cwd", lambda task_id="default": None + terminal_tool, "_session_cwd", {} ) out = json.loads(ft.read_file_tool("auth.json")) @@ -166,6 +167,7 @@ def test_read_file_tool_blocks_nested_google_oauth_path( import json import tools.file_tools as ft + import tools.terminal_tool as terminal_tool oauth = _create(fake_home, Path("auth") / "google_oauth.json") oauth.write_text( @@ -180,7 +182,7 @@ def test_read_file_tool_blocks_nested_google_oauth_path( ) monkeypatch.chdir(tmp_path) monkeypatch.setattr( - ft, "_get_live_tracking_cwd", lambda task_id="default": None + terminal_tool, "_session_cwd", {} ) out = json.loads(ft.read_file_tool(str(oauth), task_id="google-oauth-test")) @@ -195,6 +197,7 @@ def test_search_tool_blocks_direct_auth_json_path(fake_home, monkeypatch): import json import tools.file_tools as ft + import tools.terminal_tool as terminal_tool auth = _create(fake_home, "auth.json") auth.write_text("SEARCH_DIRECT_AUTH_SECRET", encoding="utf-8") @@ -223,6 +226,7 @@ def test_search_tool_filters_credential_results(fake_home, tmp_path, monkeypatch from tools.file_operations import SearchMatch, SearchResult import tools.file_tools as ft + import tools.terminal_tool as terminal_tool auth = _create(fake_home, "auth.json") token = [redacted:secret-assignment] Path("mcp-tokens") / "provider.json") @@ -256,7 +260,7 @@ def search(self, **kwargs): monkeypatch.chdir(tmp_path) monkeypatch.setattr(ft, "_get_file_ops", lambda task_id="default": FakeFileOps()) monkeypatch.setattr( - ft, "_get_live_tracking_cwd", lambda task_id="default": None + terminal_tool, "_session_cwd", {} ) search_response = ft.search_tool( -
tests/tools/test_container_cwd_sanitize.py@@ -211,7 +211,7 @@ def fake_create_environment(env_type, image, cwd, timeout, **kwargs): monkeypatch.setattr(tt, "_active_environments", {}) monkeypatch.setattr(tt, "_last_activity", {}) monkeypatch.setattr(ft, "_file_ops_cache", {}) - monkeypatch.setattr(ft, "_last_known_cwd", {}) + monkeypatch.setattr(tt, "_session_cwd", {}) task_id = "sess-fileops-host-cwd" tt.register_task_env_overrides(task_id, {"cwd": override_cwd}) -
tests/tools/test_file_staleness.py@@ -150,8 +150,8 @@ def test_different_task_isolated(self, mock_ops): self.assertNotIn("_warning", result) @patch("tools.file_tools._get_file_ops") - def test_relative_path_uses_live_cwd_for_staleness_tracking(self, mock_ops): - """Relative-path stale tracking must follow the live terminal cwd.""" + def test_relative_path_uses_recorded_session_cwd_for_staleness_tracking(self, mock_ops): + """Relative-path stale tracking must follow the session's recorded cwd.""" start_dir = os.path.join(self._tmpdir, "start") live_dir = os.path.join(self._tmpdir, "worktree") os.makedirs(start_dir, exist_ok=True) @@ -165,15 +165,12 @@ def test_relative_path_uses_live_cwd_for_staleness_tracking(self, mock_ops): f.write("live copy\n") fake_ops = _make_fake_ops("live copy\n", 10) - fake_ops.env = SimpleNamespace(cwd=live_dir) - fake_ops.cwd = start_dir mock_ops.return_value = fake_ops - from tools import file_tools + from tools import terminal_tool - with file_tools._file_ops_lock: - previous = file_tools._file_ops_cache.get("live_task") - file_tools._file_ops_cache["live_task"] = fake_ops + # The session cd'd into the worktree (recorded by the completed command). + terminal_tool.record_session_cwd("live_task", live_dir) try: with patch.dict(os.environ, {"TERMINAL_CWD": start_dir}, clear=False): @@ -187,11 +184,7 @@ def test_relative_path_uses_live_cwd_for_staleness_tracking(self, mock_ops): write_file_tool("shared.txt", "replacement", task_id="live_task") ) finally: - with file_tools._file_ops_lock: - if previous is None: - file_tools._file_ops_cache.pop("live_task", None) - else: - file_tools._file_ops_cache["live_task"] = previous + terminal_tool.clear_session_cwd("live_task") self.assertIn("_warning", result) self.assertIn("modified since you last read", result["_warning"]) -
tests/tools/test_file_tools.py@@ -677,13 +677,16 @@ def test_no_anyof_required_stays_mode_only(self): # --------------------------------------------------------------------------- -# _last_known_cwd tests (#26211: silent file creation failure in long conversations) +# Session-cwd persistence across env recreation (#26211: silent file creation +# failure in long conversations). The durable anchor is the per-session cwd +# record in terminal_tool; env cleanup cannot lose it because it never lived +# on the env. # --------------------------------------------------------------------------- -class TestLastKnownCwd: +class TestSessionCwdSurvivesEnvRecreation: """ When the terminal environment is cleaned up and re-created during a long - conversation, _last_known_cwd preserves the old environment's CWD so + conversation, the session's cwd record preserves the working directory so subsequent file writes with relative paths land in the right directory. Regression guard for issue #26211. @@ -693,12 +696,12 @@ class TestLastKnownCwd: @patch("tools.file_tools._file_ops_cache", new_callable=dict) @patch("tools.terminal_tool._get_env_config") @patch("tools.terminal_tool._create_environment") - def test_last_known_cwd_preserved_across_env_recreation( + def test_recorded_cwd_used_for_recreated_env( self, mock_create_env, mock_config, mock_cache, mock_active ): - from tools.file_tools import _get_file_ops, _last_known_cwd + import tools.terminal_tool as tt + from tools.file_tools import _get_file_ops - # Setup: create a mock env with a known CWD mock_env = MagicMock() mock_env.cwd = "/Users/user/project" mock_create_env.return_value = mock_env @@ -709,42 +712,35 @@ def test_last_known_cwd_preserved_across_env_recreation( } task_id = "default" + # The session's record holds the directory (written by the last + # completed terminal command before the env was cleaned up). + tt.record_session_cwd(task_id, "/Users/user/project") + try: + _get_file_ops(task_id) + + create_call = mock_create_env.call_args + assert create_call is not None, "_create_environment was not called" + kwargs = create_call.kwargs if create_call.kwargs else {} + cwd_passed = kwargs.get("cwd", None) + if cwd_passed is None: + args = create_call.args if create_call.args else [] + if len(args) >= 3: + cwd_passed = args[2] + + assert cwd_passed == "/Users/user/project", \ + f"Expected cwd='/Users/user/project', got {cwd_passed!r}" + finally: + tt.clear_session_cwd(task_id) - # Preset _last_known_cwd to simulate a previous env's CWD - _last_known_cwd[task_id] = "/Users/user/project" - - # Call _get_file_ops - should use _last_known_cwd for the new env - result = _get_file_ops(task_id) - - # Verify the env was created with the saved CWD, not the default - create_call = mock_create_env.call_args - assert create_call is not None, "_create_environment was not called" - - # Find cwd in the kwargs - kwargs = create_call.kwargs if create_call.kwargs else {} - # cwd is passed as positional or keyword - cwd_passed = kwargs.get("cwd", None) - if cwd_passed is None: - # Try positional args - args = create_call.args if create_call.args else [] - # Position: (env_type, image, cwd, timeout, ...) - if len(args) >= 3: - cwd_passed = args[2] - - assert cwd_passed == "/Users/user/project", \ - f"Expected cwd='/Users/user/project', got {cwd_passed!r}" - - # Cleanup - _last_known_cwd.pop(task_id, None) - @patch("tools.terminal_tool._active_environments", new_callable=dict) @patch("tools.file_tools._file_ops_cache", new_callable=dict) @patch("tools.terminal_tool._get_env_config") @patch("tools.terminal_tool._create_environment") - def test_last_known_cwd_falls_back_to_config_default_when_not_set( + def test_falls_back_to_config_default_when_no_record( self, mock_create_env, mock_config, mock_cache, mock_active ): - from tools.file_tools import _get_file_ops, _last_known_cwd + import tools.terminal_tool as tt + from tools.file_tools import _get_file_ops mock_env = MagicMock() mock_env.cwd = "/default/path" @@ -755,85 +751,44 @@ def test_last_known_cwd_falls_back_to_config_default_when_not_set( "timeout": 30, } - # _get_file_ops resolves to "default" task_id = "default" - - # Ensure _last_known_cwd is empty for this task - _last_known_cwd.pop(task_id, None) + tt.clear_session_cwd(task_id) + + _get_file_ops(task_id) - result = _get_file_ops(task_id) - create_call = mock_create_env.call_args assert create_call is not None, "_create_environment was not called" - kwargs = create_call.kwargs if create_call.kwargs else {} cwd_passed = kwargs.get("cwd", None) if cwd_passed is None: args = create_call.args if create_call.args else [] if len(args) >= 3: cwd_passed = args[2] - - # Should fall back to config default + assert cwd_passed == "/config/default/path", \ f"Expected cwd='/config/default/path', got {cwd_passed!r}" - @patch("tools.terminal_tool._active_environments", new_callable=dict) - @patch("tools.file_tools._file_ops_cache", new_callable=dict) - def test_live_cwd_read_mirrors_into_last_known_cwd(self, mock_cache, mock_active): - """Belt-and-suspenders (#26211): every successful live-cwd read records - the cwd in _last_known_cwd, so the durable anchor doesn't depend on the - cleanup-detection branch of _get_file_ops firing.""" - from tools.file_tools import _get_live_tracking_cwd, _last_known_cwd - - task_id = "default" - _last_known_cwd.pop(task_id, None) - - cached = MagicMock() - cached.env = MagicMock() - cached.env.cwd = "/Users/user/project" - cached.env.cwd_owner = "default" - mock_cache[task_id] = cached - - live = _get_live_tracking_cwd(task_id) - - assert live == "/Users/user/project" - # The read mirrored the live cwd into the durable registry. - assert _last_known_cwd.get(task_id) == "/Users/user/project" - _last_known_cwd.pop(task_id, None) - @patch("tools.terminal_tool._active_environments", new_callable=dict) @patch("tools.file_tools._file_ops_cache", new_callable=dict) @patch("tools.terminal_tool._get_env_config") @patch("tools.terminal_tool._create_environment") - def test_mirrored_cwd_survives_when_cache_already_cleared( + def test_stale_cache_cwd_rescued_into_record_on_cleanup_detection( self, mock_create_env, mock_config, mock_cache, mock_active ): - """The original save-old-cwd path only fires when _file_ops_cache still - holds the stale entry. If the cleanup thread popped BOTH dicts first, - _get_file_ops sees cached=None and never saves — but the proactive - mirror from an earlier live read already populated _last_known_cwd, so - the rebuilt env still restores the user's directory.""" - from tools.file_tools import ( - _get_file_ops, _get_live_tracking_cwd, _last_known_cwd, - ) + """If the env died but the file-ops cache entry survived, its cwd is + rescued into the session record before the cache entry is dropped — + the recreated env starts where the user left off.""" + import tools.terminal_tool as tt + from tools.file_tools import _get_file_ops task_id = "default" - _last_known_cwd.pop(task_id, None) + tt.clear_session_cwd(task_id) - # 1) Env is alive and the agent has cd'd into the project. A live read - # (happens on every relative-path resolution) mirrors the cwd. + # Stale cache entry: env was cleaned up, cache still holds the old cwd. cached = MagicMock() - cached.env = MagicMock() - cached.env.cwd = "/Users/user/project" - cached.env.cwd_owner = "default" + cached.env = None + cached.cwd = "/Users/user/project" mock_cache[task_id] = cached - assert _get_live_tracking_cwd(task_id) == "/Users/user/project" - assert _last_known_cwd.get(task_id) == "/Users/user/project" - - # 2) Cleanup thread kills the env AND clears the cache before the next - # file write — so _get_file_ops' save-old-cwd branch never runs. - mock_cache.pop(task_id, None) - mock_active.clear() mock_env = MagicMock() mock_env.cwd = "/Users/user/project" @@ -844,21 +799,23 @@ def test_mirrored_cwd_survives_when_cache_already_cleared( "timeout": 30, } - _get_file_ops(task_id) + try: + _get_file_ops(task_id) - create_call = mock_create_env.call_args - assert create_call is not None, "_create_environment was not called" - kwargs = create_call.kwargs if create_call.kwargs else {} - cwd_passed = kwargs.get("cwd", None) - if cwd_passed is None: - args = create_call.args if create_call.args else [] - if len(args) >= 3: - cwd_passed = args[2] + create_call = mock_create_env.call_args + assert create_call is not None, "_create_environment was not called" + kwargs = create_call.kwargs if create_call.kwargs else {} + cwd_passed = kwargs.get("cwd", None) + if cwd_passed is None: + args = create_call.args if create_call.args else [] + if len(args) >= 3: + cwd_passed = args[2] - # Rebuilt env restored the mirrored cwd, NOT the config default. - assert cwd_passed == "/Users/user/project", \ - f"Expected restored cwd='/Users/user/project', got {cwd_passed!r}" - _last_known_cwd.pop(task_id, None) + # Rebuilt env restored the rescued cwd, NOT the config default. + assert cwd_passed == "/Users/user/project", \ + f"Expected restored cwd='/Users/user/project', got {cwd_passed!r}" + finally: + tt.clear_session_cwd(task_id) class TestSilentFileMisplacementE2E: @@ -868,8 +825,8 @@ class TestSilentFileMisplacementE2E: agent cd's into a project, the cleanup thread kills the env, and a later relative-path write must land in the project dir (not the config default). Mocks miss this because resolution (_resolve_path_for_task) runs BEFORE - _get_file_ops rebuilds the env — only the durable _last_known_cwd fallback - in _authoritative_workspace_root makes the resolved path correct. + _get_file_ops rebuilds the env — only the durable session-cwd record + makes the resolved path correct. """ def test_relative_write_after_env_cleanup_lands_in_user_cwd(self, tmp_path, monkeypatch): @@ -889,13 +846,13 @@ def test_relative_write_after_env_cleanup_lands_in_user_cwd(self, tmp_path, monk ) task_id = "default" - ft._last_known_cwd.pop(task_id, None) + tt.clear_session_cwd(task_id) - # 1) Env alive; agent has cd'd into the project. A relative write - # while alive mirrors the live cwd into the durable registry. + # 1) Env alive; agent has cd'd into the project (the completed command + # recorded the session cwd — simulate that write here). fo = ft._get_file_ops(task_id) fo.env.cwd = str(project) - fo.env.cwd_owner = "default" + tt.record_session_cwd(task_id, str(project)) ft.write_file_tool("alive.txt", "1\n", task_id) assert (project / "alive.txt").exists() @@ -913,4 +870,4 @@ def test_relative_write_after_env_cleanup_lands_in_user_cwd(self, tmp_path, monk assert not (config_default / "report.txt").exists(), \ "file silently misplaced into config default (the #26211 bug)" - ft._last_known_cwd.pop(task_id, None) + tt.clear_session_cwd(task_id) -
tests/tools/test_file_tools_cwd_resolution.py@@ -36,8 +36,8 @@ def _isolated_cwd(tmp_path, monkeypatch): # Process cwd = decoy, analogous to "main repo" while the terminal is in # the worktree. monkeypatch.chdir(decoy) - # No live-terminal-cwd tracking recorded yet (fresh-session condition). - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + # No session cwd recorded yet (fresh-session condition). + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) return workspace, decoy @@ -72,7 +72,7 @@ def test_live_tracking_cwd_wins_over_relative_terminal_cwd(_isolated_cwd, monkey """ workspace, decoy = _isolated_cwd monkeypatch.setenv("TERMINAL_CWD", ".") - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace)) + terminal_tool.record_session_cwd("default", str(workspace)) resolved = ft._resolve_path_for_task("target.py", task_id="default") @@ -137,7 +137,7 @@ def test_container_relative_path_keeps_container_cwd_symlink(tmp_path, monkeypat container_mount.symlink_to(host_project, target_is_directory=True) monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: {"env_type": "docker"}) monkeypatch.setattr(terminal_tool, "_active_environments", {}) - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(container_mount)) + terminal_tool.record_session_cwd("default", str(container_mount)) resolved = ft._resolve_path_for_task("oilsands-sim/README.md", task_id="default") @@ -185,9 +185,9 @@ def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monke """Relative path resolving outside the live workspace must warn.""" workspace, decoy = _isolated_cwd # Live cwd = workspace, but the relative path resolves to decoy (process cwd) - # because TERMINAL_CWD is the poison '.'. Simulate by pointing live tracking - # at workspace while the resolved path is under decoy. - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace)) + # because TERMINAL_CWD is the poison '.'. Simulate by recording workspace + # as the session cwd while the resolved path is under decoy. + terminal_tool.record_session_cwd("default", str(workspace)) resolved_in_decoy = decoy / "target.py" warn = ft._path_resolution_warning("target.py", resolved_in_decoy, task_id="default") @@ -200,7 +200,7 @@ def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monke def test_no_warning_when_relative_path_inside_workspace(_isolated_cwd, monkeypatch): workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace)) + terminal_tool.record_session_cwd("default", str(workspace)) resolved_in_workspace = workspace / "target.py" warn = ft._path_resolution_warning("target.py", resolved_in_workspace, task_id="default") @@ -210,7 +210,7 @@ def test_no_warning_when_relative_path_inside_workspace(_isolated_cwd, monkeypat def test_no_warning_for_absolute_input(_isolated_cwd, monkeypatch): workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace)) + terminal_tool.record_session_cwd("default", str(workspace)) warn = ft._path_resolution_warning(str(decoy / "target.py"), decoy / "target.py", task_id="default") @@ -219,7 +219,7 @@ def test_no_warning_for_absolute_input(_isolated_cwd, monkeypatch): def test_no_warning_when_no_live_cwd(_isolated_cwd, monkeypatch): workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.delenv("TERMINAL_CWD", raising=False) warn = ft._path_resolution_warning("target.py", decoy / "target.py", task_id="default") @@ -244,7 +244,7 @@ def test_sentinel_terminal_cwd_is_treated_as_unset(_isolated_cwd, monkeypatch, s never resolved as a literal relative directory. """ workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.setenv("TERMINAL_CWD", sentinel) assert ft._configured_terminal_cwd() is None @@ -261,7 +261,7 @@ def test_relative_nonsentinel_terminal_cwd_rejected(_isolated_cwd, monkeypatch): be joined onto it as a literal subdir. """ workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.setenv("TERMINAL_CWD", "some/rel/path") assert ft._configured_terminal_cwd() is None @@ -277,7 +277,7 @@ def test_absolute_terminal_cwd_anchors_with_empty_registry(_isolated_cwd, monkey worktree — not the process cwd (main repo). """ workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.setenv("TERMINAL_CWD", str(workspace)) resolved = ft._resolve_path_for_task("target.py", task_id="default") @@ -295,7 +295,7 @@ def test_registered_task_cwd_override_anchors_before_terminal_env_exists(_isolat """ workspace, decoy = _isolated_cwd task_id = "desktop-session-cwd" - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.delenv("TERMINAL_CWD", raising=False) monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) @@ -317,7 +317,7 @@ def test_warning_fires_from_terminal_cwd_when_registry_empty(_isolated_cwd, monk worktree is flagged on the very first write. """ workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.setenv("TERMINAL_CWD", str(workspace)) # Relative path that escapes the worktree into the decoy/main checkout. @@ -336,8 +336,8 @@ def test_live_cwd_still_wins_over_absolute_terminal_cwd(_isolated_cwd, monkeypat workspace, decoy = _isolated_cwd other = decoy.parent / "other" other.mkdir() - # Live cwd = workspace; TERMINAL_CWD points elsewhere — live must win. - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace)) + # Recorded session cwd = workspace; TERMINAL_CWD points elsewhere — record wins. + terminal_tool.record_session_cwd("default", str(workspace)) monkeypatch.setenv("TERMINAL_CWD", str(other)) resolved = ft._resolve_path_for_task("target.py", task_id="default") @@ -351,7 +351,7 @@ def test_live_cwd_still_wins_over_absolute_terminal_cwd(_isolated_cwd, monkeypat def test_write_file_reports_resolved_absolute_path(_isolated_cwd, monkeypatch): """write_file_tool must put the absolute on-disk path in files_modified.""" workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace)) + terminal_tool.record_session_cwd("t1", str(workspace)) import json out = json.loads(ft.write_file_tool("newfile.txt", "hello\n", task_id="t1")) @@ -365,7 +365,7 @@ def test_write_file_reports_resolved_absolute_path(_isolated_cwd, monkeypatch): def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch): """patch_tool (replace mode) must put the absolute on-disk path in files_modified.""" workspace, decoy = _isolated_cwd - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace)) + terminal_tool.record_session_cwd("t1", str(workspace)) import json out = json.loads(ft.patch_tool( @@ -383,24 +383,16 @@ def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch): assert (decoy / "target.py").read_text() == "DECOY_ORIGINAL\n" -# ── Fix D: shared terminal env must not leak its cwd across worktree sessions ─ -# (June 2026: two desktop sessions, each on its own worktree, share the single -# "default" terminal environment. Its `cwd` tracks whichever session ran the -# last command, so a file edit from the OTHER session resolved against that -# foreign cwd and silently landed in the wrong worktree. terminal_tool now -# stamps env.cwd_owner with the driving session; file tools trust the shared -# env's live cwd only when the resolving session owns it.) - - -class _FakeOwnedEnv: - def __init__(self, cwd: str, cwd_owner: str): - self.cwd = cwd - self.cwd_owner = cwd_owner +# ── Cross-session isolation: one session's cwd never leaks into another ────── +# (June 2026 bug class: two desktop sessions, each on its own worktree, shared +# the single "default" terminal environment and could inherit each other's cwd. +# The per-session record store solves this structurally: each session's cd +# state lives in its own record, keyed by the raw session id.) @pytest.fixture def _two_worktree_sessions(tmp_path, monkeypatch): - """Two worktree sessions sharing one terminal env owned by session B.""" + """Two worktree sessions: B has cd'd (record), both registered overrides.""" wt_a = tmp_path / "wt_a" wt_b = tmp_path / "wt_b" main = tmp_path / "main" @@ -410,78 +402,60 @@ def _two_worktree_sessions(tmp_path, monkeypatch): monkeypatch.chdir(main) monkeypatch.delenv("TERMINAL_CWD", raising=False) monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.setattr(ft, "_file_ops_cache", {}) - # Both sessions register their worktree cwd (TUI/desktop registration path). + # Both sessions register their worktree cwd (TUI/desktop registration path; + # registration seeds each session's record). terminal_tool.register_task_env_overrides("sess-a", {"cwd": str(wt_a)}) terminal_tool.register_task_env_overrides("sess-b", {"cwd": str(wt_b)}) - # The shared "default" env: session B ran the last command, so its live cwd - # is wt_b and B owns it. + # Session B ran the last command; the shared env's live cwd is wt_b but + # only B's RECORD carries it. monkeypatch.setattr( terminal_tool, "_active_environments", - {"default": _FakeOwnedEnv(str(wt_b), "sess-b")}, + {"default": _FakeEnv(str(wt_b))}, ) return wt_a, wt_b, main -def test_live_cwd_ignored_for_non_owning_session(_two_worktree_sessions): - wt_a, wt_b, _main = _two_worktree_sessions - # Owner sees the live cwd; the other session must NOT inherit it. - assert ft._get_live_tracking_cwd("sess-b") == str(wt_b) - assert ft._get_live_tracking_cwd("sess-a") is None +class _FakeEnv: + def __init__(self, cwd: str): + self.cwd = cwd def test_resolution_routes_to_resolving_sessions_worktree(_two_worktree_sessions): """The wrong-worktree fix: A resolves into wt_a, not the shared env's wt_b.""" wt_a, wt_b, _main = _two_worktree_sessions - # Session A does not own the shared env → falls back to its own registered - # worktree cwd instead of B's live cwd. resolved_a = ft._resolve_path_for_task("target.py", task_id="sess-a") assert resolved_a == (wt_a / "target.py") assert not str(resolved_a).startswith(str(wt_b)) -def test_owning_session_still_resolves_against_live_cwd(_two_worktree_sessions): - """No regression: the owner keeps resolving against the live cwd.""" +def test_session_with_cd_record_resolves_against_it(_two_worktree_sessions): + """B's record (its own cd state) is authoritative for B.""" wt_a, wt_b, _main = _two_worktree_sessions resolved_b = ft._resolve_path_for_task("target.py", task_id="sess-b") assert resolved_b == (wt_b / "target.py") assert not str(resolved_b).startswith(str(wt_a)) -def test_unknown_owner_keeps_prior_single_session_behavior(tmp_path, monkeypatch): - """An env with no owner (CLI / legacy) still yields its live cwd.""" - ws = tmp_path / "ws" - ws.mkdir() - monkeypatch.setattr(ft, "_file_ops_cache", {}) - monkeypatch.setattr( - terminal_tool, - "_active_environments", - {"default": _FakeOwnedEnv(str(ws), "")}, - ) - assert ft._get_live_tracking_cwd("default") == str(ws) - assert ft._get_live_tracking_cwd("any-session") == str(ws) +def test_sessions_cd_updates_only_its_own_resolution(_two_worktree_sessions, tmp_path): + """B cd's elsewhere → B's resolution follows, A's is untouched.""" + wt_a, wt_b, _main = _two_worktree_sessions + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + terminal_tool.record_session_cwd("sess-b", str(elsewhere)) + + assert ft._resolve_path_for_task("f.py", task_id="sess-b") == (elsewhere / "f.py") + assert ft._resolve_path_for_task("f.py", task_id="sess-a") == (wt_a / "f.py") -def test_preserved_cwd_does_not_override_non_owning_sessions_worktree( +def test_unregistered_session_never_inherits_another_sessions_record( _two_worktree_sessions, monkeypatch ): - """#26211 belt-and-suspenders must not break worktree isolation. - - The owner (session B) doing an owned live read mirrors wt_b into the shared - _last_known_cwd['default'] registry. Session A — which does NOT own the env - but HAS its own registered worktree (wt_a) — must still resolve into wt_a, - not inherit B's preserved cwd through the shared-container key. The - session-specific registered override must beat the durable shared anchor. - """ - wt_a, wt_b, _main = _two_worktree_sessions - monkeypatch.setattr(ft, "_last_known_cwd", {}) - - # Owner B resolves first — this mirrors wt_b into _last_known_cwd['default']. - assert ft._resolve_path_for_task("target.py", task_id="sess-b") == (wt_b / "target.py") - assert ft._last_known_cwd.get("default") == str(wt_b) - - # A still routes to its own registered worktree despite the shared anchor. - resolved_a = ft._resolve_path_for_task("target.py", task_id="sess-a") - assert resolved_a == (wt_a / "target.py") - assert not str(resolved_a).startswith(str(wt_b)) + """Session C: no record, no override. Must NOT inherit A's or B's cwd.""" + wt_a, wt_b, main = _two_worktree_sessions + resolved = ft._resolve_path_for_task("target.py", task_id="sess-c") + assert not str(resolved).startswith(str(wt_a)) + assert not str(resolved).startswith(str(wt_b)) + assert resolved == (main / "target.py").resolve() -
tests/tools/test_file_tools_tilde_profile.py@@ -21,6 +21,7 @@ import pytest import tools.file_tools as ft +import tools.terminal_tool as terminal_tool # --------------------------------------------------------------------------- @@ -82,7 +83,7 @@ def test_relative_tilde_resolves_to_profile_home(self, tmp_path, monkeypatch): process_home.mkdir() monkeypatch.setenv("HOME", str(process_home)) - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) with patch("hermes_constants.get_subprocess_home", return_value=str(profile_home)): resolved = ft._resolve_path_for_task("~/test_file.txt", task_id="test") @@ -98,7 +99,7 @@ def test_absolute_tilde_in_workspace_root(self, tmp_path, monkeypatch): process_home.mkdir() monkeypatch.setenv("HOME", str(process_home)) - monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) with patch("hermes_constants.get_subprocess_home", return_value=str(profile_home)): # _resolve_base_dir uses the workspace root from config; if it contains ~, -
tests/tools/test_resolve_path.py@@ -52,33 +52,23 @@ def test_result_is_resolved(self, monkeypatch, tmp_path): assert ".." not in str(result) assert result == (tmp_path / "b" / "file.txt") - def test_relative_path_prefers_live_file_ops_cwd(self, monkeypatch, tmp_path): - """Live env.cwd must win after the terminal session changes directory.""" + def test_relative_path_prefers_recorded_session_cwd(self, monkeypatch, tmp_path): + """The session's recorded cwd must win after the terminal changes directory.""" start_dir = tmp_path / "start" live_dir = tmp_path / "worktree" start_dir.mkdir() live_dir.mkdir() monkeypatch.setenv("TERMINAL_CWD", str(start_dir)) - from tools import file_tools + from tools import file_tools, terminal_tool task_id = "live-cwd" - fake_ops = SimpleNamespace( - env=SimpleNamespace(cwd=str(live_dir)), - cwd=str(start_dir), - ) - - with file_tools._file_ops_lock: - previous = file_tools._file_ops_cache.get(task_id) - file_tools._file_ops_cache[task_id] = fake_ops + # The session's completed `cd` recorded the new directory. + terminal_tool.record_session_cwd(task_id, str(live_dir)) try: result = file_tools._resolve_path("nested/file.txt", task_id=task_id) finally: - with file_tools._file_ops_lock: - if previous is None: - file_tools._file_ops_cache.pop(task_id, None) - else: - file_tools._file_ops_cache[task_id] = previous + terminal_tool.clear_session_cwd(task_id) assert result == live_dir / "nested" / "file.txt" -
tests/tools/test_session_cwd_store.py@@ -0,0 +1,244 @@ +"""Session-cwd record store (cwd rearchitecture, step 1: dual-write). + +The store is the future single source of truth for per-session working +directories. Step 1 only guarantees the WRITE side: every path that learns a +session's live cwd must record it under the raw session key. Readers still +use the legacy env.cwd ladder; these tests pin the invariants the later +read-side flip will rely on. +""" + +import pytest + +import tools.terminal_tool as tt + + [redacted:email](autouse=True) +def _clean_store(monkeypatch): + monkeypatch.setattr(tt, "_session_cwd", {}) + monkeypatch.setattr(tt, "_task_env_overrides", {}) + + +class TestRecordSemantics: + def test_records_are_keyed_by_raw_session_key(self): + tt.record_session_cwd("sess-a", "/wt/a") + tt.record_session_cwd("sess-b", "/wt/b") + # No cross-talk: each session reads back exactly its own record. + assert tt.get_session_cwd("sess-a") == "/wt/a" + assert tt.get_session_cwd("sess-b") == "/wt/b" + assert tt.get_session_cwd("sess-c") is None + + def test_none_and_empty_keys_collapse_to_default(self): + tt.record_session_cwd(None, "/somewhere") + assert tt.get_session_cwd(None) == "/somewhere" + assert tt.get_session_cwd("") == "/somewhere" + assert tt.get_session_cwd("default") == "/somewhere" + + def test_invalid_cwd_values_are_ignored(self): + tt.record_session_cwd("sess-a", None) + tt.record_session_cwd("sess-a", "") + tt.record_session_cwd("sess-a", " ") + tt.record_session_cwd("sess-a", 123) # type: ignore[arg-type] + assert tt.get_session_cwd("sess-a") is None + + def test_clear_drops_only_the_named_session(self): + tt.record_session_cwd("sess-a", "/wt/a") + tt.record_session_cwd("sess-b", "/wt/b") + tt.clear_session_cwd("sess-a") + assert tt.get_session_cwd("sess-a") is None + assert tt.get_session_cwd("sess-b") == "/wt/b" + + +class TestDualWriteSites: + def test_register_cwd_override_seeds_the_session_record(self): + """A registered workspace cwd IS the session's cwd until a `cd`.""" + tt.register_task_env_overrides("desktop-sess", {"cwd": "/wt/desktop"}) + assert tt.get_session_cwd("desktop-sess") == "/wt/desktop" + + def test_register_without_cwd_does_not_touch_the_record(self): + tt.register_task_env_overrides("rl-42", {"docker_image": "x:y"}) + assert tt.get_session_cwd("rl-42") is None + + def test_clear_task_env_overrides_drops_the_record(self): + tt.register_task_env_overrides("desktop-sess", {"cwd": "/wt/desktop"}) + tt.clear_task_env_overrides("desktop-sess") + assert tt.get_session_cwd("desktop-sess") is None + + def test_reregistration_updates_the_record(self): + """ACP session/load switching project roots mid-session.""" + tt.register_task_env_overrides("acp-sess", {"cwd": "/proj/one"}) + tt.register_task_env_overrides("acp-sess", {"cwd": "/proj/two"}) + assert tt.get_session_cwd("acp-sess") == "/proj/two" + + +class TestPostCommandDualWrite: + """The env's post-command cwd tracking must mirror into the session record.""" + + def _run(self, monkeypatch, task_id, env): + import json + monkeypatch.setattr(tt, "_active_environments", {task_id: env}) + monkeypatch.setattr(tt, "_last_activity", {}) + monkeypatch.setattr( + tt, "_get_env_config", + lambda: {"env_type": "local", "cwd": "/default", "timeout": 60, + "lifetime_seconds": 3600}, + ) + monkeypatch.setattr( + tt, "_check_all_guards", + lambda command, env_type, **kwargs: {"approved": True}, + ) + return json.loads(tt.terminal_tool(command="cd /new/dir", task_id=task_id)) + + def test_cd_result_is_recorded_under_the_session_key(self, monkeypatch): + class FakeEnv: + env = {} + cwd = "/start" + def execute(self, command, **kwargs): + # Simulate the env's own post-command tracking (marker parse). + self.cwd = "/new/dir" + return {"output": "", "returncode": 0} + + result = self._run(monkeypatch, "sess-a", FakeEnv()) + assert result["exit_code"] == 0 + assert tt.get_session_cwd("sess-a") == "/new/dir" + # And ONLY that session's record was touched. + assert tt.get_session_cwd("sess-b") is None + + def test_envs_without_cwd_tracking_record_nothing(self, monkeypatch): + class FakeEnv: + env = {} + def execute(self, command, **kwargs): + return {"output": "", "returncode": 0} + + result = self._run(monkeypatch, "sess-a", FakeEnv()) + assert result["exit_code"] == 0 + assert tt.get_session_cwd("sess-a") is None + + +class TestFileToolsReadTheRecord: + """Step 2: file-tool path resolution prefers the session's own record.""" + + def test_two_sessions_resolve_into_their_own_recorded_cwds(self, tmp_path, monkeypatch): + import tools.file_tools as ft + + wt_a = tmp_path / "wt_a" + wt_b = tmp_path / "wt_b" + for d in (wt_a, wt_b): + d.mkdir() + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.setattr(ft, "_file_ops_cache", {}) + monkeypatch.setattr(tt, "_active_environments", {}) + + # Each session ran commands that recorded its own cwd. No env alive, + # no registered overrides — just the records. + tt.record_session_cwd("sess-a", str(wt_a)) + tt.record_session_cwd("sess-b", str(wt_b)) + + assert ft._resolve_path_for_task("f.py", task_id="sess-a") == (wt_a / "f.py") + assert ft._resolve_path_for_task("f.py", task_id="sess-b") == (wt_b / "f.py") + + def test_record_beats_foreign_env_cwd_without_ownership_metadata(self, tmp_path, monkeypatch): + """The leak-A scenario, solved structurally: the shared env's cwd is + never consulted for path resolution — only the session's own record.""" + import tools.file_tools as ft + + wt_a = tmp_path / "wt_a" + wt_b = tmp_path / "wt_b" + for d in (wt_a, wt_b): + d.mkdir() + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.setattr(ft, "_file_ops_cache", {}) + + class _Env: + cwd = str(wt_b) # another session's leftover cd on the shared env + + monkeypatch.setattr(tt, "_active_environments", {"default": _Env()}) + tt.record_session_cwd("sess-a", str(wt_a)) + + resolved = ft._resolve_path_for_task("f.py", task_id="sess-a") + assert resolved == (wt_a / "f.py") + assert not str(resolved).startswith(str(wt_b)) + + +class TestDelegateSeedsChildRecord: + def test_child_record_seeded_from_parent_then_isolated(self): + tt.record_session_cwd("parent-task", "/parent/worktree") + # what delegate_tool does at spawn: + tt.record_session_cwd("child-1", tt.get_session_cwd("parent-task")) + + assert tt.get_session_cwd("child-1") == "/parent/worktree" + # child cds somewhere; parent record must be untouched. + tt.record_session_cwd("child-1", "/child/scratch") + assert tt.get_session_cwd("parent-task") == "/parent/worktree" + assert tt.get_session_cwd("child-1") == "/child/scratch" + + +class TestCommandCwdReadsTheRecord: + """_resolve_command_cwd: workdir > session record > default. Nothing else.""" + + def test_record_beats_default(self): + tt.record_session_cwd("sess-a", "/my/worktree") + resolved = tt._resolve_command_cwd( + workdir=None, + default_cwd="/config/default", + session_key="sess-a", + ) + assert resolved == "/my/worktree" + + def test_workdir_still_beats_the_record(self): + tt.record_session_cwd("sess-a", "/my/worktree") + resolved = tt._resolve_command_cwd( + workdir="/explicit/place", + default_cwd="/config/default", + session_key="sess-a", + ) + assert resolved == "/explicit/place" + + def test_no_record_falls_back_to_default(self): + resolved = tt._resolve_command_cwd( + workdir=None, + default_cwd="/config/default", + session_key="sess-a", + ) + assert resolved == "/config/default" + + def test_other_sessions_record_is_not_consulted(self): + tt.record_session_cwd("sess-b", "/other/worktree") + resolved = tt._resolve_command_cwd( + workdir=None, + default_cwd="/config/default", + session_key="sess-a", + ) + assert resolved == "/config/default" + + def test_cd_then_next_command_runs_in_the_new_dir(self, monkeypatch): + """E2E through terminal_tool: the record round-trips cd state.""" + import json + + class FakeEnv: + env = {} + cwd = "/start" + def execute(self, command, **kwargs): + self.last_cwd_arg = kwargs.get("cwd") + if command.startswith("cd "): + self.cwd = command[3:] + return {"output": "", "returncode": 0} + + fake = FakeEnv() + monkeypatch.setattr(tt, "_active_environments", {"sess-a": fake}) + monkeypatch.setattr(tt, "_last_activity", {}) + monkeypatch.setattr( + tt, "_get_env_config", + lambda: {"env_type": "local", "cwd": "/default", "timeout": 60, + "lifetime_seconds": 3600}, + ) + monkeypatch.setattr( + tt, "_check_all_guards", + lambda command, env_type, **kwargs: {"approved": True}, + ) + + json.loads(tt.terminal_tool(command="cd /project", task_id="sess-a")) + assert tt.get_session_cwd("sess-a") == "/project" + json.loads(tt.terminal_tool(command="pwd", task_id="sess-a")) + assert fake.last_cwd_arg == "/project" -
tests/tools/test_terminal_task_cwd.py@@ -76,8 +76,8 @@ def execute(self, command, **kwargs): assert calls == [{"timeout": 60, "cwd": "/explicit/workdir", "bounded_capture": True}] -def test_foreground_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch): - """A prior `cd` updates env.cwd; terminal_tool must honor that live cwd.""" +def test_foreground_command_prefers_recorded_session_cwd_over_init_time_cwd(monkeypatch): + """A prior `cd` records the session cwd; terminal_tool must honor it.""" calls = [] class FakeEnv: @@ -91,6 +91,7 @@ def execute(self, command, **kwargs): task_id = "session-live-cwd" monkeypatch.setattr(terminal_tool, "_active_environments", {task_id: FakeEnv()}) monkeypatch.setattr(terminal_tool, "_last_activity", {}) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.setattr(terminal_tool, "_task_env_overrides", {task_id: {"cwd": "/workspace/init"}}) monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: _minimal_terminal_config(cwd="/workspace/init")) monkeypatch.setattr(terminal_tool, "_start_cleanup_thread", lambda: None) @@ -100,6 +101,8 @@ def execute(self, command, **kwargs): "_check_all_guards", lambda command, env_type, **kwargs: {"approved": True}, ) + # The prior command's completed `cd` recorded the session cwd. + terminal_tool.record_session_cwd(task_id, "/workspace/live") result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id)) @@ -107,8 +110,8 @@ def execute(self, command, **kwargs): assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/live", "bounded_capture": True})] -def test_background_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch): - """Background process launches must also use the live session cwd.""" +def test_background_command_prefers_recorded_session_cwd_over_init_time_cwd(monkeypatch): + """Background process launches must also use the recorded session cwd.""" class FakeEnv: env = {} @@ -129,6 +132,7 @@ def spawn_local(self, **kwargs): task_id = "session-live-cwd-bg" monkeypatch.setattr(terminal_tool, "_active_environments", {task_id: FakeEnv()}) monkeypatch.setattr(terminal_tool, "_last_activity", {}) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.setattr(terminal_tool, "_task_env_overrides", {task_id: {"cwd": "/workspace/init"}}) monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: _minimal_terminal_config(cwd="/workspace/init")) monkeypatch.setattr(terminal_tool, "_start_cleanup_thread", lambda: None) @@ -139,6 +143,7 @@ def spawn_local(self, **kwargs): lambda command, env_type, **kwargs: {"approved": True}, ) monkeypatch.setattr(process_registry_mod, "process_registry", registry) + terminal_tool.record_session_cwd(task_id, "/workspace/live") result = json.loads( terminal_tool.terminal_tool( @@ -162,15 +167,13 @@ def spawn_local(self, **kwargs): }] -def test_registering_cwd_override_updates_live_env_cwd(monkeypatch): +def test_registering_cwd_override_updates_session_record(monkeypatch): """An ACP ``update_cwd`` (re-)registered mid-session must win over a - previously ``cd``-ed live ``env.cwd``. + previously ``cd``-ed session cwd. - Preferring live ``env.cwd`` (so session-local ``cd`` survives) means a - freshly registered ``cwd`` override would otherwise sit *below* the - already-set ``env.cwd`` and be silently ignored. ``register_task_env_overrides`` - syncs the new cwd onto the live cached env so an explicit ACP project-root - change takes effect, as the editor client expects. + Registration writes the session record directly, so an explicit ACP + project-root change takes effect on the next command, as the editor + client expects. """ class FakeEnv: @@ -181,15 +184,18 @@ class FakeEnv: fake_env = FakeEnv() monkeypatch.setattr(terminal_tool, "_active_environments", {task_id: fake_env}) monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) + # The session had cd'd somewhere before the editor switched project roots. + terminal_tool.record_session_cwd(task_id, "/workspace/old") terminal_tool.register_task_env_overrides(task_id, {"cwd": "/workspace/new"}) - # The live env now reflects the editor's new project root. + # The live env mirror still updates (legacy env seeding) … assert fake_env.cwd == "/workspace/new" - - # A subsequent command resolves to the new cwd (env.cwd precedence). + # … and the session record — what commands actually resolve against — too. + assert terminal_tool.get_session_cwd(task_id) == "/workspace/new" assert terminal_tool._resolve_command_cwd( - workdir=None, env=fake_env, default_cwd="/workspace/config" + workdir=None, default_cwd="/workspace/config", session_key=task_id ) == "/workspace/new" @@ -264,20 +270,14 @@ def execute(self, command, **kwargs): assert calls == [("pwd", {"timeout": 60, "cwd": "/home/user/src/hermes-agent", "bounded_capture": True})] -def test_same_session_env_cwd_is_trusted_after_first_claim(monkeypatch): - """Once a session has claimed the env, subsequent commands trust env.cwd. - - The prev_owner check only rejects env.cwd when a DIFFERENT session owned it - before this call. After the first command (which claims ownership), - subsequent calls in the same session should trust the live env.cwd so that - in-session `cd` state survives. - """ +def test_same_session_recorded_cwd_survives_across_commands(monkeypatch): + """In-session `cd` state survives: the record written by one command is + used by the next command in the same session.""" calls = [] class FakeEnv: env = {} cwd = "/workspace/deep" - cwd_owner = "session-X" def execute(self, command, **kwargs): calls.append((command, kwargs)) @@ -288,6 +288,7 @@ def execute(self, command, **kwargs): monkeypatch.setattr(terminal_tool, "_active_environments", {"default": env}) monkeypatch.setattr(terminal_tool, "_last_activity", {}) monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) + monkeypatch.setattr(terminal_tool, "_session_cwd", {}) monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: _minimal_terminal_config(cwd="/workspace/config")) monkeypatch.setattr(terminal_tool, "_start_cleanup_thread", lambda: None) monkeypatch.setattr(terminal_tool, "_resolve_container_task_id", lambda value: "default") @@ -297,13 +298,17 @@ def execute(self, command, **kwargs): lambda command, env_type, **kwargs: {"approved": True}, ) - # First call: env was owned by "session-X" (same session_key since - # get_current_session_key falls back to task_id). prev_owner == current - # session, so env.cwd is trusted. + # First command runs in the config cwd (no record yet) and afterwards + # mirrors the env's post-command cwd into the session record. result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id)) + assert result["exit_code"] == 0 + assert calls[0] == ("pwd", {"timeout": 60, "cwd": "/workspace/config", "bounded_capture": True}) + assert terminal_tool.get_session_cwd(task_id) == "/workspace/deep" + # Second command in the same session trusts the record. + result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id)) assert result["exit_code"] == 0 - assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/deep", "bounded_capture": True})] + assert calls[1] == ("pwd", {"timeout": 60, "cwd": "/workspace/deep", "bounded_capture": True}) def test_safe_getcwd_returns_real_cwd(monkeypatch): -
tools/delegate_tool.py@@ -1906,6 +1906,18 @@ def _heartbeat_loop(): child_task_id = _subagent_id or f"subagent-{task_index}-{_uuid.uuid4().hex[:8]}" parent_task_id = getattr(parent_agent, "_current_task_id", None) + # Seed the child's session-cwd record from the parent's (cwd rearch): + # children share the parent's container, and today they inherit the + # parent's live env.cwd implicitly. Seeding at spawn preserves that + # starting directory while keeping the child's subsequent `cd`s + # isolated in its own record (a child's cd no longer bleeds back into + # the parent once readers flip to the record store). + try: + from tools.terminal_tool import get_session_cwd, record_session_cwd + + record_session_cwd(child_task_id, get_session_cwd(parent_task_id)) + except Exception as e: + logger.debug("Child cwd seed failed: %s", e) wall_start = time.time() parent_reads_snapshot = ( list(file_state.known_reads(parent_task_id)) if parent_task_id else [] -
tools/file_tools.py@@ -269,103 +269,37 @@ def _registered_task_cwd_override(task_id: str = "default") -> str | None: return _sentinel_free_abs_cwd(overrides.get("cwd")) -def _live_cwd_if_owned(env, task_id: str) -> str | None: - """The env's live cwd, but only when THIS session owns it. - - The terminal env is shared (collapsed to the ``"default"`` container), so its - ``cwd`` tracks the LAST session that ran a command. With two worktree - sessions open, trusting it blindly routes one session's edits into the other - session's checkout (the wrong-worktree-patch bug). ``terminal_tool`` stamps - ``env.cwd_owner`` with the session that last drove the env; return its cwd - only when that owner matches the resolving session, else ``None`` so the - caller falls through to this session's own registered cwd override. Unknown - owner / ``default`` keys keep the prior behavior (single-session / CLI). - """ - if env is None: - return None - live = getattr(env, "cwd", None) - if not live: - return None - owner = str(getattr(env, "cwd_owner", "") or "") - tid = str(task_id or "") - if owner and tid and owner != "default" and tid != "default" and owner != tid: - return None - return live - - -def _get_live_tracking_cwd(task_id: str = "default") -> str | None: - """Return the task's live terminal cwd for bookkeeping when available.""" - try: - from tools.terminal_tool import _resolve_container_task_id - container_key = _resolve_container_task_id(task_id) - except Exception: - container_key = task_id - - with _file_ops_lock: - cached = _file_ops_cache.get(container_key) or _file_ops_cache.get(task_id) - if cached is not None: - env = getattr(cached, "env", None) - live_cwd = _live_cwd_if_owned(env, task_id) - if live_cwd: - _remember_last_known_cwd(container_key, live_cwd) - return live_cwd - # Legacy: a cache entry carrying its own cwd with no env to own it. - if env is None and getattr(cached, "cwd", None): - legacy_cwd = getattr(cached, "cwd", None) - _remember_last_known_cwd(container_key, legacy_cwd) - return legacy_cwd - - try: - from tools.terminal_tool import _active_environments, _env_lock - - with _env_lock: - env = _active_environments.get(container_key) or _active_environments.get(task_id) - live_cwd = _live_cwd_if_owned(env, task_id) - if live_cwd: - _remember_last_known_cwd(container_key, live_cwd) - return live_cwd - except Exception: - pass - - return None - - def _authoritative_workspace_root(task_id: str = "default") -> str | None: """Best-effort absolute workspace root for divergence checks. - Prefers the live terminal cwd (the directory the agent is actually working - in). When no terminal command has run yet — so the live registry is empty — - falls back to a registered task/session cwd override (TUI/Desktop/ACP - sessions register a raw-keyed cwd before any tool runs), then to a - sentinel-free absolute ``$TERMINAL_CWD``. This is what lets a worktree or - Desktop session warn about (and resolve into) its workspace from the very - first ``write_file``/``patch``, before any ``cd`` has populated the live cwd. + Resolution: + + 1. The session's own cwd RECORD (``terminal_tool.get_session_cwd``) — + written on every completed terminal command and seeded by workspace + registration, keyed by the raw session id. Because the record is + per-session, one session's ``cd`` can never leak into another + session's resolution. + 2. A registered task/session cwd override (TUI/Desktop/ACP sessions + register a raw-keyed cwd before any tool runs). Normally already + mirrored into the record at registration; kept as a direct fallback + so a cleared/never-written record still resolves the workspace. + 3. A sentinel-free absolute ``$TERMINAL_CWD`` (the worktree path set by + ``cli.py``/``main.py`` for ``-w`` sessions). Returns ``None`` only when there is genuinely no reliable anchor, in which case callers fall back to the process cwd. """ - live = _get_live_tracking_cwd(task_id) - if live: - return live - # A session-specific registered override (TUI/Desktop/ACP workspace cwd) - # is more authoritative than the shared last-known anchor: it is keyed by - # the raw session id, so when two worktree sessions share the single - # "default" terminal env, a NON-owning session must resolve against its OWN - # registered worktree — never the other session's leftover cwd. (Checked - # before _last_known_cwd, which is keyed by the shared container id.) + try: + from tools.terminal_tool import get_session_cwd + + recorded = get_session_cwd(task_id) + except Exception: + recorded = None + if recorded: + return recorded registered = _registered_task_cwd_override(task_id) if registered: return registered - # When the terminal env was cleaned up mid-conversation, the live cwd is - # gone but the directory the agent navigated to is still recorded in the - # durable _last_known_cwd registry. Prefer it over the config/process - # fallback so a relative-path write resolved BEFORE the env is rebuilt - # still lands in the user's directory (root cause of #26211: write happens - # via _resolve_path_for_task -> here, which runs before _get_file_ops - # rebuilds the env). Keyed by the resolved container id, same as the save. - preserved = _last_known_cwd_for(task_id) - if preserved: - return preserved return _configured_terminal_cwd() @@ -794,45 +728,6 @@ def _is_expected_write_exception(exc: Exception) -> bool: _file_ops_lock = threading.Lock() _file_ops_cache: dict = {} -# Per-task last-known CWD — preserved across env re-creation so -# relative-path file writes land in the right directory after the -# terminal environment is cleaned up and rebuilt (root cause of #26211). -_last_known_cwd: dict = {} - - -def _remember_last_known_cwd(task_id: str, cwd: str | None) -> None: - """Mirror a live terminal cwd into the durable ``_last_known_cwd`` registry. - - Belt-and-suspenders for #26211: the cleanup thread can pop BOTH - ``_file_ops_cache`` and ``_active_environments`` before ``_get_file_ops`` - reaches its stale-cache detection branch, in which case the old cwd is - never saved and the rebuilt env falls back to the config default — exactly - the silent-misplacement bug. By recording the cwd on every successful live - read (which happens on every relative-path file resolution while the env is - alive), the durable anchor no longer depends on the cleanup-detection - branch firing, so it survives recreation regardless of pop ordering. - """ - if not cwd: - return - with _file_ops_lock: - if _last_known_cwd.get(task_id) != cwd: - _last_known_cwd[task_id] = cwd - - -def _last_known_cwd_for(task_id: str = "default") -> str | None: - """Read the durable last-known cwd for *task_id*, container-key aware. - - The registry is keyed by the resolved container id (the same key used by - the save sites in ``_get_file_ops`` / ``_get_live_tracking_cwd``), so look - up the resolved key first and fall back to the raw task id. - """ - try: - from tools.terminal_tool import _resolve_container_task_id - container_key = _resolve_container_task_id(task_id) - except Exception: - container_key = task_id - with _file_ops_lock: - return _last_known_cwd.get(container_key) or _last_known_cwd.get(task_id) # Track files read per task to detect re-read loops and deduplicate reads. # Per task_id we store: @@ -1069,13 +964,18 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: _last_activity[task_id] = time.time() return cached else: - # Environment was cleaned up -- preserve the old cwd before - # invalidating the stale cache entry (fixes #26211: silent - # file-creation failures in long-running conversations). + # Environment was cleaned up -- preserve the old cwd in the + # session record before invalidating the stale cache entry + # (fixes #26211: silent file-creation failures in long-running + # conversations). Usually a no-op: every completed command + # already recorded its cwd. old_cwd = getattr(cached, "cwd", None) if old_cwd: - with _file_ops_lock: - _last_known_cwd[task_id] = old_cwd + try: + from tools.terminal_tool import record_session_cwd + record_session_cwd(raw_task_id, old_cwd) + except Exception: + pass with _file_ops_lock: _file_ops_cache.pop(task_id, None) @@ -1113,7 +1013,12 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: else: image = "" - cwd = overrides.get("cwd") or _last_known_cwd.get(task_id) or config["cwd"] + try: + from tools.terminal_tool import get_session_cwd + recorded_cwd = get_session_cwd(raw_task_id) + except Exception: + recorded_cwd = None + cwd = overrides.get("cwd") or recorded_cwd or config["cwd"] # Re-apply the container cwd guard that _get_env_config() already # ran on config["cwd"] (see #50636). A per-task cwd override # registered by the gateway/TUI/ACP for workspace tracking is a -
tools/terminal_tool.py@@ -1069,6 +1069,58 @@ def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None: # Thread-safe because each task_id is unique per rollout. _task_env_overrides: Dict[str, Dict[str, Any]] = {} +# ── Per-session cwd records (cwd rearchitecture, step 1) ──────────────────── +# +# The durable source of truth for "which directory is THIS session working +# in". Keyed by the raw session/task key (NOT the collapsed container id): +# the terminal env is shared across sessions, so any cwd state stored on the +# env is a global mutable timeshared between sessions — the root cause of the +# wrong-worktree bug class (env.cwd_owner stamping, _last_known_cwd, and the +# ownership ladder in file_tools are all patches over that misplacement). +# +# Step 1 (this change): dual-write only. Every site that learns a session's +# live cwd (post-command tracking, cwd-override registration) also records it +# here. Readers still use the legacy env.cwd ladder. Later steps flip +# file_tools and _resolve_command_cwd to read this store, then delete the +# env-side tracking + ownership guards. +_session_cwd: Dict[str, str] = {} +_session_cwd_lock = threading.Lock() + + +def record_session_cwd(session_key: Optional[str], cwd: Optional[str]) -> None: + """Record *cwd* as the working directory of *session_key*. + + Called wherever a session's live cwd becomes known: after a terminal + command completes (the env's post-command tracking has just parsed the + resulting cwd) and when a surface registers a workspace cwd override. + Empty/None session keys collapse to ``"default"`` (single-session CLI). + Non-string / empty cwds are ignored. + """ + if not isinstance(cwd, str) or not cwd.strip(): + return + key = str(session_key or "default") + with _session_cwd_lock: + if _session_cwd.get(key) != cwd: + _session_cwd[key] = cwd + + +def get_session_cwd(session_key: Optional[str]) -> Optional[str]: + """Return the recorded working directory for *session_key*, if any. + + No fallback chain here on purpose: callers decide what an absent record + means (config default, TERMINAL_CWD seed, process cwd). ``None``/empty + keys read the ``"default"`` record. + """ + key = str(session_key or "default") + with _session_cwd_lock: + return _session_cwd.get(key) + + +def clear_session_cwd(session_key: str) -> None: + """Drop a session's cwd record (session teardown).""" + with _session_cwd_lock: + _session_cwd.pop(session_key, None) + def register_task_env_overrides(task_id: str, overrides: Dict[str, Any]): """ @@ -1090,15 +1142,14 @@ def register_task_env_overrides(task_id: str, overrides: Dict[str, Any]): # If a live environment already exists for this task, a freshly registered # ``cwd`` override (e.g. the ACP client switching the editor's project root - # mid-session via ``session/load`` / ``session/resume``) must take effect on - # the cached env too. ``terminal_tool`` resolves the per-command cwd as - # ``workdir > env.cwd > config/override cwd`` so that ordinary in-session - # ``cd`` state is preserved; without syncing here the override would sit - # below the (already-set) ``env.cwd`` and be silently ignored once any - # command has run. Pushing it onto the live env keeps ``cd`` tracking intact - # while letting an explicit ACP cwd change win, as the client expects. + # mid-session via ``session/load`` / ``session/resume``) must take effect + # immediately. The session record is what commands resolve against; + # the live env's cwd is also updated so env-side seeding stays consistent. new_cwd = overrides.get("cwd") if isinstance(new_cwd, str) and new_cwd.strip(): + # A registered workspace cwd IS the session's working directory until + # a `cd` changes it. + record_session_cwd(task_id, new_cwd) # The live env is cached under the raw task_id for per-session surfaces # (ACP/gateway/dashboard) and under the collapsed container id for # isolation-keyed rollouts. Try the raw id first, then the container id, @@ -1118,6 +1169,7 @@ def clear_task_env_overrides(task_id: str): Called during cleanup to avoid stale entries accumulating. """ _task_env_overrides.pop(task_id, None) + clear_session_cwd(task_id) def _resolve_container_task_id(task_id: Optional[str]) -> str: @@ -1986,43 +2038,21 @@ def _resolve_notification_flag_conflict( def _resolve_command_cwd( *, workdir: Optional[str], - env: Any, default_cwd: str, - prev_owner: Optional[str] = None, + session_key: Optional[str] = None, ) -> str: - """Return the cwd for a command, preferring the live session cwd. - - ``terminal_tool`` historically re-sent the init-time/config cwd on every - call. That broke session-local ``cd`` state: the environment tracked the - new directory in ``env.cwd``, but foreground/background calls kept forcing - the old cwd back through ``env.execute(..., cwd=...)``. Explicit - ``workdir=`` must still override everything. - - When ``prev_owner`` is provided and differs from the current session, - ``env.cwd`` was mutated by a *different* session's ``cd`` and must NOT be - trusted — fall through to ``default_cwd`` (the config/override cwd) so - the command runs in this session's own workspace, not the previous - session's leftover checkout. This mirrors the ``_live_cwd_if_owned`` - guard file_tools uses for the same shared-env problem. + """Return the cwd for a command. Explicit ``workdir=`` overrides everything. + + Otherwise the session's own cwd RECORD (``get_session_cwd``) wins — it is + written after every completed command for this session, so it IS the + session's ``cd`` state, with no shared-env ambiguity: another session's + ``cd`` lands in another record and can't affect us. A session with no + record yet (first command) runs in ``default_cwd`` (config/override cwd), + which is also what seeds a fresh environment. """ if workdir: return workdir - - live_cwd = getattr(env, "cwd", None) - if isinstance(live_cwd, str) and live_cwd.strip(): - # The env is shared (collapsed to "default"); its cwd tracks the LAST - # session that ran a command. If a different session owned the env - # before this call claimed it, env.cwd is that session's leftover `cd` - # — not ours. Don't use it. - if prev_owner is not None: - session_key = getattr(env, "cwd_owner", "") - # cwd_owner was already overwritten to the current session at the - # call site, so compare against the captured previous owner. - if prev_owner and prev_owner != "default" and session_key != prev_owner: - return default_cwd - return live_cwd - - return default_cwd + return get_session_cwd(session_key) or default_cwd def terminal_tool( @@ -2111,7 +2141,7 @@ def terminal_tool( else: image = "" - cwd = overrides.get("cwd") or config["cwd"] + cwd = overrides.get("cwd") or get_session_cwd(task_id) or config["cwd"] # A per-task cwd override (registered by the gateway/TUI for workspace # tracking, or by RL/benchmark envs) wins over config["cwd"] — but # config["cwd"] was already sanitized for container backends in @@ -2164,6 +2194,7 @@ def terminal_tool( # Use a per-task creation lock so concurrent tool calls for the same # task_id wait for the first one to finish creating the sandbox, # instead of each creating their own (wasting Modal resources). + env = None with _env_lock: # Prefer the collapsed container id, but fall back to an env cached # under the raw task_id. Per-session surfaces (ACP/gateway/dashboard) @@ -2265,6 +2296,16 @@ def terminal_tool( env = new_env logger.info("%s environment ready for task %s", env_type, effective_task_id[:8]) + if env is None: + # Unreachable in practice (either the cached branch or the creation + # branch assigned env above); guard for type-safety and so a future + # refactor of the branches can't fall through to an AttributeError. + return json.dumps({ + "output": "", + "exit_code": -1, + "error": "Terminal environment unavailable (creation raced cleanup)", + }, ensure_ascii=False) + # Hard-block: gateway lifecycle commands (systemctl/launchctl/hermes # restart|stop targeting hermes-gateway) must never run inside the # gateway process itself. The restart would SIGTERM the gateway, which @@ -2362,24 +2403,13 @@ def terminal_tool( "EOF." ) - # Claim the (shared "default") terminal env for the session driving this - # command. File tools read env.cwd_owner to decide whether the env's live - # cwd is THIS session's `cd` or a different worktree session's — without - # it, two open worktree sessions sharing the env route each other's edits - # to the wrong checkout. get_current_session_key()'s contextvar doesn't - # cross tool-worker threads, so fall back to the raw task_id (which IS the - # session_key for the top-level agent) — a stable, thread-safe anchor. + # The session key that drives cwd records: get_current_session_key()'s + # contextvar doesn't cross tool-worker threads, so fall back to the raw + # task_id (which IS the session_key for the top-level agent) — a + # stable, thread-safe anchor. from tools.approval import get_current_session_key session_key = get_current_session_key(default="") or (task_id or "") - # Capture the env's previous owner BEFORE claiming it — _resolve_command_cwd - # needs to know whether env.cwd was left by a *different* session's `cd` - # (in which case it's stale for this session and must be ignored). - prev_cwd_owner = getattr(env, "cwd_owner", "") or "" - try: - env.cwd_owner = session_key - except Exception: - pass if background: # Spawn a tracked background process via the process registry. @@ -2389,9 +2419,8 @@ def terminal_tool( effective_cwd = _resolve_command_cwd( workdir=workdir, - env=env, default_cwd=cwd, - prev_owner=prev_cwd_owner, + session_key=session_key, ) try: if env_type == "local": @@ -2650,9 +2679,8 @@ def terminal_tool( try: command_cwd = _resolve_command_cwd( workdir=workdir, - env=env, default_cwd=cwd, - prev_owner=prev_cwd_owner, + session_key=session_key, ) execute_kwargs = { "timeout": effective_timeout, @@ -2693,7 +2721,15 @@ def terminal_tool( # Got a result break - + + # Dual-write (cwd rearch step 1): the env's post-command tracking + # (marker parse / local sync) has just updated env.cwd with the + # directory this command finished in. That cwd belongs to THIS + # session — record it under the session key so the durable record + # never depends on the shared env surviving or on who drives the + # env next. + record_session_cwd(session_key, getattr(env, "cwd", None)) + # Extract output output = result.get("output", "") returncode = result.get("returncode", 0)
-
-
#65487 fix(execute-code): resolve project-mode cwd from the session's own directory
files:
tests/tools/test_code_execution_modes.py,tools/code_execution_tool.pyShow the diff
-
tests/tools/test_code_execution_modes.py@@ -220,6 +220,69 @@ def test_project_expands_tilde(self): with patch.dict(os.environ, {"TERMINAL_CWD": "~"}): self.assertEqual(_resolve_child_cwd("project", "/tmp/staging"), home) + def test_project_prefers_registered_task_cwd_override(self): + import tempfile + import tools.terminal_tool as terminal_tool + + with tempfile.TemporaryDirectory() as td: + task_id = "session-cwd-test" + with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}): + with patch.object(terminal_tool, "_task_env_overrides", {}, create=False): + terminal_tool.register_task_env_overrides(task_id, {"cwd": td}) + self.assertEqual(_resolve_child_cwd("project", "/tmp/staging", task_id=task_id), td) + + def test_project_prefers_session_cwd_record_over_override(self): + """The session's cwd RECORD (its live `cd` state) outranks the + registration-time workspace override — same ladder as file tools + and the terminal, so a `cd` before execute_code is honored.""" + import tempfile + import tools.terminal_tool as terminal_tool + + with tempfile.TemporaryDirectory() as reg, tempfile.TemporaryDirectory() as cded: + task_id = "session-record-test" + with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}): + with patch.object(terminal_tool, "_task_env_overrides", {}, create=False), \ + patch.object(terminal_tool, "_session_cwd", {}, create=False): + terminal_tool.register_task_env_overrides(task_id, {"cwd": reg}) + # Simulate a later `cd`: post-command tracking rewrites the record. + terminal_tool.record_session_cwd(task_id, cded) + self.assertEqual( + _resolve_child_cwd("project", "/tmp/staging", task_id=task_id), cded + ) + + def test_project_uses_session_cwd_record_without_any_override(self): + """A session that only `cd`'d (no session.cwd.set registration) still + resolves to its recorded directory.""" + import tempfile + import tools.terminal_tool as terminal_tool + + with tempfile.TemporaryDirectory() as cded: + task_id = "record-only-test" + with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}): + with patch.object(terminal_tool, "_task_env_overrides", {}, create=False), \ + patch.object(terminal_tool, "_session_cwd", {}, create=False): + terminal_tool.record_session_cwd(task_id, cded) + self.assertEqual( + _resolve_child_cwd("project", "/tmp/staging", task_id=task_id), cded + ) + + def test_project_stale_record_falls_through_to_override(self): + """A recorded directory that no longer exists is skipped; the + registered override is the next rung.""" + import tempfile + import tools.terminal_tool as terminal_tool + + with tempfile.TemporaryDirectory() as reg: + task_id = "stale-record-test" + with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}): + with patch.object(terminal_tool, "_task_env_overrides", {}, create=False), \ + patch.object(terminal_tool, "_session_cwd", {}, create=False): + terminal_tool.register_task_env_overrides(task_id, {"cwd": reg}) + terminal_tool.record_session_cwd(task_id, "/deleted/dir/gone") + self.assertEqual( + _resolve_child_cwd("project", "/tmp/staging", task_id=task_id), reg + ) + # --------------------------------------------------------------------------- # Schema description @@ -316,6 +379,28 @@ def test_project_mode_runs_in_session_cwd(self): os.path.realpath(td), ) + def test_project_mode_uses_registered_session_cwd_override(self): + """Project mode must honor session.cwd.set-style overrides even when + TERMINAL_CWD is absent or points elsewhere.""" + import tempfile + import tools.terminal_tool as terminal_tool + + with tempfile.TemporaryDirectory() as td: + task_id = "session-cwd-test" + with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}): + with patch.object(terminal_tool, "_task_env_overrides", {}, create=False): + terminal_tool.register_task_env_overrides(task_id, {"cwd": td}) + with _mock_mode("project"): + with patch("model_tools.handle_function_call", side_effect=_mock_handle_function_call): + raw = execute_code( + code="import os; print(os.getcwd())", + task_id=task_id, + enabled_tools=list(SANDBOX_ALLOWED_TOOLS), + ) + result = json.loads(raw) + self.assertEqual(result["status"], "success") + self.assertEqual(os.path.realpath(result["output"].strip()), os.path.realpath(td)) + def test_project_mode_interpreter_is_venv_python(self): """Project mode: sys.executable inside the child is the venv's python when VIRTUAL_ENV is set to a real venv.""" -
tools/code_execution_tool.py@@ -1339,7 +1339,7 @@ def execute_code( # Env scrubbing and tool whitelist apply identically in both modes. _mode = _get_execution_mode() _child_python = _resolve_child_python(_mode) - _child_cwd = _resolve_child_cwd(_mode, tmpdir) + _child_cwd = _resolve_child_cwd(_mode, tmpdir, task_id=task_id or "") _script_path = os.path.join(tmpdir, "script.py") proc = subprocess.Popen( @@ -1745,17 +1745,43 @@ def _resolve_child_python(mode: str) -> str: return sys.executable -def _resolve_child_cwd(mode: str, staging_dir: str) -> str: +def _resolve_child_cwd(mode: str, staging_dir: str, task_id: str = "") -> str: """Resolve the working directory for the execute_code subprocess. - ``strict``: the staging tmpdir (today's behavior). - - ``project``: the session's TERMINAL_CWD (same as the terminal tool), or - ``os.getcwd()`` if TERMINAL_CWD is unset or doesn't point at a real dir. - Falls back to the staging tmpdir as a last resort so we never invoke - Popen with a nonexistent cwd. + - ``project``: the session's own cwd — its per-session cwd record + (written after every completed terminal command), then the raw + per-session cwd override registered via ``session.cwd.set`` / + ``register_task_env_overrides``, then the session's TERMINAL_CWD + (same as the terminal tool), or ``os.getcwd()`` if none points at a + real dir. Falls back to the staging tmpdir as a last resort so we + never invoke Popen with a nonexistent cwd. + + This mirrors the resolution ladder file tools and the terminal use + (record → registered override → TERMINAL_CWD), so all file-writing + paths within a session agree on the working directory. (#56047) """ if mode != "project": return staging_dir + if task_id: + # 1. The session's cwd record — IS the session's `cd` state. + try: + from tools.terminal_tool import get_session_cwd + + recorded = get_session_cwd(task_id) + except Exception: + recorded = None + if recorded and os.path.isdir(recorded): + return recorded + # 2. Registered workspace override (session.cwd.set → gateway/TUI/ACP). + try: + from tools.file_tools import _registered_task_cwd_override + + session_cwd = _registered_task_cwd_override(task_id) + except Exception: + session_cwd = None + if session_cwd and os.path.isdir(session_cwd): + return session_cwd raw = os.environ.get("TERMINAL_CWD", "").strip() if raw: expanded = os.path.expanduser(raw)
-
9 PRs closed without merging
From the close comments: merged via another change 3 · superseded by another PR 2 · done elsewhere 1 · duplicate 1 · other 2
- #35768 fix(skills): ignore nested assets in skill lookup · other
-
#36895 fix(skills): ignore asset markdown in skill_view collision scan · done elsewhere →
9137b86,#37164 -
#49658 fix(tui): always capture current CWD instead of inheriting stale HERMES_CWD · other →
#49662,#49637 -
#49662 fix(cli): use live launch cwd for HERMES_CWD instead of stale env · superseded by another PR →
#49658 -
#56055 fix(agent): resolve execute_code cwd from per-session override (#56047) · merged via another change →
298a949,#65487,#56803,#65213
Show 4 more
-
#56069 fix(code-exec): honor session cwd override · duplicate →
#35513,#56055,#46843,#56047 -
#56406 fix(tools): honor per-session cwd override in execute_code project mode · superseded by another PR →
#35513 -
#56803 fix(execute-code): honor session cwd overrides · merged via another change →
4d6686c,#65487,#56055,#65213 - #109839 fix(tui): make the launch cwd win over an inherited HERMES_CWD · merged via another change
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.07: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab