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
53 changes: 42 additions & 11 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1542,6 +1542,10 @@ def _docker_sandbox_dir_candidates(session_key: str = "") -> List[str]:
live (``session:<session_key>``) are kept as a fallback candidate so
files produced in that window still deliver (self-heal, no migration).

Named profiles never search the unowned ``default`` workspace. MEDIA
lookup is first-existing-file, so appending that sandbox would leak
same-name files from the default profile into a coder/work session.

Takes the key explicitly because the delivery pipeline runs after
``_handle_message_with_agent`` cleared the turn's session contextvars
(#93950) β€” an ambient lookup here would silently collapse onto
Expand All @@ -1551,20 +1555,47 @@ def _docker_sandbox_dir_candidates(session_key: str = "") -> List[str]:
try:
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()
return []

profile: Optional[str] = None
if session_key:
try:
from tools.terminal_tool import profile_name_from_session_key

profile = profile_name_from_session_key(session_key)
except Exception:
parts = str(session_key).split(":")
if len(parts) >= 2 and parts[0] == "agent":
namespace = parts[1] or "main"
profile = "default" if namespace == "main" else namespace
else:
try:
from hermes_cli.profiles import get_active_profile_name

profile = get_active_profile_name() or "default"
except Exception:
profile = None
if profile == "custom":
profile = "default"

shared = ""
if profile is not None:
try:
from tools.terminal_tool import docker_shared_container_key_for_profile

shared = docker_shared_container_key_for_profile(profile)
except Exception:
shared = ""
if shared:
candidates.append(sanitize_task_id_for_path(f"shared:{shared}"))
try:
from hermes_cli.profiles import get_active_profile_name

profile = get_active_profile_name() or "default"
except Exception:
profile = "default"
if profile != "default":
if profile and profile != "default":
candidates.append(sanitize_task_id_for_path(f"profile:{profile}"))
candidates.append("default")
if profile == "default":
# Default profile (and CLI) owns the shared "default" sandbox.
# Named profiles must not search it: that directory belongs to
# another profile, and first-existing-file lookup would leak
# same-name files across profiles.
candidates.append("default")
if session_key:
# Bug-window legacy layout: per-session sandboxes.
candidates.append(sanitize_task_id_for_path(f"session:{session_key}"))
Expand Down
245 changes: 245 additions & 0 deletions tests/tools/test_shared_container_task_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
Docker Backend in ``website/docs/user-guide/configuration.md``.
"""

from pathlib import Path

import pytest

from tools import terminal_tool
Expand Down Expand Up @@ -309,3 +311,246 @@ def test_shared_key_ignored_outside_persistent_docker(monkeypatch):
assert terminal_tool._resolve_container_task_id(None) == "session:sess-A"
finally:
clear_session_vars(tokens)


def test_profile_name_from_session_key():
assert terminal_tool.profile_name_from_session_key("agent:main:telegram:dm:1") == "default"
assert terminal_tool.profile_name_from_session_key("agent:coder:telegram:dm:1") == "coder"
assert terminal_tool.profile_name_from_session_key("") == "default"
assert terminal_tool.profile_name_from_session_key("sess-A") == "default"


def test_multiplex_does_not_leak_default_shared_key(tmp_path, monkeypatch):
# os.environ holds the default profile's key. A secondary profile with
# no key in its own config must stay isolated.
from agent import secret_scope
from gateway.session_context import clear_session_vars, set_session_vars

home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
work = home / "profiles" / "work"
work.mkdir(parents=True)
(work / "config.yaml").write_text(
"terminal:\n docker_shared_container_key: ''\n", encoding="utf-8"
)

_persistent_docker(monkeypatch)
monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace")
previous = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
tokens = set_session_vars(session_key="agent:work:telegram:dm:1", profile="work")
try:
assert terminal_tool._resolve_container_task_id(None) == "profile:work"
finally:
clear_session_vars(tokens)
secret_scope.set_multiplex_active(previous)


def test_multiplex_honors_secondary_profile_shared_key(tmp_path, monkeypatch):
from agent import secret_scope
from gateway.session_context import clear_session_vars, set_session_vars

home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
work = home / "profiles" / "work"
work.mkdir(parents=True)
(work / "config.yaml").write_text(
"terminal:\n docker_shared_container_key: work-lab\n", encoding="utf-8"
)

_persistent_docker(monkeypatch)
monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace")
previous = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
tokens = set_session_vars(session_key="agent:work:telegram:dm:1", profile="work")
try:
assert terminal_tool._resolve_container_task_id(None) == "shared:work-lab"
finally:
clear_session_vars(tokens)
secret_scope.set_multiplex_active(previous)


def test_sandbox_candidates_follow_session_profile(monkeypatch):
from gateway.platforms.base import _docker_sandbox_dir_candidates
from tools.environments.base import sanitize_task_id_for_path

monkeypatch.delenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", raising=False)
names = _docker_sandbox_dir_candidates("agent:coder:telegram:dm:1")
assert names[0] == sanitize_task_id_for_path("profile:coder")
assert "default" not in names
assert names[-1] == sanitize_task_id_for_path("session:agent:coder:telegram:dm:1")


def test_sandbox_candidates_default_session_stays_default(monkeypatch):
from gateway.platforms.base import _docker_sandbox_dir_candidates
from tools.environments.base import sanitize_task_id_for_path

monkeypatch.delenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", raising=False)
names = _docker_sandbox_dir_candidates("agent:main:telegram:dm:123456")
assert names[0] == "default"
assert sanitize_task_id_for_path("profile:custom") not in names
assert sanitize_task_id_for_path("session:agent:main:telegram:dm:123456") in names


def test_sandbox_candidates_multiplex_does_not_use_default_shared_key(tmp_path, monkeypatch):
from agent import secret_scope
from gateway.platforms.base import _docker_sandbox_dir_candidates
from tools.environments.base import sanitize_task_id_for_path

home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
work = home / "profiles" / "work"
work.mkdir(parents=True)
(work / "config.yaml").write_text("terminal: {}\n", encoding="utf-8")
monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace")
previous = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
try:
names = _docker_sandbox_dir_candidates("agent:work:telegram:dm:1")
finally:
secret_scope.set_multiplex_active(previous)
assert names[0] == sanitize_task_id_for_path("profile:work")
assert sanitize_task_id_for_path("shared:team/workspace") not in names
assert "default" not in names


def test_multiplex_probe_error_does_not_leak_default_shared_key(tmp_path, monkeypatch):
# If is_multiplex_active() throws, a secondary profile must not inherit
# the process env key.
from agent import secret_scope
from gateway.session_context import clear_session_vars, set_session_vars

home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
work = home / "profiles" / "work"
work.mkdir(parents=True)
(work / "config.yaml").write_text("terminal: {}\n", encoding="utf-8")

_persistent_docker(monkeypatch)
monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace")

def _boom():
raise RuntimeError("multiplex probe failed")

monkeypatch.setattr(secret_scope, "is_multiplex_active", _boom)
tokens = set_session_vars(session_key="agent:work:telegram:dm:1", profile="work")
try:
assert terminal_tool._resolve_container_task_id(None) == "profile:work"
finally:
clear_session_vars(tokens)


def test_sandbox_candidates_key_lookup_error_does_not_use_default_shared_key(monkeypatch):
from gateway.platforms.base import _docker_sandbox_dir_candidates
from tools.environments.base import sanitize_task_id_for_path

monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace")

def _boom(profile=None):
raise RuntimeError("key lookup failed")

monkeypatch.setattr(
"tools.terminal_tool.docker_shared_container_key_for_profile", _boom
)
names = _docker_sandbox_dir_candidates("agent:coder:telegram:dm:1")
assert sanitize_task_id_for_path("shared:team/workspace") not in names
assert names[0] == sanitize_task_id_for_path("profile:coder")
assert "default" not in names


def test_sandbox_candidates_unknown_profile_does_not_use_default(monkeypatch):
from gateway.platforms.base import _docker_sandbox_dir_candidates

monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace")

def _boom():
raise RuntimeError("no active profile")

monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", _boom)
names = _docker_sandbox_dir_candidates("")
assert names == []
assert "default" not in names


def _enable_docker_sandbox(tmp_path, monkeypatch):
sandbox = tmp_path / "sandboxes"
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("TERMINAL_SANDBOX_DIR", str(sandbox))
monkeypatch.delenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", raising=False)
return sandbox


def _workspace(sandbox: Path, task_id: str) -> Path:
from tools.environments.base import sanitize_task_id_for_path

name = task_id if task_id == "default" else sanitize_task_id_for_path(task_id)
ws = sandbox / "docker" / name / "workspace"
ws.mkdir(parents=True, exist_ok=True)
return ws


def _first_existing(roots, relative: str):
for root in roots:
candidate = root / relative
if candidate.is_file():
return candidate.resolve()
return None


def test_named_profile_media_skips_unowned_default_workspace(tmp_path, monkeypatch):
# Default has foo.png, coder does not. Coder-routed MEDIA must not
# pick up the default profile's file.
from gateway.platforms.base import _default_docker_workspace_host_roots

sandbox = _enable_docker_sandbox(tmp_path, monkeypatch)
default_ws = _workspace(sandbox, "default")
(default_ws / "foo.png").write_bytes(b"default-copy")

roots = _default_docker_workspace_host_roots("agent:coder:telegram:dm:1")
assert default_ws.resolve() not in [r.resolve() for r in roots]
assert _first_existing(roots, "foo.png") is None


def test_named_profile_media_prefers_own_workspace_copy(tmp_path, monkeypatch):
from gateway.platforms.base import _default_docker_workspace_host_roots

sandbox = _enable_docker_sandbox(tmp_path, monkeypatch)
default_ws = _workspace(sandbox, "default")
(default_ws / "foo.png").write_bytes(b"default-copy")
coder_ws = _workspace(sandbox, "profile:coder")
(coder_ws / "foo.png").write_bytes(b"coder-copy")

roots = _default_docker_workspace_host_roots("agent:coder:telegram:dm:1")
found = _first_existing(roots, "foo.png")
assert found is not None
assert found.read_bytes() == b"coder-copy"
assert default_ws.resolve() not in [r.resolve() for r in roots]


def test_sandbox_roots_key_lookup_error_does_not_read_default_workspace(tmp_path, monkeypatch):
from gateway.platforms.base import _default_docker_workspace_host_roots

sandbox = _enable_docker_sandbox(tmp_path, monkeypatch)
monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "team/workspace")
default_ws = _workspace(sandbox, "default")
(default_ws / "foo.png").write_bytes(b"default-copy")

def _boom(profile=None):
raise RuntimeError("key lookup failed")

monkeypatch.setattr(
"tools.terminal_tool.docker_shared_container_key_for_profile", _boom
)
roots = _default_docker_workspace_host_roots("agent:coder:telegram:dm:1")
assert default_ws.resolve() not in [r.resolve() for r in roots]
assert _first_existing(roots, "foo.png") is None
Loading
Loading