fix(telegram): warn on docker-only media paths

This commit is contained in:
helix4u 2026-04-08 21:13:28 -06:00 committed by kshitij
parent b668c09ab2
commit 588333908c
7 changed files with 146 additions and 3 deletions

View file

@ -1759,7 +1759,14 @@ class TelegramAdapter(BasePlatformAdapter):
try:
if not os.path.exists(file_path):
return SendResult(success=False, error=f"File not found: {file_path}")
error = f"File not found: {file_path}"
if file_path.startswith(("/workspace/", "/output/")):
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.)"
)
return SendResult(success=False, error=error)
display_name = file_name or os.path.basename(file_path)
_thread = self._metadata_thread_id(metadata)

View file

@ -96,6 +96,10 @@ from hermes_cli.env_loader import load_hermes_dotenv
_env_path = _hermes_home / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
_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'
@ -585,6 +589,7 @@ class GatewayRunner:
def __init__(self, config: Optional[GatewayConfig] = None):
self.config = config or load_gateway_config()
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
self._warn_if_docker_media_delivery_is_likely_misconfigured()
# Load ephemeral config from config.yaml / env vars.
# Both are injected at API-call time only and never persisted.
@ -691,6 +696,51 @@ class GatewayRunner:
self._background_tasks: set = set()
def _warn_if_docker_media_delivery_is_likely_misconfigured(self) -> None:
"""Warn when Docker-backed gateway setups lack an obvious output bind 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` often exists only inside Docker.
"""
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
return
connected = self.config.get_connected_platforms()
messaging_platforms = [p for p in connected if p not in {Platform.LOCAL, Platform.API_SERVER, Platform.WEBHOOK}]
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:
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. "
"MEDIA file delivery can fail for files that only exist inside the container, such as "
"'/workspace/...'."
)
# -- Setup skill availability ----------------------------------------

View file

@ -403,7 +403,11 @@ DEFAULT_CONFIG = {
"container_persistent": True, # Persist filesystem across sessions
# Docker volume mounts — share host directories with the container.
# Each entry is "host_path:container_path" (standard Docker -v syntax).
# Example: ["/home/user/projects:/workspace/projects", "/data:/data"]
# 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.
"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.

View file

@ -107,6 +107,7 @@ async def test_runner_allows_cron_only_mode_when_no_platforms_are_enabled(monkey
assert state["gateway_state"] == "running"
<<<<<<< HEAD
@pytest.mark.asyncio
async def test_runner_records_connected_platform_state_on_success(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@ -319,3 +320,23 @@ async def test_start_gateway_replace_clears_marker_on_permission_denied(
assert ok is False
# Marker must NOT be left behind
assert not (tmp_path / ".gateway-takeover.json").exists()
def test_runner_warns_when_docker_gateway_lacks_explicit_output_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"]')
config = GatewayConfig(
platforms={
Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")
},
sessions_dir=tmp_path / "sessions",
)
with caplog.at_level("WARNING"):
GatewayRunner(config)
assert any(
"host-visible output mount" in record.message
for record in caplog.records
)

View file

@ -483,6 +483,19 @@ class TestSendDocument:
assert "not found" in result.error.lower()
connected_adapter._bot.send_document.assert_not_called()
@pytest.mark.asyncio
async def test_send_document_workspace_path_has_docker_hint(self, connected_adapter):
"""Container-local-looking paths get a more actionable Docker hint."""
result = await connected_adapter.send_document(
chat_id="12345",
file_path="/workspace/report.txt",
)
assert result.success is False
assert "docker sandbox" in result.error.lower()
assert "host-visible path" in result.error.lower()
connected_adapter._bot.send_document.assert_not_called()
@pytest.mark.asyncio
async def test_send_document_not_connected(self, adapter):
"""If bot is None, returns not connected error."""

View file

@ -257,7 +257,7 @@ terminal:
docker_volumes:
- "/home/user/projects:/workspace/projects" # Read-write (default)
- "/home/user/datasets:/data:ro" # Read-only
- "/home/user/outputs:/outputs" # Agent writes, you read
- "/home/user/.hermes/cache/documents:/output" # Gateway-visible exports
```
This is useful for:
@ -265,6 +265,22 @@ This is useful for:
- **Receiving files** from the agent (generated code, reports, exports)
- **Shared workspaces** where both you and the agent access the same files
If you use a messaging gateway and want the agent to send generated files via
`MEDIA:/...`, prefer a dedicated host-visible export mount such as
`/home/user/.hermes/cache/documents:/output`.
- Write files inside Docker to `/output/...`
- Emit the **host path** in `MEDIA:`, for example:
`MEDIA:/home/user/.hermes/cache/documents/report.txt`
- Do **not** emit `/workspace/...` or `/output/...` unless that exact path also
exists for the gateway process on the host
:::warning
YAML duplicate keys silently override earlier ones. If you already have a
`docker_volumes:` block, merge new mounts into the same list instead of adding
another `docker_volumes:` key later in the file.
:::
Can also be set via environment variable: `TERMINAL_DOCKER_VOLUMES='["/host:/container"]'` (JSON array).
### Docker Credential Forwarding

View file

@ -112,6 +112,38 @@ hermes gateway
The bot should come online within seconds. Send it a message on Telegram to verify.
## Sending Generated Files from Docker-backed Terminals
If your terminal backend is `docker`, keep in mind that Telegram attachments are
sent by the **gateway process**, not from inside the container. That means the
final `MEDIA:/...` path must be readable on the host where the gateway is
running.
Common pitfall:
- the agent writes a file inside Docker to `/workspace/report.txt`
- the model emits `MEDIA:/workspace/report.txt`
- Telegram delivery fails because `/workspace/report.txt` only exists inside the
container, not on the host
Recommended pattern:
```yaml
terminal:
backend: docker
docker_volumes:
- "/home/user/.hermes/cache/documents:/output"
```
Then:
- write files inside Docker to `/output/...`
- emit the **host-visible** path in `MEDIA:`, for example:
`MEDIA:/home/user/.hermes/cache/documents/report.txt`
If you already have a `docker_volumes:` section, add the new mount to the same
list. YAML duplicate keys silently override earlier ones.
## Webhook Mode
By default, Hermes connects to Telegram using **long polling** — the gateway makes outbound requests to Telegram's servers to fetch new updates. This works well for local and always-on deployments.