mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Removes Homebrew and PyPI wheel/sdist as Hermes distribution paths while
preserving the supported source, Docker, and Nix workflows.
Changes:
- Removes the Homebrew formula, PyPI publish workflow, sdist manifest
(MANIFEST.in), and wheel/sdist release-attachment logic from scripts/release.py.
- Keeps setuptools metadata and entry points required by editable installs
and Docker/Nix builds, but adds a setup.py guard that rejects wheel/sdist
builds outside a sealed Nix derivation (HERMES_NIX_BUILD=1).
- Removes pip/Homebrew install detection, PyPI update checks, the pip
self-update path, the deprecation-banner state, the postinstall subcommand,
wheel data-directory fallbacks in agent/i18n.py and hermes_constants.py,
and the ACP Registry manifest/version-lockstep release logic.
- Adds /nix/store/ path detection so `nix run` / `nix profile install`
installs (which don't set HERMES_MANAGED) are correctly identified as
"nix" rather than falling through to "git"/"unknown".
- Retired install-method values ("pip", "homebrew") in existing
.install_method stamps (both code-scoped and home-scoped) are ignored by
the allowlist reader and fall through to "unknown" instead of resurrecting
a retired enum value.
- Updates Nix packaging to ship bare runtime data (locales, optional-mcps)
through store symlinks and wrapper env vars instead of wheel data-files.
- Removes the ACP Registry manifest/icon and their version-lockstep tests.
- Deletes or rewrites packaging, pip-update, Homebrew, and ACP Registry
tests; adds parametrized coverage for the packaging build guard covering
BOTH sdist and wheel paths (the guards live in separate cmdclass entries
— a passing sdist test proves nothing about the wheel path).
- Updates installation/platform documentation and related user-facing copy.
- Adjusts the supply-chain scan so deleted install-hook files do not trigger
a finding, while additions or modifications still require the existing
ci-reviewed label gate.
Supported installation paths (unchanged):
- git installer (install.sh)
- Docker
- Nix/NixOS
- editable development installs (uv sync, uv pip install -e ., pip install -e .)
225 lines
8.8 KiB
Python
225 lines
8.8 KiB
Python
"""Regression tests for packaging metadata in pyproject.toml."""
|
|
|
|
from pathlib import Path
|
|
import tomllib
|
|
|
|
|
|
def _load_optional_dependencies():
|
|
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
with pyproject_path.open("rb") as handle:
|
|
project = tomllib.load(handle)["project"]
|
|
return project["optional-dependencies"]
|
|
|
|
|
|
def test_matrix_extra_not_in_all():
|
|
"""The [matrix] extra pulls `mautrix[encryption]` -> `python-olm`,
|
|
which has Linux-only wheels and no native build path on Windows or
|
|
modern macOS (archived libolm, C++ errors with Clang 21+).
|
|
|
|
With matrix in [all], `uv sync --locked` on Windows tried to build
|
|
python-olm from sdist and failed on `make`. As of 2026-05-12 the
|
|
[matrix] extra is excluded from [all] entirely and routed through
|
|
`tools/lazy_deps.py` (LAZY_DEPS["platform.matrix"]) — installs at
|
|
first use, where the user is expected to have a toolchain.
|
|
"""
|
|
optional_dependencies = _load_optional_dependencies()
|
|
|
|
assert "matrix" in optional_dependencies, "[matrix] extra must still exist for `uv sync --extra matrix`"
|
|
# Must NOT appear in [all] in any form — neither unconditional nor
|
|
# platform-gated. Lazy-install handles it.
|
|
matrix_in_all = [
|
|
dep for dep in optional_dependencies["all"]
|
|
if "matrix" in dep
|
|
]
|
|
assert not matrix_in_all, (
|
|
"matrix must not appear in [all] — it's lazy-installed via "
|
|
"tools/lazy_deps.py LAZY_DEPS['platform.matrix']. Found: "
|
|
f"{matrix_in_all}"
|
|
)
|
|
|
|
|
|
def test_lazy_installable_extras_excluded_from_all():
|
|
"""Policy (2026-05-12): every extra that has a `LAZY_DEPS` entry
|
|
in `tools/lazy_deps.py` must be excluded from [all].
|
|
|
|
The lazy-install system exists so one quarantined PyPI release
|
|
(e.g. mistralai 2.4.6) can't break every fresh install. Putting a
|
|
backend in BOTH [all] and LAZY_DEPS defeats that — fresh installs
|
|
eager-install it and inherit whatever's broken upstream.
|
|
|
|
If you're tempted to add an opt-in backend to [all] for "convenience,"
|
|
add it to `LAZY_DEPS` instead so it installs at first use.
|
|
"""
|
|
optional_dependencies = _load_optional_dependencies()
|
|
|
|
# Hard-coded mirror of the extras that are in LAZY_DEPS as of
|
|
# 2026-05-12. This list intentionally duplicates rather than
|
|
# imports tools/lazy_deps.py so the test stays a contract — if
|
|
# someone adds a new lazy-install backend, they have to update
|
|
# this list AND verify [all] doesn't contain it.
|
|
lazy_covered_extras = {
|
|
"anthropic", "bedrock",
|
|
"exa", "firecrawl", "parallel-web",
|
|
"fal",
|
|
"edge-tts", "tts-premium",
|
|
"voice", # faster-whisper / sounddevice / numpy
|
|
"modal", "daytona",
|
|
"messaging", "slack", "matrix", "dingtalk", "feishu",
|
|
"honcho", "hindsight",
|
|
"supermemory", "mem0",
|
|
"mistral", # mistralai — Voxtral STT/TTS, lazy-installed (stt.mistral / tts.mistral)
|
|
}
|
|
all_extra_specs = optional_dependencies["all"]
|
|
for extra in lazy_covered_extras:
|
|
offending = [
|
|
spec for spec in all_extra_specs
|
|
if f"hermes-agent[{extra}]" in spec
|
|
]
|
|
assert not offending, (
|
|
f"[{extra}] is in [all] but also in LAZY_DEPS. "
|
|
f"Remove it from [all] in pyproject.toml — it lazy-installs "
|
|
f"at first use. Found in [all]: {offending}"
|
|
)
|
|
|
|
|
|
def _exact_pins(specs):
|
|
pins = {}
|
|
for spec in specs:
|
|
requirement = spec.split(";", 1)[0].strip()
|
|
if "==" not in requirement:
|
|
continue
|
|
package, version = requirement.split("==", 1)
|
|
package = package.split("[", 1)[0].lower().replace("_", "-")
|
|
pins[package] = version
|
|
return pins
|
|
|
|
|
|
def test_pyproject_aiohttp_pins_match_lazy_slack_pin():
|
|
"""Avoid update/lazy-install churn from conflicting aiohttp pins.
|
|
|
|
pyproject extras (messaging/slack/homeassistant/sms) exact-pin aiohttp.
|
|
The Slack lazy-install deps (LAZY_DEPS['platform.slack']) also pin it.
|
|
If the two drift, `hermes update` resolves the pyproject pin and
|
|
downgrades aiohttp, reopening the CVEs the lazy pin fixed (#31817) —
|
|
only for Slack's lazy refresh to upgrade it again on next use.
|
|
"""
|
|
from tools.lazy_deps import LAZY_DEPS
|
|
|
|
optional_dependencies = _load_optional_dependencies()
|
|
lazy_aiohttp = _exact_pins(LAZY_DEPS["platform.slack"])["aiohttp"]
|
|
|
|
pyproject_aiohttp_pins = {
|
|
extra: pins["aiohttp"]
|
|
for extra, specs in optional_dependencies.items()
|
|
if "aiohttp" in (pins := _exact_pins(specs))
|
|
}
|
|
|
|
assert pyproject_aiohttp_pins, "expected at least one pyproject extra to pin aiohttp"
|
|
mismatches = {
|
|
extra: pin
|
|
for extra, pin in pyproject_aiohttp_pins.items()
|
|
if pin != lazy_aiohttp
|
|
}
|
|
assert not mismatches, (
|
|
"pyproject.toml aiohttp pins must match "
|
|
"LAZY_DEPS['platform.slack'] to avoid hermes update downgrading "
|
|
"aiohttp before Slack's lazy refresh upgrades it again. "
|
|
f"lazy aiohttp=={lazy_aiohttp}; mismatched extras: {mismatches}"
|
|
)
|
|
|
|
|
|
def test_pyproject_pins_match_lazy_deps_pins():
|
|
"""Generalize #31817 to the whole pin surface, not just aiohttp.
|
|
|
|
Any package that is exact-pinned in BOTH a pyproject extra and a
|
|
`tools/lazy_deps.py` LAZY_DEPS entry must use the SAME version in both
|
|
places. When they drift, `hermes update` resolves the pyproject extra
|
|
pin and downgrades the package to the older version, reopening whatever
|
|
the lazy pin fixed (the aiohttp #31817 case, and the anthropic
|
|
CVE-2026-34450/34452 case found alongside it) — only for the lazy
|
|
refresh to re-upgrade it on next feature use. The lazy pin is the
|
|
security-current source of truth; extras must track it.
|
|
"""
|
|
from tools.lazy_deps import LAZY_DEPS
|
|
|
|
optional_dependencies = _load_optional_dependencies()
|
|
|
|
# package -> version, as pinned across all pyproject extras. If an
|
|
# extra pins a package at a different version than another extra, that
|
|
# is itself a bug (caught below); here we just collect the set.
|
|
pyproject_pins: dict[str, set[str]] = {}
|
|
for specs in optional_dependencies.values():
|
|
for package, version in _exact_pins(specs).items():
|
|
pyproject_pins.setdefault(package, set()).add(version)
|
|
|
|
# package -> version, as pinned across all LAZY_DEPS entries.
|
|
lazy_pins: dict[str, set[str]] = {}
|
|
for specs in LAZY_DEPS.values():
|
|
if isinstance(specs, str):
|
|
specs = (specs,)
|
|
for package, version in _exact_pins(specs).items():
|
|
lazy_pins.setdefault(package, set()).add(version)
|
|
|
|
shared = sorted(set(pyproject_pins) & set(lazy_pins))
|
|
assert shared, "expected at least one package pinned in both pyproject and LAZY_DEPS"
|
|
|
|
drift = {
|
|
package: {
|
|
"pyproject": sorted(pyproject_pins[package]),
|
|
"lazy_deps": sorted(lazy_pins[package]),
|
|
}
|
|
for package in shared
|
|
if pyproject_pins[package] != lazy_pins[package]
|
|
}
|
|
assert not drift, (
|
|
"pyproject extras pins must match tools/lazy_deps.py LAZY_DEPS pins "
|
|
"for every shared package — otherwise `hermes update` downgrades the "
|
|
"package below the security-current lazy pin (see #31817). Drift: "
|
|
f"{drift}"
|
|
)
|
|
|
|
|
|
def test_dev_extra_excluded_from_all():
|
|
"""End-user installs should not pull test/lint/debug tooling."""
|
|
optional_dependencies = _load_optional_dependencies()
|
|
|
|
assert "dev" in optional_dependencies
|
|
assert not any(
|
|
spec == "hermes-agent[dev]"
|
|
for spec in optional_dependencies["all"]
|
|
)
|
|
|
|
|
|
def test_messaging_extra_includes_qrcode_for_weixin_setup():
|
|
optional_dependencies = _load_optional_dependencies()
|
|
|
|
messaging_extra = optional_dependencies["messaging"]
|
|
assert any(dep.startswith("qrcode") for dep in messaging_extra)
|
|
|
|
|
|
def test_dingtalk_extra_includes_qrcode_for_qr_auth():
|
|
"""DingTalk's QR-code device-flow auth (hermes_cli/dingtalk_auth.py)
|
|
needs the qrcode package."""
|
|
optional_dependencies = _load_optional_dependencies()
|
|
|
|
dingtalk_extra = optional_dependencies["dingtalk"]
|
|
assert any(dep.startswith("qrcode") for dep in dingtalk_extra)
|
|
|
|
|
|
def test_feishu_extra_includes_qrcode_for_qr_login():
|
|
"""Feishu's QR login flow (gateway/platforms/feishu.py) needs the
|
|
qrcode package."""
|
|
optional_dependencies = _load_optional_dependencies()
|
|
|
|
feishu_extra = optional_dependencies["feishu"]
|
|
assert any(dep.startswith("qrcode") for dep in feishu_extra)
|
|
|
|
|
|
def test_nemo_relay_extra_uses_supported_official_distribution_range():
|
|
optional_dependencies = _load_optional_dependencies()
|
|
|
|
assert optional_dependencies["nemo-relay"] == ["nemo-relay>=0.5,<1.0"]
|
|
assert not any(
|
|
spec == "hermes-agent[nemo-relay]"
|
|
for spec in optional_dependencies["all"]
|
|
)
|