fix(tui_gateway): honor target profile's terminal.cwd on desktop profile switch (#40892)
* fix(tui_gateway): honor target profile's terminal.cwd on desktop profile switch The desktop's app-global remote mode serves every profile from one tui_gateway backend, so the process-global TERMINAL_CWD only reflects the launch profile. After switching profiles, a new session resolved its workspace from that stale env var and inherited the previous profile's directory. Add _profile_configured_cwd() to read a non-launch profile's own terminal.cwd from its config.yaml (skipping placeholder/empty/missing and non-existent paths so callers fall back cleanly), and wire it into _completion_cwd() with precedence: explicit client cwd -> existing session cwd -> bound profile's configured cwd -> TERMINAL_CWD -> os.getcwd(). Fixes #40334 * test(tui_gateway): cover per-profile cwd resolution (#40334) Pin the new contract: _profile_configured_cwd reads a profile's own terminal.cwd and rejects placeholders/missing paths, and _completion_cwd prefers a bound profile's cwd over a stale launch-profile TERMINAL_CWD while still letting an explicit client cwd win.
This commit is contained in:
@@ -107,6 +107,64 @@ def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path):
|
|||||||
server._clear_session_context(tokens)
|
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):
|
def test_terminal_task_cwd_local_backend_uses_session_cwd(monkeypatch, tmp_path):
|
||||||
"""A local terminal backend must keep host-validated session cwd behaviour."""
|
"""A local terminal backend must keep host-validated session cwd behaviour."""
|
||||||
project = tmp_path / "project"
|
project = tmp_path / "project"
|
||||||
|
|||||||
+40
-2
@@ -688,6 +688,40 @@ def _profile_home(profile: str | None) -> Path | None:
|
|||||||
return home if (home / "state.db").exists() or home.exists() else 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:
|
def write_json(obj: dict) -> bool:
|
||||||
"""Emit one JSON frame. Routes via the most-specific transport available.
|
"""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:
|
def _completion_cwd(params: dict | None = None) -> str:
|
||||||
|
params = params or {}
|
||||||
raw = (
|
raw = (
|
||||||
(params or {}).get("cwd")
|
params.get("cwd")
|
||||||
or _sessions.get((params or {}).get("session_id") or "", {}).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.environ.get("TERMINAL_CWD")
|
||||||
or os.getcwd()
|
or os.getcwd()
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user