diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index f91a83a503..aa0c83d128 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1552,6 +1552,10 @@ def _docker_sandbox_dir_candidates(session_key: str = "") -> List[str]: from tools.environments.base import sanitize_task_id_for_path except Exception: return ["default"] + # Explicit trusted-profiles opt-in: one shared container identity. + shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() + if shared: + candidates.append(sanitize_task_id_for_path(f"shared:{shared}")) try: from hermes_cli.profiles import get_active_profile_name diff --git a/tests/tools/test_shared_container_task_id.py b/tests/tools/test_shared_container_task_id.py index cb1eeef644..7e5c8da224 100644 --- a/tests/tools/test_shared_container_task_id.py +++ b/tests/tools/test_shared_container_task_id.py @@ -253,3 +253,59 @@ def test_ssh_backend_keeps_session_scoping(monkeypatch): assert terminal_tool._resolve_container_task_id(None) == "session:sess-A" finally: clear_session_vars(tokens) + + +# --- Trusted-profiles shared container opt-in (#84671) ------------------------ + + +def test_shared_key_unifies_profiles(monkeypatch): + from gateway.session_context import clear_session_vars, set_session_vars + + _persistent_docker(monkeypatch) + monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace") + tokens = set_session_vars(session_key="s1", profile="work") + try: + a = terminal_tool._resolve_container_task_id(None) + finally: + clear_session_vars(tokens) + tokens = set_session_vars(session_key="s2", profile="research") + try: + b = terminal_tool._resolve_container_task_id(None) + finally: + clear_session_vars(tokens) + assert a == b == "shared:team/workspace" + + +def test_shared_key_applies_to_cli_no_session(monkeypatch): + # CLI (no session key) must land in the same shared container as gateway + # sessions, or the opt-in splits the container it exists to unify. + _persistent_docker(monkeypatch) + monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace") + monkeypatch.delenv("HERMES_SESSION_KEY", raising=False) + assert terminal_tool._resolve_container_task_id(None) == "shared:team/workspace" + + +def test_empty_shared_key_keeps_profile_scoping(monkeypatch): + from gateway.session_context import clear_session_vars, set_session_vars + + _persistent_docker(monkeypatch) + monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "") + tokens = set_session_vars(session_key="s1", profile="work") + try: + assert terminal_tool._resolve_container_task_id(None) == "profile:work" + finally: + clear_session_vars(tokens) + + +def test_shared_key_ignored_outside_persistent_docker(monkeypatch): + # The opt-in is a persistent-Docker concept only: SSH keeps session + # scoping even when the key is set. + from gateway.session_context import clear_session_vars, set_session_vars + + monkeypatch.setenv("TERMINAL_ENV", "ssh") + monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace") + tokens = set_session_vars(session_key="sess-A", profile="work") + try: + assert terminal_tool._resolve_container_task_id(None) == "session:sess-A" + finally: + clear_session_vars(tokens) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 532595ea70..3390f472ee 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1513,11 +1513,25 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str: # the default profile share the SAME container — CLI's historical key # IS the default profile's container. if _docker_persistent_profile_scoped(): + # Explicit opt-in: trusted profiles configuring the same + # terminal.docker_shared_container_key share ONE container/cache + # slot (and sandbox dir) regardless of profile name (#84671). + shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() + if shared: + return f"shared:{shared}" profile = _current_session_profile() or "default" if profile == "default": return "default" return f"profile:{profile}" return f"session:{session_key}" + # CLI/no-session path: honour the shared-container opt-in here too, or a + # CLI run of a keyed profile would land in "default" while its gateway + # sessions land in "shared:" — splitting the very container the + # setting exists to unify. + if _docker_persistent_profile_scoped(): + shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() + if shared: + return f"shared:{shared}" return "default"