diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index b8fe6e4e705f3..f3a022c81b21d 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 e6743d4a40383..553153a0e0ee5 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -859,6 +859,62 @@ class TestDockerContainerMediaPathTranslation: 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