diff --git a/gateway/run.py b/gateway/run.py index 6a14341189..6aa8b221ff 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -286,6 +286,10 @@ if _config_path.exists(): # Only bridge explicit absolute paths from config.yaml. if _cfg_key == "cwd" and str(_val) in (".", "auto", "cwd"): continue + # Expand shell tilde in cwd so subprocess.Popen never + # receives a literal "~/" which the kernel rejects. + if _cfg_key == "cwd" and isinstance(_val, str): + _val = os.path.expanduser(_val) if isinstance(_val, list): os.environ[_env_var] = json.dumps(_val) else: diff --git a/tests/gateway/test_config_cwd_bridge.py b/tests/gateway/test_config_cwd_bridge.py index 7f6a757500..af967af24b 100644 --- a/tests/gateway/test_config_cwd_bridge.py +++ b/tests/gateway/test_config_cwd_bridge.py @@ -41,6 +41,10 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): # TERMINAL_CWD. Mirrors the fix in gateway/run.py. if cfg_key == "cwd" and str(val) in (".", "auto", "cwd"): continue + # Expand shell tilde so subprocess.Popen never receives a literal + # "~/" which the kernel rejects. + if cfg_key == "cwd" and isinstance(val, str): + val = os.path.expanduser(val) if isinstance(val, list): env[env_var] = json.dumps(val) else: @@ -55,6 +59,8 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): if alias_env not in env: alias_val = cfg.get(alias_key) if isinstance(alias_val, str) and alias_val.strip(): + if alias_key == "cwd": + alias_val = os.path.expanduser(alias_val) env[alias_env] = alias_val.strip() # --- Replicate lines 144-147: MESSAGING_CWD fallback --- @@ -205,3 +211,32 @@ class TestNestedTerminalCwdPlaceholderSkip: assert result["TERMINAL_ENV"] == "docker" assert result["TERMINAL_TIMEOUT"] == "300" assert result["TERMINAL_CWD"] == "/from/env" + + +class TestTildeExpansion: + """terminal.cwd values containing shell tilde must be expanded. + + subprocess.Popen does not expand shell syntax, so a literal "~/" + causes FileNotFoundError. Regression test for commit 3c42064e. + """ + + def test_terminal_cwd_tilde_expanded(self): + """terminal.cwd: '~/projects' should expand to /home//projects.""" + cfg = {"terminal": {"cwd": "~/projects"}} + result = _simulate_config_bridge(cfg) + assert result["TERMINAL_CWD"] == os.path.expanduser("~/projects") + + def test_top_level_cwd_tilde_expanded(self): + """top-level cwd: '~/' should expand to user's home directory.""" + cfg = {"cwd": "~/"} + result = _simulate_config_bridge(cfg) + assert result["TERMINAL_CWD"] == os.path.expanduser("~/") + + def test_tilde_with_nested_precedence(self): + """Nested terminal.cwd should win over top-level, both expanded.""" + cfg = { + "cwd": "~/top", + "terminal": {"cwd": "~/nested"}, + } + result = _simulate_config_bridge(cfg) + assert result["TERMINAL_CWD"] == os.path.expanduser("~/nested") diff --git a/tools/environments/local.py b/tools/environments/local.py index 4aa6b64e2d..1029545f08 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -305,6 +305,8 @@ class LocalEnvironment(BaseEnvironment): """ def __init__(self, cwd: str = "", timeout: int = 60, env: dict = None): + if cwd: + cwd = os.path.expanduser(cwd) super().__init__(cwd=cwd or os.getcwd(), timeout=timeout, env=env) self.init_session() diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 24a6bff40e..395ee8f5b6 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -925,6 +925,8 @@ def _get_env_config() -> Dict[str, Any]: # /workspace and track the original host path separately. Otherwise keep the # normal sandbox behavior and discard host paths. cwd = os.getenv("TERMINAL_CWD", default_cwd) + if cwd: + cwd = os.path.expanduser(cwd) host_cwd = None host_prefixes = ("/Users/", "/home/", "C:\\", "C:/") if env_type == "docker" and mount_docker_cwd: