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
191 changes: 191 additions & 0 deletions gateway/media_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Path helpers for gateway MEDIA file delivery."""

from __future__ import annotations

import json
import logging
import os
import posixpath
from typing import Iterable

logger = logging.getLogger(__name__)


def parse_docker_volume_spec(spec: str) -> tuple[str, str] | None:
"""Parse a Docker ``-v`` bind mount as ``(host_path, container_path)``.

Hermes stores ``terminal.docker_volumes`` in ``TERMINAL_DOCKER_VOLUMES`` as
JSON strings like ``/host/exports:/container/path[:options]``. Docker's
optional third field can be ``ro``, ``rw``, ``cached``, ``delegated``,
propagation flags, SELinux flags, or comma-separated combinations. Named
volumes and malformed entries are ignored because the host gateway cannot
derive a readable filesystem path from them.
"""
if not isinstance(spec, str):
return None

raw = spec.strip()
if not raw:
return None

parts = raw.split(":")
if len(parts) < 2:
return None

if parts[-1].strip().startswith("/"):
# host:container with no options. Join earlier parts so unusual but
# valid POSIX host paths containing ':' still round-trip.
host_path = ":".join(parts[:-1])
container_path = parts[-1]
elif len(parts) >= 3 and parts[-2].strip().startswith("/"):
# host:container:options. The options field is intentionally not
# enumerated: Docker supports more than ro/rw, and unknown options do
# not change the host/container path relationship.
host_path = ":".join(parts[:-2])
container_path = parts[-2]
else:
return None

if not host_path or not container_path:
return None

host_path = os.path.expandvars(os.path.expanduser(host_path.strip()))
container_path = posixpath.normpath(container_path.strip())

if not os.path.isabs(host_path) or not container_path.startswith("/"):
return None

return os.path.normpath(host_path), container_path


def docker_bind_mounts_from_env(raw_volumes: str | None = None) -> tuple[tuple[str, str], ...]:
"""Return configured Docker bind mounts from ``TERMINAL_DOCKER_VOLUMES``.

Invalid or missing environment values return an empty tuple so gateway
response post-processing can continue with the original MEDIA path.
"""
raw = os.getenv("TERMINAL_DOCKER_VOLUMES", "") if raw_volumes is None else raw_volumes
raw = str(raw or "").strip()
if not raw:
return ()

try:
parsed = json.loads(raw)
except Exception:
logger.debug("Could not parse TERMINAL_DOCKER_VOLUMES", exc_info=True)
return ()

if not isinstance(parsed, list):
return ()

mounts = []
for entry in parsed:
mount = parse_docker_volume_spec(entry)
if mount:
mounts.append(mount)
return tuple(mounts)


def _container_path_is_within(path: str, root: str) -> bool:
if root == "/":
return path.startswith("/")
return path == root or path.startswith(f"{root}/")


def is_docker_media_bind_mount(container_root: str) -> bool:
"""Return whether a Docker bind mount can map outbound MEDIA paths.

The user chooses the export path through ``terminal.docker_volumes`` /
``TERMINAL_DOCKER_VOLUMES``. Do not hard-code a container directory such as
``/output``; any explicit non-root bind mount is mappable because the host
side is user-specified and readable by the gateway. Root mounts are still
ignored because they would make every absolute container path look
host-readable.
"""
container_root = posixpath.normpath(str(container_root or ""))
return container_root.startswith("/") and container_root != "/"


def docker_media_bind_mounts_from_env(raw_volumes: str | None = None) -> tuple[tuple[str, str], ...]:
"""Return Docker bind mounts eligible for outbound MEDIA path mapping."""
return tuple(
(host_root, container_root)
for host_root, container_root in docker_bind_mounts_from_env(raw_volumes)
if is_docker_media_bind_mount(container_root)
)


def _host_path_is_within_root(path: str, root: str) -> bool:
"""Return True when ``path`` resolves inside ``root`` on the host FS."""
try:
real_root = os.path.normcase(os.path.realpath(os.path.abspath(root)))
real_path = os.path.normcase(os.path.realpath(os.path.abspath(path)))
return os.path.commonpath([real_root, real_path]) == real_root
except (OSError, ValueError):
return False


def _translate_docker_path_to_host(
path: str,
mounts: Iterable[tuple[str, str]],
) -> str | None:
container_path = posixpath.normpath(path)
if not container_path.startswith("/"):
return None

best_mount = None
for host_root, container_root in mounts:
if not _container_path_is_within(container_path, container_root):
continue
if best_mount is None or len(container_root) > len(best_mount[1]):
best_mount = (host_root, container_root)

if best_mount is None:
return None

host_root, container_root = best_mount
rel_path = posixpath.relpath(container_path, container_root)
candidate = host_root if rel_path == "." else os.path.normpath(
os.path.join(host_root, *rel_path.split("/"))
)
if not _host_path_is_within_root(candidate, host_root):
return None
return candidate


def resolve_outbound_media_path(path: str) -> str:
"""Return a host-readable path for an extracted ``MEDIA:`` directive.

The gateway delivers files from the host process, but terminal tools can run
in a Docker sandbox. When Docker is the active backend and the MEDIA path is
inside a user-configured Docker bind mount, this maps the container path to
the corresponding host path. The mapping is derived from
``TERMINAL_DOCKER_VOLUMES`` / ``terminal.docker_volumes``; it is not tied to
a hard-coded container directory such as ``/output``. Named volumes,
malformed volume config, root container mounts, non-Docker backends, and
unmapped paths fall back to the expanded original path without raising.
"""
try:
expanded = os.path.expanduser(path)

if os.getenv("TERMINAL_ENV", "local").strip().lower() == "docker":
translated = _translate_docker_path_to_host(
expanded,
docker_media_bind_mounts_from_env(),
)
if translated:
return translated

try:
if os.path.exists(expanded):
return expanded
except OSError:
pass

return expanded
except Exception:
logger.debug("Could not resolve outbound MEDIA path", exc_info=True)
try:
return os.path.expanduser(path)
except Exception:
return path
3 changes: 2 additions & 1 deletion gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from abc import ABC, abstractmethod
from urllib.parse import urlsplit

from gateway.media_paths import resolve_outbound_media_path
from utils import normalize_proxy_url

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -2145,7 +2146,7 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
path = path[1:-1].strip()
path = path.lstrip("`\"'").rstrip("`\"',.;:)}]")
if path:
media.append((os.path.expanduser(path), has_voice_tag))
media.append((resolve_outbound_media_path(path), has_voice_tag))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replacement targets the pre-current extraction loop. Please salvage the resolver into current main's masked known-extension and extensionless passes (gateway/platforms/base.py:3645-3696) while preserving the current skip-on-expanduser()-failure behavior.


# Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
if media:
Expand Down
15 changes: 10 additions & 5 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -2948,15 +2948,20 @@ async def _handle_callback_query(
def _missing_media_path_error(self, label: str, path: str) -> str:
"""Build an actionable file-not-found error for gateway MEDIA delivery.

Paths like /workspace/... or /output/... often only exist inside the
Docker sandbox, while the gateway process runs on the host.
Paths under Docker bind mounts may use arbitrary user-chosen container
roots (for example /workspace/..., /output/..., or /agent-artifacts/...),
while the gateway process runs on the host.
"""
error = f"{label} file not found: {path}"
if path.startswith(("/workspace/", "/output/", "/outputs/")):
looks_like_container_path = path.startswith(
("/workspace/", "/output/", "/outputs/")
)
docker_backend = os.getenv("TERMINAL_ENV", "").strip().lower() == "docker"
if looks_like_container_path or (path.startswith("/") and docker_backend):
error += (
" (path may only exist inside the Docker sandbox. "
"Bind-mount a host directory and emit the host-visible "
"path in MEDIA: for gateway file delivery.)"
"Bind-mount a host directory for that container path or "
"emit a host-visible path in MEDIA: for gateway file delivery.)"
)
return error

Expand Down
49 changes: 15 additions & 34 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from agent.account_usage import fetch_account_usage, render_account_usage_lines
from agent.async_utils import safe_schedule_threadsafe
from agent.i18n import t
from gateway.media_paths import docker_media_bind_mounts_from_env
from hermes_cli.config import cfg_get

# --- Agent cache tuning ---------------------------------------------------
Expand Down Expand Up @@ -418,9 +419,6 @@ def _reload_runtime_env_preserving_config_authority() -> None:
os.environ["HERMES_MAX_ITERATIONS"] = str(agent_cfg["max_turns"])


_DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P<host>.+):(?P<container>/[^:]+?)(?::(?P<options>[^:]+))?$")
_DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"}

# Bridge config.yaml values into the environment so os.getenv() picks them up.
# config.yaml is authoritative for terminal settings — overrides .env.
_config_path = _hermes_home / 'config.yaml'
Expand Down Expand Up @@ -1420,13 +1418,14 @@ def _wire_teams_pipeline_runtime(self) -> None:


def _warn_if_docker_media_delivery_is_risky(self) -> None:
"""Warn when Docker-backed gateways lack an explicit export mount.

MEDIA delivery happens in the gateway process, so paths emitted by the model
must be readable from the host. A plain container-local path like
`/workspace/report.txt` or `/output/report.txt` often exists only inside
Docker, so users commonly need a dedicated export mount such as
`host-dir:/output`.
"""Warn when Docker-backed gateways lack host-readable bind mounts.

MEDIA delivery happens in the gateway process, so paths emitted by the
model must be readable from the host. A plain container-local path like
`/workspace/report.txt` or `/artifacts/report.txt` often exists only
inside Docker, so users commonly need a dedicated bind mount such as
`host-dir:/artifacts`. The container path is user-chosen through
``terminal.docker_volumes`` / ``TERMINAL_DOCKER_VOLUMES``.
"""
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
return
Expand All @@ -1436,34 +1435,16 @@ def _warn_if_docker_media_delivery_is_risky(self) -> None:
if not messaging_platforms:
return

raw_volumes = os.getenv("TERMINAL_DOCKER_VOLUMES", "").strip()
volumes: List[str] = []
if raw_volumes:
try:
parsed = json.loads(raw_volumes)
if isinstance(parsed, list):
volumes = [str(v) for v in parsed if isinstance(v, str)]
except Exception:
logger.debug("Could not parse TERMINAL_DOCKER_VOLUMES for gateway media warning", exc_info=True)

has_explicit_output_mount = False
for spec in volumes:
match = _DOCKER_VOLUME_SPEC_RE.match(spec)
if not match:
continue
container_path = match.group("container")
if container_path in _DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS:
has_explicit_output_mount = True
break

if has_explicit_output_mount:
if docker_media_bind_mounts_from_env():
return

logger.warning(
"Docker backend is enabled for the messaging gateway but no explicit host-visible "
"output mount (for example '/home/user/.hermes/cache/documents:/output') is configured. "
"This is fine if the model already emits host-visible paths, but MEDIA file delivery can fail "
"for container-local paths like '/workspace/...' or '/output/...'."
"Docker bind mount is configured. This is fine if the model already emits "
"host-visible paths, but MEDIA file delivery can fail for container-local paths "
"like '/workspace/...' or '/artifacts/...'. Configure terminal.docker_volumes "
"with a host_path:container_path bind mount and emit MEDIA: paths under that "
"container_path."
)


Expand Down
7 changes: 4 additions & 3 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,9 +611,10 @@ def _ensure_hermes_home_managed(home: Path):
# Each entry is "host_path:container_path" (standard Docker -v syntax).
# Example:
# ["/home/user/projects:/workspace/projects",
# "/home/user/.hermes/cache/documents:/output"]
# For gateway MEDIA delivery, write inside Docker to /output/... and emit
# the host-visible path in MEDIA:, not the container path.
# "/home/user/.hermes/cache/documents:/agent-artifacts"]
# Gateway MEDIA delivery accepts host paths or mounted container paths
# derived from this list, such as MEDIA:/agent-artifacts/report.txt for
# the example above (or any other non-root container_path you choose).
"docker_volumes": [],
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
# Default off because passing host directories into a sandbox weakens isolation.
Expand Down
35 changes: 32 additions & 3 deletions tests/gateway/test_runner_startup_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,10 +389,10 @@ async def test_runner_degrades_gracefully_when_all_adapters_missing(monkeypatch,
), "Expected degraded-mode warning when all adapters are missing"


def test_runner_warns_when_docker_gateway_lacks_explicit_output_mount(monkeypatch, tmp_path, caplog):
def test_runner_warns_when_docker_gateway_lacks_host_visible_bind_mount(monkeypatch, tmp_path, caplog):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_DOCKER_VOLUMES", '["/etc/localtime:/etc/localtime:ro"]')
monkeypatch.setenv("TERMINAL_DOCKER_VOLUMES", '["exports:/agent-media:rw"]')
config = GatewayConfig(
platforms={
Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")
Expand All @@ -404,6 +404,35 @@ def test_runner_warns_when_docker_gateway_lacks_explicit_output_mount(monkeypatc
GatewayRunner(config)

assert any(
"host-visible output mount" in record.message
"host-visible Docker bind mount" in record.message
for record in caplog.records
)


@pytest.mark.parametrize("container_path", ["/output", "/outputs", "/output/reports", "/agent-artifacts"])
def test_runner_accepts_user_specified_docker_media_bind_mount(
monkeypatch,
tmp_path,
caplog,
container_path,
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv(
"TERMINAL_DOCKER_VOLUMES",
f'["{tmp_path}/exports:{container_path}:rw"]',
)
config = GatewayConfig(
platforms={
Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")
},
sessions_dir=tmp_path / "sessions",
)

with caplog.at_level("WARNING"):
GatewayRunner(config)

assert not any(
"host-visible Docker bind mount" in record.message
for record in caplog.records
)
Loading