Skip to content
Merged
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
219 changes: 215 additions & 4 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,210 @@ def _path_is_within(path: Path, root: Path) -> bool:
return False


def _parse_docker_volume_mounts() -> List[Tuple[Path, Path]]:
"""Parse configured Docker volume mounts into ``(host_path, container_path)``.

Source of truth is ``TERMINAL_DOCKER_VOLUMES`` (JSON list of
``host:container[:mode]`` specs), matching terminal/docker runtime config.
Named volumes and non-absolute hosts are skipped because they cannot be
resolved on the gateway host for media delivery.
"""
raw = os.getenv("TERMINAL_DOCKER_VOLUMES", "").strip()
if not raw:
return []
try:
import json as _json

parsed = _json.loads(raw)
except Exception:
return []
if not isinstance(parsed, list):
return []

mounts: List[Tuple[Path, Path]] = []
for entry in parsed:
if not isinstance(entry, str):
continue
spec = entry.strip()
if not spec:
continue
# Prefer the first ':/' so absolute container paths are unambiguous.
sep = spec.find(":/")
if sep <= 0:
continue
host_raw = spec[:sep]
container_and_mode = spec[sep + 1 :] # starts with /
container_raw = container_and_mode.split(":", 1)[0]
if not container_raw.startswith("/"):
continue
# Skip named volumes (no absolute/drive host path).
host_expanded = os.path.expanduser(host_raw)
if not (
host_expanded.startswith("/")
or (len(host_expanded) > 1 and host_expanded[1] == ":")
):
continue
try:
host_path = Path(host_expanded).resolve(strict=False)
container_path = Path(container_raw)
except (OSError, RuntimeError, ValueError):
continue
if not container_path.is_absolute():
continue
mounts.append((host_path, container_path))
return mounts


def _default_docker_workspace_host_root() -> Optional[Path]:
"""Host path for Docker's default persistent ``/workspace`` mount."""
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
return None
if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in {
"1",
"true",
"yes",
"on",
}:
return None
# Explicit cwd mount takes over /workspace when enabled.
if os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() in {
"1",
"true",
"yes",
"on",
}:
cwd = os.getenv("TERMINAL_CWD") or os.getcwd()
try:
host = Path(os.path.expanduser(cwd)).resolve(strict=False)
except (OSError, RuntimeError, ValueError):
return None
return host if host.is_dir() else None
try:
from tools.environments.base import get_sandbox_dir

root = (get_sandbox_dir() / "docker" / "default" / "workspace").resolve(strict=False)
except Exception:
return None
return root if root.is_dir() else None


def _docker_persistent_home_host_root() -> Optional[Path]:
"""Host path for Docker's default persistent ``/root`` home mount.

Persistent containers bind ``<sandbox>/docker/<task>/home`` to ``/root``
(tools/environments/docker.py), so an agent that writes ``/root/out.png``
produced a real host file the gateway couldn't find. Same collapse rule as
the workspace mount: the gateway's container sharing resolves to the
``default`` task sandbox.
"""
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
return None
if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in {
"1",
"true",
"yes",
"on",
}:
return None
try:
from tools.environments.base import get_sandbox_dir

root = (get_sandbox_dir() / "docker" / "default" / "home").resolve(strict=False)
except Exception:
return None
return root if root.is_dir() else None


def _cache_dir_container_mounts() -> List[Tuple[Path, Path]]:
"""(host, container) pairs for the auto-mounted Hermes cache dirs.

The agent legitimately sees generated artifacts at ``/root/.hermes/...``
(``agent_visible_image`` from image_generate, cache-dir reads) and will
naturally emit those container paths in MEDIA tags. These mounts are
longer prefixes than the ``/root`` home mount, so longest-prefix matching
picks the cache translation over the home translation for them.
"""
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
return []
try:
from tools.credential_files import get_cache_directory_mounts

return [
(Path(m["host_path"]), Path(m["container_path"]))
for m in get_cache_directory_mounts()
]
except Exception:
return []


def _translate_docker_container_media_path(candidate: Path) -> Optional[Path]:
"""Translate a container-absolute path to its host path when possible.

Uses longest-prefix match across configured ``docker_volumes``, the
auto-mounted Hermes cache dirs (``/root/.hermes/...``), the default
persistent Docker ``/workspace`` host root, and the persistent ``/root``
home mount.
"""
if not candidate.is_absolute():
return None

# In-process gateways (Desktop backend, `hermes serve`) may not have
# bridged terminal.* config into TERMINAL_* env vars — run the idempotent
# bridge so the mount parsing below sees the active backend and volumes
# (same guard _binary_reference_block applies for inbound attachments).
try:
from tools.terminal_tool import _ensure_terminal_env_bridged

_ensure_terminal_env_bridged()
except Exception:
pass

mounts = list(_parse_docker_volume_mounts())
mounts.extend(_cache_dir_container_mounts())
# Synthetic /workspace mount for default persistent sandbox / cwd bind.
default_ws = _default_docker_workspace_host_root()
if default_ws is not None and not any(c.as_posix() == "/workspace" for _, c in mounts):
mounts.append((default_ws, Path("/workspace")))
# Synthetic /root mount for the persistent home bind. Cache mounts above
# are longer prefixes, so /root/.hermes/... still translates to the host
# cache — this only catches stray home writes like /root/out.png.
default_home = _docker_persistent_home_host_root()
if default_home is not None and not any(c.as_posix() == "/root" for _, c in mounts):
# /root/.hermes/* that did NOT match a cache mount is the container's
# credential/secret surface (.env, auth.json, ... are individually
# bind-mounted from the real host stores). Translating those through
# the home mount would resolve to sandbox-home copies OUTSIDE the
# host-side credential denylist prefixes — refuse instead so the
# normal "container path doesn't exist on host" rejection applies.
if not candidate.as_posix().startswith("/root/.hermes"):
mounts.append((default_home, Path("/root")))

if not mounts:
return None

# Longest container-prefix match.
best: Optional[Tuple[Path, Path, int]] = None
candidate_posix = candidate.as_posix()
for host_root, container_root in mounts:
container_posix = container_root.as_posix().rstrip("/") or "/"
if candidate_posix == container_posix or candidate_posix.startswith(container_posix + "/"):
score = len(container_posix)
if best is None or score > best[2]:
best = (host_root, container_root, score)
if best is None:
return None

host_root, container_root, _ = best
try:
relative = candidate.relative_to(container_root)
translated = (host_root / relative).resolve(strict=True)
except (OSError, RuntimeError, ValueError):
return None
if translated != host_root and not _path_is_within(translated, host_root):
return None
return translated


def validate_media_delivery_path(path: str) -> Optional[str]:
"""Return a safe absolute file path for native media delivery, else None.

Expand Down Expand Up @@ -1498,10 +1702,17 @@ def validate_media_delivery_path(path: str) -> Optional[str]:
if not expanded.is_absolute():
return None

try:
resolved = expanded.resolve(strict=True)
except (OSError, RuntimeError, ValueError):
return None
# Docker agents emit MEDIA:/workspace/... (or other configured container
# mount paths). Resolve those to host paths before the normal host-side
# existence / denylist checks.
translated = _translate_docker_container_media_path(expanded)
if translated is not None:
resolved = translated
else:
try:
resolved = expanded.resolve(strict=True)
except (OSError, RuntimeError, ValueError):
return None

if not resolved.is_file():
return None
Expand Down
135 changes: 135 additions & 0 deletions tests/gateway/test_platform_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,141 @@ def test_root_home_workdir_symlink_to_credential_blocked(self, tmp_path, monkeyp
assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None


class TestDockerContainerMediaPathTranslation:
"""MEDIA:/workspace (and configured mounts) must resolve to host paths."""

def test_configured_workspace_mount_translates(self, tmp_path, monkeypatch):
import json

host_ws = tmp_path / "host-ws"
host_ws.mkdir()
media = host_ws / "shot.png"
media.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv(
"TERMINAL_DOCKER_VOLUMES",
json.dumps([f"{host_ws}:/workspace"]),
)
monkeypatch.delenv("TERMINAL_ENV", raising=False)

assert BasePlatformAdapter.validate_media_delivery_path(
"/workspace/shot.png"
) == str(media.resolve())

def test_configured_output_mount_translates(self, tmp_path, monkeypatch):
import json

host_out = tmp_path / "documents"
host_out.mkdir()
media = host_out / "report.pdf"
media.write_bytes(b"%PDF-1.4")
monkeypatch.setenv(
"TERMINAL_DOCKER_VOLUMES",
json.dumps([f"{host_out}:/output"]),
)

assert BasePlatformAdapter.validate_media_delivery_path(
"/output/report.pdf"
) == str(media.resolve())

def test_longest_prefix_wins(self, tmp_path, monkeypatch):
import json

host_a = tmp_path / "a"
host_b = tmp_path / "b"
host_a.mkdir()
host_b.mkdir()
nested = host_b / "file.png"
nested.write_bytes(b"png")
monkeypatch.setenv(
"TERMINAL_DOCKER_VOLUMES",
json.dumps([
f"{host_a}:/data",
f"{host_b}:/data/nested",
]),
)

assert BasePlatformAdapter.validate_media_delivery_path(
"/data/nested/file.png"
) == str(nested.resolve())

def test_default_persistent_workspace_fallback(self, tmp_path, monkeypatch):
sandbox = tmp_path / "sandboxes"
ws = sandbox / "docker" / "default" / "workspace"
ws.mkdir(parents=True)
media = ws / "out.png"
media.write_bytes(b"png")
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("TERMINAL_SANDBOX_DIR", str(sandbox))
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", raising=False)

assert BasePlatformAdapter.validate_media_delivery_path(
"/workspace/out.png"
) == str(media.resolve())

def test_unmapped_container_path_fails(self, monkeypatch):
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
assert BasePlatformAdapter.validate_media_delivery_path("/workspace/nope.png") is None

def test_persistent_home_root_write_translates(self, tmp_path, monkeypatch):
"""An agent writing /root/out.png in a persistent container produced a
real host file under <sandbox>/docker/default/home — deliver it."""
sandbox = tmp_path / "sandboxes"
home = sandbox / "docker" / "default" / "home"
home.mkdir(parents=True)
media = home / "out.png"
media.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("TERMINAL_SANDBOX_DIR", str(sandbox))
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)

assert BasePlatformAdapter.validate_media_delivery_path(
"/root/out.png"
) == str(media.resolve())

def test_cache_dir_container_path_translates_to_host_cache(self, tmp_path, monkeypatch):
"""MEDIA:/root/.hermes/cache/images/... (the agent_visible_image path
under docker) must translate to the HOST cache file, not the sandbox
home copy."""
hermes_home = tmp_path / ".hermes"
cache = hermes_home / "cache" / "images"
cache.mkdir(parents=True)
media = cache / "generated.png"
media.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)

assert BasePlatformAdapter.validate_media_delivery_path(
"/root/.hermes/cache/images/generated.png"
) == str(media.resolve())

def test_container_credential_path_never_translates_through_home(self, tmp_path, monkeypatch):
"""/root/.hermes/* outside a cache mount (the sandbox's credential
surface: .env, auth.json) must NOT resolve through the persistent
home mount — those host-side copies sit outside the credential
denylist prefixes and would otherwise deliver."""
sandbox = tmp_path / "sandboxes"
home = sandbox / "docker" / "default" / "home"
secret = home / ".hermes"
secret.mkdir(parents=True)
(secret / "auth.json").write_text('{"token": "SECRET"}')
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("TERMINAL_SANDBOX_DIR", str(sandbox))
monkeypatch.delenv("TERMINAL_DOCKER_VOLUMES", raising=False)

assert BasePlatformAdapter.validate_media_delivery_path(
"/root/.hermes/auth.json"
) is None


# ---------------------------------------------------------------------------
# should_send_media_as_audio
# ---------------------------------------------------------------------------
Expand Down
Loading