2026-04-03 13:16:26 -07:00
|
|
|
"""File passthrough registry for remote terminal backends.
|
2026-03-28 23:53:40 -07:00
|
|
|
|
2026-04-03 13:16:26 -07:00
|
|
|
Remote backends (Docker, Modal, SSH) create sandboxes with no host files.
|
|
|
|
|
This module ensures that credential files, skill directories, and host-side
|
|
|
|
|
cache directories (documents, images, audio, screenshots) are mounted or
|
|
|
|
|
synced into those sandboxes so the agent can access them.
|
2026-03-28 23:53:40 -07:00
|
|
|
|
2026-04-03 13:16:26 -07:00
|
|
|
**Credentials and skills** — session-scoped registry fed by skill declarations
|
|
|
|
|
(``required_credential_files``) and user config (``terminal.credential_files``).
|
2026-03-28 23:53:40 -07:00
|
|
|
|
2026-04-03 13:16:26 -07:00
|
|
|
**Cache directories** — gateway-cached uploads, browser screenshots, TTS
|
|
|
|
|
audio, and processed images. Mounted read-only so the remote terminal can
|
|
|
|
|
reference files the host side created (e.g. ``unzip`` an uploaded archive).
|
2026-03-28 23:53:40 -07:00
|
|
|
|
2026-04-03 13:16:26 -07:00
|
|
|
Remote backends call :func:`get_credential_file_mounts`,
|
|
|
|
|
:func:`get_skills_directory_mount` / :func:`iter_skills_files`, and
|
|
|
|
|
:func:`get_cache_directory_mounts` / :func:`iter_cache_files` at sandbox
|
|
|
|
|
creation time and before each command (for resync on Modal).
|
2026-03-28 23:53:40 -07:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
2026-04-06 16:05:15 +03:00
|
|
|
from contextvars import ContextVar
|
2026-03-28 23:53:40 -07:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Dict, List
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# Session-scoped list of credential files to mount.
|
2026-04-06 16:05:15 +03:00
|
|
|
# Backed by ContextVar to prevent cross-session data bleed in the gateway pipeline.
|
|
|
|
|
_registered_files_var: ContextVar[Dict[str, str]] = ContextVar("_registered_files")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_registered() -> Dict[str, str]:
|
|
|
|
|
"""Get or create the registered credential files dict for the current context/session."""
|
|
|
|
|
try:
|
|
|
|
|
return _registered_files_var.get()
|
|
|
|
|
except LookupError:
|
|
|
|
|
val: Dict[str, str] = {}
|
|
|
|
|
_registered_files_var.set(val)
|
|
|
|
|
return val
|
|
|
|
|
|
2026-03-28 23:53:40 -07:00
|
|
|
|
|
|
|
|
# Cache for config-based file list (loaded once per process).
|
|
|
|
|
_config_files: List[Dict[str, str]] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_hermes_home() -> Path:
|
refactor: replace inline HERMES_HOME re-implementations with get_hermes_home()
16 callsites across 14 files were re-deriving the hermes home path
via os.environ.get('HERMES_HOME', ...) instead of using the canonical
get_hermes_home() from hermes_constants. This breaks profiles — each
profile has its own HERMES_HOME, and the inline fallback defaults to
~/.hermes regardless.
Fixed by importing and calling get_hermes_home() at each site. For
files already inside the hermes process (agent/, hermes_cli/, tools/,
gateway/, plugins/), this is always safe. Files that run outside the
process context (mcp_serve.py, mcp_oauth.py) already had correct
try/except ImportError fallbacks and were left alone.
Skipped: hermes_constants.py (IS the implementation), env_loader.py
(bootstrap), profiles.py (intentionally manipulates the env var),
standalone scripts (optional-skills/, skills/), and tests.
2026-04-07 10:40:34 -07:00
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
|
return get_hermes_home()
|
2026-03-28 23:53:40 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_credential_file(
|
|
|
|
|
relative_path: str,
|
|
|
|
|
container_base: str = "/root/.hermes",
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Register a credential file for mounting into remote sandboxes.
|
|
|
|
|
|
|
|
|
|
*relative_path* is relative to ``HERMES_HOME`` (e.g. ``google_token.json``).
|
|
|
|
|
Returns True if the file exists on the host and was registered.
|
2026-03-30 15:06:35 +03:00
|
|
|
|
|
|
|
|
Security: rejects absolute paths and path traversal sequences (``..``).
|
|
|
|
|
The resolved host path must remain inside HERMES_HOME so that a malicious
|
|
|
|
|
skill cannot declare ``required_credential_files: ['../../.ssh/id_rsa']``
|
|
|
|
|
and exfiltrate sensitive host files into a container sandbox.
|
2026-03-28 23:53:40 -07:00
|
|
|
"""
|
|
|
|
|
hermes_home = _resolve_hermes_home()
|
2026-03-30 15:06:35 +03:00
|
|
|
|
|
|
|
|
# Reject absolute paths — they bypass the HERMES_HOME sandbox entirely.
|
|
|
|
|
if os.path.isabs(relative_path):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"credential_files: rejected absolute path %r (must be relative to HERMES_HOME)",
|
|
|
|
|
relative_path,
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
2026-03-28 23:53:40 -07:00
|
|
|
host_path = hermes_home / relative_path
|
2026-03-30 15:06:35 +03:00
|
|
|
|
|
|
|
|
# Resolve symlinks and normalise ``..`` before the containment check so
|
|
|
|
|
# that traversal like ``../. ssh/id_rsa`` cannot escape HERMES_HOME.
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
from tools.path_security import validate_within_dir
|
|
|
|
|
|
|
|
|
|
containment_error = validate_within_dir(host_path, hermes_home)
|
|
|
|
|
if containment_error:
|
2026-03-30 15:06:35 +03:00
|
|
|
logger.warning(
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
"credential_files: rejected path traversal %r (%s)",
|
2026-03-30 15:06:35 +03:00
|
|
|
relative_path,
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
containment_error,
|
2026-03-30 15:06:35 +03:00
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
resolved = host_path.resolve()
|
2026-03-30 15:06:35 +03:00
|
|
|
if not resolved.is_file():
|
|
|
|
|
logger.debug("credential_files: skipping %s (not found)", resolved)
|
2026-03-28 23:53:40 -07:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
container_path = f"{container_base.rstrip('/')}/{relative_path}"
|
2026-04-06 16:05:15 +03:00
|
|
|
_get_registered()[container_path] = str(resolved)
|
2026-03-30 15:06:35 +03:00
|
|
|
logger.debug("credential_files: registered %s -> %s", resolved, container_path)
|
2026-03-28 23:53:40 -07:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_credential_files(
|
|
|
|
|
entries: list,
|
|
|
|
|
container_base: str = "/root/.hermes",
|
|
|
|
|
) -> List[str]:
|
|
|
|
|
"""Register multiple credential files from skill frontmatter entries.
|
|
|
|
|
|
|
|
|
|
Each entry is either a string (relative path) or a dict with a ``path``
|
|
|
|
|
key. Returns the list of relative paths that were NOT found on the host
|
|
|
|
|
(i.e. missing files).
|
|
|
|
|
"""
|
|
|
|
|
missing = []
|
|
|
|
|
for entry in entries:
|
|
|
|
|
if isinstance(entry, str):
|
|
|
|
|
rel_path = entry.strip()
|
|
|
|
|
elif isinstance(entry, dict):
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
rel_path = (entry.get("path") or entry.get("name") or "").strip()
|
2026-03-28 23:53:40 -07:00
|
|
|
else:
|
|
|
|
|
continue
|
|
|
|
|
if not rel_path:
|
|
|
|
|
continue
|
|
|
|
|
if 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:
|
2026-04-07 17:28:04 -07:00
|
|
|
from hermes_cli.config import read_raw_config
|
2026-03-28 23:53:40 -07:00
|
|
|
hermes_home = _resolve_hermes_home()
|
2026-04-07 17:28:04 -07:00
|
|
|
cfg = read_raw_config()
|
|
|
|
|
cred_files = cfg.get("terminal", {}).get("credential_files")
|
|
|
|
|
if isinstance(cred_files, list):
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
from tools.path_security import validate_within_dir
|
|
|
|
|
|
2026-04-07 17:28:04 -07:00
|
|
|
for item in cred_files:
|
|
|
|
|
if isinstance(item, str) and item.strip():
|
|
|
|
|
rel = item.strip()
|
|
|
|
|
if os.path.isabs(rel):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"credential_files: rejected absolute config path %r", rel,
|
|
|
|
|
)
|
|
|
|
|
continue
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
host_path = hermes_home / rel
|
|
|
|
|
containment_error = validate_within_dir(host_path, hermes_home)
|
|
|
|
|
if containment_error:
|
2026-04-07 17:28:04 -07:00
|
|
|
logger.warning(
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
"credential_files: rejected config path traversal %r (%s)",
|
|
|
|
|
rel, containment_error,
|
2026-04-07 17:28:04 -07:00
|
|
|
)
|
|
|
|
|
continue
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
resolved_path = host_path.resolve()
|
|
|
|
|
if resolved_path.is_file():
|
2026-04-07 17:28:04 -07:00
|
|
|
container_path = f"/root/.hermes/{rel}"
|
|
|
|
|
result.append({
|
refactor: extract shared helpers to deduplicate repeated code patterns (#7917)
* refactor: add shared helper modules for code deduplication
New modules:
- gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator,
strip_markdown, ThreadParticipationTracker, redact_phone
- hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers
- tools/path_security.py: validate_within_dir, has_traversal_component
- utils.py additions: safe_json_loads, read_json_file, read_jsonl,
append_jsonl, env_str/lower/int/bool helpers
- hermes_constants.py additions: get_config_path, get_skills_dir,
get_logs_dir, get_env_path
* refactor: migrate gateway adapters to shared helpers
- MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost
- strip_markdown: bluebubbles, feishu, sms
- redact_phone: sms, signal
- ThreadParticipationTracker: discord, matrix
- _acquire/_release_platform_lock: telegram, discord, slack, whatsapp,
signal, weixin
Net -316 lines across 19 files.
* refactor: migrate CLI modules to shared helpers
- tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines)
- setup.py: use cli_output print helpers + curses_radiolist (-101 lines)
- mcp_config.py: use cli_output prompt (-15 lines)
- memory_setup.py: use curses_radiolist (-86 lines)
Net -263 lines across 5 files.
* refactor: migrate to shared utility helpers
- safe_json_loads: agent/display.py (4 sites)
- get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py
- get_skills_dir: skill_utils.py, prompt_builder.py
- Token estimation dedup: skills_tool.py imports from model_metadata
- Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files
- Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write
- Platform dict: new platforms.py, skills_config + tools_config derive from it
- Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main
* test: update tests for shared helper migrations
- test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate()
- test_mattermost: use _dedup instead of _seen_posts/_prune_seen
- test_signal: import redact_phone from helpers instead of signal
- test_discord_connect: _platform_lock_identity instead of _token_lock_identity
- test_telegram_conflict: updated lock error message format
- test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
2026-04-11 13:59:52 -07:00
|
|
|
"host_path": str(resolved_path),
|
2026-04-07 17:28:04 -07:00
|
|
|
"container_path": container_path,
|
|
|
|
|
})
|
2026-03-28 23:53:40 -07:00
|
|
|
except Exception as e:
|
2026-04-10 11:49:35 +08:00
|
|
|
logger.warning("Could not read terminal.credential_files from config: %s", e)
|
2026-03-28 23:53:40 -07:00
|
|
|
|
|
|
|
|
_config_files = result
|
|
|
|
|
return _config_files
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_credential_file_mounts() -> List[Dict[str, str]]:
|
|
|
|
|
"""Return all credential files that should be mounted into remote sandboxes.
|
|
|
|
|
|
|
|
|
|
Each item has ``host_path`` and ``container_path`` keys.
|
|
|
|
|
Combines skill-registered files and user config.
|
|
|
|
|
"""
|
|
|
|
|
mounts: Dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
# Skill-registered files
|
2026-04-06 16:05:15 +03:00
|
|
|
for container_path, host_path in _get_registered().items():
|
2026-03-28 23:53:40 -07:00
|
|
|
# Re-check existence (file may have been deleted since registration)
|
|
|
|
|
if Path(host_path).is_file():
|
|
|
|
|
mounts[container_path] = host_path
|
|
|
|
|
|
|
|
|
|
# Config-based files
|
|
|
|
|
for entry in _load_config_files():
|
|
|
|
|
cp = entry["container_path"]
|
|
|
|
|
if cp not in mounts and Path(entry["host_path"]).is_file():
|
|
|
|
|
mounts[cp] = entry["host_path"]
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
{"host_path": hp, "container_path": cp}
|
|
|
|
|
for cp, hp in mounts.items()
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
def get_skills_directory_mount(
|
|
|
|
|
container_base: str = "/root/.hermes",
|
2026-04-03 21:14:34 -07:00
|
|
|
) -> list[Dict[str, str]]:
|
|
|
|
|
"""Return mount info for all skill directories (local + external).
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
|
|
|
|
|
Skills may include ``scripts/``, ``templates/``, and ``references/``
|
|
|
|
|
subdirectories that the agent needs to execute inside remote sandboxes.
|
|
|
|
|
|
|
|
|
|
**Security:** Bind mounts follow symlinks, so a malicious symlink inside
|
|
|
|
|
the skills tree could expose arbitrary host files to the container. When
|
|
|
|
|
symlinks are detected, this function creates a sanitized copy (regular
|
|
|
|
|
files only) in a temp directory and returns that path instead. When no
|
|
|
|
|
symlinks are present (the common case), the original directory is returned
|
|
|
|
|
directly with zero overhead.
|
|
|
|
|
|
2026-04-03 21:14:34 -07:00
|
|
|
Returns a list of dicts with ``host_path`` and ``container_path`` keys.
|
|
|
|
|
The local skills dir mounts at ``<container_base>/skills``, external dirs
|
|
|
|
|
at ``<container_base>/external_skills/<index>``.
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
"""
|
2026-04-03 21:14:34 -07:00
|
|
|
mounts = []
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
hermes_home = _resolve_hermes_home()
|
|
|
|
|
skills_dir = hermes_home / "skills"
|
2026-04-03 21:14:34 -07:00
|
|
|
if skills_dir.is_dir():
|
|
|
|
|
host_path = _safe_skills_path(skills_dir)
|
|
|
|
|
mounts.append({
|
|
|
|
|
"host_path": host_path,
|
|
|
|
|
"container_path": f"{container_base.rstrip('/')}/skills",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Mount external skill dirs
|
|
|
|
|
try:
|
|
|
|
|
from agent.skill_utils import get_external_skills_dirs
|
|
|
|
|
for idx, ext_dir in enumerate(get_external_skills_dirs()):
|
|
|
|
|
if ext_dir.is_dir():
|
|
|
|
|
host_path = _safe_skills_path(ext_dir)
|
|
|
|
|
mounts.append({
|
|
|
|
|
"host_path": host_path,
|
|
|
|
|
"container_path": f"{container_base.rstrip('/')}/external_skills/{idx}",
|
|
|
|
|
})
|
|
|
|
|
except ImportError:
|
|
|
|
|
pass
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
|
2026-04-03 21:14:34 -07:00
|
|
|
return mounts
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
_safe_skills_tempdir: Path | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_skills_path(skills_dir: Path) -> str:
|
|
|
|
|
"""Return *skills_dir* if symlink-free, else a sanitized temp copy."""
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# Reuse the same temp dir across calls to avoid accumulation.
|
|
|
|
|
if _safe_skills_tempdir and _safe_skills_tempdir.is_dir():
|
|
|
|
|
shutil.rmtree(_safe_skills_tempdir, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-"))
|
|
|
|
|
_safe_skills_tempdir = safe_dir
|
|
|
|
|
|
|
|
|
|
for item in skills_dir.rglob("*"):
|
|
|
|
|
if item.is_symlink():
|
|
|
|
|
continue
|
|
|
|
|
rel = item.relative_to(skills_dir)
|
|
|
|
|
target = safe_dir / rel
|
|
|
|
|
if item.is_dir():
|
|
|
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
elif item.is_file():
|
|
|
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
shutil.copy2(str(item), str(target))
|
|
|
|
|
|
|
|
|
|
def _cleanup():
|
|
|
|
|
if safe_dir.is_dir():
|
|
|
|
|
shutil.rmtree(safe_dir, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
atexit.register(_cleanup)
|
|
|
|
|
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]]:
|
|
|
|
|
"""Yield individual (host_path, container_path) entries for skills files.
|
|
|
|
|
|
2026-04-03 21:14:34 -07:00
|
|
|
Includes both the local skills dir and any external dirs configured via
|
|
|
|
|
skills.external_dirs. Skips symlinks entirely. Preferred for backends
|
|
|
|
|
that upload files individually (Daytona, Modal) rather than mounting a
|
|
|
|
|
directory.
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
"""
|
2026-04-03 21:14:34 -07:00
|
|
|
result: List[Dict[str, str]] = []
|
|
|
|
|
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
hermes_home = _resolve_hermes_home()
|
|
|
|
|
skills_dir = hermes_home / "skills"
|
2026-04-03 21:14:34 -07:00
|
|
|
if skills_dir.is_dir():
|
|
|
|
|
container_root = f"{container_base.rstrip('/')}/skills"
|
|
|
|
|
for item in skills_dir.rglob("*"):
|
|
|
|
|
if item.is_symlink() or not item.is_file():
|
|
|
|
|
continue
|
|
|
|
|
rel = item.relative_to(skills_dir)
|
|
|
|
|
result.append({
|
|
|
|
|
"host_path": str(item),
|
|
|
|
|
"container_path": f"{container_root}/{rel}",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Include external skill dirs
|
|
|
|
|
try:
|
|
|
|
|
from agent.skill_utils import get_external_skills_dirs
|
|
|
|
|
for idx, ext_dir in enumerate(get_external_skills_dirs()):
|
|
|
|
|
if not ext_dir.is_dir():
|
|
|
|
|
continue
|
|
|
|
|
container_root = f"{container_base.rstrip('/')}/external_skills/{idx}"
|
|
|
|
|
for item in ext_dir.rglob("*"):
|
|
|
|
|
if item.is_symlink() or not item.is_file():
|
|
|
|
|
continue
|
|
|
|
|
rel = item.relative_to(ext_dir)
|
|
|
|
|
result.append({
|
|
|
|
|
"host_path": str(item),
|
|
|
|
|
"container_path": f"{container_root}/{rel}",
|
|
|
|
|
})
|
|
|
|
|
except ImportError:
|
|
|
|
|
pass
|
feat: mount skills directory into all remote backends with live sync (#3890)
Skills with scripts/, templates/, and references/ subdirectories need
those files available inside sandboxed execution environments. Previously
the skills directory was missing entirely from remote backends.
Live sync — files stay current as credentials refresh and skills update:
- Docker/Singularity: bind mounts are inherently live (host changes
visible immediately)
- Modal: _sync_files() runs before each command with mtime+size caching,
pushing only changed credential and skill files (~13μs no-op overhead)
- SSH: rsync --safe-links before each command (naturally incremental)
- Daytona: _upload_if_changed() with mtime+size caching before each command
Security — symlink filtering:
- Docker/Singularity: sanitized temp copy when symlinks detected
- Modal/Daytona: iter_skills_files() skips symlinks
- SSH: rsync --safe-links skips symlinks pointing outside source tree
- Temp dir cleanup via atexit + reuse across calls
Non-root user support:
- SSH: detects remote home via echo $HOME, syncs to $HOME/.hermes/
- Daytona: detects sandbox home before sync, uploads to $HOME/.hermes/
- Docker/Modal/Singularity: run as root, /root/.hermes/ is correct
Also:
- credential_files.py: fix name/path key fallback in required_credential_files
- Singularity, SSH, Daytona: gained credential file support
- 14 tests covering symlink filtering, name/path fallback, iter_skills_files
2026-03-30 02:45:41 -07:00
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-04-03 13:16:26 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Cache directory mounts (documents, images, audio, screenshots)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# The four cache subdirectories that should be mirrored into remote backends.
|
|
|
|
|
# Each tuple is (new_subpath, old_name) 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/screenshots", "browser_screenshots"),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_cache_directory_mounts(
|
|
|
|
|
container_base: str = "/root/.hermes",
|
|
|
|
|
) -> List[Dict[str, str]]:
|
|
|
|
|
"""Return mount entries for each cache directory that exists on disk.
|
|
|
|
|
|
|
|
|
|
Used by Docker to create bind mounts. Each entry has ``host_path`` and
|
|
|
|
|
``container_path`` keys. The host path is resolved via
|
|
|
|
|
``get_hermes_dir()`` for backward compatibility with old directory layouts.
|
|
|
|
|
"""
|
|
|
|
|
from hermes_constants import get_hermes_dir
|
|
|
|
|
|
|
|
|
|
mounts: List[Dict[str, str]] = []
|
|
|
|
|
for new_subpath, old_name in _CACHE_DIRS:
|
|
|
|
|
host_dir = get_hermes_dir(new_subpath, old_name)
|
|
|
|
|
if host_dir.is_dir():
|
|
|
|
|
# Always map to the *new* container layout regardless of host layout.
|
|
|
|
|
container_path = f"{container_base.rstrip('/')}/{new_subpath}"
|
|
|
|
|
mounts.append({
|
|
|
|
|
"host_path": str(host_dir),
|
|
|
|
|
"container_path": container_path,
|
|
|
|
|
})
|
|
|
|
|
return mounts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def iter_cache_files(
|
|
|
|
|
container_base: str = "/root/.hermes",
|
|
|
|
|
) -> List[Dict[str, str]]:
|
|
|
|
|
"""Return individual (host_path, container_path) entries for cache files.
|
|
|
|
|
|
|
|
|
|
Used by Modal to upload files individually and resync before each command.
|
|
|
|
|
Skips symlinks. The container paths use the new ``cache/<subdir>`` layout.
|
|
|
|
|
"""
|
|
|
|
|
from hermes_constants import get_hermes_dir
|
|
|
|
|
|
|
|
|
|
result: List[Dict[str, str]] = []
|
|
|
|
|
for new_subpath, old_name in _CACHE_DIRS:
|
|
|
|
|
host_dir = get_hermes_dir(new_subpath, old_name)
|
|
|
|
|
if not host_dir.is_dir():
|
|
|
|
|
continue
|
|
|
|
|
container_root = f"{container_base.rstrip('/')}/{new_subpath}"
|
|
|
|
|
for item in host_dir.rglob("*"):
|
|
|
|
|
if item.is_symlink() or not item.is_file():
|
|
|
|
|
continue
|
|
|
|
|
rel = item.relative_to(host_dir)
|
|
|
|
|
result.append({
|
|
|
|
|
"host_path": str(item),
|
|
|
|
|
"container_path": f"{container_root}/{rel}",
|
|
|
|
|
})
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
2026-03-28 23:53:40 -07:00
|
|
|
def clear_credential_files() -> None:
|
|
|
|
|
"""Reset the skill-scoped registry (e.g. on session reset)."""
|
2026-04-06 16:05:15 +03:00
|
|
|
_get_registered().clear()
|
2026-03-28 23:53:40 -07:00
|
|
|
|
|
|
|
|
|