diff --git a/gateway/run.py b/gateway/run.py index d604947e996d..edef1189bfc7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5579,8 +5579,10 @@ async def _prepare_inbound_message_text( if event.media_urls and event.message_type == MessageType.DOCUMENT: import mimetypes as _mimetypes + from tools.credential_files import to_agent_visible_cache_path _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} + _terminal_backend = os.getenv("TERMINAL_ENV", "local") for i, path in enumerate(event.media_urls): mtype = event.media_types[i] if i < len(event.media_types) else "" if mtype in ("", "application/octet-stream"): @@ -5599,16 +5601,21 @@ async def _prepare_inbound_message_text( display_name = parts[2] if len(parts) >= 3 else basename display_name = re.sub(r'[^\w.\- ]', '_', display_name) + # The agent runs in the terminal backend's filesystem view; under + # docker the host cache path is bind-mounted at a different + # container path, so rewrite before injecting into the prompt. + agent_path = to_agent_visible_cache_path(path, backend=_terminal_backend) + if mtype.startswith("text/"): context_note = ( f"[The user sent a text document: '{display_name}'. " f"Its content has been included below. " - f"The file is also saved at: {path}]" + f"The file is also saved at: {agent_path}]" ) else: context_note = ( f"[The user sent a document: '{display_name}'. " - f"The file is saved at: {path}. " + f"The file is saved at: {agent_path}. " f"Ask the user what they'd like you to do with it.]" ) message_text = f"{context_note}\n\n{message_text}" diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index e0ec46a8563e..8cd76d0fd458 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -16,6 +16,7 @@ iter_skills_files, register_credential_file, register_credential_files, + to_agent_visible_cache_path, ) @@ -476,3 +477,120 @@ def test_empty_cache(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(hermes_home)) assert iter_cache_files() == [] + + +class TestToAgentVisibleCachePath: + """Translate host cache paths to container paths for the Docker backend. + + Inbound documents from messaging platforms are saved to host paths under + ``~/.hermes/cache/``. Under the Docker terminal backend those + directories are bind-mounted at ``{container_base}/cache/`` + via :func:`get_cache_directory_mounts`. This helper produces the + container-visible path the agent will see. + """ + + def test_docker_translates_documents_path(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + docs = hermes_home / "cache" / "documents" + docs.mkdir(parents=True) + host_file = docs / "report.docx" + host_file.write_bytes(b"DOCX") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + result = to_agent_visible_cache_path(str(host_file), backend="docker") + assert result == "/root/.hermes/cache/documents/report.docx" + + def test_docker_translates_nested_subpath(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + sub = hermes_home / "cache" / "documents" / "session_abc" + sub.mkdir(parents=True) + host_file = sub / "memo.pdf" + host_file.write_bytes(b"PDF") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + result = to_agent_visible_cache_path(str(host_file), backend="docker") + assert result == "/root/.hermes/cache/documents/session_abc/memo.pdf" + + def test_docker_translates_each_cache_subdir(self, tmp_path, monkeypatch): + """Translation works for documents, images, audio, screenshots.""" + hermes_home = tmp_path / ".hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + for subdir, fname in [ + ("documents", "a.pdf"), + ("images", "b.png"), + ("audio", "c.ogg"), + ("screenshots", "d.png"), + ]: + cache_dir = hermes_home / "cache" / subdir + cache_dir.mkdir(parents=True, exist_ok=True) + f = cache_dir / fname + f.write_bytes(b"x") + assert to_agent_visible_cache_path(str(f), backend="docker") == ( + f"/root/.hermes/cache/{subdir}/{fname}" + ) + + def test_non_docker_backend_passes_through(self, tmp_path, monkeypatch): + """Local/SSH/Modal/etc. don't bind-mount cache the same way — no-op.""" + hermes_home = tmp_path / ".hermes" + docs = hermes_home / "cache" / "documents" + docs.mkdir(parents=True) + host_file = docs / "report.docx" + host_file.write_bytes(b"DOCX") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + for backend in ("local", "ssh", "modal", "daytona", "vercel", ""): + assert to_agent_visible_cache_path( + str(host_file), backend=backend + ) == str(host_file) + + def test_path_outside_cache_passes_through(self, tmp_path, monkeypatch): + """Paths not under any cache subdir are returned unchanged.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + outside = tmp_path / "elsewhere" / "foo.docx" + outside.parent.mkdir() + outside.write_bytes(b"x") + + assert to_agent_visible_cache_path(str(outside), backend="docker") == str(outside) + + def test_already_translated_container_path_passes_through(self, tmp_path, monkeypatch): + """Calling with an already-container path is a no-op (idempotent).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + already = "/root/.hermes/cache/documents/report.docx" + assert to_agent_visible_cache_path(already, backend="docker") == already + + def test_backend_case_insensitive(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + docs = hermes_home / "cache" / "documents" + docs.mkdir(parents=True) + host_file = docs / "x.pdf" + host_file.write_bytes(b"x") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + for variant in ("docker", "DOCKER", "Docker", " docker "): + assert to_agent_visible_cache_path( + str(host_file), backend=variant + ) == "/root/.hermes/cache/documents/x.pdf" + + def test_custom_container_base(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + docs = hermes_home / "cache" / "documents" + docs.mkdir(parents=True) + host_file = docs / "x.pdf" + host_file.write_bytes(b"x") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + result = to_agent_visible_cache_path( + str(host_file), backend="docker", container_base="/opt/hermes" + ) + assert result == "/opt/hermes/cache/documents/x.pdf" + + def test_invalid_path_returns_unchanged(self): + """Empty or non-path-like inputs are returned as-is.""" + assert to_agent_visible_cache_path("", backend="docker") == "" diff --git a/tools/credential_files.py b/tools/credential_files.py index 2372950cfede..303fdfc2501f 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -401,6 +401,58 @@ def iter_cache_files( return result +def to_agent_visible_cache_path( + host_path: str, + *, + backend: str, + container_base: str = "/root/.hermes", +) -> str: + """Translate a host cache path to the path the agent will see. + + Inbound documents from messaging platforms are saved under + ``~/.hermes/cache/`` on the host. Under the Docker terminal + backend those subdirectories are bind-mounted at + ``{container_base}/cache/`` via :func:`get_cache_directory_mounts`, + so any host path the gateway injects into the agent's prompt must be + rewritten or the agent will fail to open the file. + + Pass-through cases (returns ``host_path`` unchanged): + * ``backend`` is anything other than ``docker`` (case/space-insensitive). + Other backends use different mount semantics — Modal uploads files + individually via :func:`iter_cache_files`, SSH/local share the host + filesystem — so this rewrite does not apply. + * The path does not lie under any known cache subdirectory. + * The path is unparseable. + + The translation is idempotent: an already-container path under + ``container_base`` is unchanged because it doesn't match any host cache + root. + """ + if backend.strip().lower() != "docker": + return host_path + + from hermes_constants import get_hermes_dir + + try: + host_resolved = Path(host_path).resolve() + except (OSError, ValueError, RuntimeError): + return host_path + + for new_subpath, old_name in _CACHE_DIRS: + try: + host_root = get_hermes_dir(new_subpath, old_name).resolve() + except (OSError, ValueError, RuntimeError): + continue + try: + rel = host_resolved.relative_to(host_root) + except ValueError: + continue + container_root = f"{container_base.rstrip('/')}/{new_subpath}" + return f"{container_root}/{rel.as_posix()}" + + return host_path + + def clear_credential_files() -> None: """Reset the skill-scoped registry (e.g. on session reset).""" _get_registered().clear() diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index 21f8246ace38..6af6a44bd828 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -300,6 +300,8 @@ docker compose up -d When using Docker as the execution environment (not the methods above, but when the agent runs commands inside a Docker sandbox), Hermes automatically bind-mounts the skills directory (`~/.hermes/skills/`) and any credential files declared by skills into the container as read-only volumes. This means skill scripts, templates, and references are available inside the sandbox without manual configuration. +Inbound media from messaging platforms (documents, images, audio, screenshots) is cached under `~/.hermes/cache//` on the host and bind-mounted into the container at `/root/.hermes/cache//`. When the gateway tells the agent that a user-sent file is "saved at <path>", the path is rewritten to the container view so the agent can open it directly. + The same syncing happens for SSH and Modal backends — skills and credential files are uploaded via rsync or the Modal mount API before each command. ## Troubleshooting