diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index 712e38ed137e..185f75325bc4 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -57,6 +57,11 @@ def _session_cwd_override() -> str: return str(value).strip() +def get_session_cwd_override() -> str: + """Return the cwd explicitly bound to the current session context.""" + return _session_cwd_override() + + def resolve_agent_cwd() -> Path: override = _session_cwd_override() if override: diff --git a/gateway/run.py b/gateway/run.py index ec169fd65403..04f021d424ad 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17169,6 +17169,7 @@ def _set_session_env(self, context: SessionContext) -> list: _adapters = getattr(self, "adapters", None) or {} _adapter = _adapters.get(context.source.platform) _async_delivery = getattr(_adapter, "supports_async_delivery", True) + _session_cwd = self._session_cwd_for_source(context.source) return set_session_vars( platform=context.source.platform.value, chat_id=context.source.chat_id, @@ -17179,9 +17180,69 @@ def _set_session_env(self, context: SessionContext) -> list: session_key=context.session_key, message_id=str(context.source.message_id) if context.source.message_id else "", profile=getattr(context.source, "profile", "") or "", + cwd=_session_cwd, async_delivery=_async_delivery, ) + def _session_cwd_for_source(self, source: SessionSource) -> str: + """Resolve the logical cwd for one multiplexed profile session. + + Gateway startup bridges only the active profile's ``terminal.cwd`` to + process-global ``TERMINAL_CWD``. A multiplexed secondary profile must + therefore bind its own cwd through the existing session ContextVar; + otherwise it silently inherits the gateway launch directory (or the + active profile's cwd). + + Single-profile gateways deliberately return an empty override so their + existing process-level cwd behavior remains unchanged. + """ + if not getattr(getattr(self, "config", None), "multiplex_profiles", False): + return "" + + profile_home = self._resolve_profile_home_for_source(source) + try: + with _profile_runtime_scope(profile_home): + profile_config = _load_gateway_runtime_config() + except Exception: + logger.warning( + "Failed to resolve terminal cwd for profile %s; using gateway cwd", + getattr(source, "profile", "") or "default", + exc_info=True, + ) + return "" + + terminal_config = profile_config.get("terminal") or {} + if not isinstance(terminal_config, dict): + terminal_config = {} + configured_cwd = str( + terminal_config.get("cwd", profile_config.get("cwd", "")) or "" + ).strip() + terminal_backend = str( + terminal_config.get( + "env_type", + terminal_config.get( + "backend", + profile_config.get("env_type", profile_config.get("backend", "local")), + ), + ) + or "local" + ).strip() + if terminal_backend.lower() != "local": + return "" + + from gateway.cwd_placeholder import resolve_placeholder_terminal_cwd + + resolved = resolve_placeholder_terminal_cwd( + configured_cwd=configured_cwd, + terminal_backend=terminal_backend, + messaging_cwd=None, + docker_mount_cwd_to_workspace=False, + home_fallback=str(Path.home()), + ) + if not resolved: + return "" + return str(Path(resolved).expanduser()) + def _clear_session_env(self, tokens: list) -> None: """Restore session context variables to their pre-handler values.""" from gateway.session_context import clear_session_vars diff --git a/tests/gateway/test_multiplex_profile_cwd.py b/tests/gateway/test_multiplex_profile_cwd.py new file mode 100644 index 000000000000..983b7be444c4 --- /dev/null +++ b/tests/gateway/test_multiplex_profile_cwd.py @@ -0,0 +1,224 @@ +import json +from pathlib import Path + +import yaml + +from agent.runtime_cwd import resolve_agent_cwd +from gateway.config import GatewayConfig, Platform +from gateway.run import GatewayRunner +from gateway.session import SessionContext, SessionSource +from tools.terminal_tool import ( + clear_task_env_overrides, + cleanup_all_environments, + clear_session_cwd, + get_session_cwd, + register_task_env_overrides, + terminal_tool, +) + + +def _context( + profile: str = "secondary", + session_key: str = "agent:secondary:telegram:group:-1001234567890:101", +) -> SessionContext: + return SessionContext( + source=SessionSource( + platform=Platform.TELEGRAM, + chat_id="-1001234567890", + chat_type="group", + profile=profile, + ), + connected_platforms=[Platform.TELEGRAM], + home_channels={}, + session_key=session_key, + session_id="test-session", + ) + + +def _runner(profile_home: Path) -> GatewayRunner: + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + runner.adapters = {} + runner._resolve_profile_home_for_source = lambda source: profile_home + return runner + + +def test_multiplex_profile_terminal_cwd_is_bound_to_session(monkeypatch, tmp_path): + profile_home = tmp_path / "profiles" / "secondary" + workspace = tmp_path / "workspace" + profile_home.mkdir(parents=True) + workspace.mkdir() + (profile_home / "config.yaml").write_text( + yaml.safe_dump({"terminal": {"backend": "local", "cwd": str(workspace)}}) + ) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "default-workspace")) + + runner = _runner(profile_home) + tokens = runner._set_session_env(_context()) + try: + assert resolve_agent_cwd() == workspace + assert get_session_cwd(_context().session_key) is None + result = json.loads( + terminal_tool(command="pwd", task_id="test-session", session_id="test-session") + ) + assert result["exit_code"] == 0 + assert result["output"].strip() == str(workspace) + assert get_session_cwd(_context().session_key) == str(workspace) + finally: + runner._clear_session_env(tokens) + cleanup_all_environments() + clear_session_cwd(_context().session_key) + clear_task_env_overrides("test-session") + + +def test_multiplex_profile_placeholder_does_not_inherit_process_cwd(monkeypatch, tmp_path): + profile_home = tmp_path / "profiles" / "secondary" + profile_home.mkdir(parents=True) + (profile_home / "config.yaml").write_text( + yaml.safe_dump({"terminal": {"backend": "local", "cwd": "."}}) + ) + process_cwd = tmp_path / "default-workspace" + process_cwd.mkdir() + profile_launch_home = tmp_path / "launch-home" + profile_launch_home.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(process_cwd)) + monkeypatch.setenv("HOME", str(profile_launch_home)) + + runner = _runner(profile_home) + tokens = runner._set_session_env(_context()) + try: + assert resolve_agent_cwd() == profile_launch_home + result = json.loads( + terminal_tool(command="pwd", task_id="test-session", session_id="test-session") + ) + assert result["exit_code"] == 0 + assert result["output"].strip() == str(profile_launch_home) + assert get_session_cwd(_context().session_key) == str(profile_launch_home) + finally: + runner._clear_session_env(tokens) + cleanup_all_environments() + clear_session_cwd(_context().session_key) + clear_task_env_overrides("test-session") + + +def test_existing_session_cwd_is_not_overwritten(tmp_path): + profile_home = tmp_path / "profiles" / "secondary" + profile_home.mkdir(parents=True) + configured_workspace = tmp_path / "configured-workspace" + configured_workspace.mkdir() + changed_workspace = tmp_path / "changed-workspace" + changed_workspace.mkdir() + (profile_home / "config.yaml").write_text( + yaml.safe_dump( + {"terminal": {"backend": "local", "cwd": str(configured_workspace)}} + ) + ) + register_task_env_overrides("test-session", {"cwd": str(changed_workspace)}) + + runner = _runner(profile_home) + tokens = runner._set_session_env(_context()) + try: + result = json.loads( + terminal_tool(command="pwd", task_id="test-session", session_id="test-session") + ) + assert result["exit_code"] == 0 + assert result["output"].strip() == str(changed_workspace) + assert get_session_cwd(_context().session_key) == str(changed_workspace) + finally: + runner._clear_session_env(tokens) + cleanup_all_environments() + clear_session_cwd(_context().session_key) + clear_task_env_overrides("test-session") + + +def test_single_profile_gateway_keeps_process_cwd(monkeypatch, tmp_path): + profile_home = tmp_path / "profiles" / "secondary" + profile_home.mkdir(parents=True) + configured_workspace = tmp_path / "configured-workspace" + configured_workspace.mkdir() + (profile_home / "config.yaml").write_text( + yaml.safe_dump( + {"terminal": {"backend": "local", "cwd": str(configured_workspace)}} + ) + ) + process_cwd = tmp_path / "process-workspace" + process_cwd.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(process_cwd)) + + runner = _runner(profile_home) + runner.config.multiplex_profiles = False + tokens = runner._set_session_env(_context()) + try: + assert resolve_agent_cwd() == process_cwd + assert get_session_cwd(_context().session_key) is None + finally: + runner._clear_session_env(tokens) + cleanup_all_environments() + clear_session_cwd(_context().session_key) + clear_task_env_overrides("test-session") + + +def test_multiplex_nonlocal_backend_keeps_existing_process_cwd(monkeypatch, tmp_path): + profile_home = tmp_path / "profiles" / "secondary" + profile_home.mkdir(parents=True) + (profile_home / "config.yaml").write_text( + yaml.safe_dump({"terminal": {"backend": "ssh", "cwd": "/remote/project"}}) + ) + process_cwd = tmp_path / "process-workspace" + process_cwd.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(process_cwd)) + + runner = _runner(profile_home) + tokens = runner._set_session_env(_context()) + try: + assert resolve_agent_cwd() == process_cwd + assert get_session_cwd(_context().session_key) is None + finally: + runner._clear_session_env(tokens) + cleanup_all_environments() + clear_session_cwd(_context().session_key) + clear_task_env_overrides("test-session") + + +def test_multiplex_sessions_do_not_share_cached_local_environment(tmp_path): + profile_a = tmp_path / "profiles" / "alpha" + profile_b = tmp_path / "profiles" / "beta" + workspace_a = tmp_path / "workspace-a" + workspace_b = tmp_path / "workspace-b" + for path in (profile_a, profile_b, workspace_a, workspace_b): + path.mkdir(parents=True) + (profile_a / "config.yaml").write_text( + yaml.safe_dump({"terminal": {"backend": "local", "cwd": str(workspace_a)}}) + ) + (profile_b / "config.yaml").write_text( + yaml.safe_dump({"terminal": {"backend": "local", "cwd": str(workspace_b)}}) + ) + key_a = "agent:alpha:telegram:group:-1001234567890:101" + key_b = "agent:beta:telegram:group:-1001234567890:202" + + runner_a = _runner(profile_a) + tokens_a = runner_a._set_session_env(_context("alpha", key_a)) + try: + result_a = json.loads( + terminal_tool(command="pwd", task_id="session-a", session_id="session-a") + ) + assert result_a["exit_code"] == 0 + assert result_a["output"].strip() == str(workspace_a) + finally: + runner_a._clear_session_env(tokens_a) + + runner_b = _runner(profile_b) + tokens_b = runner_b._set_session_env(_context("beta", key_b)) + try: + result_b = json.loads( + terminal_tool(command="pwd", task_id="session-b", session_id="session-b") + ) + assert result_b["exit_code"] == 0 + assert result_b["output"].strip() == str(workspace_b) + finally: + runner_b._clear_session_env(tokens_b) + cleanup_all_environments() + for session_key in (key_a, key_b): + clear_session_cwd(session_key) + for task_id in ("session-a", "session-b"): + clear_task_env_overrides(task_id) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 859d30a76497..4bbbe06c11ea 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2183,7 +2183,26 @@ def terminal_tool( else: image = "" - cwd = overrides.get("cwd") or get_session_cwd(task_id) or config["cwd"] + # Resolve the durable gateway/topic key before cwd selection. ``task_id`` + # is an execution identifier and may differ from the conversation key + # that owns persistent ``cd`` state. + from tools.approval import get_current_session_key + + session_key = get_current_session_key(default="") or (task_id or "") + session_cwd = get_session_cwd(session_key) + if not overrides.get("cwd") and not session_cwd and env_type == "local": + # Multiplexed gateways bind each routed profile's cwd through a + # ContextVar because process-global TERMINAL_CWD belongs to the + # primary profile. Seed lazily on the first terminal call so + # shared local environments cannot leak another session's cwd, + # while sessions that never use terminal create no registry state. + from agent.runtime_cwd import get_session_cwd_override + + context_cwd = get_session_cwd_override() + if context_cwd: + record_session_cwd(session_key, context_cwd) + session_cwd = context_cwd + cwd = overrides.get("cwd") or session_cwd or config["cwd"] # A per-task cwd override (registered by the gateway/TUI for workspace # tracking, or by RL/benchmark envs) wins over config["cwd"] — but # config["cwd"] was already sanitized for container backends in @@ -2449,10 +2468,6 @@ def terminal_tool( # contextvar doesn't cross tool-worker threads, so fall back to the raw # task_id (which IS the session_key for the top-level agent) — a # stable, thread-safe anchor. - from tools.approval import get_current_session_key - - session_key = get_current_session_key(default="") or (task_id or "") - if background: # Spawn a tracked background process via the process registry. # For local backends: uses subprocess.Popen with output buffering.