Files
hermes-agent/hermes_cli/update_cmd_deps.py
Teknium a2a8207711 simplify(compat): tools/browser_tool — drop the last warm_agent_browser_npx_cache re-export, repoint 1 caller (update_cmd_deps internal import) + 1 test file
Consolidates on top of the concurrent browser_tool compat-removal commits (de60f789a7, 912a497ce2, da7ee6353e, 69eb1feb3e): the facade no longer re-exports any browser_tool_* sibling name; hermes_cli/update_cmd_deps.py's internal import (not its exported surface) and tests/hermes_cli/test_cmd_update.py patch targets now point at tools.browser_tool_install.
2026-09-03 14:27:29 -07:00

1053 lines
50 KiB
Python

"""Post-``hermes update`` dependency sync: venv preflight, editable reinstall, lazy refresh,
npm/Desktop rebuilds, self-lock deferral. Names are re-imported by ``update_cmd`` (so
``hermes_cli.update_cmd.<name>`` resolves/monkeypatches); origin helpers are imported lazily."""
import logging
from contextlib import suppress
import hashlib
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
from hermes_constants import venv_python_path
# Log-record parity with the origin module.
logger = logging.getLogger("hermes_cli.update_cmd")
# Files defining the editable install; a pull touching none of them cannot invalidate it.
_INSTALL_DEFINING_FILES = "pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.in", "uv.lock"
def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool:
"""True when the pulled commits cannot have invalidated the editable install: ``uv pip install
-e .`` always rewrites console-script shims (Windows: ``hermes.exe`` quarantine, ``os error 32``
on a lost race), so skip it when only non-install files changed. Safe because the editable
finder uses a *static* module list. Fails closed: no pre-pull SHA or failed diff -> False."""
if not pre_pull_sha:
return False
try:
result = subprocess.run(
git_cmd + ["diff", "--name-only", f"{pre_pull_sha}..HEAD", "--"] + list(_INSTALL_DEFINING_FILES),
cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace")
except OSError:
return False
return result.returncode == 0 and not result.stdout.strip()
# Modules imported on every startup. Unlike _UPDATE_CRITICAL_FILES (only parsed) these are
# *imported*, catching cross-module breakage (a name pulled from a sibling no longer exists).
_UPDATE_CRITICAL_MODULES = "hermes_cli.main", "run_agent", "model_tools", "toolsets"
def _critical_module_import_failures(
root, *, report_runtime_errors: bool = False) -> dict[str, tuple[str, str]]:
"""Import each ``_UPDATE_CRITICAL_MODULES`` entry in a subprocess; return failures in probe order.
Syntax validation only *parses*: a partially-updated tree (Windows ZIP copy loop) parses yet
dies with ``ImportError: cannot import name``. The subprocess (venv interpreter when present —
the updater may run under another Python) keeps import side effects out of our ``sys.modules``.
Generic import-time exceptions are tolerated unless ``report_runtime_errors=True``.
"""
from hermes_cli.update_cmd import _UPDATE_CRITICAL_MODULES, _m
from hermes_constants import FIRST_PARTY_MODULE_ROOTS
import secrets
marker = f"__HERMES_IMPORT_HEALTH_{secrets.token_hex(16)}__"
probe = (
"import importlib, json, sys\n"
"failures = []\n"
"for name in %r:\n"
" try:\n"
" importlib.import_module(name)\n"
" except ModuleNotFoundError as exc:\n"
# A missing *third-party* module means deps aren't installed, not a skewed checkout;
# only our own packages count. Roots come from hermes_constants so the user hint can't drift.
" missing = (getattr(exc, 'name', '') or '').split('.')[0]\n"
" if missing in %r or missing.startswith('hermes_') or %r:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except ImportError as exc:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except Exception as exc:\n"
" if %r:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
" except BaseException as exc:\n"
" failures.append((name, type(exc).__name__, str(exc)))\n"
"sys.stdout.write('\\n%s' + json.dumps(failures))\n"
% (_UPDATE_CRITICAL_MODULES, tuple(sorted(FIRST_PARTY_MODULE_ROOTS)), report_runtime_errors,
report_runtime_errors, marker))
try:
interpreter = sys.executable
with suppress(Exception):
venv_python = venv_python_path(Path(root) / "venv", windows=_m()._is_windows())
if venv_python.exists():
interpreter = str(venv_python)
result = subprocess.run(
[interpreter, "-c", probe], cwd=str(root), capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=120)
except subprocess.TimeoutExpired:
return _probe_failure("TimeoutExpired", "timed out before reporting import health")
except (OSError, subprocess.SubprocessError):
# Can't run the probe — don't block the update on our own tooling.
return {}
output = result.stdout or ""
if marker not in output:
return _probe_failure(
"ProbeTerminated",
f"terminated before reporting import health (exit code {result.returncode})")
try:
failures = json.loads(output.rsplit(marker, 1)[1])
if not isinstance(failures, list) or any(
not isinstance(item, list) or len(item) != 3 or not all(isinstance(v, str) for v in item)
for item in failures):
raise ValueError("invalid import-health payload")
return {str(module): (str(kind), str(detail)) for module, kind, detail in failures}
except (TypeError, ValueError):
return _probe_failure("MalformedPayload", "reported malformed import health data")
def _probe_failure(kind: str, detail: str) -> dict[str, tuple[str, str]]:
"""Failure row for the probe itself (as opposed to a module it imported)."""
return {"critical-module probe": (kind, detail)}
def _validate_critical_modules_import(
root, *, report_runtime_errors: bool = False) -> tuple[bool, str | None, str | None]:
"""Return the first critical-module import failure, if any."""
failures = _critical_module_import_failures(root, report_runtime_errors=report_runtime_errors)
if failures:
module = next(iter(failures))
return False, module, failures[module][1]
return True, None, None
def _npm_bin_exists(bin_dir: Path, name: str) -> bool:
"""True when an npm bin shim for *name* exists (POSIX or Windows)."""
return any((bin_dir / c).exists() for c in (name, f"{name}.cmd", f"{name}.ps1", f"{name}.exe"))
def _web_build_toolchain_ready(*roots: Path) -> bool:
"""True when ``tsc`` and ``vite`` shims are reachable from any of *roots*.
Callers must pass every root the build would search, or a healthy tree reads as broken."""
bin_dirs = [d for d in (root / "node_modules" / ".bin" for root in roots) if d.is_dir()]
return bool(bin_dirs) and all(
any(_npm_bin_exists(bin_dir, tool) for bin_dir in bin_dirs) for tool in ("tsc", "vite"))
def _web_toolchain_roots(web_dir: Path) -> tuple[Path, ...]:
"""Roots whose ``node_modules/.bin`` can satisfy the web build: ``npm run build`` searches the
package and each ancestor, so hoisted and package-local shims are equally valid.
``npm run build`` prepends ``node_modules/.bin`` for the package and each of its ancestors, so shims
hoisted to the workspace root and shims nested under a package that owns its lockfile (#42973) are
equally valid.
"""
return (web_dir, web_dir.parent)
def _ensure_venv_pip(pip_cmd: list, python_exe: str) -> None:
"""Bootstrap pip back into the venv via ensurepip when ``pip --version`` fails
(some environments lose it); call before the editable install."""
from hermes_cli.update_cmd import _m
try:
subprocess.run(pip_cmd + ["--version"], cwd=_m().PROJECT_ROOT, check=True, capture_output=True)
except subprocess.CalledProcessError:
subprocess.run(
[python_exe, "-m", "ensurepip", "--upgrade", "--default-pip"], cwd=_m().PROJECT_ROOT, check=True)
def _upgrade_pip_before_lazy_refresh(
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
"""Upgrade pip before lazy refreshes: older pip can fail setuptools source builds and
leave a partially-written venv. Never raises.
See #57828.
"""
from hermes_cli.update_cmd import _m
try:
_m()._run_package_only_install(install_cmd_prefix + ["install", "--upgrade", "pip"], env=env)
except subprocess.CalledProcessError as exc:
logger.debug("pip upgrade before lazy refresh failed: %s", exc)
def _capture_active_lazy_features() -> list[str]:
"""Snapshot active lazy backends before a managed runtime is replaced."""
try:
from tools import lazy_deps
return lazy_deps.active_features()
except Exception as exc:
logger.debug("Could not snapshot active lazy features: %s", exc)
return []
def _capture_active_tool_dependencies() -> list[str]:
"""Snapshot Python dependencies installed explicitly through ``hermes tools``."""
try:
from hermes_cli import tools_config
return tools_config.active_restorable_python_tool_dependencies()
except Exception as exc:
logger.debug("Could not snapshot active Hermes Tools dependencies: %s", exc)
return []
def _module_importable_in(target_python, module_name: str, env) -> bool:
"""Probe ``find_spec(module_name)`` under *target_python*; an indeterminate probe reads as
missing (safer to repair than to assume it survived)."""
try:
probe = subprocess.run(
[str(target_python), "-c",
"import importlib.util,sys; raise SystemExit(0 if importlib.util.find_spec(sys.argv[1]) else 1)",
module_name],
capture_output=True, env=env, check=False)
return probe.returncode == 0
except (subprocess.SubprocessError, OSError):
return False
def _restore_active_tool_dependencies(
dependencies: list[str], install_cmd_prefix: list[str], *, env: dict[str, str] | None = None
) -> None:
"""Restore allowlisted ``hermes tools`` dependencies (from a pre-rebuild probe) into a rebuilt
venv. Never raises: a failed optional tool must not block the update, but must be reported."""
from hermes_cli.update_cmd import _m
if not dependencies:
return
try:
from hermes_cli import tools_config
except Exception as exc:
logger.debug("Hermes Tools dependency restore skipped (import failed): %s", exc)
return
target_python = _m()._resolve_install_target_python(install_cmd_prefix, env)
missing: list[tuple[str, tuple[str, ...]]] = []
for name in dependencies:
spec = tools_config.restorable_python_tool_dependency(name)
if spec is None:
continue
module_name, install_args = spec
if target_python is not None and _module_importable_in(target_python, module_name, env):
continue
missing.append((name, install_args))
if not missing:
return
print()
print(f"→ Restoring {len(missing)} Hermes Tools dependency set(s)...")
restored: list[str] = []
failed: list[tuple[str, str]] = []
for name, install_args in missing:
try:
_m()._run_package_only_install(
install_cmd_prefix + ["install", *install_args, "--quiet"], env=env)
restored.append(name)
except Exception as exc:
# Best-effort: surface failures without aborting the update.
failed.append((name, str(exc)))
if restored:
print(f" ✓ {len(restored)} restored: {', '.join(restored)}")
for name, reason in failed:
print(f" ⚠ {name} failed to restore: {_clip(reason)}")
def _clip(reason: str, limit: int = 200) -> str:
"""Bound a failure reason for the one-line report."""
return reason if len(reason) <= limit else reason[:limit] + "..."
def _refresh_active_lazy_features(
install_cmd_prefix: list[str] | None = None, *, env: dict[str, str] | None = None,
features: list[str] | None = None) -> bool:
"""Refresh previously-activated lazy backends (cold ones untouched): the core install never
touches them, so a bumped :data:`LAZY_DEPS` pin would leave them stale forever. Returns True
when the venv is safe (refreshed / nothing active / import repair succeeded), False when a
failed lazy install left broken core imports repair couldn't fix. Never raises.
See #57828.
"""
from hermes_cli.update_cmd import _m
try:
from tools import lazy_deps
except Exception as exc:
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return True
active = features
if active is None:
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return True
if not active:
return True
print()
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
unexpected_failure = False
try:
results = (
lazy_deps.refresh_active_features(prompt=False) if features is None
else lazy_deps.restore_features(active))
except Exception as exc:
# refresh_active_features is never-raise by contract; defend anyway.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
results = {}
unexpected_failure = True
refreshed = [f for f, s in results.items() if s in {"refreshed", "restored"}]
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:
# Usually security.allow_lazy_installs=false; informational, 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 not failed and not unexpected_failure:
return True
for feature, status in failed:
print(f" ⚠ {feature} failed to refresh: {_clip(status.split(': ', 1)[-1])}")
if install_cmd_prefix is None:
print(" ⚠ Lazy refresh failed; rerun `hermes update` once resolved.")
return False
# Import-based recovery: metadata-only verifiers miss dist-info intact but import files
# wiped. Unavailable probes are indeterminate, not healthy — keep the lazy marker.
# See #57828.
status = _m()._repair_venv_via_import_probes(install_cmd_prefix, env=env)
if status == "repaired":
print(" Lazy backend(s) keep their previous version until refresh succeeds.")
return True
if status == "healthy":
print(" Lazy backend(s) keep their previous version; probed packages look intact.")
print(" Rerun `hermes update` once the upstream issue is resolved.")
return True
if status == "indeterminate":
print(" ⚠ Leaving `.lazy-refresh-incomplete` until import probes can confirm health.")
return False
def _refresh_active_memory_provider_dependencies() -> None:
"""Refresh pip deps for the configured external memory provider: its bridge packages live in
``plugin.yaml`` (not Hermes extras / ``LAZY_DEPS``), so the core reinstall can strip them;
re-run the ACTIVE provider's install last so its writes land last. Never raises.
Re-run the provider's declared install for the ACTIVE provider only, after the core install and lazy
refresh, so the last write to any shared package is the one the active provider needs. See #53272,
#70636.
"""
try:
from hermes_cli.config import load_config
cfg = load_config()
except Exception as exc:
logger.debug("Memory provider refresh skipped (config load failed): %s", exc)
return
provider = ""
memory_cfg = cfg.get("memory") if isinstance(cfg, dict) else None
if isinstance(memory_cfg, dict):
if memory_cfg.get("enabled") is False:
return
provider = str(memory_cfg.get("provider") or "").strip()
# "default"/empty is the built-in file store — no pip deps.
if not provider or provider in {"default", "builtin", "none"}:
return
try:
from hermes_cli.memory_setup import _install_dependencies
except Exception as exc:
logger.debug("Memory provider refresh skipped (import failed): %s", exc)
return
print()
print(f"→ Refreshing active memory provider dependencies ({provider})...")
try:
_install_dependencies(provider, force=True)
except Exception as exc:
print(f" ⚠ {provider} dependencies failed to refresh: {exc}")
def _is_android_python() -> bool:
from hermes_cli.update_cmd import _m
return _m().sys.platform == "android"
def _install_psutil_android_compat(
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
"""Install psutil on Android by patching its platform detection: setup gates Linux sources on
``sys.platform.startswith('linux')`` but Termux reports ``'android'`` though the Linux path
compiles fine. Only this attempt's build tree is patched (stopgap until psutil ships a fix)."""
from hermes_cli.update_cmd import _m
import tempfile
import urllib.request
from hermes_cli.psutil_android import PSUTIL_URL, prepare_patched_psutil_sdist
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / "psutil.tar.gz"
urllib.request.urlretrieve(PSUTIL_URL, archive)
src_root = prepare_patched_psutil_sdist(archive, Path(tmp))
_m()._run_install_with_heartbeat(
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)], env=env)
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
"""Best-effort uv bootstrap on Termux (official installer may fail: glibc vs bionic). Prefer a
PATH uv; else wheel-only ``pip install uv`` so the Rust crate is never source-built."""
from hermes_cli.update_cmd import _m
from hermes_cli.managed_uv import resolve_uv
existing = resolve_uv()
if existing:
return existing
if not _m()._is_termux_env():
return None
# Termux-packaged uv is on PATH but not the managed bin dir, so resolve_uv() misses it;
# prefer it over pip, which has no Android wheel and would source-build on a small device.
system_uv = shutil.which("uv")
if system_uv:
return system_uv
with suppress(Exception):
print(" → Termux detected: trying to install uv for faster dependency updates...")
result = subprocess.run(
pip_cmd + ["install", "uv", "--only-binary", ":all:"], cwd=_m().PROJECT_ROOT, check=False)
if result.returncode != 0:
return None
return resolve_uv() or shutil.which("uv")
def _npm_manifest_paths() -> tuple[Path, ...]:
"""Manifests whose changes must defeat the update-skip. The lockfile alone isn't enough (a
package.json can be edited without running npm); workspaces come from the root ``workspaces``
globs so a new one can't escape the key, and every workspace counts (desktop too) because the
single lockfile spans the whole graph. Root manifests only if package.json is unreadable."""
from hermes_cli.update_cmd import _m
root_pkg = _m().PROJECT_ROOT / "package.json"
paths = [_m().PROJECT_ROOT / "package-lock.json", root_pkg]
with suppress(OSError, json.JSONDecodeError, TypeError):
workspaces = json.loads(root_pkg.read_text(encoding="utf-8")).get("workspaces", [])
if isinstance(workspaces, dict): # legacy {"packages": [...]} form
workspaces = workspaces.get("packages", [])
for pattern in workspaces:
for match in sorted(_m().PROJECT_ROOT.glob(str(pattern))):
manifest = match / "package.json"
if manifest.is_file():
paths.append(manifest)
return tuple(paths)
def _npm_manifests_digest() -> str | None:
"""sha256 over lockfile + all workspace package.json; None when the lockfile is missing (never skip)."""
from hermes_cli.update_cmd import _m
if not (_m().PROJECT_ROOT / "package-lock.json").exists():
return None
h = hashlib.sha256()
for p in _npm_manifest_paths():
h.update(str(p.relative_to(_m().PROJECT_ROOT)).encode())
try:
h.update(p.read_bytes())
except OSError:
h.update(b"<missing>")
return h.hexdigest()
def _npm_lockfile_changed(hermes_root: Path) -> bool:
from hermes_cli.update_cmd import _m
current = _npm_manifests_digest()
if current is None:
return True
# Matching hash but no node_modules: cache was recorded by another checkout.
if not (_m().PROJECT_ROOT / "node_modules").is_dir():
return True
# Never skip when the web toolchain never landed, or later updates build on a half-installed tree.
web_dir = _m().PROJECT_ROOT / "web"
if (web_dir / "package.json").is_file() and not _web_build_toolchain_ready(
*_web_toolchain_roots(web_dir)):
return True
try:
cache_file = _npm_lock_cache_file(hermes_root)
if not cache_file.exists():
return True
return cache_file.read_text(encoding="utf-8").strip() != current
except OSError:
return True
def _npm_lock_cache_file(hermes_root: Path) -> Path:
"""Per-checkout cache path: keyed by PROJECT_ROOT so parallel worktrees don't collide."""
from hermes_cli.update_cmd import _m
cache_key = hashlib.sha256(str(_m().PROJECT_ROOT).encode()).hexdigest()[:12]
return hermes_root / f".npm_lock_hash_{cache_key}"
def _record_npm_lockfile_hash(hermes_root: Path) -> None:
digest = _npm_manifests_digest()
if digest is None:
return
try:
_npm_lock_cache_file(hermes_root).write_text(digest, encoding="utf-8")
except OSError:
logger.debug("Could not write npm lockfile hash cache")
def _repair_node_deps_on_current_checkout(
print_completion,
*,
assume_yes: bool = False,
gateway_mode: bool = False,
pre_update_snapshot_id: str | None = None,
completion_message: str = "✓ Already up to date!",
had_desktop_app_before_update: bool = False) -> bool:
"""Repair Node deps on the ``commit_count == 0`` path: a failed npm install says "re-run hermes
update" but the early return used to skip the refresh. ``_update_node_dependencies`` self-gates
on the hash recorded only after a SUCCESSFUL install, so this is a cheap no-op when healthy.
See #77211.
"""
from hermes_cli.update_cmd import (
_check_and_apply_config_migration, _m, _rebuild_desktop_after_update, _update_node_dependencies)
node_failures = _update_node_dependencies()
if node_failures:
print(f" ⚠ Node.js refresh failed for: {', '.join(node_failures)}")
print(" Fix npm and re-run `hermes update`.")
print_completion("⚠ Checkout is current, but Node.js dependencies could not be repaired.")
return False
# Pair with the web build like every other call site; it staleness-checks internally.
_m()._build_web_ui(_m().PROJECT_ROOT / "web")
_check_and_apply_config_migration(
assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id)
# A current checkout can still owe a Desktop rebuild (e.g. the Windows hand-off child
# never reaches the commits-pulled rebuild). Self-gates on the build stamp.
# Skipping it leaves a stale desktop app behind a successful-looking update. See #97343.
if not _rebuild_desktop_after_update(
_m().PROJECT_ROOT / "apps" / "desktop", had_desktop_app_before_update=had_desktop_app_before_update):
# Retry hint already printed; withhold success rather than claim completion.
# See #88251.
print_completion(
"⚠ Update partially complete — the desktop app was not rebuilt "
"and is still on the previous build.")
return False
return bool(print_completion(completion_message))
def _update_node_dependencies() -> list[str]:
"""Refresh Node deps for ui-tui and web. Returns labels whose npm install failed (empty on
success) so the caller reports a partial update instead of ``Update complete!``.
See #30271.
"""
from hermes_cli.update_cmd import _m
if not (_m().PROJECT_ROOT / "package.json").exists():
return []
npm = _m()._resolve_node_runtime_npm()
if not npm:
# Only a Windows npm reachable from WSL: flag loudly — skipping silently leaves
# deps stale, running it would corrupt the tree.
from hermes_constants import is_wsl
path_npm = shutil.which("npm")
if is_wsl() and path_npm and _m()._is_windows_npm_path(path_npm):
# Root package.json has no dependencies of its own (agent-browser and @streamdown/math were
# moved out — see #43564): agent-browser resolves at runtime via `npx agent-browser`
# (tools/browser_tool.py), and @streamdown/math is a desktop-only import now declared in
# apps/desktop/package.json. That means a plain workspace-scoped install can never prune
# anything root-only, so we only need to name the workspaces the CLI/TUI/web build actually
# requires. apps/desktop pulls in Electron as a devDependency with a ~200MB postinstall
# download, so it's deliberately never named here — desktop deps install on demand (see
# _desktop_build_needed).
print("→ Updating Node.js dependencies...")
print(" ⚠ Skipped: only a Windows npm is reachable from this WSL shell.")
print(" Install Node.js inside the WSL distro (nvm, or your distro's")
print(" package manager), then re-run `hermes update`.")
has_workspace = any(
(_m().PROJECT_ROOT / ws / "package.json").exists() for ws in ("ui-tui", "web"))
return ["ui-tui, web workspaces"] if has_workspace else []
return []
from hermes_constants import get_default_hermes_root
# node_modules is shared by every profile on this checkout: one per-checkout cache.
shared_hermes_root = get_default_hermes_root()
# Best-effort npx cache warm before the lockfile-unchanged early return. Can block
# ~11s on a cold cache — print first so it doesn't look like a hang.
# Runs before the lockfile-unchanged early return below since that's the common `hermes update` case.
# See #43564.
print("→ Warming npx cache for agent-browser...")
with suppress(Exception):
from tools.browser_tool_install import warm_agent_browser_npx_cache
warm_agent_browser_npx_cache()
if not _m()._npm_lockfile_changed(shared_hermes_root):
logger.info("npm lockfile unchanged, skipping npm install")
return []
# Root package.json has no deps of its own, so a workspace-scoped install prunes nothing
# root-only. apps/desktop is deliberately never named: its Electron devDependency has a
# ~200MB postinstall, so desktop deps install on demand (see _desktop_build_needed).
print("→ Updating Node.js dependencies...")
install_args = [
"--no-fund", "--no-audit", "--prefer-offline", "--progress=false",
"--workspace", "ui-tui", "--workspace", "web",
# Root devDependencies (shared ESLint config) would otherwise be pruned by the
# scoped install; apps/desktop stays excluded since it is never named above.
"--include-workspace-root"]
from hermes_constants import with_hermes_node_path
nixos_env = with_hermes_node_path(_m()._nixos_build_env())
# capture_output=False is deliberate: postinstall scripts print download progress and
# capturing makes a long download look hung.
# The chatty npm-deprecation noise during `hermes update` comes from the *desktop* build, not this step;
# that one is captured to update.log. See #18840.
result = _m()._run_npm_install_deterministic(
npm, _m().PROJECT_ROOT, extra_args=tuple(install_args), capture_output=False, env=nixos_env)
if result.returncode == 0:
_record_npm_lockfile_hash(shared_hermes_root)
print(" ✓ ui-tui, web workspaces installed (desktop skipped)")
return []
print(" ⚠ npm install failed")
stderr = (result.stderr or "").strip()
if stderr:
print(f" {stderr.splitlines()[-1]}")
print()
print(" ⚠ Node.js dependency refresh did not complete cleanly; the")
print(" installation may be in a mixed state (updated code, stale Node")
print(" deps). Fix npm and re-run `hermes update`.")
return ["ui-tui, web workspaces"]
def _venv_core_imports_healthy() -> tuple[bool, str]:
"""Probe the venv (in ITS interpreter — the updater may run under another Python) for core
imports, catching a half-updated venv that "Already up to date!" would otherwise never re-sync.
Returns ``(healthy, detail)``; never raises, unknown states report healthy."""
from hermes_cli.update_cmd import _m
venv_dir = _m().PROJECT_ROOT / "venv"
venv_python = venv_python_path(venv_dir, windows=_m()._is_windows())
if not venv_python.exists():
# No venv: normal for a dev checkout (healthy), but on a MANAGED install (bootstrap
# stamp or `.update-incomplete`) the venv IS the install — absence means an interrupted repair.
managed_markers = (_m().PROJECT_ROOT / ".hermes-bootstrap-complete", _m()._update_marker_path())
if any(m.exists() for m in managed_markers):
return False, f"venv python missing ({venv_python})"
return True, ""
# Import (not just metadata): dist-info can be intact with modules missing after an
# interrupted uninstall/install.
check = (
"import importlib\n"
"mods = ['fastapi', 'uvicorn', 'pydantic', 'openai', 'yaml']\n"
"missing = []\n"
"for m in mods:\n"
" try: importlib.import_module(m)\n"
" except Exception as e: missing.append(f'{m}: {e}')\n"
"print('\\n'.join(missing))\n")
try:
result = subprocess.run(
[str(venv_python), "-c", check], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=60, cwd=_m().PROJECT_ROOT)
except Exception as exc:
logger.debug("venv health probe failed to run: %s", exc)
return True, ""
missing = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
if result.returncode != 0 and not missing:
# Interpreter itself is broken — that IS unhealthy.
detail = (result.stderr or "").strip().splitlines()
return False, detail[0] if detail else "venv python failed to run"
if missing:
return False, "; ".join(missing[:4])
return True, ""
# Native extensions that pin venv files once imported: if the updater holds one, Windows blocks
# REPLACE on the mapped ``.pyd`` and the sync dies with ``os error 5``. PyYAML's ``_yaml`` is in
# every CLI process, so the guard must be HONEST: fire only when the sync would actually REWRITE
# the dist, and only AFTER the code swap so a deferral leaves new code with just the install
# pending. Keys are ``sys.modules`` prefixes; values are ``(display name, PyPI dist)``.
# If the updater process itself has any of these loaded, the dependency sync below cannot rewrite the
# backing ``.pyd``/``.dll`` — Windows blocks REPLACE on a mapped image — and the update dies with ``os error
# 5`` between uninstall and reinstall, stranding the venv half-updated (#83569). ``cryptography`` is the
# canonical case: ``hermes_cli.main`` used to import it at startup while resolving external secret sources;
# ``PyYAML``'s ``_yaml`` C extension is loaded by every CLI process (config parsing). Keep this guard as
# defence-in-depth against future eager imports (new secret sources, plugins absorbed into core, refactors
# of the startup order) — but the guard must be HONEST (#86735/#86780/#86781: a preflight that fired on
# every run, before the fetch, re-bricked the exact flow it was meant to protect). Two honesty gates: 1. It
# only fires when the dependency sync would actually REWRITE the loaded distribution
# (``_dependency_sync_would_rewrite``): if the installed version already satisfies the on-disk pyproject
# pins, uv/pip will not touch the mapped ``.pyd``, so there is no lock to trip. 2. It runs AFTER the code
# swap (git pull / ZIP commit), immediately before the venv rewrite — so the on-disk pyproject is the NEW
# one (gate 1 compares against the right target) and a deferral no longer strands the user on the old
# checkout: the next launch's marker recovery completes the dependency install against the already-updated
# pyproject.
_SELF_LOCKING_NATIVE_MODULES: dict[str, tuple[str, str]] = {
"cryptography.hazmat.bindings._rust": ("cryptography (_rust.pyd)", "cryptography"),
"yaml._yaml": ("PyYAML (_yaml.pyd)", "pyyaml")}
def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
"""Whether the ``.[all]`` install would replace *dist_name*'s files, judged against every
applicable pin in on-disk ``pyproject.toml`` (base + extras). False: all pins satisfied;
True: pin unsatisfied or dist missing; None: undeterminable. Never raises. Callers treat
None as fail-OPEN — PyYAML is in every process, so deferring on uncertainty always fires.
See #86735.
"""
from hermes_cli.update_cmd import _m
try:
from importlib import metadata as _ilmd
installed = _ilmd.version(dist_name)
except Exception:
return True # not installed → the sync will definitely install it
try:
import tomllib
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version
pyproject = _m().PROJECT_ROOT / "pyproject.toml"
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
project = data.get("project") or {}
req_strings: list[str] = list(project.get("dependencies") or [])
for extra_reqs in (project.get("optional-dependencies") or {}).values():
req_strings.extend(extra_reqs or [])
target = canonicalize_name(dist_name)
installed_v = Version(installed)
saw_pin = False
for req_str in req_strings:
try:
req = Requirement(req_str)
except Exception:
continue
if canonicalize_name(req.name) != target:
continue
if req.marker is not None and not req.marker.evaluate():
continue
saw_pin = True
if installed_v not in req.specifier:
return True
# Not pinned in pyproject: the resolver may still move it as a transitive — unknown.
return False if saw_pin else None
except Exception:
return None
def _detect_self_loaded_native_modules() -> list[str]:
"""Display names of native venv extensions loaded into THIS process that the sync would rewrite.
Empty off Windows (POSIX keeps an unlinked inode usable). Modules whose installed version
already satisfies the pins are NOT reported — no swap at risk. Never raises.
Returns display names (empty off Windows — POSIX lets a running process keep using an unlinked inode, so
self-locking is a Windows-only hazard). A loaded module whose installed version already satisfies the
on-disk pyproject pins is NOT reported: the dependency sync will not touch its files, so there is no
swap at risk (#86735 — the always-firing variant of this preflight bricked every Windows update).
"""
from hermes_cli.update_cmd import _m
if not _m()._is_windows():
return []
# Defer ONLY on a CONFIRMED rewrite; unknown fails OPEN (PyYAML is in every process, so
# unknown-as-at-risk always fires). A missed deferral only yields the mid-sync os error 5
# that marker recovery already handles — far less harmful than an update that never runs.
return sorted({
display for prefix, (display, dist) in _SELF_LOCKING_NATIVE_MODULES.items()
if prefix in sys.modules and _m()._dependency_sync_would_rewrite(dist) is True})
def _abort_dependency_sync_if_self_locked(gateway_resume=None) -> None:
"""Defer the venv rewrite when THIS process holds something it must replace (runs after the
code swap, so a deferral leaves NEW code with only the install pending). Two hazards:
a mapped ``.pyd`` -> exit 2, next launch's marker recovery finishes; the ``hermes.exe`` shim
we run from -> every launch is the shim so the marker would defer forever: hand the
install to a child under the venv interpreter and exit 0.
See #88838, #89599.
"""
from hermes_cli.update_cmd import _m
locked = _m()._detect_self_loaded_native_modules()
if locked:
_m()._defer_update_for_self_lock(locked)
exit_code = 2
elif _m()._reexec_dependency_sync_off_windows_shim():
exit_code = 0
else:
return
if gateway_resume is not None:
_m()._resume_windows_gateways_after_update(gateway_resume)
sys.exit(exit_code)
def _defer_update_for_self_lock(loaded: list[str]) -> None:
"""Explain + drop the update-incomplete marker (next fresh launch completes the install) when
the updater holds a lock the sync must replace; the caller exits 2."""
from hermes_cli.update_cmd import _m
print("✗ This updater process has already loaded native venv modules that")
print(" the dependency sync must replace:")
for name in loaded:
print(f" {name}")
print()
print(" On Windows a mapped extension cannot be replaced by the process")
print(" holding it. The code update has been applied; only the dependency")
print(" sync has been deferred: the next `hermes` launch will complete it")
print(" in a fresh process before anything imports these modules.")
_m()._write_update_incomplete_marker()
def _desktop_app_present(desktop_dir: Path) -> bool:
"""Return whether a packaged or source Desktop build exists."""
from hermes_cli.update_cmd import _m
return (
_m()._desktop_packaged_executable(desktop_dir) is not None
or _m()._desktop_dist_exists(desktop_dir))
def _rebuild_desktop_after_update(
desktop_dir: Path, *, had_desktop_app_before_update: bool) -> bool:
"""Rebuild an installed Desktop app when its source or artifact changed. Returns ``False``
only when a rebuild was attempted and failed (caller withholds ``✓ Update complete!`` and
writes a failing ``.update_exit_code`` in gateway mode); every other outcome is ``True``.
See #88251.
"""
from hermes_cli.update_cmd import _m
# The release tree is git-ignored and can vanish mid-update; pre-update presence suffices.
# Never make people who never used Desktop pay for an Electron build.
has_desktop_app = had_desktop_app_before_update or _desktop_app_present(desktop_dir)
if not (
(desktop_dir / "package.json").exists() and _m()._resolve_node_runtime_npm() and has_desktop_app):
return True
print("→ Checking if desktop app needs rebuilding...")
# Check the content-hash stamp IN-PROCESS first (the subprocess spends ~1-3 s importing the
# CLI to reach the same check). Update never passes --source, so source_mode=False.
# Any pre-check error falls through to the subprocess.
try:
skip_desktop_build = not _m()._desktop_build_needed(
desktop_dir, _m().PROJECT_ROOT, source_mode=False)
except Exception:
skip_desktop_build = False
if skip_desktop_build:
print(" ✓ Desktop app up to date")
return True
desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
# Capture the loud build output into update.log; retry once on failure (still-settling
# rebuild window), then surface the tail. Put Hermes-managed Node on PATH: the desktop
# updater chain loses shell PATH customizations, so a bare-PATH child hits `node: not found`.
from hermes_constants import with_hermes_node_path
build_env = with_hermes_node_path()
for _attempt in range(2):
build_result = _m()._run_logged_subprocess(
desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env)
if build_result.returncode == 0:
break
if build_result.returncode != 0:
print(" ⚠ Desktop build failed (run `hermes desktop` to retry)")
tail = "\n".join((build_result.stdout or "").strip().splitlines()[-15:])
if tail:
print(tail)
from hermes_constants import display_hermes_home as _dhh
print(f" Full build log: {_dhh()}/logs/update.log")
return False
print(" ✓ Desktop app up to date")
return True
def _path_uid(path) -> Optional[int]:
"""Owner uid of ``path`` (``None`` when unreadable). Separate seam so tests can simulate
root-owned files without chown. Never raises."""
try:
return os.stat(path, follow_symlinks=False).st_uid
except OSError:
return None
def _venv_foreign_owned_paths(venv_root, limit: int = 5) -> list:
"""Up to ``limit`` ``(path_str, uid)`` venv entries not owned by the current user.
A venv touched by ``sudo pip``/``sudo hermes`` dies mid-update with ``venv/bin/hermes`` already
deleted — never mutate a venv we can't safely mutate. Deliberately BOUNDED (venv root,
``venv/bin``, first site-packages top level, ``*.dist-info`` children; ~2000 stats). POSIX-only:
``[]`` on Windows and as root; ``[]`` on any surprise — must NEVER raise or add latency.
See #83529.
A later normal ``hermes update`` then dies mid-mutation inside ``uv pip install -e .`` ("Permission
denied (os error 13)") with ``venv/bin/hermes`` already deleted — the CLI is bricked. Same philosophy as
the contended-venv gate (#87331): a venv we cannot safely mutate is never mutated at all.
"""
from hermes_cli.update_cmd import _path_uid
try:
if not hasattr(os, "geteuid"):
return [] # windows-footgun: ok — POSIX ownership concept only
euid = os.geteuid() # windows-footgun: ok — guarded by hasattr above
if euid == 0:
return [] # root can rewrite anything; nothing to refuse
venv_root = Path(venv_root)
budget = 2000 # max stat() calls — hard bound on preflight cost
foreign: list = []
def _check(p) -> bool:
"""stat one path; True while scan should continue."""
nonlocal budget
if budget <= 0 or len(foreign) >= limit:
return False
budget -= 1
uid = _path_uid(p)
if uid is not None and uid != euid:
foreign.append((str(p), uid))
return budget > 0 and len(foreign) < limit
def _entries(d) -> list:
try:
return list(os.scandir(d))
except OSError:
return []
def _scan_dir(d, recurse_dist_info: bool = False) -> None:
for entry in _entries(d):
if not _check(entry.path):
return
if recurse_dist_info and entry.name.endswith(".dist-info"):
for child in _entries(entry.path):
if not _check(child.path):
return
if not _check(venv_root):
return foreign[:limit]
_scan_dir(venv_root / "bin")
# First lib/python*/site-packages (POSIX venv layout).
site_packages = next(iter(sorted(venv_root.glob("lib/python*/site-packages"))), None)
if site_packages is not None:
_scan_dir(site_packages, recurse_dist_info=True)
return foreign[:limit]
except Exception:
# Advisory preflight: structural surprise = "no verdict", never a blocked update.
return []
def _refuse_update_if_venv_foreign_owned(project_root) -> None:
"""Refuse-before-mutate ownership gate, run after the pull and before the first venv mutation:
foreign-owned files would brick the install mid-mutation, so refuse with the recovery command
while the venv is intact. No subprocess calls — tests mock ``subprocess.run`` with sequenced effects.
See #83529.
"""
foreign = _venv_foreign_owned_paths(Path(project_root) / "venv")
if not foreign:
return
print("\n✗ Update stopped: this install's venv contains files owned by another user.")
print(" Updating now would fail midway (Permission denied) and leave Hermes broken.")
print(" This usually happens after running hermes or pip with sudo. Offending paths:")
for p, uid in foreign:
print(f" - {p} (owner uid {uid})")
print("\n Fix ownership, then re-run the update:")
print(f" sudo chown -R $(id -un): {project_root}")
print(" hermes update")
print("\n Nothing in the venv was modified.")
sys.exit(1)
def _sync_python_dependencies_after_pull(
git_cmd, branch, pre_pull_sha, *, active_lazy_features, active_tool_dependencies,
_windows_gateway_resume):
"""Reinstall Python deps for the pulled checkout. Order matters: ownership preflight ->
self-lock deferral -> core marker -> ``.[all]`` -> bytecode sweep -> lazy/tool refresh (own
marker) -> memory-provider deps -> critical-import probe (warn only; stale bytecode self-heals)."""
from hermes_cli.update_cmd import (
_m, _pip_install_prefix, _sweep_bytecode_after_update, _validate_critical_modules_import,
_write_lazy_refresh_incomplete_marker, _write_update_incomplete_marker)
# Reinstall Python dependencies. Prefer .[all], but if one optional extra breaks on this machine, keep
# base deps and reinstall the remaining extras individually so update does not silently strip working
# capabilities. Ownership preflight (#83529): refuse before the first venv mutation if the venv contains
# foreign-owned files (sudo-pip residue) — the install below would die mid-mutation and brick the CLI.
_refuse_update_if_venv_foreign_owned(_m().PROJECT_ROOT)
# Self-lock deferral: if THIS process holds a native extension the sync must rewrite, defer
# NOW (after the code swap) so only the install is pending for the next launch's marker.
_m()._abort_dependency_sync_if_self_locked(_windows_gateway_resume)
# Drop the core-install breadcrumb BEFORE touching the venv so a killed install is finished
# by the next launch (``_recover_from_interrupted_install``). Lazy refresh uses its own marker.
_write_update_incomplete_marker()
deps_current = _editable_install_is_current(git_cmd, _m().PROJECT_ROOT, pre_pull_sha)
print(
"→ Python dependencies unchanged — skipping reinstall" if deps_current
else "→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# `uv self update` if we already have a managed uv.
update_managed_uv()
uv_bin = ensure_uv()
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
if not uv_bin:
_ensure_venv_pip(pip_cmd, sys.executable)
install_prefix, lazy_env = _pip_install_prefix(uv_bin)
install_group = "all"
is_termux = _m()._is_termux_env(lazy_env)
if is_termux:
if lazy_env is not None:
lazy_env.pop("PYTHONPATH", None)
lazy_env.pop("PYTHONHOME", None)
install_group = "termux-all"
uv_note = "uv + " if uv_bin else ""
print(f" → Termux detected: using {uv_note}curated termux-all optional profile...")
if deps_current:
# Verification normally runs inside the skipped install; run it here so a wrong skip
# self-heals (both verifiers reinstall what they find missing).
_m()._verify_core_dependencies_installed(install_prefix, env=lazy_env, group=install_group)
_m()._verify_console_scripts_installed(install_prefix, env=lazy_env)
else:
if is_termux and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat(install_prefix, env=lazy_env)
_m()._install_python_dependencies_with_optional_fallback(
install_prefix, env=lazy_env, group=install_group)
# Clear the core breadcrumb before lazy refresh, which uses its own marker so a lazy
# failure can't be "healed" by a narrow core import probe.
_m()._clear_update_incomplete_marker()
# Still the old interpreter process: refresh caches/modules before lazy refresh imports
# newly-pulled modules. The install may have regenerated bytecode from build-cache
# copies — this second sweep catches those stragglers.
_sweep_bytecode_after_update(branch)
_m()._reload_updated_runtime_modules()
# Stale pip can fail source builds and leave partially-written packages.
# See #57828.
_write_lazy_refresh_incomplete_marker()
_m()._upgrade_pip_before_lazy_refresh(install_prefix, env=lazy_env)
# Clear the lazy marker only when refresh/repair is confirmed healthy.
if _m()._refresh_active_lazy_features(install_prefix, env=lazy_env, features=active_lazy_features):
_m()._clear_lazy_refresh_incomplete_marker()
else:
print(
" ⚠ Lazy-refresh recovery incomplete — run `hermes` again "
"to finish import-based venv repair.")
_m()._restore_active_tool_dependencies(active_tool_dependencies, install_prefix, env=lazy_env)
# Heal memory-provider bridge packages last — the steps above may have stripped them.
_m()._refresh_active_memory_provider_dependencies()
# Remaining import failures are real breakage. Warn only — never roll back: `cannot import
# name X` is also the stale-bytecode signature, which self-heals next launch.
import_ok, failing_module, import_error = _validate_critical_modules_import(_m().PROJECT_ROOT)
if not import_ok:
print()
print(f" ⚠ {failing_module} still fails to import after updating:")
print(f" {import_error}")
print(" Run `hermes update` again — if it persists, reinstall:")
print(" https://hermes-agent.nousresearch.com")