fix(ssh): preserve remote tilde cwd

This commit is contained in:
helix4u 2026-07-03 14:28:45 -06:00 committed by kshitij
parent af0ce1cf8e
commit 83fb8ec277
4 changed files with 93 additions and 12 deletions

View file

@ -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,

View file

@ -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

View file

@ -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")

View file

@ -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: