diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 136703e1042..7998af7292c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -107,6 +107,64 @@ def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path): server._clear_session_context(tokens) +def _write_profile_cfg(home: Path, cwd: str | None) -> Path: + import yaml + + home.mkdir(parents=True, exist_ok=True) + cfg = {"terminal": {"cwd": cwd}} if cwd is not None else {} + (home / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8") + return home + + +def test_profile_configured_cwd_reads_target_profile(tmp_path): + """A profile's own terminal.cwd is read from its config.yaml.""" + project = tmp_path / "proj" + project.mkdir() + home = _write_profile_cfg(tmp_path / "home", str(project)) + assert server._profile_configured_cwd(home) == str(project) + + +def test_profile_configured_cwd_skips_placeholders_and_missing(tmp_path): + """Placeholder values, missing config, and bad paths fall through to None.""" + assert server._profile_configured_cwd(None) is None + assert server._profile_configured_cwd(tmp_path / "nope") is None + for placeholder in (".", "auto", "cwd", ""): + home = _write_profile_cfg(tmp_path / placeholder.strip("."), placeholder) + assert server._profile_configured_cwd(home) is None + home = _write_profile_cfg(tmp_path / "ghost", str(tmp_path / "does-not-exist")) + assert server._profile_configured_cwd(home) is None + + +def test_completion_cwd_prefers_profile_over_stale_env(monkeypatch, tmp_path): + """Issue #40334: a new session bound to another profile must use THAT + profile's terminal.cwd, not the launch profile's stale TERMINAL_CWD.""" + profile_b = tmp_path / "ef-design" + profile_b.mkdir() + home = _write_profile_cfg(tmp_path / "home-b", str(profile_b)) + stale = tmp_path / "mahjong" + stale.mkdir() + + monkeypatch.setenv("TERMINAL_CWD", str(stale)) + monkeypatch.setattr(server, "_profile_home", lambda name: home if name else None) + + assert server._completion_cwd({"profile": "ef-design"}) == str(profile_b) + # No profile → unchanged fallback to the launch env var. + assert server._completion_cwd({}) == str(stale) + + +def test_completion_cwd_explicit_cwd_wins_over_profile(monkeypatch, tmp_path): + """An explicit client-provided cwd still beats the profile config.""" + explicit = tmp_path / "explicit" + explicit.mkdir() + profile_b = tmp_path / "configured" + profile_b.mkdir() + home = _write_profile_cfg(tmp_path / "home-c", str(profile_b)) + + monkeypatch.setattr(server, "_profile_home", lambda name: home if name else None) + result = server._completion_cwd({"cwd": str(explicit), "profile": "ef-design"}) + assert result == str(explicit) + + def test_terminal_task_cwd_local_backend_uses_session_cwd(monkeypatch, tmp_path): """A local terminal backend must keep host-validated session cwd behaviour.""" project = tmp_path / "project" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5df9e51de29..69c662d6409 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -688,6 +688,40 @@ def _profile_home(profile: str | None) -> Path | None: return home if (home / "state.db").exists() or home.exists() else None +# Placeholder ``terminal.cwd`` values that don't name a real directory — the +# gateway resolves these to the home dir at runtime, so they must NOT be treated +# as an explicit workspace (mirrors gateway/run.py's config bridge). +_CWD_PLACEHOLDERS = {".", "auto", "cwd"} + + +def _profile_configured_cwd(profile_home: Path | None) -> str | None: + """Resolve a non-launch profile's ``terminal.cwd`` from its own config.yaml. + + The desktop's app-global remote mode serves every profile from one backend, + so the process-global ``TERMINAL_CWD`` belongs to the *launch* profile. A new + session bound to another profile must take its workspace from THAT profile's + config, not the stale env var (issue #40334). Returns an absolute, existing + directory, or None for placeholders / missing / invalid paths. + """ + if profile_home is None: + return None + try: + import yaml + + p = Path(profile_home) / "config.yaml" + if not p.exists(): + return None + with open(p, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + raw = str((data.get("terminal") or {}).get("cwd") or "").strip() + if not raw or raw in _CWD_PLACEHOLDERS: + return None + resolved = os.path.abspath(os.path.expanduser(raw)) + return resolved if os.path.isdir(resolved) else None + except Exception: + return None + + def write_json(obj: dict) -> bool: """Emit one JSON frame. Routes via the most-specific transport available. @@ -995,9 +1029,13 @@ def _normalize_completion_path(path_part: str) -> str: def _completion_cwd(params: dict | None = None) -> str: + params = params or {} raw = ( - (params or {}).get("cwd") - or _sessions.get((params or {}).get("session_id") or "", {}).get("cwd") + params.get("cwd") + or _sessions.get(params.get("session_id") or "", {}).get("cwd") + # A session bound to another profile resolves its workspace from THAT + # profile's config before falling back to the launch profile's env var. + or _profile_configured_cwd(_profile_home(params.get("profile"))) or os.environ.get("TERMINAL_CWD") or os.getcwd() )