fix(agent): admit and sync gateway-staged attachments on remote execution backends (#110174)

Desktop paste/file attachments land in Hermes-managed staging dirs on the
GATEWAY (composer-pastes/ for large text pastes, attachments/ for dropped
files), but on the Remote SSH topology the workspace root (TERMINAL_CWD) is a
path on the SSH HOST - the two filesystems are fully disjoint, as the issue
thread confirms. Two gaps combined to reject every staged attachment with
"path is outside the allowed workspace":

- _resolve_path admitted only allowed_root + composer-paste roots, so a
  gateway-staged attachments/ path was refused outright. Admit the
  _CACHE_DIRS staging roots (attachments/, images/, cache/*, composer-pastes/)
  via a helper that asks get_cache_directory_mounts - the gateway's OWN
  payload is never a workspace escape, and the path-traversal and
  credential-deny guards in _ensure_reference_path_allowed still run after.
  Anything else outside the workspace stays blocked.
- composer-pastes/ was missing from _CACHE_DIRS, so its bytes never reached
  the remote: ssh/daytona/vercel_sandbox sync via iter_sync_files ->
  iter_cache_files, and to_agent_visible_cache_path only translates mounted
  dirs - a paste attached on a fresh session dangled on the remote host.

Tests cover the disjoint-filesystem SSH topology end-to-end (text inlines,
binary renders the synced ~/.hermes path), the still-refused stranger path,
local-backend unchanged, and the composer-pastes mount+sync enumeration.

Consolidates PR #110387 by Finn763 (the _agent_staged_path guard widening and
the SSH-topology tests, adapted to the current _ensure_reference_path_allowed
ordering) with PR #103412 by ericmaddox (whose mapping insight is subsumed by
the _CACHE_DIRS entry, which fixes both the sync and the translation).

Co-authored-by: ericmaddox <ericmaddox@users.noreply.github.com>
This commit is contained in:
finn763
2026-09-24 19:25:38 -05:00
committed by brooklyn!
parent a723194c1e
commit a164569429
4 changed files with 153 additions and 0 deletions

View File

@@ -459,6 +459,27 @@ def _composer_paste_roots() -> list[Path]:
return [hermes_dir / COMPOSER_PASTES_DIRNAME for hermes_dir in _hermes_dirs()]
def _agent_staged_path(path: Path) -> bool:
"""True when *path* sits in a Hermes dir the gateway stages for the agent.
Those are the ``_CACHE_DIRS`` roots — ``attachments/`` (file drops),
``images/`` (image uploads), ``cache/*`` (platform downloads) and now
``composer-pastes/`` (large text pastes) — the gateway's OWN payload, never
a workspace escape, and they live outside the workspace by construction: on
a remote execution backend (ssh and friends) the workspace root is a path
on THAT host (#110174), so the workspace check below rejected every staged
attachment there. The bytes are staged to (or already live on) the gateway
either way, so the ref still expands — text inlines; binaries point at the
backend-visible path via ``to_agent_visible_cache_path``.
"""
try:
from tools.credential_files import get_cache_directory_mounts
return any(_is_under(path, Path(entry["host_path"]).expanduser().resolve())
for entry in get_cache_directory_mounts())
except Exception:
return False
def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -> Path:
from agent.file_safety import is_nt_namespace_path
if is_nt_namespace_path(target): # raw-string check: resolving such a path is the NTLM-leak trigger
@@ -468,6 +489,7 @@ def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -
allowed_root is not None
and not _is_under(resolved, allowed_root)
and not any(_is_under(resolved, root) for root in _composer_paste_roots())
and not _agent_staged_path(resolved)
):
raise ValueError("path is outside the allowed workspace")
return resolved