Prepare dependency generations before selecting them. Keep shipped tool bytes separate from writable additions, and store facts beside their entries. Validate proposed plugin sets before config publication. Restore the previous config if the facts write fails. Consolidate duplicate updater, backup, setup, and voice helpers. Repair launcher selection, dependency consumers, download ownership, update feeds, and native Windows process and file handling. Verification: 206 changed/prior-failing Python files reported 4630 passed, one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary. The final targeted rerun reported 234 passed and two skipped. The store review regression batch reported 83 passed and one skipped. Desktop TypeScript checks, 56 selected Electron tests, 24 release tests, and the removed-import/compatibility guards passed. This is an integration checkpoint, not full audit acceptance. The complete Python suite has not run on this fixed tree. Crash-atomic plugin publication, generation cleanup, receipt correlation, and packaged lifecycle acceptance remain open in docs/pm-audit-status.md.
356 lines
17 KiB
Python
356 lines
17 KiB
Python
"""File passthrough registry for remote terminal backends (Docker, Modal, SSH).
|
|
|
|
Sandboxes start with no host files; this module tells them which credential files
|
|
(skill ``required_credential_files`` + ``terminal.credential_files`` config), skill
|
|
dirs, and host cache dirs to mount or sync in, at creation and before each command.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import posixpath
|
|
from contextvars import ContextVar
|
|
from pathlib import Path
|
|
from typing import Callable, Dict, Iterator, List, Optional, Tuple
|
|
|
|
from hermes_cli.config import cfg_get
|
|
from hermes_constants import get_hermes_dir, get_hermes_home
|
|
|
|
from agent.skill_utils import EXCLUDED_SKILL_DIRS
|
|
|
|
try: # pragma: no cover - exercised via the fail-closed test below
|
|
from agent.file_safety import get_read_block_error
|
|
except ImportError: # noqa: F401 - sentinel consumed in register_credential_file
|
|
get_read_block_error = None # type: ignore[assignment]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Session-scoped registry; ContextVar prevents cross-session bleed in the gateway.
|
|
_registered_files_var: ContextVar[Dict[str, str]] = ContextVar("_registered_files")
|
|
|
|
# Cache for config-based file list (loaded once per process; tests reset it).
|
|
_config_files: List[Dict[str, str]] | None = None
|
|
# Reused across calls so sanitized skill copies don't accumulate.
|
|
_safe_skills_tempdir: Path | None = None
|
|
|
|
|
|
def _get_registered() -> Dict[str, str]:
|
|
val = _registered_files_var.get(None)
|
|
if val is None:
|
|
_registered_files_var.set(val := {})
|
|
return val
|
|
|
|
|
|
def _mount(host_path: Path | str, container_path: str) -> Dict[str, str]:
|
|
return {"host_path": str(host_path), "container_path": container_path}
|
|
|
|
|
|
def _contained_host_path(rel: str, hermes_home: Path, abs_msg: str, traversal_msg: str) -> Optional[Path]:
|
|
"""Resolve *rel* under HERMES_HOME, refusing absolute paths and escapes."""
|
|
if os.path.isabs(rel):
|
|
logger.warning(abs_msg, rel)
|
|
return None
|
|
host_path = hermes_home / rel
|
|
from tools.path_security import validate_within_dir # resolves symlinks and ``..`` before checking
|
|
|
|
if containment_error := validate_within_dir(host_path, hermes_home):
|
|
logger.warning(traversal_msg, rel, containment_error)
|
|
return None
|
|
return host_path.resolve()
|
|
|
|
|
|
def register_credential_file(relative_path: str, container_base: str = "/root/.hermes") -> bool:
|
|
"""Register a HERMES_HOME-relative credential file for mounting; True if it exists and was registered.
|
|
|
|
Rejects absolute paths and traversal out of HERMES_HOME. Containment alone is not
|
|
enough: HERMES_HOME holds the MASTER stores (``.env``, ``auth.json``, ``mcp-tokens/``),
|
|
which are refused via the canonical read deny-list so the mount surface cannot hand a
|
|
skill what the read surface denies. Fails CLOSED (logged) if the guard is unavailable or raises.
|
|
"""
|
|
resolved = _contained_host_path(
|
|
relative_path, get_hermes_home(),
|
|
"credential_files: rejected absolute path %r (must be relative to HERMES_HOME)",
|
|
"credential_files: rejected path traversal %r (%s)")
|
|
if resolved is None:
|
|
return False
|
|
if not resolved.is_file():
|
|
logger.debug("credential_files: skipping %s (not found)", resolved)
|
|
return False
|
|
# Master credential stores are never mountable, even though they sit inside HERMES_HOME and therefore
|
|
# pass the containment check above. Fails CLOSED: if the canonical guard can't be consulted we refuse
|
|
# the mount rather than risk bind-mounting auth.json into a sandbox. The import lives at module top (no
|
|
# circular-import concern — file_safety is stdlib-only); the sentinel + logger.exception keep guard
|
|
# failures debuggable instead of silently swallowed (#67665).
|
|
if get_read_block_error is None:
|
|
logger.error("credential_files: refusing %r — agent.file_safety could not be "
|
|
"imported, so the master-store deny-list cannot be consulted", relative_path)
|
|
return False
|
|
try:
|
|
denied = get_read_block_error(str(resolved))
|
|
except Exception:
|
|
logger.exception("credential_files: refusing %r — read guard raised", relative_path)
|
|
return False
|
|
if denied:
|
|
logger.warning("credential_files: refused %r — it is a credential store the agent "
|
|
"is denied from reading; a skill may mount its own service token, "
|
|
"not the master key files", relative_path)
|
|
return False
|
|
|
|
container_path = f"{container_base.rstrip('/')}/{relative_path}"
|
|
_get_registered()[container_path] = str(resolved)
|
|
logger.debug("credential_files: registered %s -> %s", resolved, container_path)
|
|
return True
|
|
|
|
|
|
def register_credential_files(entries: list, container_base: str = "/root/.hermes") -> List[str]:
|
|
"""Register skill-frontmatter entries (str or dict with ``path``); return missing paths."""
|
|
missing = []
|
|
for entry in entries:
|
|
if isinstance(entry, dict):
|
|
entry = entry.get("path") or entry.get("name") or ""
|
|
elif not isinstance(entry, str):
|
|
continue
|
|
rel_path = entry.strip()
|
|
if rel_path and not register_credential_file(rel_path, container_base):
|
|
missing.append(rel_path)
|
|
return missing
|
|
|
|
|
|
def _load_config_files() -> List[Dict[str, str]]:
|
|
"""Load ``terminal.credential_files`` from config.yaml (cached)."""
|
|
global _config_files
|
|
if _config_files is not None:
|
|
return _config_files
|
|
|
|
result: List[Dict[str, str]] = []
|
|
try:
|
|
from hermes_cli.config import read_raw_config
|
|
hermes_home = get_hermes_home()
|
|
cred_files = cfg_get(read_raw_config(), "terminal", "credential_files")
|
|
for item in cred_files if isinstance(cred_files, list) else []:
|
|
rel = item.strip() if isinstance(item, str) else ""
|
|
if not rel:
|
|
continue
|
|
resolved_path = _contained_host_path(
|
|
rel, hermes_home,
|
|
"credential_files: rejected absolute config path %r",
|
|
"credential_files: rejected config path traversal %r (%s)")
|
|
if resolved_path is not None and resolved_path.is_file():
|
|
result.append(_mount(resolved_path, f"/root/.hermes/{rel}"))
|
|
except Exception as e:
|
|
logger.warning("Could not read terminal.credential_files from config: %s", e)
|
|
|
|
_config_files = result
|
|
return _config_files
|
|
|
|
|
|
def get_credential_file_mounts() -> List[Dict[str, str]]:
|
|
"""Skill-registered + config credential files as ``host_path``/``container_path`` dicts (re-checked for existence)."""
|
|
mounts = {cp: hp for cp, hp in _get_registered().items() if Path(hp).is_file()}
|
|
for entry in _load_config_files():
|
|
cp, hp = entry["container_path"], entry["host_path"]
|
|
if cp not in mounts and Path(hp).is_file():
|
|
mounts[cp] = hp
|
|
return [_mount(hp, cp) for cp, hp in mounts.items()]
|
|
|
|
|
|
# --- Skills directory mounts ---
|
|
|
|
def _skill_dir_roots(container_base: str) -> Iterator[Tuple[Path, str]]:
|
|
"""Yield ``(host_dir, container_root)`` for every existing skills directory.
|
|
|
|
Local skills mount at ``<base>/skills``, external at ``<base>/external_skills/<i>``, trusted
|
|
project-local at ``<base>/project_skills/<i>`` (own namespace so paths stay stable if external_dirs change).
|
|
"""
|
|
base = container_base.rstrip("/")
|
|
skills_dir = get_hermes_home() / "skills"
|
|
if skills_dir.is_dir():
|
|
yield skills_dir, f"{base}/skills"
|
|
try:
|
|
from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs
|
|
except ImportError:
|
|
return
|
|
for label, dirs in (("external_skills", get_external_skills_dirs()), ("project_skills", get_project_skills_dirs())):
|
|
yield from ((d, f"{base}/{label}/{idx}") for idx, d in enumerate(dirs) if d.is_dir())
|
|
|
|
|
|
def _walk_skill_tree(root: Path) -> Iterator[Tuple[Path, List[Path]]]:
|
|
"""Yield ``(dir, regular_non_symlink_files)`` for every directory a sandbox should receive.
|
|
|
|
Prunes ``EXCLUDED_SKILL_DIRS`` *before* descending so bookkeeping/dependency trees (``.hub``,
|
|
``.archive``, ``.curator_backups``, ``node_modules``, ``.git``, ...) the remote agent never reads
|
|
are never even walked; sync thus agrees with discovery on what is skill content. Deliberately
|
|
not ``is_excluded_skill_path()``: that also prunes ``references/``, ``templates/``, ``assets/``,
|
|
``scripts/`` — progressive-disclosure files and bundled scripts the sandbox does execute.
|
|
"""
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDED_SKILL_DIRS)
|
|
base = Path(dirpath)
|
|
yield base, [f for f in (base / n for n in filenames) if not f.is_symlink() and f.is_file()]
|
|
|
|
|
|
def get_skills_directory_mount(container_base: str = "/root/.hermes") -> list[Dict[str, str]]:
|
|
"""Directory mount entries for all skill dirs (local + external + project).
|
|
|
|
Bind mounts follow symlinks, so a dir containing any symlink is replaced by a sanitized
|
|
temp copy (regular files only); symlink-free dirs are returned directly, zero overhead.
|
|
"""
|
|
return [_mount(_safe_skills_path(d), cp) for d, cp in _skill_dir_roots(container_base)]
|
|
|
|
|
|
def _safe_skills_path(skills_dir: Path) -> str:
|
|
"""Return *skills_dir* if symlink-free, else a sanitized temp copy (same exclusions as sync)."""
|
|
global _safe_skills_tempdir
|
|
|
|
symlinks = [p for p in skills_dir.rglob("*") if p.is_symlink()]
|
|
if not symlinks:
|
|
return str(skills_dir)
|
|
for link in symlinks:
|
|
logger.warning("credential_files: skipping symlink in skills dir: %s -> %s", link, os.readlink(link))
|
|
|
|
import atexit
|
|
import shutil
|
|
import tempfile
|
|
|
|
if _safe_skills_tempdir and _safe_skills_tempdir.is_dir():
|
|
shutil.rmtree(_safe_skills_tempdir, ignore_errors=True)
|
|
safe_dir = _safe_skills_tempdir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-"))
|
|
|
|
for base, files in _walk_skill_tree(skills_dir):
|
|
(safe_dir / base.relative_to(skills_dir)).mkdir(parents=True, exist_ok=True)
|
|
for item in files:
|
|
shutil.copy2(str(item), str(safe_dir / item.relative_to(skills_dir)))
|
|
|
|
atexit.register(lambda: safe_dir.is_dir() and shutil.rmtree(safe_dir, ignore_errors=True))
|
|
logger.info("credential_files: created symlink-safe skills copy at %s", safe_dir)
|
|
return str(safe_dir)
|
|
|
|
|
|
def iter_skills_files(container_base: str = "/root/.hermes") -> List[Dict[str, str]]:
|
|
"""Per-file entries for all skills files (for backends that upload individually)."""
|
|
return [_mount(item, f"{container_root}/{item.relative_to(host_dir).as_posix()}")
|
|
for host_dir, container_root in _skill_dir_roots(container_base)
|
|
for _base, files in _walk_skill_tree(host_dir) for item in files]
|
|
|
|
|
|
# --- Cache directory mounts (documents, images, audio, videos, screenshots) ---
|
|
|
|
# (new_subpath, old_name) pairs matching hermes_constants.get_hermes_dir().
|
|
_CACHE_DIRS: list[tuple[str, str]] = [
|
|
("cache/documents", "document_cache"),
|
|
("cache/images", "image_cache"),
|
|
("cache/audio", "audio_cache"),
|
|
("cache/videos", "video_cache"),
|
|
("cache/screenshots", "browser_screenshots"),
|
|
("cache/web", "web_cache"),
|
|
("cache/delegation", "delegation_cache"),
|
|
("cache/spillover", "cache/spillover"), # oversized tool results; host side is canonical
|
|
# Flat top-level desktop staging dirs (tui_gateway attach RPCs; no legacy alias),
|
|
# mounted so vision/file tools in sandboxes reach uploads and dropped files.
|
|
# Mount it so vision can reach uploads inside sandbox containers (#69575). No legacy alias exists, so
|
|
# both tuple slots are ``images``.
|
|
("images", "images"),
|
|
# Mount it so the agent's file tools can read dropped binaries (zip/pdf/...) from inside sandbox
|
|
# containers instead of dangling host paths (#76577).
|
|
("attachments", "attachments"),
|
|
]
|
|
|
|
|
|
def _cache_dir_roots(container_base: str, *, create_missing: bool) -> Iterator[Tuple[Path, str]]:
|
|
"""Yield ``(host_dir, container_root)`` per cache dir; always maps to the *new* container layout."""
|
|
base = container_base.rstrip("/")
|
|
for new_subpath, old_name in _CACHE_DIRS:
|
|
host_dir = get_hermes_dir(new_subpath, old_name)
|
|
if not host_dir.is_dir():
|
|
if not create_missing:
|
|
continue
|
|
# Docker snapshots this list at container CREATION, so a dir appearing later
|
|
# would dangle for the container's life: create it now (empty bind mount is free).
|
|
# get_hermes_dir already picked new-vs-legacy, so this can't shadow a legacy dir.
|
|
try:
|
|
# Create missing staging dirs instead of skipping them: Docker snapshots this mount list at
|
|
# container CREATION, so a dir that appears later (first desktop attachment, first clipboard
|
|
# image) would dangle for the whole life of a persistent container (#76577). An empty
|
|
# bind-mounted dir costs nothing; a missing mount costs the feature. get_hermes_dir()
|
|
# already resolved new-vs-legacy layout, so creating its answer cannot shadow a populated
|
|
# legacy dir.
|
|
host_dir.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
continue # unwritable home (tests, RO mounts) — skip as before
|
|
yield host_dir, f"{base}/{new_subpath}"
|
|
|
|
|
|
def get_cache_directory_mounts(container_base: str = "/root/.hermes") -> List[Dict[str, str]]:
|
|
"""Bind-mount entries for each cache directory (host layout via ``get_hermes_dir``)."""
|
|
return [_mount(h, c) for h, c in _cache_dir_roots(container_base, create_missing=True)]
|
|
|
|
|
|
def _remap_cache_path(path: str, container_base: str, src: str, dst: str, join: Callable[[str, Path], str]) -> Optional[str]:
|
|
"""Translate *path* from the *src* side of a cache mount to its *dst* side; None if unmounted."""
|
|
for mount in get_cache_directory_mounts(container_base=container_base):
|
|
if Path(path).is_relative_to(mount[src]):
|
|
return join(mount[dst], Path(path).relative_to(mount[src]))
|
|
return None
|
|
|
|
|
|
def map_cache_path_to_container(host_path: str, container_base: str = "/root/.hermes") -> Optional[str]:
|
|
"""POSIX container path for a host path under an auto-mounted cache dir, else None."""
|
|
return _remap_cache_path(host_path, container_base, "host_path", "container_path", lambda root, rel: posixpath.join(root, rel.as_posix()))
|
|
|
|
|
|
def from_agent_visible_cache_path(container_path: str, container_base: str = "/root/.hermes") -> str:
|
|
"""Inverse of :func:`to_agent_visible_cache_path`; unchanged unless Docker + cache dir."""
|
|
if os.environ.get("TERMINAL_ENV", "local") != "docker":
|
|
return container_path
|
|
mapped = _remap_cache_path(container_path, container_base, "container_path", "host_path", lambda root, rel: str(Path(root) / rel))
|
|
return mapped if mapped is not None else container_path
|
|
|
|
|
|
# Backends whose file-sync lands under the remote home: ``~/.hermes`` is
|
|
# expanded by the remote shell, so it resolves regardless of the actual home.
|
|
_HOME_RELATIVE_BACKENDS = frozenset({"ssh", "daytona", "vercel_sandbox"})
|
|
|
|
|
|
def to_agent_visible_cache_path(host_path: str, container_base: str = "/root/.hermes") -> str:
|
|
"""Translate a host cache path to where the active backend (TERMINAL_ENV) sees it.
|
|
|
|
Mirrors ``_agent_cache_base_for_env`` in tools/image_generation_tool.py: docker/modal mount at
|
|
``/root/.hermes``; ssh/daytona/vercel_sandbox under ``~/.hermes``; plugin backends declare
|
|
``cache_path_base`` (None = host paths stay correct); local/singularity/unknown unchanged
|
|
(Apptainer auto-binds the host home, so translation would dangle).
|
|
|
|
* docker / modal — bind-mounted (docker) or per-file-synced (modal) at ``/root/.hermes`` (the
|
|
*container_base* default). * ssh / daytona / vercel_sandbox — file-synced under the remote user's home;
|
|
``~/.hermes`` is shell-expanded by the remote shell, so tool commands resolve it regardless of the
|
|
actual remote home. Previously these backends synced the bytes but still rendered the dangling host path
|
|
(#76577 gap).
|
|
"""
|
|
backend = (os.environ.get("TERMINAL_ENV") or "local").strip().lower()
|
|
if backend in _HOME_RELATIVE_BACKENDS:
|
|
container_base = "~/.hermes"
|
|
elif backend not in ("docker", "modal"):
|
|
try:
|
|
from agent.terminal_env_registry import provider_flag
|
|
plugin_base = provider_flag(backend, "cache_path_base", None)
|
|
except Exception:
|
|
plugin_base = None
|
|
if not plugin_base:
|
|
return host_path
|
|
container_base = str(plugin_base)
|
|
|
|
mapped = map_cache_path_to_container(host_path, container_base=container_base)
|
|
return mapped if mapped is not None else host_path
|
|
|
|
|
|
def iter_cache_files(container_base: str = "/root/.hermes") -> List[Dict[str, str]]:
|
|
"""Per-file cache entries (Modal upload/resync); skips symlinks."""
|
|
return [_mount(item, f"{root}/{item.relative_to(host_dir)}")
|
|
for host_dir, root in _cache_dir_roots(container_base, create_missing=False)
|
|
for item in host_dir.rglob("*") if not item.is_symlink() and item.is_file()]
|
|
|
|
|
|
def clear_credential_files() -> None:
|
|
"""Reset the skill-scoped registry (e.g. on session reset)."""
|
|
_get_registered().clear()
|