diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 09752fed433..a75e4ff40e8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6827,6 +6827,74 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: pass +def _refresh_active_lazy_features() -> None: + """Refresh lazy-installed backends after a code update. + + When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most + optional backends moved to ``tools/lazy_deps.py`` and only install on + first use. ``hermes update`` runs ``uv pip install -e .[all]`` which + leaves those packages untouched — so if we bump a pin in + :data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already + activated the backend keep the stale version forever. + + This function asks lazy_deps which features the user has previously + activated and reinstalls them under the current pins. Features the + user never enabled stay quiet — no churn for cold backends. + + Never raises. A failure here must not block the rest of the update. + """ + try: + from tools import lazy_deps + except Exception as exc: + logger.debug("Lazy refresh skipped (import failed): %s", exc) + return + + try: + active = lazy_deps.active_features() + except Exception as exc: + logger.debug("Lazy refresh skipped (active_features failed): %s", exc) + return + + if not active: + return + + print() + print(f"→ Refreshing {len(active)} active lazy backend(s)...") + + try: + results = lazy_deps.refresh_active_features(prompt=False) + except Exception as exc: + # refresh_active_features is documented as never-raise, but defend + # the update flow against future regressions. + print(f" ⚠ Lazy refresh failed unexpectedly: {exc}") + return + + refreshed = [f for f, s in results.items() if s == "refreshed"] + current = [f for f, s in results.items() if s == "current"] + failed = [(f, s) for f, s in results.items() if s.startswith("failed:")] + skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")] + + if refreshed: + print(f" ↑ {len(refreshed)} refreshed: {', '.join(refreshed)}") + if current: + print(f" ✓ {len(current)} already current") + if skipped: + # Most common reason: security.allow_lazy_installs=false. Show one + # line so the user knows why; not an error. + names = ", ".join(f for f, _ in skipped) + reason = skipped[0][1].split(": ", 1)[-1] + print(f" · {len(skipped)} skipped ({reason}): {names}") + if failed: + for feature, status in failed: + reason = status.split(": ", 1)[-1] + # Clip noisy pip stderr to keep update output legible. + if len(reason) > 200: + reason = reason[:200] + "..." + print(f" ⚠ {feature} failed to refresh: {reason}") + print(" Backends keep their previously-installed version; rerun") + print(" `hermes update` once the upstream issue is resolved.") + + def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -7749,6 +7817,8 @@ def _cmd_update_impl(args, gateway_mode: bool): _install_psutil_android_compat(pip_cmd) _install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group) + _refresh_active_lazy_features() + _update_node_dependencies() _build_web_ui(PROJECT_ROOT / "web") diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 9beecc0d995..714c5995eaa 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -226,3 +226,182 @@ class TestIsAvailable: monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",)) monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) assert ld.is_available("test.miss") is False + + +# --------------------------------------------------------------------------- +# Version-aware _is_satisfied (Piece B — "stale pin" detection) +# +# The original implementation returned True the moment the package name +# was importable, ignoring the spec's version range. That meant pin bumps +# in LAZY_DEPS never propagated to users who already lazy-installed the +# backend at an older version. _is_satisfied now parses the spec and +# checks the installed version against the constraint. +# --------------------------------------------------------------------------- + + +class TestIsSatisfiedVersionAware: + def _fake_version(self, monkeypatch, installed_versions: dict): + """Patch importlib.metadata.version() inside lazy_deps.""" + from importlib.metadata import PackageNotFoundError + + def _version(pkg): + if pkg in installed_versions: + return installed_versions[pkg] + raise PackageNotFoundError(pkg) + + # Patch at the import site lazy_deps uses (inside the function). + import importlib.metadata as _md + monkeypatch.setattr(_md, "version", _version) + + def test_exact_pin_match_returns_true(self, monkeypatch): + self._fake_version(monkeypatch, {"honcho-ai": "2.0.1"}) + assert ld._is_satisfied("honcho-ai==2.0.1") is True + + def test_exact_pin_mismatch_returns_false(self, monkeypatch): + # Installed 2.0.0, spec requires 2.0.1 → False (needs upgrade). + self._fake_version(monkeypatch, {"honcho-ai": "2.0.0"}) + assert ld._is_satisfied("honcho-ai==2.0.1") is False + + def test_range_within_returns_true(self, monkeypatch): + self._fake_version(monkeypatch, {"slack-bolt": "1.27.0"}) + assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is True + + def test_range_above_returns_false(self, monkeypatch): + # Installed too new for the upper bound. + self._fake_version(monkeypatch, {"slack-bolt": "2.0.0"}) + assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False + + def test_range_below_returns_false(self, monkeypatch): + self._fake_version(monkeypatch, {"slack-bolt": "1.0.0"}) + assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False + + def test_package_not_installed_returns_false(self, monkeypatch): + self._fake_version(monkeypatch, {}) + assert ld._is_satisfied("anthropic==0.86.0") is False + + def test_bare_package_name_presence_is_enough(self, monkeypatch): + # No version constraint — presence alone counts as satisfied. + self._fake_version(monkeypatch, {"somepkg": "1.0.0"}) + assert ld._is_satisfied("somepkg") is True + + def test_extras_block_in_spec_is_stripped(self, monkeypatch): + # mautrix[encryption]==0.21.0 — the [encryption] block must not + # confuse the specifier parser. + self._fake_version(monkeypatch, {"mautrix": "0.21.0"}) + assert ld._is_satisfied("mautrix[encryption]==0.21.0") is True + + def test_extras_block_mismatch_returns_false(self, monkeypatch): + self._fake_version(monkeypatch, {"mautrix": "0.20.0"}) + assert ld._is_satisfied("mautrix[encryption]==0.21.0") is False + + +# --------------------------------------------------------------------------- +# active_features + refresh_active_features (Piece A — hermes update wiring) +# --------------------------------------------------------------------------- + + +class TestActiveFeatures: + def test_no_packages_installed_returns_empty(self, monkeypatch): + monkeypatch.setattr(ld, "_is_present", lambda spec: False) + assert ld.active_features() == [] + + def test_finds_features_with_at_least_one_package_installed(self, monkeypatch): + # Pretend only honcho-ai is installed; nothing else. + monkeypatch.setattr( + ld, "_is_present", + lambda spec: ld._pkg_name_from_spec(spec) == "honcho-ai", + ) + active = ld.active_features() + assert "memory.honcho" in active + # Backends the user never enabled stay quiet. + assert "memory.hindsight" not in active + assert "platform.slack" not in active + + def test_multi_package_feature_active_if_any_present(self, monkeypatch): + # platform.slack has 3 packages; only one needs to be present + # for the feature to count as active (user activated it before, + # one transitive may have been uninstalled separately). + monkeypatch.setattr( + ld, "_is_present", + lambda spec: ld._pkg_name_from_spec(spec) == "slack-bolt", + ) + assert "platform.slack" in ld.active_features() + + +class TestRefreshActiveFeatures: + def test_no_active_features_returns_empty(self, monkeypatch): + monkeypatch.setattr(ld, "active_features", lambda: []) + assert ld.refresh_active_features() == {} + + def test_already_current_is_noop(self, monkeypatch): + monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==1.0.0",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) + # If pip were called, this would fail loudly. + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda *a, **kw: pytest.fail("pip should not be called"), + ) + result = ld.refresh_active_features() + assert result == {"test.feat": "current"} + + def test_stale_pin_triggers_reinstall(self, monkeypatch): + monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) + # First _is_satisfied check (in feature_missing) says no; after + # install, post-install check says yes. + states = iter([False, True]) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states)) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(True, "ok", ""), + ) + result = ld.refresh_active_features() + assert result == {"test.feat": "refreshed"} + + def test_install_failure_recorded_not_raised(self, monkeypatch): + # A failed refresh must NOT raise out of hermes update. + monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult( + False, "", "ERROR: PyPI 404 quarantine" + ), + ) + result = ld.refresh_active_features() + assert "test.feat" in result + assert result["test.feat"].startswith("failed:") + assert "404 quarantine" in result["test.feat"] + + def test_lazy_installs_disabled_marked_skipped(self, monkeypatch): + # security.allow_lazy_installs=false → don't error, mark skipped + # so hermes update can render "respecting your config" message. + monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) + monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) + monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) + result = ld.refresh_active_features() + assert "test.feat" in result + assert result["test.feat"].startswith("skipped:") + + def test_mixed_results_returns_per_feature_status(self, monkeypatch): + monkeypatch.setattr(ld, "active_features", lambda: ["a.ok", "b.fail"]) + monkeypatch.setitem(ld.LAZY_DEPS, "a.ok", ("pkga==1.0",)) + monkeypatch.setitem(ld.LAZY_DEPS, "b.fail", ("pkgb==1.0",)) + # a.ok: already satisfied → "current" + # b.fail: missing + install fails → "failed:" + def fake_satisfied(spec): + return ld._pkg_name_from_spec(spec) == "pkga" + monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied) + monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) + monkeypatch.setattr( + ld, "_venv_pip_install", + lambda specs, **kw: ld._InstallResult(False, "", "nope"), + ) + result = ld.refresh_active_features() + assert result["a.ok"] == "current" + assert result["b.fail"].startswith("failed:") diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 60883663439..09347e8281c 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -248,12 +248,69 @@ def _pkg_name_from_spec(spec: str) -> str: 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? +def _specifier_from_spec(spec: str) -> str: + """Extract just the version-specifier portion of a pip spec. - 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. + ``"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: @@ -442,6 +499,57 @@ def feature_install_command(feature: str) -> Optional[str]: 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: "`` — install attempt failed; caller decides + whether to surface it (we don't raise) + ``"skipped: "`` — 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]],