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
11 changes: 9 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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}"
Expand Down
118 changes: 118 additions & 0 deletions tests/tools/test_credential_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
iter_skills_files,
register_credential_file,
register_credential_files,
to_agent_visible_cache_path,
)


Expand Down Expand Up @@ -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/<subdir>``. Under the Docker terminal backend those
directories are bind-mounted at ``{container_base}/cache/<subdir>``
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") == ""
52 changes: 52 additions & 0 deletions tools/credential_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<subdir>`` on the host. Under the Docker terminal
backend those subdirectories are bind-mounted at
``{container_base}/cache/<subdir>`` 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()
Expand Down
2 changes: 2 additions & 0 deletions website/docs/user-guide/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<subdir>/` on the host and bind-mounted into the container at `/root/.hermes/cache/<subdir>/`. When the gateway tells the agent that a user-sent file is "saved at &lt;path&gt;", 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
Expand Down