"""Lazy dependency installer for opt-in Hermes backends. Backends call :func:`ensure(feature)` on first import; missing packages are pip-installed into the active venv (or the durable target) unless the user set ``security.allow_lazy_installs: false``, in which case :class:`FeatureUnavailable` carries a remediation hint. Eager ``[all]`` extras were both fragile (one yanked transitive broke every extra) and bloated; lazy installs fix both. Security model: * Venv-scoped: installs target ``sys.executable``'s venv, never system Python. * Durable-target mode (sealed images): ``HERMES_LAZY_INSTALL_TARGET`` redirects installs to a writable volume that is APPENDED to ``sys.path`` — never prepended, never via PYTHONPATH — so core site-packages wins every collision. A lazily installed package can only add modules, never shadow or break core; that guarantee is what made sealing the venv safe. An ABI stamp on the target wipes stale compiled wheels across interpreter rebuilds. * PyPI by name only: no ``--index-url``, ``git+``, or file specs (``_spec_is_safe``). * Allowlist: only specs in :data:`LAZY_DEPS` flow into pip via ``ensure``. * Opt-out ``security.allow_lazy_installs: false`` disables installs in both modes. * Install failures surface pip's stderr as FeatureUnavailable — no retries, no cache. Adding a backend: add a :data:`LAZY_DEPS` entry, then call ``ensure("ns.name")`` at the top of the backend's import path, converting FeatureUnavailable to a useful runtime error. """ from __future__ import annotations import logging import os import re import shutil import site import subprocess import sys import sysconfig from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Optional from hermes_cli._subprocess_compat import windows_hide_flags logger = logging.getLogger(__name__) # Allowlist: "namespace.backend" -> pip specs matching the pyproject extra. # Pins are exact (no ranges, security posture); bump here AND in pyproject. # Shared patched floors, spelled out as literals in every feature because # tests/test_packaging_metadata.py checks them by AST: aiohttp==3.14.3 (prior # CVEs + GHSA-cq5v-8q36-5273/GHSA-mfx4-hv73-q22v/GHSA-mq44-7p77-q5h7) and # starlette==1.3.1 (CVE-2026-48710 BadHost) — keep in sync with pyproject. LAZY_DEPS: dict[str, tuple[str, ...]] = { # ─── Inference providers ─────────────────────────────────────────────── # Native Anthropic SDK (provider=anthropic; aggregators use the openai SDK). "provider.anthropic": ("anthropic==0.87.0",), # CVE-2026-34450, CVE-2026-34452 "provider.bedrock": ("boto3==1.42.89",), # Vertex OAuth2 token minting; google-auth is NOT in [all] on purpose. "provider.vertex": ( "google-auth==2.55.1", "pyasn1==0.6.4", ), # Foundry Entra ID auth; only when model.auth_mode=entra_id. "provider.azure_identity": ("azure-identity==1.25.3",), # ─── Web search backends ─────────────────────────────────────────────── "search.exa": ("exa-py==2.10.2",), "search.firecrawl": ("firecrawl-py==4.17.0",), "search.parallel": ("parallel-web==0.4.2",), # ─── Monitoring ───────────────────────────────────────────────────────── # OTLP export; tracks the `otlp` extra. "export.otlp": ( "opentelemetry-sdk==1.39.1", "opentelemetry-exporter-otlp-proto-http==1.39.1", ), # ─── TTS providers ───────────────────────────────────────────────────── # mistralai: 2.4.6 was a malicious quarantined release — never pin below 2.4.7. # Voxtral STT + TTS share the SDK. "tts.mistral": ("mistralai==2.4.8",), "tts.edge": ("edge-tts==7.2.7",), "tts.elevenlabs": ("elevenlabs==1.59.0",), # ─── Speech-to-text providers ────────────────────────────────────────── "stt.mistral": ("mistralai==2.4.8",), "stt.faster_whisper": ( "faster-whisper==1.2.1", "sounddevice==0.5.5", "numpy==2.4.3", ), # SILK voice-note decoding (WeChat/QQ); silk-v3 codec binding. "stt.silk": ("pilk==0.2.4",), # ─── Wake word ("Hey Hermes") engines (sync with the `wake` extra) ────── # openWakeWord's ONNX model scores ~0 on macOS ARM64, so macOS uses the tflite # backend (ai-edge-litert, bridged in tools/wake_word.py). Separate feature # because specs cannot carry PEP 508 markers (";" is rejected) — the caller # applies the platform gate. "wake.openwakeword.tflite": ( "ai-edge-litert==2.1.6", ), "wake.openwakeword": ( "openwakeword==0.6.0", "onnxruntime==1.27.0", "sounddevice==0.5.5", "numpy==2.4.3", ), # Open-vocabulary keyword spotting. sentencepiece is needed by # sherpa_onnx.text2token but undeclared by sherpa-onnx. "wake.sherpa": ( "sherpa-onnx==1.13.4", "sentencepiece==0.2.2", "sounddevice==0.5.5", "numpy==2.4.3", ), "wake.porcupine": ( "pvporcupine==4.0.3", "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.2.0",), "memory.hindsight": ("hindsight-client==0.6.1",), # Cloud memory SDKs MUST be allowlisted + ensure()'d at the import site, or # they never install on the sealed Docker image (durable-target only). "memory.supermemory": ("supermemory==3.50.0",), "memory.mem0": ("mem0ai==2.0.10",), # ─── Messaging platforms (lazy-installable on demand) ────────────────── "platform.telegram": ("python-telegram-bot[webhooks]==22.8",), # brotlicffi: aiohttp needs its 2-arg Decompressor for Discord CDN's # Brotli attachments; google's `Brotli` (1-arg) fails "Can not decode br". # aiohttp is only capped transitively by these adapters, so a vulnerable # already-installed copy would satisfy them — pin the patched floor explicitly. "platform.discord": ( "discord.py[voice]==2.7.1", "brotlicffi==1.2.0.1", "aiohttp==3.14.3", ), "platform.slack": ( "slack-bolt==1.30.0", "slack-sdk==3.43.0", "aiohttp==3.14.3", ), "platform.matrix": ( "mautrix[encryption]==0.21.1", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", "aiohttp==3.14.3", ), "platform.dingtalk": ( "dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2", ), "platform.feishu": ( "lark-oapi==1.6.8", "qrcode==7.4.2", ), # WeCom callback adapter parses untrusted XML POST bodies -> defusedxml. "platform.wecom_callback": ("defusedxml==0.7.1",), # Teams pulls a heavy tree (msal, dependency-injector); also the `teams` extra. "platform.teams": ("microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.3"), # ─── Terminal backends ───────────────────────────────────────────────── "terminal.modal": ("modal==1.3.4",), "terminal.daytona": ("daytona==0.155.0",), "terminal.vercel": ("vercel==0.7.2",), # ─── Skills ──────────────────────────────────────────────────────────── "skill.google_workspace": ( "google-api-python-client==2.194.0", "google-auth==2.55.1", "google-auth-oauthlib==1.3.1", "google-auth-httplib2==0.3.1", # Explicit transitive pins: httplib2 <0.32 has a decompression-bomb DoS. "httplib2==0.32.0", "pyasn1==0.6.4", ), "skill.youtube": ("youtube-transcript-api==1.2.4",), # ─── Tools ───────────────────────────────────────────────────────────── # ACP adapter (VS Code / Zed / JetBrains) "tool.acp": ("agent-client-protocol==0.9.0",), "tool.dashboard": ( "fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.3.1", "python-multipart==0.0.32", # FastAPI UploadFile/Form streaming uploads ), # Pillow and firecrawl-anydoc are CORE deps; these entries are the self-heal # path for lean/partial installs. Call sites use prompt=False so read_file / # vision can never block on an input() prompt mid-session. "tool.vision": ("Pillow==12.3.0",), "tool.doc_extract": ("firecrawl-anydoc==0.2.4",), # imports as `anydoc`; lockstep with pyproject # MCP client SDK for the cua-driver; covers lean/broken-extra installs so # computer_use never dead-ends on `No module named 'mcp'`. "tool.computer_use": ( "mcp==2.0.0", "httpx2==2.7.0", # mcp 2.x HTTP stack — sync with pyproject [computer-use] "starlette==1.3.1", ), # huggingface-hub is SHARED with transformers (>=1.5.0,<2 via Hindsight) and # active_features() marks it active on mere presence, so `hermes update` # re-asserts this pin everywhere hub exists. It MUST stay inside transformers' # window and match uv.lock (tests/test_project_metadata.py enforces both); # bump with `uv lock --upgrade-package huggingface-hub` in lockstep. "tool.trace_upload": ("huggingface-hub==1.24.0",), } # Spec validation: name[extras]specifier only — no URLs, paths, or shell metachars. _NAME_RE = r"[A-Za-z0-9_][A-Za-z0-9_.\-]*" _NAME_EXTRAS_RE = re.compile(rf"^{_NAME_RE}(?:\[[A-Za-z0-9_,\-]+\])?") _SAFE_SPEC = re.compile(rf"^{_NAME_RE}(?:\[[A-Za-z0-9_,\-]+\])?(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?$") class FeatureUnavailable(RuntimeError): """A lazily-installable feature is missing and cannot be made available (lazy installs disabled, 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 --------------------------------------------------------------- # Internal bridge var (set by the Docker image, not user config) redirecting # lazy installs from the sealed venv to a writable durable volume. _LAZY_TARGET_ENV = "HERMES_LAZY_INSTALL_TARGET" # Stamp recording the Python X.Y + ABI the target was populated for; a mismatch # after an image rebuild wipes the store so stale .so files are never imported. _TARGET_STAMP_NAME = ".python-abi" _SUBPROCESS_KW = dict(capture_output=True, text=True, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL) def _python_abi_tag() -> str: """X.Y version + EXT_SUFFIX (ABI tag + platform); interpreters that can share compiled wheels produce the same token.""" ver = f"{sys.version_info.major}.{sys.version_info.minor}" ext = sysconfig.get_config_var("EXT_SUFFIX") or "" return f"{ver}:{ext}" def _lazy_install_target() -> Optional[Path]: """Durable install-target dir (from :data:`_LAZY_TARGET_ENV`), or None for venv-scoped mode. Created on demand by :func:`_ensure_target_ready`.""" raw = os.environ.get(_LAZY_TARGET_ENV, "").strip() return Path(raw) if raw else None def _ensure_target_ready(target: Path) -> Optional[str]: """Create the target dir and validate its ABI stamp; a stamp for a different interpreter ABI wipes the contents first (stale .so must never import). Returns None on success or an error string if the dir is not writable.""" want = _python_abi_tag() stamp = target / _TARGET_STAMP_NAME try: if target.exists(): try: have = stamp.read_text(encoding="utf-8").strip() except OSError: have = "" if have and have != want: logger.info( "Lazy install target %s was built for ABI %r but running " "ABI is %r; wiping stale packages.", target, have, want, ) for child in target.iterdir(): if child.is_dir() and not child.is_symlink(): shutil.rmtree(child, ignore_errors=True) else: try: child.unlink() except OSError: pass target.mkdir(parents=True, exist_ok=True) stamp.write_text(want, encoding="utf-8") except OSError as e: return f"lazy install target {target} is not writable: {e}" return None def _activate_target_on_syspath(target: Path) -> None: """Append the durable target to ``sys.path`` (idempotent). ``site.addsitedir`` honours ``.pth`` files but inserts near the front, so every newly added entry is moved to the END — core venv site-packages must win collisions.""" target_str = str(target) before = list(sys.path) if target_str not in before: site.addsitedir(target_str) new_entries = [p for p in sys.path if p not in before] if new_entries: sys.path[:] = [p for p in sys.path if p not in new_entries] + new_entries _invalidate_import_caches() def _invalidate_import_caches() -> None: """Make just-installed/activated dists visible to importers and importlib.metadata version() checks in this process.""" try: import importlib importlib.invalidate_caches() import importlib.metadata as _md if hasattr(_md, "_cache_clear"): _md._cache_clear() # type: ignore[attr-defined] except Exception: pass def activate_durable_lazy_target() -> None: """Wire the durable target onto ``sys.path`` early in startup so packages installed on a previous run import on this one. No-op when unset or the dir does not exist yet. Never raises.""" target = _lazy_install_target() if target is None: return try: if target.exists(): _activate_target_on_syspath(target) except Exception as e: # pragma: no cover - defensive logger.debug("Failed to activate durable lazy target %s: %s", target, e) def _allow_lazy_installs() -> bool: """Whether lazy installs are permitted. Order: (1) the config kill switch ``security.allow_lazy_installs: false`` blocks in BOTH modes; (2) the sealed venv (``HERMES_DISABLE_LAZY_INSTALLS=1``) blocks only when no durable target exists to redirect into. Unreadable config fails OPEN — blocking is an explicit user opt-in, not a default.""" try: from hermes_cli.config import load_config cfg = load_config() except Exception: cfg = None if cfg is not None: sec = cfg.get("security") or {} if not bool(sec.get("allow_lazy_installs", True)): return False if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1": return _lazy_install_target() is not None return True def _unsupported_feature_reason(feature: str) -> Optional[str]: """Platform capability gate (not policy): why a feature cannot work on this host, or None. Keeps impossible installs out of ensure() and refresh.""" if sys.platform == "win32" and feature == "platform.matrix": return ( "unsupported on Windows: Matrix E2EE depends on python-olm, " "which has no Windows wheel and requires make + libolm to build " "from sdist. Run Hermes under WSL to use Matrix on Windows." ) return None 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: """``"mautrix[encryption]>=0.20"`` -> ``"mautrix"``.""" m = re.match(rf"^({_NAME_RE})", spec) return m.group(1) if m else spec def _specifier_from_spec(spec: str) -> str: """``"mautrix[encryption]>=0.20,<1"`` -> ``">=0.20,<1"``; ``""`` if unconstrained.""" m = _NAME_EXTRAS_RE.match(spec) return spec[m.end():] if m else "" def _installed_version(spec: str) -> Optional[str]: """Installed version of the spec's package, or None when absent.""" try: from importlib.metadata import version return version(_pkg_name_from_spec(spec)) except Exception: return None def _is_satisfied(spec: str) -> bool: """Present AND inside the spec's version range. A version outside the range returns False so ``hermes update`` propagates pin bumps to installed backends. Unparseable specs/versions or a missing ``packaging`` count as satisfied — err toward "don't churn".""" installed = _installed_version(spec) if installed is None: return False spec_tail = _specifier_from_spec(spec) if not spec_tail: return True try: from packaging.specifiers import SpecifierSet from packaging.version import Version return Version(installed) in SpecifierSet(spec_tail) except Exception: return True def _is_present(spec: str) -> bool: """Presence-only check (any version); how :func:`active_features` detects backends the user activated even if the pin has since moved.""" return _installed_version(spec) is not None def _core_constraints_file() -> Optional[Path]: """Temp pip constraints file pinning every core-venv package to its installed version, passed as ``--constraint`` for durable-target installs: shared deps resolve as already-satisfied (store stays minimal) and a backend needing a conflicting version fails loudly instead of installing a shadowed copy that can never win on sys.path. None if enumeration failed (install unconstrained).""" try: import tempfile from importlib.metadata import distributions lines = [] seen = set() for dist in distributions(): name = dist.metadata["Name"] if dist.metadata else None ver = dist.version if not name or not ver or name.lower() in seen: continue seen.add(name.lower()) lines.append(f"{name}=={ver}") if not lines: return None fd, path = tempfile.mkstemp(prefix="hermes-core-constraints-", suffix=".txt") with os.fdopen(fd, "w", encoding="utf-8") as f: f.write("\n".join(sorted(lines)) + "\n") return Path(path) except Exception as e: logger.debug("Could not build core constraints file: %s", e) return None def _installed_dist_roots(spec: str, target: Optional[Path]) -> set[Path]: """Package directories a freshly installed *spec* owns, from the dist's own file list (``python-telegram-bot`` ships ``telegram``; some ship several).""" name = _pkg_name_from_spec(spec) try: import importlib.metadata as _md if target is not None: dists = list(_md.distributions(name=name, path=[str(target)])) dist = dists[0] if dists else None else: dist = _md.distribution(name) except Exception: return set() if dist is None: return set() roots: set[Path] = set() try: for entry in dist.files or (): parts = entry.parts if not parts or parts[0].startswith(".") or parts[0] == "__pycache__": continue if parts[0].endswith((".dist-info", ".egg-info")): # no importable code continue root = Path(dist.locate_file(parts[0])) if root.is_dir(): roots.add(root) except Exception: return set() return roots def _warm_installed_bytecode(specs: tuple[str, ...], target: Optional[Path]) -> None: """Byte-compile what was just installed. A fresh install writes no ``__pycache__`` (and drops the old one), so the next import — often the foreground of a user request, silent, reading as a hang (~2-10s for a big SDK) — would pay the compile. Pay it here while the caller already waits on an installer. Best-effort; never invalidates a successful install.""" if sys.dont_write_bytecode: return try: import compileall except Exception: # pragma: no cover — stdlib, but never break an install return for spec in specs: try: roots = _installed_dist_roots(spec, target) except Exception as exc: logger.debug("Bytecode warm skipped for %s: %s", spec, exc) continue for root in roots: try: compileall.compile_dir(str(root), quiet=2, force=False, workers=1) except Exception as exc: logger.debug("Bytecode warm skipped for %s: %s", root, exc) def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult: """Install ``specs`` via the uv -> pip -> ensurepip ladder, venv-scoped by default or into the durable ``--target`` (constrained to core versions, see :func:`_core_constraints_file`) when :data:`_LAZY_TARGET_ENV` is set. Independent of ``hermes_cli.tools_config._pip_install`` so this module has no CLI dependency.""" if not specs: return _InstallResult(True, "", "") target = _lazy_install_target() constraints: Optional[Path] = None if target is not None: err = _ensure_target_ready(target) if err: return _InstallResult(False, "", err) constraints = _core_constraints_file() extra_args: list[str] = [] if target is not None: extra_args += ["--target", str(target)] if constraints is not None: extra_args += ["--constraint", str(constraints)] def _run(cmd: list[str], **kw) -> subprocess.CompletedProcess: # _SUBPROCESS_KW carries stdin=DEVNULL # noqa: subprocess-stdin return subprocess.run(cmd, **_SUBPROCESS_KW, creationflags=windows_hide_flags(), **kw) def _finish(r: subprocess.CompletedProcess) -> _InstallResult: if r.returncode == 0: if target is not None: _activate_target_on_syspath(target) _warm_installed_bytecode(specs, target) return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "") try: from tools.environments.local import hermes_subprocess_env uv_env = hermes_subprocess_env(inherit_credentials=False) uv_env["VIRTUAL_ENV"] = str(Path(sys.executable).parent.parent) # Tier 1: uv. Managed uv first ($HERMES_HOME/bin is never on PATH). A # lookup, not ensure_uv(): downloading uv mid-turn is far more than the # caller asked for; the pip tier covers the no-uv case. try: from hermes_cli.managed_uv import resolve_uv uv_bin = resolve_uv() or shutil.which("uv") except Exception: uv_bin = shutil.which("uv") if uv_bin: try: # --compile-bytecode: uv writes no __pycache__ by default, so the # first import would recompile the backend AND its transitives. # Covers the whole install; _warm_installed_bytecode is the # belt-and-braces pass for the spec's own roots on any tier. r = _run([uv_bin, "pip", "install", "--compile-bytecode", *extra_args, *specs], timeout=timeout, env=uv_env) if r.returncode != 0: logger.debug("uv pip install failed: %s", r.stderr) # A uv resolver failure is authoritative: falling through to pip # would discard uv policy (exclude-newer) and could install a # quarantined release. return _finish(r) except subprocess.TimeoutExpired as e: logger.debug("uv invocation failed: %s", e) return _InstallResult(False, "", f"uv pip install timed out: {e}") except FileNotFoundError as e: # uv vanished between lookup and spawn; it never evaluated the # requirements, so pip remains a valid fallback. logger.debug("uv invocation failed: %s", e) # Tier 2: python -m pip (ensurepip bootstrap if needed) pip_cmd = [sys.executable, "-m", "pip"] try: if _run(pip_cmd + ["--version"], timeout=15).returncode != 0: raise FileNotFoundError("pip not in venv") except (subprocess.TimeoutExpired, FileNotFoundError): try: _run([sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], timeout=120, check=True) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: return _InstallResult(False, "", f"pip not available and ensurepip failed: {e}") try: return _finish(_run(pip_cmd + ["install", *extra_args, *specs], timeout=timeout)) 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}") finally: if constraints is not None: try: constraints.unlink() except OSError: pass # ---- 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 _prompt_toolkit_active() -> bool: """A bare input() deadlocks while a prompt_toolkit app owns the terminal (keystrokes go to its loop, not stdin), so ensure() skips the confirmation under the TUI — reaching it is already gated by security.allow_lazy_installs.""" if "prompt_toolkit.application.current" not in sys.modules: return False try: from prompt_toolkit.application.current import get_app_or_none app = get_app_or_none() return app is not None and bool(getattr(app, "is_running", False)) except Exception: return False def ensure(feature: str, *, prompt: bool = True) -> None: """Make every package for ``feature`` importable, installing if needed. Raises :class:`FeatureUnavailable` when installs are disabled or fail. ``prompt``: confirm on a TTY first; non-interactive callers pass False and rely on the config flag as the gate.""" 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 unsupported = _unsupported_feature_reason(feature) if unsupported: raise FeatureUnavailable(feature, missing, unsupported) # Package-manager installs (NixOS etc.) have a read-only site-packages: the # ladder would burn ~15s on ensurepip then fail. Fail fast — unless a durable # target is configured, where installs legitimately work. The reason MUST # start with "unsupported ": _refresh_features classifies skips by that prefix. if _lazy_install_target() is None: try: from hermes_cli.config import get_managed_system managed_by = get_managed_system() except Exception: managed_by = "" # config unreadable — proceed with the install if managed_by: raise FeatureUnavailable( feature, missing, f"unsupported on {managed_by}-managed installs: this build's " f"packages come from {managed_by}, so Hermes cannot install " f"them at runtime. Add the dependencies for {feature!r} via " f"{managed_by} (or run a pip/uv install of Hermes instead)." ) for spec in missing: # belt and braces on top of the allowlist 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 not _prompt_toolkit_active() 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 pip's own error (quarantine 404, network) — tail-clipped, # since pip can dump pages of resolution traces. snippet = (result.stderr or result.stdout or "").strip()[-2000:] raise FeatureUnavailable( feature, missing, f"pip install failed: {snippet or 'no error output'}" ) _invalidate_import_caches() 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, *, venv_pip: bool = False) -> Optional[str]: """Manual install command for a feature, or None. ``venv_pip=True`` uses ``{sys.executable} -m pip`` — correct in every layout and immune to PEP 668 ``externally-managed-environment`` failures a bare ``pip install`` invites.""" if feature not in LAZY_DEPS: return None joined = " ".join(repr(s) for s in LAZY_DEPS[feature]) if venv_pip: return f"{sys.executable} -m pip install {joined}" return "uv pip install " + joined @dataclass class InstallSpecsResult: """Outcome of :func:`install_specs` for one batch of pip specs. ``ok`` — install succeeded (or nothing was missing). ``blocked`` — installs are gated off (config kill switch, sealed venv without a durable target) or a spec failed validation; nothing was executed. ``reason`` explains why. ``command`` — human-readable description of what ran (for UIs/logs). """ ok: bool blocked: bool = False reason: str = "" command: str = "" stdout: str = "" stderr: str = "" def install_specs(specs: list[str] | tuple[str, ...], *, timeout: int = 300) -> InstallSpecsResult: """Install data-driven pip specs (e.g. plugin manifest ``pip_dependencies``) with the same environment routing and gating as :func:`ensure`. Unlike ``ensure``, unknown packages are allowed — the caller owns manifest trust, this function owns spec hygiene (:func:`_spec_is_safe`) and routing. Never raises; inspect the :class:`InstallSpecsResult`.""" cleaned = tuple(str(s).strip() for s in specs if str(s).strip()) if not cleaned: return InstallSpecsResult(ok=True, command="") for spec in cleaned: if not _spec_is_safe(spec): return InstallSpecsResult( ok=False, blocked=True, reason=f"refusing to install unsafe spec {spec!r}", ) if not _allow_lazy_installs(): target = _lazy_install_target() if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1" and target is None: reason = ( "runtime installs are disabled on this deployment: the agent " "environment is immutable and no writable install target is " "configured (HERMES_LAZY_INSTALL_TARGET)" ) else: reason = "runtime installs disabled (security.allow_lazy_installs=false)" return InstallSpecsResult(ok=False, blocked=True, reason=reason) target = _lazy_install_target() display = "uv pip install " + ( f"--target {target} " if target is not None else "" ) + " ".join(cleaned) logger.info("Installing pip specs %s (target=%s)", " ".join(cleaned), target or "venv") try: result = _venv_pip_install(cleaned, timeout=timeout) except Exception as exc: logger.warning("install_specs failed unexpectedly: %s", exc) return InstallSpecsResult( ok=False, command=display, stderr=f"install failed: {exc}" ) _invalidate_import_caches() # dashboard rechecks availability inline return InstallSpecsResult( ok=result.success, command=display, stdout=result.stdout, stderr=result.stderr, ) def active_features() -> list[str]: """Features whose ANCHOR package (first spec) is present at any version — shared helpers like asyncpg are deliberately not proof a backend was enabled. Drives the ``hermes update`` refresh pass.""" return [f for f, specs in LAZY_DEPS.items() if specs and _is_present(specs[0])] def refresh_active_features(*, prompt: bool = False) -> dict[str, str]: """Re-run ``ensure`` for every active feature (``hermes update``). Returns ``{feature: "current" | "refreshed" | "failed: " | "skipped: "}``. Never raises — lazy failures must not block the update flow.""" return _refresh_features(active_features(), prompt=prompt, restoring=False) def restore_features(features: list[str]) -> dict[str, str]: """Restore features captured before a managed-runtime rebuild; still subject to ``security.allow_lazy_installs`` (opt-out -> "skipped").""" return _refresh_features(features, prompt=False, restoring=True) def _refresh_features( features: list[str], *, prompt: bool, restoring: bool ) -> dict[str, str]: """Refresh or restore a known set of allowlisted lazy features.""" results: dict[str, str] = {} for feature in features: if feature not in LAZY_DEPS: continue missing = feature_missing(feature) if not missing: results[feature] = "current" continue unsupported = _unsupported_feature_reason(feature) if unsupported: results[feature] = f"skipped: {unsupported}" continue try: ensure(feature, prompt=False if restoring else prompt) results[feature] = "restored" if restoring else "refreshed" except FeatureUnavailable as e: # Opt-outs and platform-incompatible features are skips, not failures. if ( "lazy installs disabled" in str(e) or "declined" in str(e) or e.reason.startswith("unsupported ") ): results[feature] = f"skipped: {e.reason}" else: results[feature] = f"failed: {e.reason}" except Exception as e: results[feature] = f"failed: {e}" return results def ensure_and_bind( feature: str, importer: Callable[[], dict[str, Any]], target_globals: dict, *, prompt: bool = False, ) -> bool: """:func:`ensure` the feature, then ``target_globals.update(importer())`` so module-level names are rebound after a lazy install without hand-listing them. ``importer`` runs only after ensure succeeds. Returns False (and logs) if deps could not be installed or imported. Example:: def _import(): from slack_bolt.async_app import AsyncApp return {"AsyncApp": AsyncApp, "SLACK_AVAILABLE": True} return ensure_and_bind("platform.slack", _import, globals(), prompt=False) """ try: ensure(feature, prompt=prompt) except FeatureUnavailable as exc: logger.warning("%s", exc) return False except Exception as exc: logger.warning("Failed to ensure feature %r: %s", feature, exc) return False try: bindings = importer() except ImportError as exc: logger.warning("Failed to import feature %r after install: %s", feature, exc) return False target_globals.update(bindings) return True