From b470cd95abd898d8077cafa906921aac2341c9bb Mon Sep 17 00:00:00 2001 From: CharZhou <17255546+CharZhou@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:54:24 +0800 Subject: [PATCH 1/2] fix(gateway): support Docker /workspace media paths in gateway delivery Translate MEDIA paths under configured Docker volume mounts (and the default persistent /workspace) to host paths before media delivery validation, using longest container-prefix match so host:/workspace and /output export mounts work. --- gateway/platforms/base.py | 143 +++++++++++++++++++++++++++- tests/gateway/test_platform_base.py | 79 +++++++++++++++ 2 files changed, 218 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 470fde9abfcb..3deb9897c3a4 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1460,6 +1460,134 @@ 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 _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``, then falls + back to the default persistent Docker ``/workspace`` host root. + """ + if not candidate.is_absolute(): + return None + + mounts = list(_parse_docker_volume_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"))) + + 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. @@ -1498,10 +1626,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 diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 8f781f47549e..e6743d4a4038 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -781,6 +781,85 @@ 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 + + # --------------------------------------------------------------------------- # should_send_media_as_audio # --------------------------------------------------------------------------- From 78a0deb4fb24b89b971545f152961c175d087e12 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:56:29 -0700 Subject: [PATCH 2/2] fix(gateway): widen container->host media translation to home, cache, and in-process gateways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the salvaged commit (#37207 by @charzhou): - Persistent /root home mount translates too: an agent writing /root/out.png produced a real host file under /docker/default/home the gateway could not find. - /root/.hermes cache mounts translate to the HOST cache (longest-prefix beats the home mount), so MEDIA: paths deliver. - /root/.hermes/* OUTSIDE a cache mount never translates through the home mount: those are the sandbox's credential copies (.env, auth.json) that sit outside the host-side denylist prefixes — fail closed. - Run the idempotent terminal-config->env bridge before mount parsing so in-process gateways (Desktop backend, hermes serve) see the active backend and docker_volumes (covers #42299's /output case there too). --- gateway/platforms/base.py | 80 ++++++++++++++++++++++++++++- tests/gateway/test_platform_base.py | 56 ++++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 3deb9897c3a4..f43622204081 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1547,20 +1547,96 @@ def _default_docker_workspace_host_root() -> Optional[Path]: 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 ``/docker//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``, then falls - back to the default persistent Docker ``/workspace`` host root. + 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 diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index e6743d4a4038..553153a0e0ee 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -859,6 +859,62 @@ def test_unmapped_container_path_fails(self, monkeypatch): 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 /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