diff --git a/gateway/run.py b/gateway/run.py index 646a4856928..caecc8438a9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1436,6 +1436,9 @@ if _config_path.exists(): # config.yaml overrides .env for these since it's the documented config path. _terminal_cfg = _cfg.get("terminal", {}) if _terminal_cfg and isinstance(_terminal_cfg, dict): + _terminal_backend = str( + _terminal_cfg.get("backend") or os.environ.get("TERMINAL_ENV") or "" + ).strip().lower() _terminal_env_map = { "backend": "TERMINAL_ENV", "cwd": "TERMINAL_CWD", @@ -1474,10 +1477,18 @@ 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. + # Expand shell tilde in local/container cwd so subprocess.Popen + # never receives a literal "~/" which the kernel rejects. + # SSH cwd is interpreted by the remote shell, so preserve + # "~" / "~/..." for the SSH backend instead of expanding it + # to the Hermes host/container HOME (often /opt/data). if _cfg_key == "cwd" and isinstance(_val, str): - _val = os.path.expanduser(_val) + _raw_cwd = _val.strip() + if not ( + _terminal_backend == "ssh" + and (_raw_cwd == "~" or _raw_cwd.startswith("~/")) + ): + _val = os.path.expanduser(_val) if isinstance(_val, (list, dict)): os.environ[_env_var] = json.dumps(_val) else: @@ -1656,8 +1667,11 @@ os.environ["HERMES_EXEC_ASK"] = "1" # fallback (deprecated — the warning above tells users to migrate). _configured_cwd = os.environ.get("TERMINAL_CWD", "") if not _configured_cwd or _configured_cwd in {".", "auto", "cwd"}: - _fallback = os.getenv("MESSAGING_CWD") or str(Path.home()) - os.environ["TERMINAL_CWD"] = _fallback + if os.environ.get("TERMINAL_ENV", "").strip().lower() == "ssh": + os.environ.pop("TERMINAL_CWD", None) + else: + _fallback = os.getenv("MESSAGING_CWD") or str(Path.home()) + os.environ["TERMINAL_CWD"] = _fallback from gateway.config import ( ChannelOverride, diff --git a/tests/gateway/test_config_cwd_bridge.py b/tests/gateway/test_config_cwd_bridge.py index 299b7e6b3be..f36b4a7ee7b 100644 --- a/tests/gateway/test_config_cwd_bridge.py +++ b/tests/gateway/test_config_cwd_bridge.py @@ -28,6 +28,9 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): # --- Replicate lines 59-87: terminal config bridge --- terminal_cfg = cfg.get("terminal", {}) if terminal_cfg and isinstance(terminal_cfg, dict): + terminal_backend = str( + terminal_cfg.get("backend") or env.get("TERMINAL_ENV") or "" + ).strip().lower() terminal_env_map = { "backend": "TERMINAL_ENV", "cwd": "TERMINAL_CWD", @@ -45,10 +48,16 @@ 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. + # Expand shell tilde for local/container backends so subprocess.Popen + # never receives a literal "~/" which the kernel rejects. SSH cwd is + # interpreted by the remote shell, so preserve "~" / "~/..." there. if cfg_key == "cwd" and isinstance(val, str): - val = os.path.expanduser(val) + raw_cwd = val.strip() + if not ( + terminal_backend == "ssh" + and (raw_cwd == "~" or raw_cwd.startswith("~/")) + ): + val = os.path.expanduser(val) if isinstance(val, list): env[env_var] = json.dumps(val) else: @@ -64,14 +73,25 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): 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) + alias_backend = str( + cfg.get("backend") or env.get("TERMINAL_ENV") or "" + ).strip().lower() + raw_cwd = alias_val.strip() + if not ( + alias_backend == "ssh" + and (raw_cwd == "~" or raw_cwd.startswith("~/")) + ): + alias_val = os.path.expanduser(alias_val) env[alias_env] = alias_val.strip() # --- Replicate lines 144-147: MESSAGING_CWD fallback --- configured_cwd = env.get("TERMINAL_CWD", "") if not configured_cwd or configured_cwd in {".", "auto", "cwd"}: - messaging_cwd = env.get("MESSAGING_CWD") or "/root" # Path.home() for root - env["TERMINAL_CWD"] = messaging_cwd + if env.get("TERMINAL_ENV", "").strip().lower() == "ssh": + env.pop("TERMINAL_CWD", None) + else: + messaging_cwd = env.get("MESSAGING_CWD") or "/root" # Path.home() for root + env["TERMINAL_CWD"] = messaging_cwd return env @@ -249,3 +269,26 @@ class TestTildeExpansion: } result = _simulate_config_bridge(cfg) assert result["TERMINAL_CWD"] == os.path.expanduser("~/nested") + + def test_ssh_terminal_cwd_tilde_preserved_for_remote_shell(self, monkeypatch): + """SSH cwd '~' must mean the remote user's home, not the gateway host HOME.""" + monkeypatch.setenv("HOME", "/opt/data") + cfg = {"terminal": {"backend": "ssh", "cwd": "~"}} + result = _simulate_config_bridge(cfg) + assert result["TERMINAL_ENV"] == "ssh" + assert result["TERMINAL_CWD"] == "~" + + def test_ssh_terminal_cwd_tilde_child_preserved_for_remote_shell(self, monkeypatch): + """SSH cwd '~/x' must survive until the SSH shell expands remote HOME.""" + monkeypatch.setenv("HOME", "/opt/data") + cfg = {"terminal": {"backend": "ssh", "cwd": "~/work"}} + result = _simulate_config_bridge(cfg) + assert result["TERMINAL_CWD"] == "~/work" + + def test_ssh_terminal_placeholder_cwd_does_not_fallback_to_host_home(self, monkeypatch): + """SSH placeholder cwd should let terminal_tool use its remote-home default.""" + monkeypatch.setenv("HOME", "/opt/data") + cfg = {"terminal": {"backend": "ssh", "cwd": "auto"}} + result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/host/project"}) + assert result["TERMINAL_ENV"] == "ssh" + assert "TERMINAL_CWD" not in result diff --git a/tests/tools/test_terminal_tool.py b/tests/tools/test_terminal_tool.py index aac71feccc4..8182a46b729 100644 --- a/tests/tools/test_terminal_tool.py +++ b/tests/tools/test_terminal_tool.py @@ -232,6 +232,30 @@ def test_get_env_config_ignores_bad_docker_json_for_ssh_backend(monkeypatch): assert config["docker_env"] == {} +def test_get_env_config_preserves_ssh_tilde_cwd(monkeypatch): + """SSH cwd '~' is expanded by the remote shell, not the Hermes host.""" + monkeypatch.setenv("TERMINAL_ENV", "ssh") + monkeypatch.setenv("TERMINAL_CWD", "~") + monkeypatch.setenv("HOME", "/opt/data") + + config = terminal_tool._get_env_config() + + assert config["env_type"] == "ssh" + assert config["cwd"] == "~" + + +def test_get_env_config_preserves_ssh_tilde_child_cwd(monkeypatch): + """SSH cwd '~/x' must not become the local/container HOME path.""" + monkeypatch.setenv("TERMINAL_ENV", "ssh") + monkeypatch.setenv("TERMINAL_CWD", "~/project") + monkeypatch.setenv("HOME", "/opt/data") + + config = terminal_tool._get_env_config() + + assert config["env_type"] == "ssh" + assert config["cwd"] == "~/project" + + def test_get_env_config_still_rejects_bad_docker_json_for_docker_backend(monkeypatch): """Selecting Docker should keep the existing actionable config error.""" monkeypatch.setenv("TERMINAL_ENV", "docker") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 69ef5dc8698..71bbc68658f 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1286,7 +1286,7 @@ 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: + if cwd and not (env_type == "ssh" and (cwd == "~" or cwd.startswith("~/"))): cwd = os.path.expanduser(cwd) host_cwd = None if env_type == "docker" and mount_docker_cwd: