fix(gateway): widen container->host media translation to home, cache, and in-process gateways

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
  <sandbox>/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:<agent_visible_image> 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).
This commit is contained in:
Teknium 2026-08-08 05:56:29 -07:00
parent a7dd885439
commit 238351a60c
2 changed files with 134 additions and 2 deletions

View File

@ -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 ``<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``, 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

View File

@ -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 <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