Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions agent/runtime_cwd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17179,9 +17180,69 @@ def _set_session_env(self, context: SessionContext) -> list:
session_key=context.session_key,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This binds the resolver used by agent startup and terminal seeding, but gateway/run.py:15307 still reads process-global TERMINAL_CWD before this session binding for @-reference expansion, and the footer does so at line 16885. Please route those paths through the scoped resolver or establish this cwd before preprocessing, with coverage.

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
Expand Down
224 changes: 224 additions & 0 deletions tests/gateway/test_multiplex_profile_cwd.py
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 20 additions & 5 deletions tools/terminal_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down