diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index f71614054c..d1935c8090 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -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) diff --git a/gateway/run.py b/gateway/run.py index b72e95eb83..d7dcaf1451 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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.+):(?P/[^:]+?)(?::(?P[^:]+))?$") +_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 ---------------------------------------- diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 1dedc1710a..786ff622d9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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. diff --git a/tests/gateway/test_runner_startup_failures.py b/tests/gateway/test_runner_startup_failures.py index 977d66fb3b..ddcdd1aaa0 100644 --- a/tests/gateway/test_runner_startup_failures.py +++ b/tests/gateway/test_runner_startup_failures.py @@ -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 + ) diff --git a/tests/gateway/test_telegram_documents.py b/tests/gateway/test_telegram_documents.py index 86e5cb30fb..2036f46a21 100644 --- a/tests/gateway/test_telegram_documents.py +++ b/tests/gateway/test_telegram_documents.py @@ -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.""" diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index dbc6b0e47e..f91a25c384 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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 diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 6dbf9e61df..a92fc8d223 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -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.