"""Tests for the config.yaml → env var bridge logic in gateway/run.py. Specifically tests that top-level `cwd:` and `backend:` in config.yaml are correctly bridged to TERMINAL_CWD / TERMINAL_ENV env vars as convenience aliases for `terminal.cwd` / `terminal.backend`. The bridge logic is module-level code in gateway/run.py, so we test the semantics by reimplementing the relevant config bridge snippet and asserting the expected env var outcomes. """ import os import json from gateway.cwd_placeholder import CWD_PLACEHOLDERS, resolve_placeholder_terminal_cwd from tools.terminal_tool import _is_ssh_remote_tilde_cwd def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): """Simulate the gateway config bridge logic from gateway/run.py. Returns the resulting env dict (only TERMINAL_* and MESSAGING_CWD keys). """ env = dict(initial_env or {}) # --- Replicate lines 54-56: generic top-level bridge (for context) --- for key, val in cfg.items(): if isinstance(val, (str, int, float, bool)) and key not in env: env[key] = str(val) # --- 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", "timeout": "TERMINAL_TIMEOUT", "home_mode": "TERMINAL_HOME_MODE", "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", "container_cpu": "TERMINAL_CONTAINER_CPU", "container_memory": "TERMINAL_CONTAINER_MEMORY", "container_disk": "TERMINAL_CONTAINER_DISK", "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", } for cfg_key, env_var in terminal_env_map.items(): if cfg_key in terminal_cfg: val = terminal_cfg[cfg_key] # Skip cwd placeholder values — don't overwrite already-resolved # TERMINAL_CWD. Mirrors the fix in gateway/run.py. if cfg_key == "cwd" and str(val) in {".", "auto", "cwd"}: continue # 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. # Uses the same production predicate as gateway/run.py. if cfg_key == "cwd" and isinstance(val, str): if not _is_ssh_remote_tilde_cwd(terminal_backend, val.strip()): val = os.path.expanduser(val) if isinstance(val, list): env[env_var] = json.dumps(val) else: env[env_var] = str(val) # --- NEW: top-level aliases (the fix being tested) --- top_level_aliases = { "cwd": "TERMINAL_CWD", "backend": "TERMINAL_ENV", } for alias_key, alias_env in top_level_aliases.items(): 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 gateway/run.py placeholder TERMINAL_CWD resolution --- configured_cwd = env.get("TERMINAL_CWD", "") if not configured_cwd or configured_cwd in CWD_PLACEHOLDERS: resolved = resolve_placeholder_terminal_cwd( configured_cwd=configured_cwd, terminal_backend=env.get("TERMINAL_ENV", ""), messaging_cwd=env.get("MESSAGING_CWD"), docker_mount_cwd_to_workspace=env.get( "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false" ).lower() in {"true", "1", "yes"}, home_fallback="/root", ) if resolved is None: env.pop("TERMINAL_CWD", None) else: env["TERMINAL_CWD"] = resolved return env class TestTopLevelCwdAlias: """Top-level `cwd:` should be treated as `terminal.cwd`.""" def test_top_level_cwd_sets_terminal_cwd(self): cfg = {"cwd": "/home/hermes/projects"} result = _simulate_config_bridge(cfg) assert result["TERMINAL_CWD"] == "/home/hermes/projects" def test_top_level_backend_sets_terminal_env(self): cfg = {"backend": "docker"} result = _simulate_config_bridge(cfg) assert result["TERMINAL_ENV"] == "docker" def test_top_level_cwd_and_backend(self): cfg = {"backend": "local", "cwd": "/home/hermes/projects"} result = _simulate_config_bridge(cfg) assert result["TERMINAL_CWD"] == "/home/hermes/projects" assert result["TERMINAL_ENV"] == "local" def test_nested_terminal_takes_precedence_over_top_level(self): """terminal.cwd should win over top-level cwd.""" cfg = { "cwd": "/should/not/use", "terminal": {"cwd": "/home/hermes/real"}, } result = _simulate_config_bridge(cfg) assert result["TERMINAL_CWD"] == "/home/hermes/real" def test_nested_terminal_backend_takes_precedence(self): cfg = { "backend": "should-not-use", "terminal": {"backend": "docker"}, } result = _simulate_config_bridge(cfg) assert result["TERMINAL_ENV"] == "docker" def test_no_cwd_falls_back_to_messaging_cwd(self): cfg = {} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes/projects"}) assert result["TERMINAL_CWD"] == "/home/hermes/projects" def test_no_cwd_no_messaging_cwd_falls_back_to_home(self): cfg = {} result = _simulate_config_bridge(cfg) assert result["TERMINAL_CWD"] == "/root" # Path.home() for root user def test_dot_cwd_triggers_messaging_fallback(self): """cwd: '.' should trigger MESSAGING_CWD fallback.""" cfg = {"cwd": "."} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"}) # "." is stripped but truthy, so it gets set as TERMINAL_CWD # Then the MESSAGING_CWD fallback does NOT trigger since TERMINAL_CWD # is set and not in (".", "auto", "cwd"). # Wait — "." IS in the fallback list! So this should fall through. # Actually the alias sets it to ".", then the messaging fallback # checks if it's in (".", "auto", "cwd") and overrides. assert result["TERMINAL_CWD"] == "/home/hermes" def test_auto_cwd_triggers_messaging_fallback(self): cfg = {"cwd": "auto"} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"}) assert result["TERMINAL_CWD"] == "/home/hermes" def test_empty_cwd_ignored(self): cfg = {"cwd": ""} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"}) assert result["TERMINAL_CWD"] == "/home/hermes" def test_whitespace_only_cwd_ignored(self): cfg = {"cwd": " "} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/fallback"}) assert result["TERMINAL_CWD"] == "/fallback" def test_messaging_cwd_env_var_works(self): """MESSAGING_CWD in initial env should be picked up as fallback.""" cfg = {} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes/projects"}) assert result["TERMINAL_CWD"] == "/home/hermes/projects" def test_top_level_cwd_beats_messaging_cwd(self): """Explicit top-level cwd should take precedence over MESSAGING_CWD.""" cfg = {"cwd": "/from/config"} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"}) assert result["TERMINAL_CWD"] == "/from/config" class TestNestedTerminalCwdPlaceholderSkip: """terminal.cwd placeholder values must not clobber TERMINAL_CWD. When config.yaml has terminal.cwd: "." (or "auto"/"cwd"), the gateway config bridge should NOT write that placeholder to TERMINAL_CWD. This prevents .env or MESSAGING_CWD values from being overwritten. See issues #10225, #4672, #10817. """ def test_terminal_dot_cwd_does_not_clobber_env(self): """terminal.cwd: '.' should not overwrite a pre-set TERMINAL_CWD.""" cfg = {"terminal": {"cwd": "."}} result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"}) assert result["TERMINAL_CWD"] == "/my/project" def test_terminal_auto_cwd_does_not_clobber_env(self): cfg = {"terminal": {"cwd": "auto"}} result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"}) assert result["TERMINAL_CWD"] == "/my/project" def test_terminal_cwd_keyword_does_not_clobber_env(self): cfg = {"terminal": {"cwd": "cwd"}} result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/my/project"}) assert result["TERMINAL_CWD"] == "/my/project" def test_terminal_explicit_cwd_does_override(self): """terminal.cwd: '/explicit/path' SHOULD override TERMINAL_CWD.""" cfg = {"terminal": {"cwd": "/explicit/path"}} result = _simulate_config_bridge(cfg, {"TERMINAL_CWD": "/old/value"}) assert result["TERMINAL_CWD"] == "/explicit/path" def test_terminal_dot_cwd_falls_back_to_messaging_cwd(self): """terminal.cwd: '.' with no TERMINAL_CWD should fall to MESSAGING_CWD.""" cfg = {"terminal": {"cwd": "."}} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"}) assert result["TERMINAL_CWD"] == "/from/env" def test_terminal_dot_cwd_and_messaging_cwd_both_set(self): """Pre-set TERMINAL_CWD from .env wins over terminal.cwd: '.'.""" cfg = {"terminal": {"cwd": ".", "backend": "local"}} result = _simulate_config_bridge(cfg, { "TERMINAL_CWD": "/my/project", "MESSAGING_CWD": "/fallback", }) assert result["TERMINAL_CWD"] == "/my/project" def test_non_cwd_terminal_keys_still_bridge(self): """Other terminal config keys (backend, timeout) should still bridge normally.""" cfg = {"terminal": {"cwd": ".", "backend": "docker", "timeout": "300"}} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"}) assert result["TERMINAL_ENV"] == "docker" assert result["TERMINAL_TIMEOUT"] == "300" assert result.get("TERMINAL_CWD") is None def test_docker_placeholder_does_not_inherit_host_home(self): """terminal.cwd: '.' + docker + mount off must not resolve to host home.""" cfg = { "terminal": { "cwd": ".", "backend": "docker", "docker_mount_cwd_to_workspace": False, }, } result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/user"}) assert "TERMINAL_CWD" not in result def test_docker_placeholder_mount_on_preserves_messaging_cwd(self): """Mount-enabled docker still needs the host cwd signal for /workspace.""" cfg = { "terminal": { "cwd": ".", "backend": "docker", "docker_mount_cwd_to_workspace": True, }, } result = _simulate_config_bridge( cfg, {"MESSAGING_CWD": "/host/project"} ) assert result["TERMINAL_CWD"] == "/host/project" assert result["TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE"] == "True" def test_ssh_placeholder_does_not_inherit_host_home(self): cfg = {"terminal": {"cwd": "auto", "backend": "ssh"}} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/user"}) assert "TERMINAL_CWD" not in result def test_local_placeholder_still_falls_back_to_messaging_cwd(self): cfg = {"terminal": {"cwd": ".", "backend": "local"}} result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/user"}) assert result["TERMINAL_CWD"] == "/home/user" def test_terminal_home_mode_bridges_to_env(self): cfg = {"terminal": {"home_mode": "profile"}} result = _simulate_config_bridge(cfg) assert result["TERMINAL_HOME_MODE"] == "profile" 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") 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