mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(telegram): warn on docker-only media paths
This commit is contained in:
parent
b668c09ab2
commit
588333908c
7 changed files with 146 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ----------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue