hermes-agent/tools/lazy_deps.py
Ben cbd6ba1bdd fix(docker): redirect lazy installs to a durable target so opt-in backends work in the immutable image (#51136)
The published Docker image seals the agent venv (root-owned, read-only
/opt/hermes) and sets HERMES_DISABLE_LAZY_INSTALLS=1 so a runtime install
can't mutate and brick the core. But opt-in backends (Firecrawl web search,
Exa, Feishu, ...) deliberately keep their SDKs in tools/lazy_deps.py and out
of [all] (pyproject policy 2026-05-12: one quarantined release must not break
every install). The two policies collided: the SDK isn't baked in AND can't
lazy-install, so the default Firecrawl web_search/web_extract fail out of the
box in Docker (#51136), as do Exa (#49445) and Feishu (#50205).

Fix the whole class instead of baking in one backend: when
HERMES_LAZY_INSTALL_TARGET is set, lazy installs are redirected to a writable
dir on the durable /opt/data volume via `pip/uv install --target`, and that
dir is APPENDED to the end of sys.path. Because the core venv always wins
name collisions, a package installed this way can only ADD new modules — it
can never shadow, downgrade, or break a module the core ships. The worst a
bad/incompatible backend package can do is fail to import and report itself
unavailable; the agent core stays healthy. That structural guarantee is what
made it safe to seal the venv, and it is preserved here even with installs
re-enabled.

- tools/lazy_deps.py: durable-target mode — `--target` install + core-pinned
  `--constraint` file (shared deps resolve to core's versions, conflicts fail
  loudly at install time), append-only sys.path activation, ABI/Python-version
  stamp that wipes the store if an image rebuild bumps the interpreter, and a
  reworked gate so HERMES_DISABLE_LAZY_INSTALLS=1 redirects (rather than hard-
  blocks) when a target is set. security.allow_lazy_installs=false still
  disables installs in every mode.
- hermes_bootstrap.py: activate the durable target on sys.path at first import
  (before any backend imports its SDK) so packages installed on a previous run
  are importable on this run.
- Dockerfile: set HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages.
- docker/stage2-hook.sh: seed + chown the dir on the data volume.
- tests: real-install E2E proving installs land in the target, import cleanly,
  don't leak into the sealed venv, and that a core package is never shadowed;
  ABI-stamp wipe/preserve; gate matrix; Dockerfile/stage2 contract test.

Fixes #51136
2026-06-25 09:20:13 +10:00

910 lines
37 KiB
Python

"""
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 by default.** Installs target ``sys.executable`` in the
active venv. We never touch the system Python.
* **Durable-target mode (immutable images).** When the deployment seals the
agent's own venv (the Docker image sets ``HERMES_DISABLE_LAZY_INSTALLS=1``
and makes ``/opt/hermes`` read-only), setting
``HERMES_LAZY_INSTALL_TARGET`` redirects lazy installs to a writable
directory on the durable data volume (e.g. ``/opt/data/lazy-packages``).
That directory is **appended to the end of ``sys.path``** — never
prepended, never exported via ``PYTHONPATH`` — so the agent's own
site-packages wins every name collision. A package installed this way can
only ADD new importable modules; it can never shadow, downgrade, or break
a module the core already ships. The worst a bad/incompatible backend
package can do is fail to import and report itself unavailable — the agent
core stays healthy. This is the structural guarantee that a lazily
installed package cannot brick Hermes, which is what made it safe to seal
the venv in the first place. Compiled-wheel safety across image rebuilds
is handled by an ABI/Python-version stamp on the target subdir (see
:func:`_ensure_target_ready`).
* **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 in BOTH modes. 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 site
import subprocess
import sys
import sysconfig
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, 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.87.0",), # CVE-2026-34450, CVE-2026-34452
# AWS Bedrock provider
"provider.bedrock": ("boto3==1.42.89",),
# Microsoft Foundry — Entra ID auth (managed identity, workload identity,
# service principal, az login, VS Code, azd, PowerShell). Only loaded
# when model.auth_mode=entra_id is selected; key-based azure-foundry
# users never pay this import.
"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",),
# ─── 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.
#
# mistralai pin tracks the `mistral` extra in pyproject.toml. PyPI
# quarantined the project 2026-05-12 (malicious 2.4.6, Mini Shai-Hulud);
# 2.4.6 was removed and clean releases resumed (2.4.7, 2.4.8). Voxtral
# STT + TTS share the same 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",
),
# ─── 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",),
# brotlicffi gives aiohttp a working 2-arg Decompressor.process() for
# Discord CDN's Brotli-encoded attachments. Without it, aiohttp falls
# back to google's `Brotli` package (1-arg API), and any .txt/.md/.doc
# uploaded to the Discord gateway fails to decode at att.read() with
# "Can not decode content-encoding: br" — see #12511 / #15744.
"platform.discord": ("discord.py[voice]==2.7.1", "brotlicffi==1.2.0.1"),
"platform.slack": (
"slack-bolt==1.27.0",
"slack-sdk==3.40.1",
"aiohttp==3.13.4", # CVE-2026-34513/34518/34519/34520/34525
),
"platform.matrix": (
"mautrix[encryption]==0.21.0",
"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",
),
# WeCom callback-mode adapter — parses untrusted XML POST bodies. Pulls
# defusedxml only; aiohttp/httpx are core dependencies of every messaging
# adapter and ship via `platform.discord` / `platform.slack` / etc.
"platform.wecom_callback": ("defusedxml==0.7.1",),
# Microsoft Teams adapter — microsoft-teams-apps pulls a heavy tree
# (microsoft-teams-api/cards/common, dependency-injector, msal). Lazy-
# installed on demand like every other messaging platform; also exposed
# as the `teams` extra in pyproject for packagers / explicit installs.
"platform.teams": ("microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"),
# ─── Terminal backends ─────────────────────────────────────────────────
"terminal.modal": ("modal==1.3.4",),
"terminal.daytona": ("daytona==0.155.0",),
# ─── 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",
"starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
"python-multipart==0.0.27", # FastAPI UploadFile/Form for streaming uploads (NS-501)
),
# Vision image-resize recovery (Pillow). Pillow is now a CORE dependency
# (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback
# for stripped/source-build installs that somehow dropped it. The vision
# call site uses prompt=False so it can never raise a blocking input()
# prompt mid-session (#40490).
"tool.vision": ("Pillow==12.2.0",),
# Computer Use (cua-driver) — the MCP client SDK used to spawn and talk
# to the cua-driver process over stdio. Matches the `mcp` / `computer-use`
# extras in pyproject.toml. The one-liner installer pulls this in via
# `[all]`; lazy-installing here covers lean / partial / broken-extra
# installs so computer_use never dead-ends on `No module named 'mcp'`.
"tool.computer_use": (
"mcp==1.26.0",
"starlette==1.0.1", # CVE-2026-48710 — keep in sync with pyproject [computer-use]
),
}
# 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
# =============================================================================
# Environment variable that redirects lazy installs away from the (sealed)
# agent venv and into a writable directory on a durable volume. Set by the
# Docker image to /opt/data/lazy-packages. This is an internal bridge var,
# not user-facing config: the user-facing knob remains
# security.allow_lazy_installs in config.yaml. When unset, lazy installs go
# into the active venv as before.
_LAZY_TARGET_ENV = "HERMES_LAZY_INSTALL_TARGET"
# Name of the stamp file written into the target dir recording the Python
# X.Y + ABI it was populated for. If a container rebuild bumps the
# interpreter, compiled wheels (.so) in the durable store would be ABI-
# incompatible; we detect the mismatch and wipe the store so packages get
# re-resolved against the new interpreter rather than importing a stale .so.
_TARGET_STAMP_NAME = ".python-abi"
def _python_abi_tag() -> str:
"""A stable token identifying the running interpreter's ABI.
Combines the X.Y version with the EXT_SUFFIX (which encodes the ABI
tag and platform, e.g. ``cpython-313-x86_64-linux-gnu``). Two
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]:
"""Return the durable install-target dir, or None for venv-scoped mode.
Returns a path only when :data:`_LAZY_TARGET_ENV` is set to a non-empty
value. The directory is created on demand by :func:`_ensure_target_ready`.
"""
raw = os.environ.get(_LAZY_TARGET_ENV, "").strip()
if not raw:
return None
return Path(raw)
def _ensure_target_ready(target: Path) -> Optional[str]:
"""Create the target dir and validate its ABI stamp.
If the stamp is missing it is written. If it is present but records a
different interpreter ABI than the one now running (e.g. the container
image was rebuilt onto a newer Python), the directory's contents are
wiped and the stamp rewritten, so stale compiled wheels can't be
imported against an incompatible interpreter.
Returns ``None`` on success, or an error string if the directory can't
be created / written (e.g. read-only mount, permission error).
"""
want = _python_abi_tag()
stamp = target / _TARGET_STAMP_NAME
try:
if target.exists():
have = ""
try:
have = stamp.read_text(encoding="utf-8").strip()
except (OSError, FileNotFoundError):
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`` so its packages import.
Appended to the END (never prepended) so the agent's own venv
site-packages takes precedence on every name collision. Idempotent.
Uses :func:`site.addsitedir` so ``.pth`` files (namespace packages,
editable installs) inside the target are honoured, then enforces the
append ordering — ``addsitedir`` would otherwise insert near the front.
"""
target_str = str(target)
# Snapshot existing entries so we can restore precedence afterwards.
before = list(sys.path)
if target_str not in before:
site.addsitedir(target_str)
# site.addsitedir may have inserted target (and any .pth-added dirs) at
# the front. Move every newly-added entry to the end, preserving the
# core venv's precedence. New entries are those not present `before`.
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
# importlib.metadata caches the path-based distribution finder; clear it
# so a just-activated dir is visible to version() checks this process.
try:
import importlib
importlib.invalidate_caches()
except Exception:
pass
def activate_durable_lazy_target() -> None:
"""Public: wire the durable lazy-install target onto ``sys.path``.
Safe no-op when :data:`_LAZY_TARGET_ENV` is unset or the directory does
not yet exist. Called once early in process startup (before backends
import) so packages installed into the durable store on a previous run
are importable on this run. 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:
"""Return whether lazy installs are permitted in this environment.
Resolution order:
1. ``security.allow_lazy_installs: false`` in config.yaml is an absolute
opt-out — it disables installs in BOTH venv-scoped and durable-target
modes. This is the user-facing kill switch.
2. ``HERMES_DISABLE_LAZY_INSTALLS=1`` seals the *agent venv* (set by the
immutable Docker image). It blocks venv-scoped installs — UNLESS a
durable install target is configured, in which case installs are
redirected there (a path that structurally cannot break the sealed
venv) and are therefore allowed.
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.
"""
# (1) Config kill switch wins in every mode.
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
# (2) Sealed-venv env var: blocks ONLY when there is no safe durable
# target to redirect into. With a target set, the install goes to the
# data volume (append-only on sys.path), so the seal is preserved.
if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1":
return _lazy_install_target() is not None
return True
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 _specifier_from_spec(spec: str) -> str:
"""Extract just the version-specifier portion of a pip spec.
``"honcho-ai==2.0.1"`` → ``"==2.0.1"``
``"mautrix[encryption]>=0.20,<1"`` → ``">=0.20,<1"``
``"package"`` → ``""`` (no version constraint)
"""
# Strip the package name + optional [extras] block.
m = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*(?:\[[A-Za-z0-9_,\-]+\])?", spec)
if not m:
return ""
return spec[m.end():]
def _is_satisfied(spec: str) -> bool:
"""Is ``spec`` already satisfied in the current env?
Checks both presence AND version. If the package is installed at a
version outside the spec's range, returns False so the caller will
upgrade/downgrade to the pinned version. This is what makes
``hermes update`` propagate pin bumps in :data:`LAZY_DEPS` to already-
installed backends instead of silently leaving stale versions in place.
If ``packaging`` is unavailable for any reason (it's a transitive of
pip so this should never happen), we fall back to a presence-only check
so we err on the side of "don't churn".
"""
pkg = _pkg_name_from_spec(spec)
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError:
return False
try:
installed = version(pkg)
except PackageNotFoundError:
return False
except Exception:
return False
spec_tail = _specifier_from_spec(spec)
if not spec_tail:
# Bare ``"package"`` — no version constraint, presence is enough.
return True
try:
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
except ImportError:
# packaging unavailable — fall back to "installed counts as satisfied".
return True
try:
return Version(installed) in SpecifierSet(spec_tail)
except (InvalidSpecifier, InvalidVersion, Exception):
# Malformed spec or installed version we can't parse — don't churn.
return True
def _is_present(spec: str) -> bool:
"""Cheap presence-only check (package name installed at any version).
Used by :func:`active_features` to detect backends the user has
previously activated, regardless of whether the version pin moved.
"""
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 _core_constraints_file() -> Optional[Path]:
"""Write a pip constraints file pinning every package already importable
in the core environment to its installed version.
Passed as ``--constraint`` for durable-target installs so the resolver
pins shared transitive deps (httpx, pydantic, aiohttp, …) to the exact
versions the core venv already ships, instead of pulling newer copies
into the durable store. Two payoffs:
* The durable store stays minimal — only genuinely-new packages land
there; shared deps resolve to "already satisfied" against core.
* A backend that *requires* a version conflicting with core fails loudly
at install time (resolver conflict) rather than silently installing a
shadowed copy that can never win on sys.path anyway.
Returns the path to a temp constraints file, or None if enumeration
failed (in which case the caller installs without constraints — still
safe, just less tidy).
"""
try:
from importlib.metadata import distributions
except ImportError:
return None
try:
import tempfile
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:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
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 _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult:
"""Install ``specs`` using the uv → pip → ensurepip ladder.
Two modes:
* **Venv-scoped (default).** Installs into the active venv
(``sys.executable``). Used on normal installs.
* **Durable-target.** When :data:`_LAZY_TARGET_ENV` is set, installs into
that directory via ``--target`` and constrains shared deps to the
core venv's versions (see :func:`_core_constraints_file`). The target
is append-only on ``sys.path`` so it can never shadow core. Used by
the immutable Docker image to keep lazy installs off the sealed venv.
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, "", "")
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()
target_args: list[str] = []
if target is not None:
# --target tells both uv and pip to install into an arbitrary dir.
target_args = ["--target", str(target)]
constraint_args: list[str] = []
if constraints is not None:
constraint_args = ["--constraint", str(constraints)]
try:
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", *target_args, *constraint_args, *specs],
capture_output=True, text=True, timeout=timeout, env=uv_env,
stdin=subprocess.DEVNULL,
)
if r.returncode == 0:
if target is not None:
_activate_target_on_syspath(target)
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,
stdin=subprocess.DEVNULL,
)
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,
stdin=subprocess.DEVNULL,
)
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", *target_args, *constraint_args, *specs],
capture_output=True, text=True, timeout=timeout,
stdin=subprocess.DEVNULL,
)
if r.returncode == 0 and target is not None:
_activate_target_on_syspath(target)
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}")
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 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)"
)
# Only show the interactive confirmation when we own a TTY and
# prompt_toolkit isn't running. A bare input() deadlocks when a
# prompt_toolkit app owns the terminal because keystrokes route to
# its event loop rather than stdin, so the prompt blocks forever.
# Under the TUI we skip the prompt and proceed — lazy installs are
# gated by security.allow_lazy_installs, so reaching here is
# already user opt-in.
_pt_active = False
if "prompt_toolkit.application.current" in sys.modules:
try:
from prompt_toolkit.application.current import get_app_or_none
_app = get_app_or_none()
_pt_active = _app is not None and getattr(_app, "is_running", False)
except Exception:
_pt_active = False
if prompt and not _pt_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 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)
def active_features() -> list[str]:
"""Return the list of features the user has ever lazy-installed.
A feature counts as "active" if at least one of its declared packages
is currently installed in the venv (presence check, ignoring version).
Features the user has never enabled stay quiet.
Used by ``hermes update`` to figure out which lazy backends need a
refresh pass when pins move in :data:`LAZY_DEPS`.
"""
active = []
for feature, specs in LAZY_DEPS.items():
if any(_is_present(s) for s in specs):
active.append(feature)
return active
def refresh_active_features(*, prompt: bool = False) -> dict[str, str]:
"""Re-run ``ensure`` for every feature the user has previously activated.
Returns a ``{feature: status}`` map where status is one of:
``"current"`` — pins already satisfied, no install run
``"refreshed"`` — pins were stale, reinstall succeeded
``"failed: <reason>"`` — install attempt failed; caller decides
whether to surface it (we don't raise)
``"skipped: <reason>"`` — gated off (config flag, user decline)
Intended for ``hermes update``. Never raises; lazy-install failures
here must not block the rest of the update flow.
"""
results: dict[str, str] = {}
for feature in active_features():
missing = feature_missing(feature)
if not missing:
results[feature] = "current"
continue
try:
ensure(feature, prompt=prompt)
results[feature] = "refreshed"
except FeatureUnavailable as e:
# Distinguish "user opted out" from "install failed" so the
# update command can render the right message.
if "lazy installs disabled" in str(e) or "declined" in str(e):
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:
"""Ensure a feature is installed, then rebind names into the caller's globals.
Combines :func:`ensure` with a post-install import step that rebinds
module-level names. This eliminates the error-prone pattern of manually
listing every global that needs updating after lazy-install.
``importer`` is a zero-arg callable that returns a dict of
``{name: value}`` for all symbols the caller needs rebound. It is called
only after :func:`ensure` succeeds (or if the packages are already
installed).
Returns True on success, False if deps couldn't be installed or imported.
Example usage in a platform adapter::
def check_slack_requirements() -> bool:
if SLACK_AVAILABLE:
return True
def _import():
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
from slack_sdk.web.async_client import AsyncWebClient
import aiohttp
return {
"AsyncApp": AsyncApp,
"AsyncSocketModeHandler": AsyncSocketModeHandler,
"AsyncWebClient": AsyncWebClient,
"aiohttp": aiohttp,
"SLACK_AVAILABLE": True,
}
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
"""
try:
ensure(feature, prompt=prompt)
except (FeatureUnavailable, Exception):
return False
try:
bindings = importer()
except ImportError:
return False
target_globals.update(bindings)
return True