""" Lazy dependency installer for opt-in Hermes Agent backends. Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock, Slack, Matrix, etc.) require Python packages that not every user needs. The historical approach was to bundle them all under ``pyproject.toml`` extras (``hermes-agent[all]``) and install them eagerly at setup time. That has two problems: 1. **Fragility.** When one extra's transitive dependency becomes unavailable on PyPI (quarantined for malware, yanked, broken upload), the *entire* ``[all]`` resolve fails and fresh installs silently fall back to a stripped tier — losing 10+ unrelated extras at once. 2. **Bloat.** A user who only ever talks to one provider pulls hundreds of packages they will never import. The lazy-install pattern fixes both. Backends call :func:`ensure` at the top of their first-import path. If the deps are missing, ``ensure`` checks the ``security.allow_lazy_installs`` config flag (default true) and runs a venv-scoped pip install. If the user has explicitly disabled lazy installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear remediation hint pointing at ``hermes tools`` or the manual pip command. Security model: * **Venv-scoped only.** Installs target ``sys.executable`` in the active venv. We never touch the system Python. * **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc. We do NOT support ``--index-url`` overrides, ``git+https://``, file: paths, or any other input that could be hijacked by a malicious config. * **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be installed via this path. A typo in feature name doesn't get the user install-anything semantics. * **Opt-out.** Setting ``security.allow_lazy_installs: false`` in ``config.yaml`` disables runtime installs. Users in restricted networks or strict security postures can pin themselves to whatever was installed at setup time. * **Offline detection.** If the install fails (offline, mirror down, PyPI 404 / quarantine), we surface the failure as :class:`FeatureUnavailable` with the actual pip stderr — no silent retries, no caching of bad state. Adding a new backend: 1. Add an entry to :data:`LAZY_DEPS` with the package specs. 2. At the top of the backend module's import path, call ``ensure("feature.name")`` inside a try/except that converts :class:`FeatureUnavailable` to a useful runtime error. """ from __future__ import annotations import logging import os import re import shutil import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) # ============================================================================= # Allowlist of lazy-installable backends. # # Keys are dot-separated feature names ("namespace.backend"). Values are # tuples of pip-installable specs that match the corresponding extra in # pyproject.toml. The framework enforces that only specs from this map # can flow into the pip install command. # ============================================================================= LAZY_DEPS: dict[str, tuple[str, ...]] = { # ─── Inference providers ─────────────────────────────────────────────── # Native Anthropic SDK — needed when provider=anthropic (not via # OpenRouter / aggregators which use the openai SDK). "provider.anthropic": ("anthropic==0.86.0",), # AWS Bedrock provider "provider.bedrock": ("boto3==1.42.89",), # ─── Web search backends ─────────────────────────────────────────────── "search.exa": ("exa-py==2.10.2",), "search.firecrawl": ("firecrawl-py==4.17.0",), "search.parallel": ("parallel-web==0.4.2",), # ─── TTS providers ───────────────────────────────────────────────────── # Pinned to exact versions to match pyproject.toml's no-ranges policy # (see comment at top of [project.dependencies]). When bumping, update # both this map AND the corresponding extra in pyproject.toml. # # NOTE: tts.mistral / stt.mistral entries are intentionally absent — # the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini # Shai-Hulud worm). Re-add when PyPI restores a clean release; see # comment in pyproject.toml above the (removed) `mistral` extra for # the full restoration checklist. "tts.edge": ("edge-tts==7.2.7",), "tts.elevenlabs": ("elevenlabs==1.59.0",), # ─── Speech-to-text providers ────────────────────────────────────────── "stt.faster_whisper": ( "faster-whisper==1.2.1", "sounddevice==0.5.5", "numpy==2.4.3", ), # ─── Image generation backends ───────────────────────────────────────── "image.fal": ("fal-client==0.13.1",), # ─── Memory providers ────────────────────────────────────────────────── "memory.honcho": ("honcho-ai==2.0.1",), "memory.hindsight": ("hindsight-client==0.6.1",), # ─── Messaging platforms (lazy-installable on demand) ────────────────── "platform.telegram": ("python-telegram-bot[webhooks]==22.6",), "platform.discord": ("discord.py[voice]==2.7.1",), "platform.slack": ( "slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3", ), "platform.matrix": ( "mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", ), "platform.dingtalk": ( "dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2", ), "platform.feishu": ( "lark-oapi==1.5.3", "qrcode==7.4.2", ), # ─── Terminal backends ───────────────────────────────────────────────── "terminal.modal": ("modal==1.3.4",), "terminal.daytona": ("daytona==0.155.0",), "terminal.vercel": ("vercel==0.5.7",), # ─── Skills ──────────────────────────────────────────────────────────── "skill.google_workspace": ( "google-api-python-client==2.194.0", "google-auth-oauthlib==1.3.1", "google-auth-httplib2==0.3.1", ), "skill.youtube": ("youtube-transcript-api==1.2.4",), # ─── Tools ───────────────────────────────────────────────────────────── # ACP adapter (VS Code / Zed / JetBrains integration) "tool.acp": ("agent-client-protocol==0.9.0",), # Dashboard (`hermes dashboard`) "tool.dashboard": ( "fastapi==0.133.1", "uvicorn[standard]==0.41.0", ), } # Conservative regex for spec validation — package name plus optional # version range. Reject anything that looks like a URL, file path, or shell # metacharacter. _SAFE_SPEC = re.compile( r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras] r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier r"$" ) class FeatureUnavailable(RuntimeError): """A lazily-installable feature is missing and cannot be made available. Either the deps were never installed and the user has disabled lazy installs, or the install attempt failed. """ def __init__(self, feature: str, missing: tuple[str, ...], reason: str): self.feature = feature self.missing = missing self.reason = reason super().__init__(self._format()) def _format(self) -> str: spec_list = " ".join(repr(s) for s in self.missing) return ( f"Feature {self.feature!r} unavailable: {self.reason}. " f"To enable manually: uv pip install {spec_list} " f"(or: pip install {spec_list})." ) @dataclass(frozen=True) class _InstallResult: success: bool stdout: str stderr: str # ============================================================================= # Internals # ============================================================================= def _allow_lazy_installs() -> bool: """Return the ``security.allow_lazy_installs`` config flag. Defaults to True. If config is unreadable we fail open (allow), because refusing to install would lock people out of their own backends; the decision to block is an explicit user opt-in. """ if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1": return False try: from hermes_cli.config import load_config cfg = load_config() except Exception: return True sec = cfg.get("security") or {} val = sec.get("allow_lazy_installs", True) return bool(val) def _spec_is_safe(spec: str) -> bool: """Reject pip specs that contain URLs, paths, or shell metacharacters.""" if not spec or len(spec) > 200: return False if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")): return False if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec: return False return bool(_SAFE_SPEC.match(spec)) def _pkg_name_from_spec(spec: str) -> str: """Extract the bare package name from a pip spec. ``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"`` ``"mautrix[encryption]>=0.20"`` → ``"mautrix"`` """ m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec) return m.group(1) if m else spec def _is_satisfied(spec: str) -> bool: """Best-effort check: is ``spec`` already satisfied in the current env? We don't enforce the version range — if the package is importable we assume the user knows what they're doing. This matches how the lazy-import sites already behave. """ pkg = _pkg_name_from_spec(spec) try: from importlib.metadata import PackageNotFoundError, version except ImportError: return False try: version(pkg) return True except PackageNotFoundError: return False except Exception: return False def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult: """Install ``specs`` into the active venv using uv → pip → ensurepip ladder. Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but kept independent here so this module has no CLI dependency. """ if not specs: return _InstallResult(True, "", "") venv_root = Path(sys.executable).parent.parent uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} # Tier 1: uv (preferred — fast, doesn't need pip in the venv) uv_bin = shutil.which("uv") if uv_bin: try: r = subprocess.run( [uv_bin, "pip", "install", *specs], capture_output=True, text=True, timeout=timeout, env=uv_env, ) if r.returncode == 0: return _InstallResult(True, r.stdout or "", r.stderr or "") logger.debug("uv pip install failed: %s", r.stderr) except (subprocess.TimeoutExpired, FileNotFoundError) as e: logger.debug("uv invocation failed: %s", e) # Tier 2: python -m pip (with ensurepip bootstrap if needed) pip_cmd = [sys.executable, "-m", "pip"] try: probe = subprocess.run( pip_cmd + ["--version"], capture_output=True, text=True, timeout=15, ) if probe.returncode != 0: raise FileNotFoundError("pip not in venv") except (subprocess.TimeoutExpired, FileNotFoundError): try: subprocess.run( [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], capture_output=True, text=True, timeout=120, check=True, ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: return _InstallResult(False, "", f"pip not available and ensurepip failed: {e}") try: r = subprocess.run( pip_cmd + ["install", *specs], capture_output=True, text=True, timeout=timeout, ) return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "") except subprocess.TimeoutExpired as e: return _InstallResult(False, "", f"pip install timed out: {e}") except Exception as e: return _InstallResult(False, "", f"pip install failed: {e}") # ============================================================================= # Public API # ============================================================================= def feature_specs(feature: str) -> tuple[str, ...]: """Return the registered specs for a feature, or raise KeyError.""" if feature not in LAZY_DEPS: raise KeyError(f"Unknown lazy feature: {feature!r}") return LAZY_DEPS[feature] def feature_missing(feature: str) -> tuple[str, ...]: """Return the subset of specs for ``feature`` not currently installed.""" return tuple(s for s in feature_specs(feature) if not _is_satisfied(s)) def ensure(feature: str, *, prompt: bool = True) -> None: """Make sure all packages for ``feature`` are importable. If they're missing, attempts to install them in the active venv. Raises :class:`FeatureUnavailable` if the user has disabled lazy installs or if the install attempt fails. ``prompt``: when True (default) and stdin is a TTY, asks the user to confirm before installing. Non-interactive callers (gateway, cron, batch) get prompt=False and skip the confirmation — config flag is the gate in that case. """ if feature not in LAZY_DEPS: raise FeatureUnavailable( feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist" ) missing = feature_missing(feature) if not missing: return # Validate every spec against the allowlist + safety regex. Belt and # braces — the keys-in-LAZY_DEPS check above already constrains this. for spec in missing: if not _spec_is_safe(spec): raise FeatureUnavailable( feature, missing, f"refusing to install unsafe spec {spec!r}" ) if not _allow_lazy_installs(): raise FeatureUnavailable( feature, missing, "lazy installs disabled (security.allow_lazy_installs=false)" ) if prompt and sys.stdin.isatty() and sys.stdout.isatty(): spec_list = ", ".join(missing) try: answer = input( f"\nFeature {feature!r} requires: {spec_list}\n" f"Install into the active venv now? [Y/n] " ).strip().lower() except (EOFError, KeyboardInterrupt): answer = "n" if answer and answer not in ("y", "yes"): raise FeatureUnavailable( feature, missing, "user declined install at prompt" ) logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature) result = _venv_pip_install(missing) if not result.success: # Surface the actual pip error so the user can debug PyPI-side # issues (404 quarantine, network down, etc.). snippet = (result.stderr or result.stdout or "").strip() if snippet: # Clip to a readable size — pip can dump pages of resolution traces. snippet = snippet[-2000:] raise FeatureUnavailable( feature, missing, f"pip install failed: {snippet or 'no error output'}" ) # Verify post-install. importlib.metadata caches per-process, so if we # just installed something the cache may not see it without a refresh. try: import importlib.metadata as _md if hasattr(_md, "_cache_clear"): _md._cache_clear() # type: ignore[attr-defined] except Exception: pass still_missing = feature_missing(feature) if still_missing: raise FeatureUnavailable( feature, still_missing, "install reported success but packages still not importable " "(may require Python restart)" ) logger.info("Lazy install complete for feature %r", feature) def is_available(feature: str) -> bool: """Return True if the feature's deps are already satisfied.""" if feature not in LAZY_DEPS: return False return not feature_missing(feature) def feature_install_command(feature: str) -> Optional[str]: """Return the ``pip install`` command a user could run manually, or None.""" if feature not in LAZY_DEPS: return None specs = LAZY_DEPS[feature] return "uv pip install " + " ".join(repr(s) for s in specs)