Working directory configuration fixes across components
Cluster 29265 · agent. Data as of
Nothing open on GitHub. Fix merged in #35028 on 1 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #35028. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address how the working directory is determined and respected in gateway sessions, agent resolution, and the local backend CLI. They span a feature request and several bug fixes across different parts of the system.
Open issues0
Open PRs0
Fixes on record
-
#35028 fix(agent): resolve agent cwd from TERMINAL_CWD via one reader (closes #24882, #24969, #27383)
files:
agent/prompt_builder.py,agent/runtime_cwd.py,agent/system_prompt.py,tests/agent/test_prompt_builder.py,tests/agent/test_runtime_cwd.py,tests/agent/test_system_prompt.py,tests/tools/test_gateway_cwd_contract.pyShow the diff
-
agent/prompt_builder.py@@ -14,6 +14,7 @@ from hermes_constants import get_hermes_home, get_skills_dir, is_wsl from typing import Optional +from agent.runtime_cwd import resolve_agent_cwd from agent.skill_utils import ( extract_skill_conditions, extract_skill_description, @@ -802,7 +803,7 @@ def build_environment_hints() -> str: host_lines.append(f"User home directory: {os.path.expanduser('~')}") try: - host_lines.append(f"Current working directory: {os.getcwd()}") + host_lines.append(f"Current working directory: {resolve_agent_cwd()}") except OSError: pass -
agent/runtime_cwd.py@@ -0,0 +1,33 @@ +"""Single source of truth for the agent working directory. + +`TERMINAL_CWD` is the runtime carrier for the configured working directory +(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at +gateway/cron startup). The local-CLI backend deliberately leaves it unset and +relies on the launch dir. Reading it in one place keeps the system prompt, the +tool surfaces, and context-file discovery agreeing on where the agent lives. + +The #29531 per-session extension point is this function: a future PR adds a +contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the +`set_session_vars` seam — by design, not a reopening hazard. +""" + +import os +from pathlib import Path + + +def resolve_agent_cwd() -> Path: + raw = os.environ.get("TERMINAL_CWD", "").strip() + if raw: + p = Path(raw).expanduser() + if p.is_dir(): + return p + return Path(os.getcwd()) + + +def resolve_context_cwd() -> Path | None: + # None means "no configured cwd": build_context_files_prompt then falls back + # to the launch dir (os.getcwd()) — correct for the local CLI. The gateway + # avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py). + # No getcwd arm here: that fallback is owned by the caller, not this resolver. + raw = os.environ.get("TERMINAL_CWD", "").strip() + return Path(raw).expanduser() if raw else None -
agent/system_prompt.py@@ -24,7 +24,6 @@ from __future__ import annotations import json -import os from typing import Any, Dict, List, Optional from agent.prompt_builder import ( @@ -41,6 +40,7 @@ TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, ) +from agent.runtime_cwd import resolve_context_cwd def _ra(): @@ -288,13 +288,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) context_parts.append(system_message) if not agent.skip_context_files: - # Use TERMINAL_CWD for context file discovery when set (gateway - # mode). The gateway process runs from the hermes-agent install - # dir, so os.getcwd() would pick up the repo's AGENTS.md and - # other dev files — inflating token usage by ~10k for no benefit. - _context_cwd = os.getenv("TERMINAL_CWD") or None + # Prefer the configured TERMINAL_CWD (gateway mode). When unset (local + # CLI), None lets build_context_files_prompt fall back to the launch + # dir — the user's real cwd there, but the install dir for the gateway + # daemon, which is why the gateway sets TERMINAL_CWD. context_files_prompt = _r.build_context_files_prompt( - cwd=_context_cwd, skip_soul=_soul_loaded) + cwd=resolve_context_cwd(), skip_soul=_soul_loaded) if context_files_prompt: context_parts.append(context_files_prompt) -
tests/agent/test_prompt_builder.py@@ -927,6 +927,29 @@ def test_build_environment_hints_suppresses_host_on_docker_backend(self, monkeyp assert "Terminal backend: docker" in result assert "inside" in result.lower() + def test_build_environment_hints_uses_terminal_cwd_over_launch_dir(self, monkeypatch, tmp_path): + """THE BUG: gateway/cron set TERMINAL_CWD but the prompt emitted os.getcwd() + (the daemon launch dir). Regression for #24882/#24969/#27383/#29265.""" + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + configured = tmp_path / "workspace" + configured.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(configured)) + monkeypatch.chdir(tmp_path) + _pb._clear_backend_probe_cache() + assert f"Current working directory: {configured}" in _pb.build_environment_hints() + + def test_build_environment_hints_falls_back_to_launch_dir(self, monkeypatch, tmp_path): + """The #19242 local-CLI contract: no TERMINAL_CWD → the launch dir.""" + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.chdir(tmp_path) + _pb._clear_backend_probe_cache() + assert f"Current working directory: {tmp_path}" in _pb.build_environment_hints() + def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatch): """When the probe succeeds, its output must appear in the hint block.""" import agent.prompt_builder as _pb @@ -1247,4 +1270,3 @@ def test_guidance_is_string(self): # ========================================================================= - -
tests/agent/test_runtime_cwd.py@@ -0,0 +1,79 @@ +"""Tests for agent/runtime_cwd.py — the single source of truth for the agent working directory.""" + +import os +from pathlib import Path + +import pytest + +import agent.runtime_cwd as rt +from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd + + +def _raise_oserror(*args, **kwargs): + raise OSError("cwd gone") + + +class TestResolveAgentCwd: + def test_prefers_terminal_cwd_over_getcwd(self, monkeypatch, tmp_path): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + monkeypatch.chdir(os.path.expanduser("~")) + assert resolve_agent_cwd() == tmp_path + + def test_falls_back_to_getcwd_when_unset(self, monkeypatch, tmp_path): + # The #19242 local-CLI contract: TERMINAL_CWD is unset, so the launch dir wins. + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.chdir(tmp_path) + assert resolve_agent_cwd() == tmp_path + + def test_skips_nonexistent_terminal_cwd(self, monkeypatch, tmp_path): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "gone")) + monkeypatch.chdir(tmp_path) + assert resolve_agent_cwd() == tmp_path + + def test_expands_leading_tilde(self, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", "~") + assert resolve_agent_cwd() == Path(os.path.expanduser("~")) + + def test_whitespace_only_terminal_cwd_falls_back_to_getcwd(self, monkeypatch, tmp_path): + # " ".strip() → "" → falsy, so the launch dir wins (not a " " path). + monkeypatch.setenv("TERMINAL_CWD", " ") + monkeypatch.chdir(tmp_path) + assert resolve_agent_cwd() == tmp_path + + def test_propagates_oserror_from_getcwd(self, monkeypatch): + # The fallback arm calls os.getcwd(), which can raise OSError (deleted cwd). + # The resolver must NOT swallow it — build_environment_hints owns the + # try/except OSError guard at the call site (prompt_builder.py:805). + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.setattr(rt.os, "getcwd", _raise_oserror) + with pytest.raises(OSError): + resolve_agent_cwd() + + +class TestResolveContextCwd: + def test_returns_dir_when_set(self, monkeypatch, tmp_path): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + assert resolve_context_cwd() == tmp_path + + def test_returns_none_when_unset(self, monkeypatch): + # Unset → None; the caller (build_context_files_prompt) then getcwds — + # the local-CLI #19242 contract. Discovery still runs; it is NOT skipped. + monkeypatch.delenv("TERMINAL_CWD", raising=False) + assert resolve_context_cwd() is None + + def test_returns_nonexistent_dir_unguarded(self, monkeypatch, tmp_path): + # Deliberate asymmetry vs resolve_agent_cwd: context discovery has no isdir + # guard, so a missing dir is returned (not None) — discovery just finds nothing. + missing = tmp_path / "gone" + monkeypatch.setenv("TERMINAL_CWD", str(missing)) + assert resolve_context_cwd() == missing + + def test_expands_leading_tilde(self, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", "~") + assert resolve_context_cwd() == Path(os.path.expanduser("~")) + + def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch): + # " ".strip() → "" → None, so the caller getcwds for discovery rather + # than building Path(" ") and resolving garbage under the launch dir. + monkeypatch.setenv("TERMINAL_CWD", " ") + assert resolve_context_cwd() is None -
tests/agent/test_system_prompt.py@@ -0,0 +1,57 @@ +"""Tests for agent/system_prompt.py — context-file cwd wiring.""" + +from types import SimpleNamespace +from unittest.mock import patch + +from agent.system_prompt import build_system_prompt_parts + + +def _make_agent(**overrides): + base = dict( + load_soul_identity=False, + skip_context_files=False, + valid_tool_names=[], + _task_completion_guidance=False, + _tool_use_enforcement=False, + _environment_probe=False, + _kanban_worker_guidance="", + _memory_store=None, + _memory_manager=None, + model="", + provider="", + platform="", + pass_session_id=False, + session_id="", + ) + base.update(overrides) + return SimpleNamespace(**base) + + +def _captured_context_cwd(agent): + """The cwd build_system_prompt_parts hands to build_context_files_prompt.""" + captured = {} + + def fake_context_files(cwd=None, skip_soul=False): + captured["cwd"] = cwd + return "" + + with ( + patch("run_agent.load_soul_md", return_value=""), + patch("run_agent.build_nous_subscription_prompt", return_value=""), + patch("run_agent.build_environment_hints", return_value=""), + patch("run_agent.build_context_files_prompt", side_effect=fake_context_files), + ): + build_system_prompt_parts(agent) + return captured["cwd"] + + +class TestContextFileCwd: + def test_none_when_terminal_cwd_unset(self, monkeypatch): + # Unset → None, so discovery falls back to the launch dir inside + # build_context_files_prompt (the local-CLI #19242 contract). + monkeypatch.delenv("TERMINAL_CWD", raising=False) + assert _captured_context_cwd(_make_agent()) is None + + def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + assert _captured_context_cwd(_make_agent()) == tmp_path -
tests/tools/test_gateway_cwd_contract.py@@ -0,0 +1,69 @@ +"""Tool-surface cwd contract tests for gateway workspaces. + +These cover the platform-neutral part of #29265: once the gateway has resolved +``TERMINAL_CWD``, the user-visible tool surfaces should agree on that workspace. + +Unlike the system-prompt readers fixed in the gateway-cwd-resolver cluster +(agent/runtime_cwd.py), these tool sites already read ``TERMINAL_CWD``-first and +were deliberately left out of scope. This file is a *characterization* guard: it +pins the already-correct behavior so the supersession of PR #29365 is airtight +and a future refactor of these sites can't silently regress the contract. +""" + +from __future__ import annotations + +from pathlib import Path + +from tools import code_execution_tool, file_tools, terminal_tool + + +def test_terminal_env_config_uses_terminal_cwd(monkeypatch, tmp_path): + """The terminal tool's default cwd should come from TERMINAL_CWD.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + + monkeypatch.setenv("TERMINAL_ENV", "local") + monkeypatch.setenv("TERMINAL_CWD", str(workspace)) + + config = terminal_tool._get_env_config() + + assert config["cwd"] == str(workspace) + + +def test_file_tool_relative_paths_use_terminal_cwd(monkeypatch, tmp_path): + """Relative file/search/patch paths resolve under TERMINAL_CWD.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + + monkeypatch.setenv("TERMINAL_CWD", str(workspace)) + + resolved = file_tools._resolve_path_for_task("notes/today.md", task_id="cwd-contract") + + assert resolved == (workspace / "notes" / "today.md").resolve() + + +def test_execute_code_project_mode_uses_terminal_cwd(monkeypatch, tmp_path): + """Project-mode execute_code should run scripts from TERMINAL_CWD.""" + workspace = tmp_path / "workspace" + staging = tmp_path / "staging" + workspace.mkdir() + staging.mkdir() + + monkeypatch.setenv("TERMINAL_CWD", str(workspace)) + + resolved = code_execution_tool._resolve_child_cwd("project", str(staging)) + + assert Path(resolved) == workspace + + +def test_execute_code_project_mode_falls_back_when_terminal_cwd_missing(monkeypatch, tmp_path): + """Invalid TERMINAL_CWD should not break execute_code project mode startup.""" + staging = tmp_path / "staging" + staging.mkdir() + + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "missing")) + + resolved = code_execution_tool._resolve_child_cwd("project", str(staging)) + + assert Path(resolved).is_dir() + assert Path(resolved) != tmp_path / "missing"
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#29365 fix(gateway): honor configured cwd in gateway sessions · done elsewhere →
#29265,#35028
Closed issues · 1
- Closed#29265[Feature]: Configurable default working directory for QQBot and Weixin gateway sessions
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: 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 LabAt a glance
- Subsystem
- agent
- Merged PRs
- #35028
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 1 Jun 2026