Introduce the pm store: a unified, hash-verified package store that
replaces lazy_deps and the old installer's ad-hoc tool downloads.
Store tools are provisioned on PATH (ffmpeg, node/npm via pinned uv),
with a resumable 8-way downloader, verify() returning failure reasons,
and adopt() made EPERM-safe. chromium ships in the payload for every
target. The 3600-line install.sh is replaced by a staged bootstrapper
(heavy deps are pm's job after this); setup-hermes.sh, Dockerfile and
nix pin tables are rewired onto the store. Old install-script tests,
lazy_deps/managed_uv/build_info, and the ps1/bash installer test
batteries are removed with the machinery they tested.
Rebuilt from ethie/pm onto upstream/main (ac6c8028e0) after the
utf-8-sig sweep. 16 hot files (main also churned them) hand-merged:
platform adapters, main.py, electron/main.ts, tui_gateway/server.py,
cua_backend, installer-tests workflow, install.sh (full rewrite),
setup-hermes.sh, plugins doc.
150 lines
6.2 KiB
Python
150 lines
6.2 KiB
Python
import ast
|
|
import re
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _distribution_name(requirement: str) -> str:
|
|
"""Extract the PEP 508 distribution name from a requirement string.
|
|
|
|
Robust to markers (``; python_version < '3.12'``), direct references
|
|
(``name @ https://...``), extras (``name[extra]``) and every version
|
|
operator (``==``, ``>=``, ``<=``, ``~=``, ``!=``, ``<``, ``>``), so a
|
|
future dep declared with any valid specifier shape doesn't silently
|
|
mis-parse here.
|
|
"""
|
|
spec = requirement.split(";", 1)[0] # drop environment markers
|
|
spec = spec.split("@", 1)[0] # drop direct-reference URLs
|
|
spec = spec.split("[", 1)[0] # drop extras
|
|
spec = re.split(r"[=<>!~]", spec, maxsplit=1)[0] # drop any version operator
|
|
return spec.strip().lower()
|
|
|
|
|
|
def test_packaging_declared_as_core_dependency():
|
|
"""Regression for #40503.
|
|
|
|
``packaging`` is imported directly on three production paths
|
|
(plugins/memory/hindsight/__init__.py, pm/extras.py,
|
|
hermes_cli/main.py) yet was undeclared, so it only reached users
|
|
transitively. The slim Docker image shipped without it, silently
|
|
disabling Hindsight append-mode and version-constraint checks. It must
|
|
be a declared core dependency so it installs everywhere and the
|
|
update-repair step (``_verify_core_dependencies_installed``) guards it.
|
|
"""
|
|
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
|
core = data["project"]["dependencies"]
|
|
names = {_distribution_name(dep) for dep in core}
|
|
assert "packaging" in names, (
|
|
"packaging is imported on production paths (hindsight version compare, "
|
|
"version constraints, requirement parsing) and must be a "
|
|
"declared core dependency, not a transitive — see #40503"
|
|
)
|
|
|
|
|
|
def test_faster_whisper_is_not_a_base_dependency():
|
|
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
|
deps = data["project"]["dependencies"]
|
|
|
|
assert not any(dep.startswith("faster-whisper") for dep in deps)
|
|
|
|
stt_extra = data["project"]["optional-dependencies"]["stt-whisper"]
|
|
assert any(dep.startswith("faster-whisper") for dep in stt_extra)
|
|
|
|
|
|
# Minimum non-vulnerable Starlette: CVE-2026-48710 ("BadHost") was fixed in
|
|
# 1.0.1. Anything below that lets a malformed Host header desync
|
|
# ``request.url.path`` from the dispatched ASGI path, bypassing path-based
|
|
# authz in middleware/endpoints that gate on ``request.url``. Starlette is a
|
|
# transitive dep (fastapi in [web]; sse-starlette/mcp in [mcp]/[computer-use]/
|
|
# [dev]) so we pin it directly in every extra that exposes a server surface and
|
|
# enforce the floor in both pyproject and the committed lockfile.
|
|
_STARLETTE_CVE_FLOOR = (1, 0, 1)
|
|
_UPDATE_DOWNGRADE_GUARD_FLOORS = {
|
|
# `hermes update` reinstalls exact pins from pyproject/uv.lock. These
|
|
# reviewed CVE pins must not slide back to stale versions that downgrade
|
|
# already-patched user environments.
|
|
"cryptography": (50, 0, 0),
|
|
"starlette": (1, 3, 1),
|
|
"python-multipart": (0, 0, 32),
|
|
}
|
|
|
|
|
|
def _version_tuple(spec: str) -> tuple[int, ...]:
|
|
# "1.0.1" -> (1, 0, 1); tolerant of pre/post suffixes by truncating.
|
|
head = spec.split("+", 1)[0]
|
|
parts = []
|
|
for chunk in head.split("."):
|
|
digits = "".join(ch for ch in chunk if ch.isdigit())
|
|
if not digits:
|
|
break
|
|
parts.append(int(digits))
|
|
return tuple(parts)
|
|
|
|
|
|
def test_starlette_pinned_above_cve_2026_48710_floor_in_pyproject():
|
|
"""Every extra that declares Starlette must pin a patched (>=1.0.1) version.
|
|
|
|
Regression guard for #35067 / CVE-2026-48710. A future edit that drops the
|
|
pin (re-exposing the unbounded transitive ``starlette>=0.27`` from mcp /
|
|
``>=0.40.0`` from fastapi) or pins a pre-1.0.1 version fails here instead of
|
|
shipping a Host-header auth-bypass to dashboard / MCP-HTTP users.
|
|
"""
|
|
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
|
extras = data["project"]["optional-dependencies"]
|
|
|
|
found = {}
|
|
for extra, specs in extras.items():
|
|
for spec in specs:
|
|
name = spec.split("==", 1)[0].split(">", 1)[0].split("<", 1)[0].split("[", 1)[0].strip()
|
|
if name.lower() == "starlette":
|
|
assert "==" in spec, f"[{extra}] must exact-pin starlette, got {spec!r}"
|
|
ver = spec.split("==", 1)[1].split(";", 1)[0].strip()
|
|
found[extra] = ver
|
|
|
|
# The four server-surface extras must each carry the direct pin.
|
|
for extra in ("web", "mcp", "computer-use", "dev"):
|
|
assert extra in found, (
|
|
f"[{extra}] no longer pins starlette directly — CVE-2026-48710 "
|
|
f"regression risk (mcp/fastapi pull it transitively with no upper bound)"
|
|
)
|
|
|
|
for extra, ver in found.items():
|
|
assert _version_tuple(ver) >= _STARLETTE_CVE_FLOOR, (
|
|
f"[{extra}] pins starlette=={ver}, below the CVE-2026-48710 fix "
|
|
f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))}"
|
|
)
|
|
|
|
|
|
def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710():
|
|
"""The committed uv.lock must resolve starlette to a patched version.
|
|
|
|
pyproject pins protect the declared extras, but the lockfile is what
|
|
hash-verified installs (``uv sync --locked``) actually pull. Assert the
|
|
resolved version is >= the CVE-2026-48710 fix floor so a stale-lock
|
|
regression can't ship a vulnerable Starlette to users.
|
|
"""
|
|
lock = (REPO_ROOT / "uv.lock").read_text(encoding="utf-8")
|
|
versions = []
|
|
in_starlette = False
|
|
for line in lock.splitlines():
|
|
if line.startswith("[[package]]"):
|
|
in_starlette = False
|
|
elif line.strip() == 'name = "starlette"':
|
|
in_starlette = True
|
|
elif in_starlette and line.startswith("version = "):
|
|
versions.append(line.split("=", 1)[1].strip().strip('"'))
|
|
in_starlette = False
|
|
|
|
assert versions, "starlette not found in uv.lock"
|
|
for ver in versions:
|
|
assert _version_tuple(ver) >= _STARLETTE_CVE_FLOOR, (
|
|
f"uv.lock resolves starlette=={ver}, below the CVE-2026-48710 fix "
|
|
f"floor {'.'.join(map(str, _STARLETTE_CVE_FLOOR))} — regenerate the "
|
|
f"lockfile after bumping the pin"
|
|
)
|