mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
A brew Python upgrade (original report) or an interrupted venv rebuild (v0.19.0 report in the same thread) can leave certifi importable while its bundled cacert.pem is missing or a dangling symlink. Every TLS connection then fails — Feishu/Telegram/WeChat/DingTalk all down — with an opaque 'Could not find a suitable TLS CA certificate bundle' from deep inside httpx/requests. The existing repair infrastructure only probed `hasattr(certifi, 'contents')`, which PASSES in exactly this failure state, so neither the early venv self-heal nor `hermes update`'s import-probe repair ever classified certifi as broken. Extended, not replaced: - hermes_cli/_early_recovery.py: the in-process probe now also validates that certifi.where() exists and is a plausible bundle (>=1KiB), so the pre-import self-heal repairs it like any other wiped core package. - hermes_cli/main.py (_detect_broken_lazy_refresh_imports): the subprocess probe script used by `hermes update`'s venv repair applies the same bundle-file check inside the target venv. - hermes_cli/doctor.py: `hermes doctor` already failed the cert check; `hermes doctor --fix` now repairs it (pip force-reinstall certifi + module-cache invalidation + re-verify), covering brew/manual venvs where no update marker exists. Failures funnel into the manual-action list with the exact command. - agent/ssl_guard.py: the startup SSLConfigurationError hint now leads with `hermes doctor --fix` instead of only the raw pip command. Fixes #29866
253 lines
9.7 KiB
Python
253 lines
9.7 KiB
Python
"""Dependency-light venv recovery that runs BEFORE hermes_cli.main's imports.
|
|
|
|
The ``hermes`` console entry point is ``hermes_cli.main:main``. Importing
|
|
``hermes_cli.main`` pulls in third-party packages at module level (``dotenv``
|
|
via ``hermes_cli.env_loader``, ``yaml`` via ``hermes_cli.config``, ...). In
|
|
the exact failure state the update-recovery markers exist for — a failed lazy
|
|
backend refresh or interrupted core install that wiped a core package's
|
|
import files (#57828) — a normal launch crashes *while importing main.py*,
|
|
before ``_recover_from_interrupted_install()`` can run. The marker system is
|
|
unreachable precisely when it is needed most.
|
|
|
|
This module is deliberately **stdlib-only** so importing it can never fail on
|
|
a corrupted venv. ``hermes_cli.main`` imports and calls
|
|
:func:`recover_if_needed` at the very top of its module body, before any
|
|
third-party import.
|
|
|
|
Scope: this early pass only repairs enough for ``hermes_cli.main`` to become
|
|
importable again (force-reinstall of the known-fragile core packages, using
|
|
the pins from pyproject.toml). It NEVER clears the recovery markers — the
|
|
full, confirmed marker lifecycle stays with ``_recover_from_interrupted_install()``
|
|
in main.py, which runs right after import succeeds.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
# Core packages a failed lazy ``uv pip install`` is known to leave with intact
|
|
# distribution metadata but wiped import files (#57828). ``module`` is what we
|
|
# probe via a real import; ``attr`` guards against an empty/stub module.
|
|
# main.py's marker-recovery path reuses these tables — keep them here (the
|
|
# dependency-light module) so both layers probe and repair the same set.
|
|
LAZY_REFRESH_IMPORT_PROBES: tuple[tuple[str, str], ...] = (
|
|
("yaml", "SafeDumper"),
|
|
("dotenv", "load_dotenv"),
|
|
("click", "Command"),
|
|
("certifi", "contents"),
|
|
("rich", "print"),
|
|
("cryptography", "__version__"),
|
|
("jwt", "encode"),
|
|
)
|
|
|
|
LAZY_REFRESH_REPAIR_PACKAGES: dict[str, str] = {
|
|
"yaml": "PyYAML",
|
|
"dotenv": "python-dotenv",
|
|
"click": "click",
|
|
"certifi": "certifi",
|
|
"rich": "rich",
|
|
"cryptography": "cryptography",
|
|
"jwt": "PyJWT",
|
|
}
|
|
|
|
|
|
def _project_root() -> Path:
|
|
return Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def _pinned_specs(packages: list[str], project_root: Path) -> list[str]:
|
|
"""Map bare package names to their pinned specs from pyproject.toml.
|
|
|
|
Stdlib-only (tomllib + naive requirement-head parsing — ``packaging`` may
|
|
itself be broken in the failure state this module exists for). Unknown
|
|
packages fall back to their bare name.
|
|
"""
|
|
pyproject = project_root / "pyproject.toml"
|
|
if not pyproject.is_file():
|
|
return packages
|
|
try:
|
|
import tomllib
|
|
|
|
with open(pyproject, "rb") as f:
|
|
raw_deps = tomllib.load(f).get("project", {}).get("dependencies", []) or []
|
|
except Exception:
|
|
return packages
|
|
|
|
name_to_spec: dict[str, str] = {}
|
|
for spec in raw_deps:
|
|
head = spec.split(";", 1)[0].strip()
|
|
bare = head
|
|
for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
|
|
if op in bare:
|
|
bare = bare.split(op, 1)[0]
|
|
break
|
|
key = bare.strip().split("[", 1)[0].strip().lower()
|
|
if key:
|
|
name_to_spec[key] = head
|
|
return [name_to_spec.get(pkg.lower(), pkg) for pkg in packages]
|
|
|
|
|
|
def _certifi_bundle_broken() -> bool:
|
|
"""True when certifi imports but its ``cacert.pem`` is missing/corrupt.
|
|
|
|
A brew Python upgrade or an interrupted venv rebuild can leave certifi's
|
|
distribution metadata (and even the module) intact while the bundled
|
|
``cacert.pem`` is gone or a dangling symlink — every TLS connection then
|
|
fails with an opaque ``Could not find a suitable TLS CA certificate
|
|
bundle`` from deep inside httpx/requests (#29866). An attribute probe
|
|
alone passes in that state, so validate the bundle path itself.
|
|
"""
|
|
try:
|
|
import certifi
|
|
|
|
bundle = Path(certifi.where())
|
|
# <1 KiB cannot hold a single PEM certificate — treat as corrupt.
|
|
return not bundle.is_file() or bundle.stat().st_size < 1024
|
|
except Exception:
|
|
# Import failure is caught by the regular probe table; a failure to
|
|
# even stat is treated as broken.
|
|
return True
|
|
|
|
|
|
def _probe_broken_packages() -> list[str]:
|
|
"""Import-probe the fragile core packages in THIS process.
|
|
|
|
Returns repair package names (deduped, probe order) for modules that fail
|
|
to import or lack their sentinel attribute. Failed imports leave nothing
|
|
in ``sys.modules``, so a post-repair retry in the same process works.
|
|
|
|
certifi additionally gets a bundle-file check: the module can import
|
|
cleanly while ``cacert.pem`` is missing (#29866).
|
|
"""
|
|
broken: list[str] = []
|
|
for mod_name, attr in LAZY_REFRESH_IMPORT_PROBES:
|
|
try:
|
|
mod = importlib.import_module(mod_name)
|
|
if not hasattr(mod, attr):
|
|
raise ImportError(f"{mod_name} missing {attr}")
|
|
if mod_name == "certifi" and _certifi_bundle_broken():
|
|
raise ImportError("certifi cacert.pem missing or corrupt")
|
|
except Exception:
|
|
pkg = LAZY_REFRESH_REPAIR_PACKAGES.get(mod_name)
|
|
if pkg and pkg not in broken:
|
|
broken.append(pkg)
|
|
return broken
|
|
|
|
|
|
def _run_repair_install(specs: list[str], project_root: Path) -> bool:
|
|
"""ensurepip + ``pip install --force-reinstall`` the given specs.
|
|
|
|
Streams nothing to stdout (``hermes acp`` speaks JSON-RPC on stdout);
|
|
output is captured and replayed to stderr only on failure. Never raises.
|
|
"""
|
|
try:
|
|
subprocess.run(
|
|
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "pip", "install", "--force-reinstall", *specs],
|
|
cwd=project_root,
|
|
capture_output=True,
|
|
text=True, encoding="utf-8", errors="replace",
|
|
)
|
|
except Exception as exc:
|
|
print(f" ✗ Early venv repair could not run pip: {exc}", file=sys.stderr)
|
|
return False
|
|
if result.returncode != 0:
|
|
tail = (result.stderr or result.stdout or "")[-2000:]
|
|
if tail:
|
|
print(tail, file=sys.stderr)
|
|
return False
|
|
return True
|
|
|
|
|
|
def recover_if_needed(
|
|
project_root: Path | None = None,
|
|
argv: list[str] | None = None,
|
|
) -> None:
|
|
"""Repair wiped core packages so ``hermes_cli.main`` can import at all.
|
|
|
|
Fast path (no marker present) is two ``lstat`` calls. Only acts when a
|
|
recovery marker from a prior ``hermes update`` exists AND an import probe
|
|
confirms a core package is actually broken. Markers are intentionally
|
|
NOT cleared here — ``_recover_from_interrupted_install()`` in main.py owns
|
|
the confirmed marker lifecycle and runs immediately after import succeeds.
|
|
|
|
Never raises: on any failure the import of main.py proceeds and surfaces
|
|
the real error.
|
|
"""
|
|
try:
|
|
args = sys.argv[1:] if argv is None else argv
|
|
# Same deliberately-loose match as main(): the real update flow writes
|
|
# and clears its own markers — a recovery install must not race it.
|
|
if "update" in args:
|
|
return
|
|
root = _project_root() if project_root is None else project_root
|
|
core_marker = root / ".update-incomplete"
|
|
lazy_marker = root / ".lazy-refresh-incomplete"
|
|
if not core_marker.exists() and not lazy_marker.exists():
|
|
return
|
|
# Managed/Docker/PyPI installs have no source tree here — the marker
|
|
# is not ours to act on; main.py's recovery clears it.
|
|
if not (root / "pyproject.toml").is_file():
|
|
return
|
|
|
|
broken = _probe_broken_packages()
|
|
if not broken:
|
|
# Imports are fine — main.py will load and run full recovery.
|
|
return
|
|
|
|
# Single-flight: share main.py's recovery lock so an early repair
|
|
# never races a concurrent full recovery into the same shared venv.
|
|
lock_path = root / ".update-incomplete.lock"
|
|
try:
|
|
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
os.write(fd, f"{os.getpid()}\n".encode())
|
|
os.close(fd)
|
|
except FileExistsError:
|
|
try:
|
|
if time.time() - lock_path.stat().st_mtime > 3600:
|
|
lock_path.unlink()
|
|
except OSError:
|
|
pass
|
|
return
|
|
except OSError:
|
|
pass # read-only fs / perms — proceed unlocked, install surfaces it
|
|
|
|
try:
|
|
specs = _pinned_specs(broken, root)
|
|
print(
|
|
"⚠ Core package(s) broken by an interrupted update — "
|
|
f"repairing before launch: {', '.join(broken)}",
|
|
file=sys.stderr,
|
|
)
|
|
if _run_repair_install(specs, root) and not _probe_broken_packages():
|
|
print(" ✓ Core packages repaired.", file=sys.stderr)
|
|
else:
|
|
print(
|
|
" ✗ Automatic repair incomplete. Recover manually with:",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
f" {sys.executable} -m pip install --force-reinstall "
|
|
+ " ".join(specs),
|
|
file=sys.stderr,
|
|
)
|
|
finally:
|
|
try:
|
|
lock_path.unlink()
|
|
except OSError:
|
|
pass
|
|
except Exception:
|
|
# Never block launch — the import of main.py will surface the truth.
|
|
pass
|