From 95804887c1bf5495d9ffdcba5b1044812ad89ca4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:19:08 -0700 Subject: [PATCH] =?UTF-8?q?refactor(doctor):=20tighten=20platform=20+=20to?= =?UTF-8?q?ols=20checks=20=E2=80=94=20shared=20helpers,=20backend=20dispat?= =?UTF-8?q?ch=20table,=20compact=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/doctor_platform.py | 622 ++++++++------------------ hermes_cli/doctor_tools.py | 810 +++++++++++++--------------------- 2 files changed, 495 insertions(+), 937 deletions(-) diff --git a/hermes_cli/doctor_platform.py b/hermes_cli/doctor_platform.py index 0351810216..71894ddd19 100644 --- a/hermes_cli/doctor_platform.py +++ b/hermes_cli/doctor_platform.py @@ -1,7 +1,6 @@ """Host-platform checks for hermes doctor: interpreter, SQLite, certificates, macOS TCC, gateway supervision, command install. -Split out of ``hermes_cli/doctor.py``; every moved name is re-imported there, so -``hermes_cli.doctor.`` keeps resolving (and monkeypatching) as before. +Split out of ``hermes_cli/doctor.py``, which re-exports every name so ``hermes_cli.doctor.`` keeps resolving (and monkeypatching). """ from __future__ import annotations @@ -13,15 +12,7 @@ import sys from pathlib import Path from hermes_cli.colors import Colors, color from hermes_cli.config import is_nix_install_method, recommended_update_command_for_method -from hermes_cli.doctor_report import ( - Finding, - _fail_and_issue, - _section, - check_fail, - check_info, - check_ok, - check_warn, -) +from hermes_cli.doctor_report import Finding, _fail_and_issue, _section, check_fail, check_info, check_ok, check_warn from hermes_constants import is_termux as _is_termux @@ -42,11 +33,9 @@ def _sqlite_upgrade_hint(install_method: str | None = None) -> str: from hermes_cli.doctor import PROJECT_ROOT, detect_install_method method = install_method or detect_install_method(PROJECT_ROOT) if method == "docker": - command = recommended_update_command_for_method(method) - action = f"run `{command}`, then recreate all Hermes containers" + action = f"run `{recommended_update_command_for_method(method)}`, then recreate all Hermes containers" elif is_nix_install_method(method): - # The Nix helper is prose guidance, not a literal shell command. - action = recommended_update_command_for_method(method) + action = recommended_update_command_for_method(method) # prose guidance, not a shell command elif method == "apt": action = f"run `{recommended_update_command_for_method(method)}`" else: @@ -58,16 +47,10 @@ def _sqlite_upgrade_hint(install_method: str | None = None) -> str: def _hermes_database_paths(hermes_home: Path) -> list[tuple[str, Path]]: - """Return (display name, path) pairs for Hermes-managed SQLite databases.""" - # backup.py owns the canonical list of per-profile stores; reuse it. + """(display name, path) pairs for Hermes-managed SQLite databases: backup.py's per-profile store list + per-board kanban.db.""" from hermes_cli.backup import _QUICK_STATE_FILES - entries = [ - (name, hermes_home / name) - for name in _QUICK_STATE_FILES - if name.endswith(".db") - ] - # Non-default kanban boards each keep their own kanban.db. + entries = [(name, hermes_home / name) for name in _QUICK_STATE_FILES if name.endswith(".db")] for board_db in sorted((hermes_home / "kanban" / "boards").glob("*/kanban.db")): entries.append((str(board_db.relative_to(hermes_home)), board_db)) return entries @@ -79,10 +62,10 @@ _SQLITE_HEADER_MAGIC = b"SQLite format 3\x00" def _unreadable_reason(db_path: Path) -> str: """Explain why a database file could not be read, without opening it. - ``read_header_bytes_preopen`` collapses every ``OSError`` into ``None``, - but doctor's job is to say *which* problem it hit. ``stat()`` and - ``access()`` answer that from directory metadata alone — neither takes a - file descriptor, so neither can cancel the file's POSIX advisory locks. + ``read_header_bytes_preopen`` collapses every ``OSError`` into ``None``, but + doctor must say *which* problem it hit. ``stat()`` and ``access()`` answer + that from directory metadata alone — neither takes a file descriptor, so + neither can cancel the file's POSIX advisory locks. """ try: db_path.stat() @@ -94,24 +77,15 @@ def _unreadable_reason(db_path: Path) -> str: def _read_journal_mode(db_path: Path) -> tuple[str | None, str | None]: - """Return (journal mode, error) from the file header without opening the database. + """Return (journal mode, error) from header byte 18 (2=WAL, 1=rollback) without opening the database. - Header byte 18 is 2 for WAL and 1 for a rollback journal. Opening the - database through the SQLite engine — even read-only — creates -wal/-shm - sidecar files, which a diagnostic must not do. - - The byte read is routed through ``read_header_bytes_preopen`` rather than - a bare ``open()``: closing *any* descriptor for a database file cancels - this process's POSIX advisory locks on it, so a raw read would drop the - locks a live connection is holding (see ``hermes_cli.sqlite_safe_read``). - ``run_doctor`` is also called in-process by the dashboard console, which - holds live ``SessionDB`` connections. The helper refuses in that case and - the mode is reported as unreadable instead. + Opening through SQLite — even read-only — creates -wal/-shm sidecars, which a diagnostic must not do. + The read goes through ``read_header_bytes_preopen`` rather than a bare ``open()``: closing *any* + descriptor cancels this process's POSIX advisory locks (see ``hermes_cli.sqlite_safe_read``), and the + dashboard console runs ``run_doctor`` in-process with live ``SessionDB`` connections — the helper + refuses then and the mode is reported as unreadable. """ - from hermes_cli.sqlite_safe_read import ( - has_live_connection, - read_header_bytes_preopen, - ) + from hermes_cli.sqlite_safe_read import has_live_connection, read_header_bytes_preopen header = read_header_bytes_preopen(db_path, length=20) if header is None: @@ -122,23 +96,19 @@ def _read_journal_mode(db_path: Path) -> tuple[str | None, str | None]: return None, "file is empty" if len(header) < 20 or not header.startswith(_SQLITE_HEADER_MAGIC): return None, "file is not a database" - if header[18] == 2: - return "wal", None - if header[18] == 1: - return "rollback", None + mode = {2: "wal", 1: "rollback"}.get(header[18]) + if mode: + return mode, None return None, f"unrecognized file-format version {header[18]}" def _format_db_size(db_path: Path) -> str: - # backup.py owns human-readable size formatting; reuse it (as with - # _QUICK_STATE_FILES above) and keep only the stat-failure wrap here. - from hermes_cli.backup import _format_size + from hermes_cli.backup import _format_size # backup.py owns size formatting try: - nbytes = db_path.stat().st_size + return _format_size(db_path.stat().st_size) except OSError: return "size unknown" - return _format_size(nbytes) def _report_database_journal_modes( @@ -164,19 +134,13 @@ def _report_database_journal_modes( size = _format_db_size(path) if error is not None: if vulnerable: - check_warn( - f"{name}: journal mode could not be read", - f"({error}; cannot rule out WAL exposure)", - ) + check_warn(f"{name}: journal mode could not be read", f"({error}; cannot rule out WAL exposure)") else: check_info(f"{name}: journal mode could not be read ({error})") elif mode == "wal": if vulnerable: exposed.append(name) - check_warn( - f"{name} is in WAL mode ({size})", - "(exposed to the WAL-reset bug until SQLite is upgraded)", - ) + check_warn(f"{name} is in WAL mode ({size})", "(exposed to the WAL-reset bug until SQLite is upgraded)") else: check_info(f"{name}: WAL journal mode ({size})") elif vulnerable: @@ -188,17 +152,10 @@ def _report_database_journal_modes( def _read_pyproject_version() -> str | None: - """Read the ``version = "..."`` from ``pyproject.toml`` at the project root. - - Returns None when running from an installed wheel (no pyproject.toml ships - with the package) or when the file can't be parsed. Reads only the - ``[project]`` version, ignoring any version strings that appear in other - tables. - """ + """Read the ``[project]`` version from pyproject.toml; None for installed wheels (no pyproject) or unreadable files.""" from hermes_cli.doctor import PROJECT_ROOT - pyproject = PROJECT_ROOT / "pyproject.toml" try: - text = pyproject.read_text(encoding="utf-8") + text = (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") except OSError: return None in_project = False @@ -208,19 +165,15 @@ def _read_pyproject_version() -> str | None: in_project = line == "[project]" continue if in_project and line.startswith("version") and "=" in line: - value = line.split("=", 1)[1] - value = value.split("#", 1)[0].strip().strip("\"'") + value = line.split("=", 1)[1].split("#", 1)[0].strip().strip("\"'") return value or None return None def _check_version_consistency(issues: list[str]) -> None: - """Verify pyproject.toml version matches hermes_cli.__version__. + """Detect pyproject.toml vs hermes_cli.__version__ drift (a git conflict resolution can revert one but not the other). - A git conflict resolution (reset/merge) can revert one file without the - other, leaving ``hermes --version`` reporting a stale version while - ``pyproject.toml`` is current. Detect that drift so users can re-sync. - Silent no-op for installed wheels where pyproject.toml isn't present. + Silent no-op for installed wheels (no pyproject). """ try: from hermes_cli import __version__ as init_version @@ -228,7 +181,6 @@ def _check_version_consistency(issues: list[str]) -> None: return pyproject_version = _read_pyproject_version() if pyproject_version is None: - # Installed wheel or unreadable pyproject — nothing to cross-check. return if pyproject_version == init_version: check_ok("Version files consistent", f"({init_version})") @@ -243,36 +195,20 @@ def _check_version_consistency(issues: list[str]) -> None: def _check_s6_supervision(issues: list[str]) -> None: - """Inside a container under our s6 /init, surface what s6 sees. + """Inside a container under our s6 /init, report static services and per-profile gateway slots that are ``up``. - Runs as a counterpart to :func:`_check_gateway_service_linger` for - the systemd-on-host case. No-op everywhere except in the s6 - container so host runs aren't cluttered with irrelevant output. - - Reports: - - Whether the main-hermes and dashboard static services are up - - How many per-profile gateway slots are registered (via - ``S6ServiceManager.list_profile_gateways()``) and how many are - currently supervised as ``up`` + Counterpart to :func:`_check_gateway_service_linger` (systemd-on-host); no-op outside the s6 container. """ try: - from hermes_cli.service_manager import ( - S6ServiceManager, - detect_service_manager, - ) + from hermes_cli.service_manager import S6ServiceManager, detect_service_manager except Exception: return - if detect_service_manager() != "s6": return _section("s6 Supervision") - mgr = S6ServiceManager() - - # Static services. They live under /run/service/ via s6-rc symlinks, - # so the same s6-svstat probe works. - for static in ("main-hermes", "dashboard"): + for static in ("main-hermes", "dashboard"): # s6-rc symlinks under /run/service/, same s6-svstat probe if mgr.is_running(static): check_ok(f"{static}: up") else: @@ -282,7 +218,6 @@ def _check_s6_supervision(issues: list[str]) -> None: if not profiles: check_info("No per-profile gateways registered yet — create one with `hermes profile create `") return - up_count = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}")) check_ok( f"Per-profile gateways: {up_count}/{len(profiles)} supervised up" @@ -291,15 +226,10 @@ def _check_s6_supervision(issues: list[str]) -> None: def check_certificates(should_fix: bool = False, issues: "list | None" = None) -> None: - """Verify the certifi CA bundle is loadable. + """Verify the certifi CA bundle is loadable before the first HTTPS call tracebacks. - Surfaces the SSLConfigurationError user-friendly path before they hit - a wall of tracebacks on the first outbound HTTPS call. - - With ``--fix``, a broken bundle (missing/corrupt ``cacert.pem`` — e.g. - after a brew Python upgrade rebuilt the venv, #29866) is repaired by - force-reinstalling certifi into THIS interpreter's environment and - re-verifying. + ``--fix`` repairs a broken bundle (e.g. a brew Python upgrade rebuilt the venv) by + force-reinstalling certifi into THIS interpreter's environment and re-verifying. """ try: from agent.ssl_guard import verify_ca_bundle_with_fallback @@ -308,6 +238,10 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) - check_warn("SSL certificate check skipped", str(e)) return + def add_issue(msg: str) -> None: + if issues is not None: + issues.append(msg) + try: verify_ca_bundle_with_fallback() check_ok("SSL CA certificate bundle is valid") @@ -318,46 +252,28 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) - check_warn("SSL certificate check skipped", str(e)) return + check_fail("SSL CA certificate bundle is broken", first_error) + pip_cmd = f"{sys.executable} -m pip install --force-reinstall certifi" if not should_fix: - check_fail("SSL CA certificate bundle is broken", first_error) - if issues is not None: - issues.append( - "Repair the CA bundle: run `hermes doctor --fix`, or " - f"`{sys.executable} -m pip install --force-reinstall certifi`" - ) + add_issue(f"Repair the CA bundle: run `hermes doctor --fix`, or `{pip_cmd}`") return - # --fix: force-reinstall certifi into the running interpreter's env and - # re-verify. importlib caches are invalidated so certifi.where() resolves - # the fresh install without a process restart. - check_fail("SSL CA certificate bundle is broken", first_error) print(" → Repairing: force-reinstalling certifi...") try: result = subprocess.run( [sys.executable, "-m", "pip", "install", "--force-reinstall", "certifi"], - capture_output=True, - text=True, - timeout=300, + capture_output=True, text=True, timeout=300, ) except Exception as exc: check_fail("certifi repair could not run pip", str(exc)) - if issues is not None: - issues.append( - f"Reinstall certifi manually: {sys.executable} -m pip install " - "--force-reinstall certifi" - ) + add_issue(f"Reinstall certifi manually: {pip_cmd}") return if result.returncode != 0: - tail = (result.stderr or result.stdout or "")[-500:] - check_fail("certifi reinstall failed", tail) - if issues is not None: - issues.append( - f"Reinstall certifi manually: {sys.executable} -m pip install " - "--force-reinstall certifi" - ) + check_fail("certifi reinstall failed", (result.stderr or result.stdout or "")[-500:]) + add_issue(f"Reinstall certifi manually: {pip_cmd}") return - # Drop any cached certifi module so where() re-resolves the new bundle. + # Drop cached certifi modules so where() resolves the fresh install without a restart. import importlib for mod_name in [m for m in sys.modules if m == "certifi" or m.startswith("certifi.")]: sys.modules.pop(mod_name, None) @@ -368,44 +284,25 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) - check_ok("SSL CA certificate bundle repaired (certifi reinstalled)") except SSLConfigurationError as e: check_fail("SSL CA certificate bundle still broken after reinstall", str(e)) - if issues is not None: - issues.append( - "certifi reinstall did not restore the CA bundle — check for a " - "custom CA env var (SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing " - "at a missing file, or recreate the venv." - ) + add_issue( + "certifi reinstall did not restore the CA bundle — check for a " + "custom CA env var (SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing " + "at a missing file, or recreate the venv." + ) def _check_gateway_service_linger(issues: list[str]) -> None: """Warn when a systemd user gateway service will stop after logout. - Skipped inside a container running under s6 — the linger concept - (user-systemd surviving SSH logout) doesn't apply there, and the - s6 supervision state is surfaced separately by - ``_check_s6_supervision``. + Skipped under s6 (no systemd, no logout, no linger concept; ``_check_s6_supervision`` reports that state). """ try: - from hermes_cli.gateway import ( - get_systemd_linger_status, - get_systemd_unit_path, - is_linux, - ) + from hermes_cli.gateway import get_systemd_linger_status, get_systemd_unit_path, is_linux from hermes_cli.service_manager import detect_service_manager except Exception as e: check_warn("Gateway service linger", f"(could not import gateway helpers: {e})") return - - if not is_linux(): - return - - # Inside a container under our s6 /init, _check_s6_supervision - # reports the live supervision state; the linger warning would be - # confusing here (no systemd, no logout, no "lingering" concept). - if detect_service_manager() == "s6": - return - - unit_path = get_systemd_unit_path() - if not unit_path.exists(): + if not is_linux() or detect_service_manager() == "s6" or not get_systemd_unit_path().exists(): return _section("Gateway Service") @@ -423,19 +320,11 @@ def _check_gateway_service_linger(issues: list[str]) -> None: def check_macos_tcc_grants() -> None: """Check macOS TCC grant persistence for a locally-built desktop bundle. - TCC keys permission grants (Screen Recording, Full Disk Access, - Accessibility, ...) to the app's code-signing requirement. A bundle - signed with the pre-#73681 cdhash-pinned ad-hoc identity gets a new DR on - every rebuild, so all grants silently stop matching — and the stale row - keeps the System Settings toggle ON while macOS re-prompts on every - capture (issue #86385). - - Post-#73681 builds pin ``designated => identifier "com.nousresearch.hermes"`` - (no cdhash), so new grants survive rebuilds — but grants made to older - binaries remain stale until re-granted once. The stale state is not - directly readable (TCC.db needs Full Disk Access), so this check reports - the DR class and, when the DR is stable, prints the exact one-time repair. - Silent on non-macOS and when no desktop bundle is installed. + TCC keys grants to the app's designated requirement (DR). A cdhash-pinned ad-hoc DR changes on every + rebuild, so grants silently stop matching while the Settings toggle stays ON and macOS re-prompts; + identifier-pinned builds survive rebuilds, but grants made to older binaries stay stale until re-granted + once. TCC.db needs Full Disk Access, so the DR string is the only readable signal — a cdhash anchor is a + proxy for the signing class, not a contract on DR wording. Silent on non-macOS / no bundle. """ from hermes_cli.doctor import _desktop_app_bundle, _macos_desktop_dr if sys.platform != "darwin": @@ -445,16 +334,8 @@ def check_macos_tcc_grants() -> None: return dr = _macos_desktop_dr(app) if not dr: - check_warn( - "macOS TCC grant check", - "(could not read code-signing requirement of the desktop bundle)", - ) + check_warn("macOS TCC grant check", "(could not read code-signing requirement of the desktop bundle)") return - # The DR string is the only readable signal — TCC.db itself needs Full - # Disk Access. A cdhash anchor marks the pre-#73681 ad-hoc identity - # (rebuild ⇒ new cdhash ⇒ stale grants); its absence marks identifier- - # pinned. Treat the match as a proxy for the signing class, not a - # contract on DR wording. if "cdhash" in dr.lower(): check_warn( "macOS TCC grants will reset after every update", @@ -464,13 +345,8 @@ def check_macos_tcc_grants() -> None: "identity, then re-grant permissions once.", ) return - if "certificate" in dr.lower(): - # Certificate-anchored DR (hermes desktop --setup-tcc-identity, or a - # notarized release build): the strongest anchor TCC can key on. - check_ok( - "macOS TCC signing identity is stable", - "(certificate-anchored DR; grants survive rebuilds)", - ) + if "certificate" in dr.lower(): # --setup-tcc-identity or notarized build: strongest anchor + check_ok("macOS TCC signing identity is stable", "(certificate-anchored DR; grants survive rebuilds)") else: check_ok( "macOS TCC signing identity is stable", @@ -486,19 +362,12 @@ def check_macos_tcc_grants() -> None: def _desktop_app_bundle() -> Path | None: - """Locate the locally-built desktop app bundle, if any. + """Locate the locally-built desktop bundle (``apps/desktop/release/mac-/Hermes.app``), newest arch tree first. - Mirrors the install layout the self-updater produces - (``apps/desktop/release/mac-/Hermes.app``) — the only layout whose - ad-hoc re-signed bundle can invalidate TCC grants. When multiple arch - trees coexist (stale cross-build), the newest wins, matching - ``_desktop_packaged_executable``'s selection. ``/Applications/Hermes.app`` - is deliberately not probed: it is the separately-signed Hermes-Setup - launcher (``com.nousresearch.hermes.setup``, certificate-anchored), whose - grants are stable by construction and unaffected by rebuilds. + That is the only layout whose ad-hoc re-signed bundle can invalidate TCC grants. ``/Applications/Hermes.app`` + is deliberately not probed: it is the separately-signed, certificate-anchored Hermes-Setup launcher. """ - root = Path(__file__).resolve().parents[1] - release_dir = root / "apps" / "desktop" / "release" + release_dir = Path(__file__).resolve().parents[1] / "apps" / "desktop" / "release" candidates = [p for p in release_dir.glob("mac*/Hermes.app") if p.is_dir()] if not candidates: return None @@ -506,20 +375,13 @@ def _desktop_app_bundle() -> Path | None: def _macos_desktop_dr(app: Path) -> str | None: - """Return the bundle's designated requirement string, or None on failure.""" + """Return the bundle's designated requirement string, or None on failure (a hanging codesign must never abort doctor).""" codesign = shutil.which("codesign") if not codesign: return None try: - proc = subprocess.run( - [codesign, "-d", "--requirements", "-", str(app)], - capture_output=True, - text=True, - timeout=15, - ) + proc = subprocess.run([codesign, "-d", "--requirements", "-", str(app)], capture_output=True, text=True, timeout=15) except (FileNotFoundError, subprocess.TimeoutExpired): - # Never let a hanging codesign abort the whole doctor run — the - # caller falls through to its "could not read" warning. return None if proc.returncode != 0: return None @@ -527,11 +389,9 @@ def _macos_desktop_dr(app: Path) -> str | None: def check_macos_tcc_anchor(should_fix: bool = False) -> None: - """Report (and optionally install) the dylib-complete TCC anchor (#95596). + """Report (and with --fix install) the dylib-complete TCC anchor; silent on non-macOS / non-uv interpreters. - Silent on non-macOS and for interpreters that are not uv-managed. Never - raises — a failed check must not crash doctor. Install is gated by the - module's pre-install boot probe, so ``--fix`` cannot brick the CLI. + Never raises. Install is gated by the module's pre-install boot probe, so ``--fix`` cannot brick the CLI. """ try: from hermes_cli import macos_tcc_anchor as tcc @@ -547,220 +407,133 @@ def check_macos_tcc_anchor(should_fix: bool = False) -> None: if anchored is not None: check_ok("macOS TCC anchor installed", f"({anchored})") return - check_warn( - "macOS TCC anchor missing" if status == "missing" else "macOS TCC anchor stale", - f"({detail})", - ) - except Exception as e: # diagnostics must never crash + check_warn("macOS TCC anchor missing" if status == "missing" else "macOS TCC anchor stale", f"({detail})") + except Exception as e: check_warn("macOS TCC anchor check failed", f"({e})") def check_macos_full_disk_access() -> None: - """One-grant guidance: Full Disk Access silences every folder prompt. + """One-grant guidance: Full Disk Access silences every per-folder TCC prompt. Silent on non-macOS. - macOS TCC prompts per-category (Desktop, then Downloads, then Documents, - ...), so first-run agents drip-feed permission dialogs as they touch each - folder. ONE Full Disk Access grant covers all of them, permanently — and - with the stable signing identities now in place (#73681/#95091/#95131), - it survives updates too. This check probes whether the terminal context - already has FDA and, when it doesn't, prints the exact one-switch setup - with the System Settings deep link. - - Probe: readability of ``~/Library/Application Support/com.apple.TCC`` — - the TCC database directory itself is FDA-gated, readable ONLY with the - grant, and (critically) probing it with os.access/listdir does NOT - trigger a prompt: TCC prompts fire for protected-CATEGORY paths (Desktop - etc.), while the TCC dir simply returns EPERM without one. Silent on - non-macOS. + Probe: listdir of ``~/Library/Application Support/com.apple.TCC`` — FDA-gated, and probing it does NOT + trigger a prompt (prompts fire for protected-CATEGORY paths like Desktop; the TCC dir just returns EPERM). + A missing dir / other error is indeterminate, so stay silent rather than nag. """ if sys.platform != "darwin": return tcc_dir = Path.home() / "Library" / "Application Support" / "com.apple.TCC" try: os.listdir(tcc_dir) - has_fda = True except PermissionError: - has_fda = False - except OSError: - # Missing dir / other error: can't tell — stay silent rather than - # nag on an indeterminate probe. - return - if has_fda: - check_ok( - "macOS Full Disk Access granted", - "(no per-folder permission prompts will occur)", + check_info( + "One switch silences all macOS folder prompts: grant your terminal " + "app Full Disk Access and Hermes will never trip per-folder dialogs " + "(Desktop/Downloads/Documents/...) again. Open: System Settings → " + "Privacy & Security → Full Disk Access — or run:\n" + " open \"x-apple.systempreferences:com.apple.preference" + ".security?Privacy_AllFiles\"\n" + " then enable your terminal (and Hermes.app if you use Desktop), " + "and restart them once. With Hermes' stable signing identities the " + "grant survives every update." ) + except OSError: return - check_info( - "One switch silences all macOS folder prompts: grant your terminal " - "app Full Disk Access and Hermes will never trip per-folder dialogs " - "(Desktop/Downloads/Documents/...) again. Open: System Settings → " - "Privacy & Security → Full Disk Access — or run:\n" - " open \"x-apple.systempreferences:com.apple.preference" - ".security?Privacy_AllFiles\"\n" - " then enable your terminal (and Hermes.app if you use Desktop), " - "and restart them once. With Hermes' stable signing identities the " - "grant survives every update." - ) + else: + check_ok("macOS Full Disk Access granted", "(no per-folder permission prompts will occur)") def _check_security_advisories(should_fix: bool) -> Finding: - """Compromised-package advisories; funnels remediation into manual issues.""" + """Compromised-package advisories, funnelled into manual issues; a bug here must never block the rest of doctor.""" f = Finding() - manual_issues = f.manual_issues try: - from hermes_cli.security_advisories import ( - detect_compromised, - filter_unacked, - full_remediation_text, - get_acked_ids, - ) + from hermes_cli.security_advisories import detect_compromised, filter_unacked, full_remediation_text, get_acked_ids all_hits = detect_compromised() fresh_hits = filter_unacked(all_hits) - if fresh_hits: - for hit in fresh_hits: - check_fail( - f"{hit.advisory.title}", - f"({hit.package}=={hit.installed_version})", - ) - # Print the full remediation block, indented under the - # check_fail header so it reads as a single section. - for line in full_remediation_text(hit): - if line: - print(f" {color(line, Colors.YELLOW)}") - else: - print() - # Funnel into the action list so the summary block surfaces it - # for users who scroll past the section. - manual_issues.append( - f"Resolve security advisory {hit.advisory.id}: " - f"uninstall {hit.package}=={hit.installed_version} and " - f"rotate credentials, then run " - f"`hermes doctor --ack {hit.advisory.id}`." - ) - # Acked-but-still-installed: show as informational so the user - # knows the package is still on disk after the ack. - acked_ids = get_acked_ids() - for h in all_hits: - if h.advisory.id in acked_ids: - check_warn( - f"{h.package}=={h.installed_version} still installed " - f"(advisory {h.advisory.id} acknowledged)", - ) - else: + if not fresh_hits: check_ok("No active security advisories") + return f + for hit in fresh_hits: + check_fail(f"{hit.advisory.title}", f"({hit.package}=={hit.installed_version})") + for line in full_remediation_text(hit): # indented under the header as one section + print(f" {color(line, Colors.YELLOW)}" if line else "") + # Also into the action list so the summary block surfaces it. + f.manual_issues.append( + f"Resolve security advisory {hit.advisory.id}: " + f"uninstall {hit.package}=={hit.installed_version} and " + f"rotate credentials, then run " + f"`hermes doctor --ack {hit.advisory.id}`." + ) + acked_ids = get_acked_ids() # acked-but-still-installed stays visible + for h in all_hits: + if h.advisory.id in acked_ids: + check_warn(f"{h.package}=={h.installed_version} still installed (advisory {h.advisory.id} acknowledged)") except Exception as e: - # Never let a bug in the advisory check block the rest of doctor. check_warn(f"Security advisory check failed: {e}") return f def _check_python_environment(should_fix: bool) -> Finding: - """Interpreter, linked SQLite, venv, macOS TCC anchors, version-file drift.""" + """Interpreter, linked SQLite, venv, macOS TCC anchors/FDA/grants, version-file drift.""" f = Finding() - issues = f.issues - py_version = sys.version_info - if py_version >= (3, 11): - check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}") - elif py_version >= (3, 10): - check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}") + v = sys.version_info + label = f"Python {v.major}.{v.minor}.{v.micro}" + if v >= (3, 11): + check_ok(label) + elif v >= (3, 10): + check_ok(label) check_warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)") - elif py_version >= (3, 8): - check_warn(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ recommended)") + elif v >= (3, 8): + check_warn(label, "(3.10+ recommended)") else: - _fail_and_issue( - f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", - "(3.10+ required)", - "Upgrade Python to 3.10+", - issues, - ) + _fail_and_issue(label, "(3.10+ required)", "Upgrade Python to 3.10+", f.issues) - # Linked SQLite library (issue #69784): version + source id matter independently - # of the Python minor — uv's python-build-standalone can keep a vulnerable - # SQLite across Python upgrades. + # Linked SQLite: version + source id matter independently of the Python minor + # (uv's python-build-standalone can keep a vulnerable SQLite across upgrades). try: import sqlite3 from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id - _sqlite_ver = sqlite3.sqlite_version - _sqlite_src = sqlite_source_id() - _sqlite_src_short = ( - (_sqlite_src[:48] + "…") if len(_sqlite_src) > 48 else _sqlite_src - ) + src = sqlite_source_id() if is_sqlite_wal_reset_vulnerable(): - # Warn-only: Hermes already refuses to enable WAL on fresh DBs. - # Do not append to ``issues`` because runtime repair remains - # best-effort and unsupported installs may need manual action. - check_warn( - f"SQLite {_sqlite_ver} (WAL-reset bug)", - _sqlite_upgrade_hint(), - ) + # Warn-only: Hermes already refuses to enable WAL on fresh DBs, and + # runtime repair is best-effort, so this never goes into ``issues``. + check_warn(f"SQLite {sqlite3.sqlite_version} (WAL-reset bug)", _sqlite_upgrade_hint()) else: - check_ok(f"SQLite {_sqlite_ver}") - if _sqlite_src_short: - check_info(f"SQLite source id: {_sqlite_src_short}") + check_ok(f"SQLite {sqlite3.sqlite_version}") + if src: + check_info(f"SQLite source id: {(src[:48] + '…') if len(src) > 48 else src}") _report_database_journal_modes() except Exception as e: check_warn(f"SQLite version probe failed: {e}") - # Check if in virtual environment - in_venv = sys.prefix != sys.base_prefix - if in_venv: + + if sys.prefix != sys.base_prefix: check_ok("Virtual environment active") else: check_warn("Not in virtual environment", "(recommended)") - # macOS TCC interpreter anchor (#95596): dylib-complete re-land of the - # mechanism reverted in #95563. Silent on non-macOS. check_macos_tcc_anchor(should_fix=should_fix) - - # macOS Full Disk Access (issue #52010 follow-up): one grant silences - # every per-folder prompt permanently. Silent on non-macOS. check_macos_full_disk_access() - - # Detect drift between pyproject.toml and hermes_cli/__init__.py versions - # (a git conflict resolution can silently revert one but not the other). - _check_version_consistency(issues) - - # macOS TCC grant persistence (issue #86385): a locally-built desktop - # bundle whose DR is cdhash-pinned loses every permission grant on each - # rebuild; a post-#73681 identifier-pinned DR survives, but grants made - # to older binaries stay stale (toggle shows ON while macOS re-prompts). + _check_version_consistency(f.issues) check_macos_tcc_grants() return f def _check_certificates(should_fix: bool) -> Finding: f = Finding() - manual_issues = f.manual_issues - check_certificates(should_fix=should_fix, issues=manual_issues) + check_certificates(should_fix=should_fix, issues=f.manual_issues) return f def _check_required_packages(should_fix: bool) -> Finding: f = Finding() - issues = f.issues - required_packages = [ - ("openai", "OpenAI SDK"), - ("rich", "Rich (terminal UI)"), - ("dotenv", "python-dotenv"), - ("yaml", "PyYAML"), - ("httpx", "HTTPX"), - ] - - optional_packages = [ - ("croniter", "Croniter (cron expressions)"), - ("telegram", "python-telegram-bot"), - ("discord", "discord.py"), - ] - - for module, name in required_packages: + for module, name in (("openai", "OpenAI SDK"), ("rich", "Rich (terminal UI)"), ("dotenv", "python-dotenv"), + ("yaml", "PyYAML"), ("httpx", "HTTPX")): try: __import__(module) check_ok(name) except ImportError: - _fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", issues) - - for module, name in optional_packages: + _fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", f.issues) + for module, name in (("croniter", "Croniter (cron expressions)"), ("telegram", "python-telegram-bot"), ("discord", "discord.py")): try: __import__(module) check_ok(name, "(optional)") @@ -771,9 +544,8 @@ def _check_required_packages(should_fix: bool) -> Finding: def _check_gateway_supervision(should_fix: bool) -> Finding: f = Finding() - issues = f.issues - _check_gateway_service_linger(issues) - _check_s6_supervision(issues) + _check_gateway_service_linger(f.issues) + _check_s6_supervision(f.issues) return f @@ -781,79 +553,49 @@ def _check_command_installation(should_fix: bool) -> Finding: """Venv entry point and the ~/.local/bin (or $PREFIX/bin) symlink; skipped on Windows.""" from hermes_cli.doctor import PROJECT_ROOT f = Finding() - issues, manual_issues = f.issues, f.manual_issues - if sys.platform != "win32": - _section("Command Installation") - # Determine the venv entry point location - _venv_bin = None - for _venv_name in ("venv", ".venv"): - _candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes" - if _candidate.exists(): - _venv_bin = _candidate - break + if sys.platform == "win32": + return f + _section("Command Installation") + venv_bin = next((c for c in (PROJECT_ROOT / n / "bin" / "hermes" for n in ("venv", ".venv")) if c.exists()), None) + # Expected command link directory (mirrors install.sh logic). + prefix = os.environ.get("PREFIX", "") + if prefix and (os.environ.get("TERMUX_VERSION") or "com.termux/files/usr" in prefix): + link_dir, display = Path(prefix) / "bin", "$PREFIX/bin" + else: + link_dir, display = Path.home() / ".local" / "bin", "~/.local/bin" + link = link_dir / "hermes" - # Determine the expected command link directory (mirrors install.sh logic) - _prefix = os.environ.get("PREFIX", "") - _is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix - if _is_termux_env and _prefix: - _cmd_link_dir = Path(_prefix) / "bin" - _cmd_link_display = "$PREFIX/bin" - else: - _cmd_link_dir = Path.home() / ".local" / "bin" - _cmd_link_display = "~/.local/bin" - _cmd_link = _cmd_link_dir / "hermes" + if venv_bin is None: + check_warn("Venv entry point not found", "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')") + f.manual_issues.append(f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'") + return f + check_ok(f"Venv entry point exists ({venv_bin.relative_to(PROJECT_ROOT)})") - if _venv_bin is None: - check_warn( - "Venv entry point not found", - "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')" - ) - manual_issues.append( - f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'" - ) - else: - check_ok(f"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})") - - # Check the symlink at the command link location - if _cmd_link.is_symlink(): - _target = _cmd_link.resolve() - _expected = _venv_bin.resolve() - if _target == _expected: - check_ok(f"{_cmd_link_display}/hermes → correct target") - else: - check_warn( - f"{_cmd_link_display}/hermes points to wrong target", - f"(→ {_target}, expected → {_expected})" - ) - if should_fix: - _cmd_link.unlink() - _cmd_link.symlink_to(_venv_bin) - check_ok(f"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}") - f.fixed += 1 - else: - issues.append(f"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'") - elif _cmd_link.exists(): - # It's a regular file, not a symlink — possibly a wrapper script - check_ok(f"{_cmd_link_display}/hermes exists (non-symlink)") - else: - check_fail( - f"{_cmd_link_display}/hermes not found", - "(hermes command may not work outside the venv)" - ) - if should_fix: - _cmd_link_dir.mkdir(parents=True, exist_ok=True) - _cmd_link.symlink_to(_venv_bin) - check_ok(f"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}") - f.fixed += 1 - - # Check if the link dir is on PATH - _path_dirs = os.environ.get("PATH", "").split(os.pathsep) - if str(_cmd_link_dir) not in _path_dirs: - check_warn( - f"{_cmd_link_display} is not on your PATH", - "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")" - ) - manual_issues.append(f"Add {_cmd_link_display} to your PATH") - else: - issues.append(f"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'") + if link.is_symlink(): + target, expected = link.resolve(), venv_bin.resolve() + if target == expected: + check_ok(f"{display}/hermes → correct target") + return f + check_warn(f"{display}/hermes points to wrong target", f"(→ {target}, expected → {expected})") + if not should_fix: + f.issues.append(f"Broken symlink at {display}/hermes — run 'hermes doctor --fix'") + return f + link.unlink() + link.symlink_to(venv_bin) + check_ok(f"Fixed symlink: {display}/hermes → {venv_bin}") + f.fixed += 1 + elif link.exists(): # regular file (wrapper script), not a symlink + check_ok(f"{display}/hermes exists (non-symlink)") + else: + check_fail(f"{display}/hermes not found", "(hermes command may not work outside the venv)") + if not should_fix: + f.issues.append(f"Missing {display}/hermes symlink — run 'hermes doctor --fix'") + return f + link_dir.mkdir(parents=True, exist_ok=True) + link.symlink_to(venv_bin) + check_ok(f"Created symlink: {display}/hermes → {venv_bin}") + f.fixed += 1 + if str(link_dir) not in os.environ.get("PATH", "").split(os.pathsep): + check_warn(f"{display} is not on your PATH", "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")") + f.manual_issues.append(f"Add {display} to your PATH") return f diff --git a/hermes_cli/doctor_tools.py b/hermes_cli/doctor_tools.py index 2eb5c46e82..87e0a2af1f 100644 --- a/hermes_cli/doctor_tools.py +++ b/hermes_cli/doctor_tools.py @@ -1,7 +1,6 @@ """External-tool checks for hermes doctor: terminal backends, git/rg, Node + agent-browser, npm audit, tool availability. -Split out of ``hermes_cli/doctor.py``; every moved name is re-imported there, so -``hermes_cli.doctor.`` keeps resolving (and monkeypatching) as before. +Split out of ``hermes_cli/doctor.py``, which re-exports every name so ``hermes_cli.doctor.`` keeps resolving (and monkeypatching). """ from __future__ import annotations @@ -25,15 +24,18 @@ def _safe_which(cmd: str) -> str | None: return None +def _run_ok(cmd: list[str], timeout: int, **kw) -> bool: + """True when *cmd* exits 0 within *timeout*; a timeout counts as failure.""" + try: + return subprocess.run(cmd, capture_output=True, timeout=timeout, **kw).returncode == 0 + except subprocess.TimeoutExpired: + return False + + def _termux_browser_setup_steps(node_installed: bool) -> list[str]: - steps: list[str] = [] - step = 1 - if not node_installed: - steps.append(f"{step}) pkg install nodejs") - step += 1 - steps.append(f"{step}) npm install -g agent-browser") - steps.append(f"{step + 1}) agent-browser install") - return steps + steps = [] if node_installed else ["1) pkg install nodejs"] + n = len(steps) + 1 + return steps + [f"{n}) npm install -g agent-browser", f"{n + 1}) agent-browser install"] def _termux_install_all_fallback_notes() -> list[str]: @@ -47,11 +49,8 @@ def _termux_install_all_fallback_notes() -> list[str]: def _is_kanban_worker_env_gate(item: dict) -> bool: """Return True when Kanban is unavailable only because this is not a worker process.""" - if item.get("name") != "kanban": + if item.get("name") != "kanban" or os.environ.get("HERMES_KANBAN_TASK"): return False - if os.environ.get("HERMES_KANBAN_TASK"): - return False - tools = item.get("tools") or [] return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools) @@ -64,57 +63,35 @@ def _doctor_tool_availability_detail(toolset: str) -> str: def _doctor_web_capability_rows() -> list[tuple[str, str, str]]: - """Return doctor rows for web search/extract provider readiness (#78412). + """Return ``(status, label, detail)`` rows (status ``ok``/``warn``) for web search/extract readiness. - Each row is ``(status, label, detail)`` where *status* is ``ok`` or ``warn``. - Uses the same active-provider resolvers as the tools, but reports readiness - from ``is_available()`` so an explicitly selected but unconfigured backend - does not look healthy. + Uses the same active-provider resolvers as the tools but reports ``is_available()`` + readiness, so an explicitly selected but unconfigured backend does not look healthy. """ rows: list[tuple[str, str, str]] = [] try: - from agent.web_search_registry import ( - get_active_extract_provider, - get_active_search_provider, - ) + from agent.web_search_registry import get_active_extract_provider, get_active_search_provider from tools.web_tools import _ensure_web_plugins_loaded, _provider_is_ready - # Doctor runs in a fresh process — bundled web providers register - # during plugin discovery, which nothing has triggered yet here. - # Without this the registry is empty and every row reads - # "no provider selected or registered" (idempotent, cheap on rerun). + # Doctor is a fresh process: bundled web providers only register during plugin + # discovery, which nothing has triggered yet (idempotent, cheap on rerun). _ensure_web_plugins_loaded() except Exception: return rows - for capability, getter in ( - ("web search", get_active_search_provider), - ("web extract", get_active_extract_provider), - ): + for capability, getter in (("web search", get_active_search_provider), ("web extract", get_active_extract_provider)): try: provider = getter() except Exception: provider = None if provider is None: - rows.append( - ( - "warn", - capability, - "(no provider selected or registered)", - ) - ) + rows.append(("warn", capability, "(no provider selected or registered)")) continue name = getattr(provider, "name", None) or type(provider).__name__ if _provider_is_ready(provider): rows.append(("ok", capability, f"({name})")) else: - rows.append( - ( - "warn", - capability, - f"({name} selected; provider not configured)", - ) - ) + rows.append(("warn", capability, f"({name} selected; provider not configured)")) return rows @@ -124,16 +101,15 @@ def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: updated_available = list(available) updated_unavailable = [] for item in unavailable: - name = item.get("name") if _is_kanban_worker_env_gate(item): - if "kanban" not in updated_available: - updated_available.append("kanban") + gated = "kanban" + elif item.get("name") == "honcho" and _honcho_is_configured_for_doctor(): + gated = "honcho" + else: + updated_unavailable.append(item) continue - if name == "honcho" and _honcho_is_configured_for_doctor(): - if "honcho" not in updated_available: - updated_available.append("honcho") - continue - updated_unavailable.append(item) + if gated not in updated_available: + updated_available.append(gated) return updated_available, updated_unavailable @@ -151,28 +127,19 @@ def _enabled_cli_toolsets_for_doctor() -> set[str] | None: def _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]: """Filter unavailable API-key toolsets to those enabled for the CLI.""" from hermes_cli.doctor import _enabled_cli_toolsets_for_doctor - api_key_unavailable = [ - item for item in unavailable - if item.get("missing_vars") or item.get("env_vars") - ] + api_key_unavailable = [item for item in unavailable if item.get("missing_vars") or item.get("env_vars")] enabled_toolsets = _enabled_cli_toolsets_for_doctor() if enabled_toolsets is None: return api_key_unavailable - return [ - item for item in api_key_unavailable - if str(item.get("name") or "") in enabled_toolsets - ] + return [item for item in api_key_unavailable if str(item.get("name") or "") in enabled_toolsets] def _check_git_and_rg(should_fix: bool) -> Finding: f = Finding() - # Git if _safe_which("git"): check_ok("git") else: check_warn("git not found", "(optional)") - - # ripgrep (optional, for faster file search) if _safe_which("rg"): check_ok("ripgrep (rg)", "(faster file search)") else: @@ -181,11 +148,117 @@ def _check_git_and_rg(should_fix: bool) -> Finding: return f +_BUILTIN_TERMINAL_BACKENDS = {"local", "docker", "singularity", "modal", "managed_modal", "daytona", "vercel_sandbox", "ssh"} + + +def _check_docker_backend(terminal_env: str, running_in_container: bool, issues: list[str]) -> None: + if terminal_env == "docker": + if not _safe_which("docker"): + _fail_and_issue("docker not found", "(required for TERMINAL_ENV=docker)", "Install Docker or change TERMINAL_ENV", issues) + elif _run_ok(["docker", "info"], timeout=10): + check_ok("docker", "(daemon running)") + else: + _fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues) + elif _safe_which("docker"): + check_ok("docker", "(optional)") + elif _is_termux(): + check_info("Docker backend is not available inside Termux (expected on Android)") + elif not running_in_container: # in-container case already explained by the caller + check_warn("docker not found", "(optional)") + + +def _check_ssh_backend(issues: list[str]) -> None: + ssh_host = os.getenv("TERMINAL_SSH_HOST") + if not ssh_host: + _fail_and_issue("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)", "Set TERMINAL_SSH_HOST in .env", issues) + return + ssh_user, ssh_port, ssh_key = (os.getenv(f"TERMINAL_SSH_{k}") for k in ("USER", "PORT", "KEY")) + cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"] + if ssh_port: + cmd += ["-p", ssh_port] + if ssh_key: + cmd += ["-i", os.path.expanduser(ssh_key)] + cmd += [f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host, "echo ok"] + if _run_ok(cmd, timeout=15, text=True, encoding='utf-8', errors='replace'): + check_ok(f"SSH connection to {ssh_host}") + else: + _fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues) + + +def _check_daytona_backend(issues: list[str]) -> None: + if os.getenv("DAYTONA_API_KEY"): + check_ok("Daytona API key", "(configured)") + else: + _fail_and_issue("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)", "Set DAYTONA_API_KEY environment variable", issues) + try: + from daytona import Daytona # noqa: F401 — SDK presence check + check_ok("daytona SDK", "(installed)") + except ImportError: + _fail_and_issue("daytona SDK not installed", "(pip install daytona)", "Install daytona SDK: pip install daytona", issues) + + +def _check_vercel_backend(issues: list[str]) -> None: + from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES + runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24" + if runtime in _SUPPORTED_VERCEL_RUNTIMES: + check_ok("Vercel runtime", f"({runtime})") + else: + supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES) + _fail_and_issue("Vercel runtime unsupported", f"({runtime}; use {supported})", f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", issues) + + if os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() in {"", "0", "51200"}: + check_ok("Vercel disk setting", "(uses platform default)") + else: + _fail_and_issue("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)", + "Vercel Sandbox does not support custom container_disk; use the shared default 51200", issues) + + if importlib.util.find_spec("vercel") is not None: + check_ok("vercel SDK", "(installed)") + else: + _fail_and_issue("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')", + "Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", issues) + + auth_status = describe_vercel_auth() + if auth_status.ok: + check_ok("Vercel auth", f"({auth_status.label})") + elif auth_status.label.startswith("partial"): + _fail_and_issue("Vercel auth incomplete", f"({auth_status.label})", "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", issues) + else: + _fail_and_issue("Vercel auth not configured", f"({auth_status.label})", + "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", issues) + for line in auth_status.detail_lines: + check_info(f"Vercel auth {line}") + + if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}: + check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation") + else: + check_info("Vercel persistence: ephemeral filesystem") + + +def _check_plugin_backend(terminal_env: str, issues: list[str]) -> None: + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() + from agent.terminal_env_registry import get_provider + + provider = get_provider(terminal_env) + except Exception: + provider = None + if provider is None: + _fail_and_issue(f"Unknown terminal backend '{terminal_env}'", "(no built-in or plugin backend by that name)", + "Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", issues) + return + for ok, label, detail in provider.doctor_checks(): + if ok: + check_ok(label, detail) + else: + _fail_and_issue(label, detail, detail.strip("()"), issues) + + def _check_terminal_backend(should_fix: bool) -> Finding: """Docker/SSH/Daytona/Vercel/plugin terminal backends, gated on TERMINAL_ENV.""" f = Finding() - issues = f.issues - # Docker (optional) terminal_env = os.getenv("TERMINAL_ENV", "local") try: from hermes_constants import is_container as _is_container @@ -193,300 +266,120 @@ def _check_terminal_backend(should_fix: bool) -> Finding: except Exception: running_in_container = False - if running_in_container: - # Inside our container the Docker terminal backend is not - # configured by default (Docker-in-Docker isn't set up); the - # local backend is the intended one. Skip the noisy "docker - # not found" warning. If the user has explicitly chosen - # TERMINAL_ENV=docker inside the container they likely mounted - # /var/run/docker.sock, so fall through to the normal check. - if terminal_env != "docker": - check_info( - "Running inside a container — using local terminal backend " - "(docker-in-docker is not configured by default)" - ) - # Skip to next section; Docker isn't relevant here. - terminal_env = "local" - if terminal_env == "docker": - if _safe_which("docker"): - # Check if docker daemon is running - try: - result = subprocess.run(["docker", "info"], capture_output=True, timeout=10) - except subprocess.TimeoutExpired: - result = None - if result is not None and result.returncode == 0: - check_ok("docker", "(daemon running)") - else: - _fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues) - else: - _fail_and_issue( - "docker not found", - "(required for TERMINAL_ENV=docker)", - "Install Docker or change TERMINAL_ENV", - issues, - ) - elif _safe_which("docker"): - check_ok("docker", "(optional)") - elif _is_termux(): - check_info("Docker backend is not available inside Termux (expected on Android)") - elif running_in_container: - pass # already explained above - else: - check_warn("docker not found", "(optional)") - - # SSH (if using ssh backend) + # Inside our container docker-in-docker isn't set up, so the local backend is the + # intended one: skip the noisy "docker not found" warning. An explicit + # TERMINAL_ENV=docker (user likely mounted docker.sock) still gets the normal check. + if running_in_container and terminal_env != "docker": + check_info("Running inside a container — using local terminal backend (docker-in-docker is not configured by default)") + terminal_env = "local" + _check_docker_backend(terminal_env, running_in_container, f.issues) if terminal_env == "ssh": - ssh_host = os.getenv("TERMINAL_SSH_HOST") - if ssh_host: - ssh_user = os.getenv("TERMINAL_SSH_USER") - ssh_port = os.getenv("TERMINAL_SSH_PORT") - ssh_key = os.getenv("TERMINAL_SSH_KEY") - target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host - cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"] - if ssh_port: - cmd += ["-p", ssh_port] - if ssh_key: - cmd += ["-i", os.path.expanduser(ssh_key)] - cmd += [target, "echo ok"] - # Try to connect - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, encoding='utf-8', errors='replace', - timeout=15 - ) - except subprocess.TimeoutExpired: - result = None - if result is not None and result.returncode == 0: - check_ok(f"SSH connection to {ssh_host}") - else: - _fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues) - else: - _fail_and_issue( - "TERMINAL_SSH_HOST not set", - "(required for TERMINAL_ENV=ssh)", - "Set TERMINAL_SSH_HOST in .env", - issues, - ) - - # Daytona (if using daytona backend) - if terminal_env == "daytona": - daytona_key = os.getenv("DAYTONA_API_KEY") - if daytona_key: - check_ok("Daytona API key", "(configured)") - else: - _fail_and_issue( - "DAYTONA_API_KEY not set", - "(required for TERMINAL_ENV=daytona)", - "Set DAYTONA_API_KEY environment variable", - issues, - ) - try: - from daytona import Daytona # noqa: F401 — SDK presence check - check_ok("daytona SDK", "(installed)") - except ImportError: - _fail_and_issue( - "daytona SDK not installed", - "(pip install daytona)", - "Install daytona SDK: pip install daytona", - issues, - ) - - # Vercel Sandbox (if using vercel_sandbox backend) - if terminal_env == "vercel_sandbox": - runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24" - from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES - if runtime in _SUPPORTED_VERCEL_RUNTIMES: - check_ok("Vercel runtime", f"({runtime})") - else: - supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES) - _fail_and_issue( - "Vercel runtime unsupported", - f"({runtime}; use {supported})", - f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", - issues, - ) - - disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() - if disk in {"", "0", "51200"}: - check_ok("Vercel disk setting", "(uses platform default)") - else: - _fail_and_issue( - "Vercel custom disk unsupported", - "(reset terminal.container_disk to 51200)", - "Vercel Sandbox does not support custom container_disk; use the shared default 51200", - issues, - ) - - if importlib.util.find_spec("vercel") is not None: - check_ok("vercel SDK", "(installed)") - else: - _fail_and_issue( - "vercel SDK not installed", - "(pip install 'hermes-agent[vercel]')", - "Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", - issues, - ) - - auth_status = describe_vercel_auth() - if auth_status.ok: - check_ok("Vercel auth", f"({auth_status.label})") - elif auth_status.label.startswith("partial"): - _fail_and_issue( - "Vercel auth incomplete", - f"({auth_status.label})", - "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", - issues, - ) - else: - _fail_and_issue( - "Vercel auth not configured", - f"({auth_status.label})", - "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", - issues, - ) - for line in auth_status.detail_lines: - check_info(f"Vercel auth {line}") - - persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"} - if persistent: - check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation") - else: - check_info("Vercel persistence: ephemeral filesystem") - - # Plugin-registered terminal backends (if one is the active backend) - if terminal_env not in { - "local", "docker", "singularity", "modal", "managed_modal", - "daytona", "vercel_sandbox", "ssh", - }: - try: - from hermes_cli.plugins import discover_plugins - - discover_plugins() - from agent.terminal_env_registry import get_provider - - _provider = get_provider(terminal_env) - except Exception: - _provider = None - if _provider is None: - _fail_and_issue( - f"Unknown terminal backend '{terminal_env}'", - "(no built-in or plugin backend by that name)", - "Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", - issues, - ) - else: - for _ok, _label, _detail in _provider.doctor_checks(): - if _ok: - check_ok(_label, _detail) - else: - _fail_and_issue(_label, _detail, _detail.strip("()"), issues) + _check_ssh_backend(f.issues) + elif terminal_env == "daytona": + _check_daytona_backend(f.issues) + elif terminal_env == "vercel_sandbox": + _check_vercel_backend(f.issues) + elif terminal_env not in _BUILTIN_TERMINAL_BACKENDS: + _check_plugin_backend(terminal_env, f.issues) return f +def _check_agent_browser(should_fix: bool) -> bool: + """agent-browser resolution; returns True when browser tools will find a usable install. + + Mirrors ``tools.browser_tool._find_agent_browser``'s own cascade (it resolves lazily via + npx or a global/Hermes-managed install) so doctor can't diverge from what the tools find; + validate=False keeps this a cheap existence check with no subprocess or install side effects. + """ + try: + from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel + resolved = _find_agent_browser(validate=False) + except Exception: + resolved = None + + if resolved and _is_npx_agent_browser_sentinel(resolved): + check_ok("agent-browser", "(resolves via npx on first use)") + if should_fix: + # Can't tell from here whether npx's cache is warm — fire the same warm-up + # `hermes update` does so the first browser call doesn't pay the registry fetch. + from tools.browser_tool import warm_agent_browser_npx_cache + if warm_agent_browser_npx_cache(): + check_info(" Warmed npx cache for agent-browser") + else: + check_info(" Could not warm npx cache (offline or npx unavailable)") + return True + if resolved and agent_browser_runnable(resolved): + check_ok("agent-browser", "(browser automation)") + return True + if resolved: + # Almost always a dangling global symlink left by agent-browser's npm + # postinstall after `hermes update` wiped node_modules. + check_warn("agent-browser found but not runnable", f"(broken symlink at {resolved}? run: npx agent-browser --version)") + elif _is_termux(): + check_info("agent-browser is not installed (expected in the tested Termux path)") + check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=True): + check_info(step) + else: + check_warn("agent-browser not installed", "(requires npm/npx on PATH)") + return False + + +def _check_chromium() -> None: + """Playwright Chromium presence, using the exact predicate browser_tool uses to hide browser_* tools. + + Lazy import: browser_tool is a ~150KB module; if it can't import that is a separate + bug surfaced elsewhere. Camofox, a CDP override, a cloud provider, or Lightpanda all + bypass the local Chromium requirement, so no warning is emitted for them. + """ + from hermes_cli.doctor import PROJECT_ROOT + try: + from tools.browser_tool import (_chromium_installed, _is_camofox_mode, _get_cloud_provider, + _get_cdp_override_raw, _using_lightpanda_engine) + except Exception: + return + if _is_camofox_mode() or bool(_get_cdp_override_raw()) or _get_cloud_provider() is not None or _using_lightpanda_engine(): + return + if _chromium_installed(): + check_ok("Playwright Chromium", "(browser engine)") + return + check_warn("Playwright Chromium not installed", "(browser_* tools will be hidden from the agent)") + with_deps = "" if sys.platform == "win32" else "--with-deps " + check_info(f"Install with: cd {PROJECT_ROOT} && npx playwright install {with_deps}chromium") + + +def _check_lightpanda() -> None: + """Lightpanda engine (browser.engine / AGENT_BROWSER_ENGINE); independent of Node since Browser Use mode spawns ``lightpanda serve`` itself.""" + try: + from tools.browser_tool import _using_lightpanda_engine, lightpanda_engine_status + from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary + except Exception: + return + # _using_lightpanda_engine() is a cached config read — a failure there is exceptional, not hidden. + if not _using_lightpanda_engine(): + return + try: + used, reason = lightpanda_engine_status() + except Exception as e: + used, reason = False, f"status check failed: {e}" + if not used: + check_warn("browser.engine=lightpanda is shadowed", f"({reason})") + check_info("Fix: pick Lightpanda in `hermes tools` → Browser Automation, or set browser.engine: auto") + elif find_lightpanda_binary(): + check_ok("Lightpanda", f"({reason})") + else: + check_warn("Lightpanda selected but binary not found", "(browser tools will fail until it is installed)") + check_info(LIGHTPANDA_INSTALL_HINT) + + def _check_node_and_browser(should_fix: bool) -> Finding: """Node.js, agent-browser resolution, Playwright Chromium, Lightpanda engine.""" - from hermes_cli.doctor import PROJECT_ROOT f = Finding() - # Node.js + agent-browser (for browser automation tools) if _safe_which("node"): check_ok("Node.js") - # agent-browser is no longer a root package.json dependency (#43564) - # — it resolves lazily via npx (or a global/Hermes-managed install) - # at first use. Mirror tools.browser_tool._find_agent_browser's own - # resolution cascade here so doctor can't diverge from what browser - # tools will actually find; validate=False keeps this a cheap - # existence check with no subprocess spawn or install side effects. - agent_browser_ok = False - try: - from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel - _resolved_ab = _find_agent_browser(validate=False) - except Exception: - _resolved_ab = None - - if _resolved_ab and _is_npx_agent_browser_sentinel(_resolved_ab): - check_ok("agent-browser", "(resolves via npx on first use)") - agent_browser_ok = True - if should_fix: - # Doctor can't tell from here whether npx's cache already - # has agent-browser warm — just fire the same warm-up - # `hermes update` does, so a session's first browser call - # doesn't pay the registry fetch either way. - from tools.browser_tool import warm_agent_browser_npx_cache - if warm_agent_browser_npx_cache(): - check_info(" Warmed npx cache for agent-browser") - else: - check_info(" Could not warm npx cache (offline or npx unavailable)") - elif _resolved_ab and agent_browser_runnable(_resolved_ab): - check_ok("agent-browser", "(browser automation)") - agent_browser_ok = True - elif _resolved_ab: - # Found on PATH but won't run — almost always a dangling global - # symlink left behind by agent-browser's npm postinstall after a - # `hermes update` wiped node_modules (issue #48521). - check_warn( - "agent-browser found but not runnable", - f"(broken symlink at {_resolved_ab}? run: npx agent-browser --version)", - ) - elif _is_termux(): - check_info("agent-browser is not installed (expected in the tested Termux path)") - check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") - check_info("Termux browser setup:") - for step in _termux_browser_setup_steps(node_installed=True): - check_info(step) - else: - check_warn("agent-browser not installed", "(requires npm/npx on PATH)") - - # Chromium presence — the browser tools silently fail to register when - # agent-browser is found but no Playwright-managed Chromium is on disk - # (tools/browser_tool.py::check_browser_requirements filters them out - # before the agent ever sees them). Reuse the exact predicate it uses - # so the two checks cannot diverge. Skip on Termux (not a tested - # path). - if agent_browser_ok and not _is_termux(): - try: - # Lazy import: browser_tool is a ~150KB module we don't want - # to eagerly load in every `hermes doctor` invocation. - from tools.browser_tool import ( - _chromium_installed, - _is_camofox_mode, - _get_cloud_provider, - _get_cdp_override_raw, - _using_lightpanda_engine, - ) - except Exception: - # If browser_tool can't even import, that's a separate bug - # surfaced elsewhere; don't crash doctor. - pass - else: - # Only warn about Chromium if the installed engine actually - # requires it: Camofox, CDP override, a cloud provider, or - # Lightpanda all bypass the local Chromium requirement. - skip_chromium_check = ( - _is_camofox_mode() - or bool(_get_cdp_override_raw()) - or _get_cloud_provider() is not None - or _using_lightpanda_engine() - ) - if not skip_chromium_check: - if _chromium_installed(): - check_ok("Playwright Chromium", "(browser engine)") - else: - check_warn( - "Playwright Chromium not installed", - "(browser_* tools will be hidden from the agent)", - ) - if sys.platform == "win32": - check_info( - f"Install with: cd {PROJECT_ROOT} && " - "npx playwright install chromium" - ) - else: - check_info( - f"Install with: cd {PROJECT_ROOT} && " - "npx playwright install --with-deps chromium" - ) + if _check_agent_browser(should_fix) and not _is_termux(): # Chromium check is not a tested Termux path + _check_chromium() elif _is_termux(): check_info("Node.js not found (browser tools are optional in the tested Termux path)") check_info("Install Node.js on Termux with: pkg install nodejs") @@ -495,142 +388,79 @@ def _check_node_and_browser(should_fix: bool) -> Finding: check_info(step) else: check_warn("Node.js not found", "(optional, needed for browser tools)") - - # Lightpanda engine (browser.engine / AGENT_BROWSER_ENGINE). Independent - # of Node: Browser Use mode spawns ``lightpanda serve`` itself. - try: - from tools.browser_tool import _using_lightpanda_engine, lightpanda_engine_status - from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary - except Exception: - pass - else: - # _using_lightpanda_engine() is a cached config read — a failure - # there would be exceptional, not something to silently hide. - if _using_lightpanda_engine(): - try: - _lp_used, _lp_reason = lightpanda_engine_status() - except Exception as e: - _lp_used, _lp_reason = False, f"status check failed: {e}" - if not _lp_used: - check_warn("browser.engine=lightpanda is shadowed", f"({_lp_reason})") - check_info( - "Fix: pick Lightpanda in `hermes tools` → Browser Automation, " - "or set browser.engine: auto" - ) - elif find_lightpanda_binary(): - check_ok("Lightpanda", f"({_lp_reason})") - else: - check_warn( - "Lightpanda selected but binary not found", - "(browser tools will fail until it is installed)", - ) - check_info(LIGHTPANDA_INSTALL_HINT) + _check_lightpanda() return f +def _plural(n: int) -> str: + return "vulnerability" if n == 1 else "vulnerabilities" + + +def _audit_one(npm_bin: str, npm_dir, label: str, audit_extra: list[str], issues: list[str]) -> None: + """Run one `npm audit --json` and report; any failure is silently skipped. + + Workspace-scoped (`--workspace `) advisories are build-time tooling (esbuild/vite), + not runtime code. `npm audit fix --workspace` crashes on current npm (arborist "edgesOut"), + and the root-level fix can crash on the same tree ("isDescendantOf"), so no manual fix + command is offered for those — they clear via a lockfile bump. + """ + import json + try: + # Resolved absolute path so Windows can execute npm.cmd (CreateProcessW can't run bare .cmd names). + audit_result = subprocess.run([npm_bin, "audit", "--json", *audit_extra], cwd=str(npm_dir), + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30) + audit_data = json.loads(audit_result.stdout) if audit_result.stdout.strip() else {} + counts = audit_data.get("metadata", {}).get("vulnerabilities", {}) + critical, high, moderate = (counts.get(k, 0) for k in ("critical", "high", "moderate")) + total = critical + high + moderate + workspace_scoped = bool(audit_extra) and audit_extra[0] == "--workspace" + if total == 0: + check_ok(f"{label} deps", "(no known vulnerabilities)") + elif critical > 0 or high > 0: + if workspace_scoped: + remedy = "build-tool advisory; clears via lockfile bump" + else: + flag = " --workspaces=false" if audit_extra == ["--workspaces=false"] else "" + remedy = f"run: cd {npm_dir} && npm audit fix{flag}" + check_warn(f"{label} deps", f"({critical} critical, {high} high, {moderate} moderate — {remedy})") + if workspace_scoped: + check_info(" ^ build-time tooling (not runtime); if manual npm remediation " + "errors with an arborist crash it's a known npm bug — clears via a lockfile bump") + issues.append(f"{label} has {total} npm {_plural(total)}") + else: + check_ok(f"{label} deps", f"({moderate} moderate {_plural(moderate)})") + except Exception: + pass + + def _check_npm_audit(should_fix: bool) -> Finding: - """npm audit per Node package tree (root, web/ui-tui workspaces, WhatsApp bridge).""" + """npm audit per Node package tree (root, web/ui-tui workspaces, WhatsApp bridge). + + PROJECT_ROOT is audited with --workspaces=false so the apps/* glob (Electron, node-pty, ...) + is never resolved for a routine security check; web and ui-tui are audited via --workspace. + The WhatsApp bridge may live under a writable HERMES_HOME mirror rather than the (possibly + read-only) install tree in Docker, so it is resolved through the shared helper. + """ from hermes_cli.doctor import PROJECT_ROOT f = Finding() - issues = f.issues - # npm audit for all Node.js packages - _npm_bin = _safe_which("npm") - if _npm_bin: - # Each entry: (cwd, label, extra_audit_args) - # PROJECT_ROOT is audited with --workspaces=false so that the apps/* - # glob (which pulls in Electron, node-pty, etc.) is never resolved - # for a routine security check. The web and ui-tui workspaces are - # audited separately via --workspace flags. See #38772. - # The WhatsApp bridge may live under a writable HERMES_HOME mirror - # instead of the (possibly read-only) install tree in Docker — resolve - # it through the shared helper so we audit the dir that actually holds - # node_modules. See #49561. + npm_bin = _safe_which("npm") + if npm_bin: try: from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir - _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir() + whatsapp_bridge_dir = resolve_whatsapp_bridge_dir() except Exception: - _whatsapp_bridge_dir = PROJECT_ROOT / "scripts" / "whatsapp-bridge" - npm_audit_targets = [ + whatsapp_bridge_dir = PROJECT_ROOT / "scripts" / "whatsapp-bridge" + for npm_dir, label, audit_extra in ( (PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]), (PROJECT_ROOT, "web workspace", ["--workspace", "web"]), (PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]), - (_whatsapp_bridge_dir, "WhatsApp bridge", []), - ] - for npm_dir, label, audit_extra in npm_audit_targets: - # For workspace-scoped audits run from PROJECT_ROOT the - # node_modules check must use the workspace root; standalone dirs - # (whatsapp-bridge) check their own node_modules. + (whatsapp_bridge_dir, "WhatsApp bridge", []), + ): + # Workspace-scoped audits run from PROJECT_ROOT check the root node_modules; + # standalone dirs (whatsapp-bridge) check their own. check_dir = PROJECT_ROOT if audit_extra else npm_dir - if not (check_dir / "node_modules").exists(): - continue - try: - # Use resolved absolute path so Windows can execute - # npm.cmd (CreateProcessW can't run bare .cmd names). - audit_result = subprocess.run( - [_npm_bin, "audit", "--json", *audit_extra], - cwd=str(npm_dir), - capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30, - ) - import json as _json - audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {} - vuln_count = audit_data.get("metadata", {}).get("vulnerabilities", {}) - critical = vuln_count.get("critical", 0) - high = vuln_count.get("high", 0) - moderate = vuln_count.get("moderate", 0) - total = critical + high + moderate - # Determine a scoped fix command for the remediation hint. - if audit_extra and audit_extra[0] == "--workspace": - # Detection (`npm audit --workspace `) is read-only and - # safe, but `npm audit fix --workspace ` crashes on - # current npm with "Cannot read properties of null (reading - # 'edgesOut')" — an arborist bug with workspace-filtered - # audit fix. The root-level `npm audit fix` can crash on the - # same tree with "isDescendantOf", so do not hand the user a - # manual fix command for these build-tool advisories. - fix_cmd = None - elif audit_extra == ["--workspaces=false"]: - fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false" - else: - fix_cmd = f"cd {npm_dir} && npm audit fix" - if total == 0: - check_ok(f"{label} deps", "(no known vulnerabilities)") - elif critical > 0 or high > 0: - if fix_cmd: - vuln_detail = ( - f"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}" - ) - else: - vuln_detail = ( - f"{critical} critical, {high} high, {moderate} moderate — " - "build-tool advisory; clears via lockfile bump" - ) - check_warn( - f"{label} deps", - f"({vuln_detail})" - ) - if audit_extra and audit_extra[0] == "--workspace": - # The web/ui-tui workspace advisories are in build-time - # tooling (esbuild/vite, etc.), not runtime code that ships - # to users. Manual npm remediation may error with a known - # arborist crash (edgesOut / isDescendantOf) on this monorepo - # tree — in that case it is an npm bug, not a Hermes one. - check_info( - " ^ build-time tooling (not runtime); if manual npm remediation " - "errors with an arborist crash it's a known npm bug — clears " - "via a lockfile bump" - ) - issues.append( - f"{label} has {total} npm " - f"{'vulnerability' if total == 1 else 'vulnerabilities'}" - ) - else: - check_ok( - f"{label} deps", - f"({moderate} moderate " - f"{'vulnerability' if moderate == 1 else 'vulnerabilities'})", - ) - except Exception: - pass + if (check_dir / "node_modules").exists(): + _audit_one(npm_bin, npm_dir, label, audit_extra, f.issues) if _is_termux(): check_info("Termux compatibility fallbacks:") @@ -642,17 +472,15 @@ def _check_npm_audit(should_fix: bool) -> Finding: def _check_tool_availability(should_fix: bool) -> Finding: from hermes_cli.doctor import PROJECT_ROOT, _doctor_web_capability_rows f = Finding() - issues = f.issues try: - # Add project root to path for imports sys.path.insert(0, str(PROJECT_ROOT)) from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS - + available, unavailable = check_tool_availability() available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable) # Web is split into search/extract readiness rows so an explicitly - # selected but unconfigured backend cannot look healthy (#78412). + # selected but unconfigured backend cannot look healthy. web_rows = [] if "web" in available or any(item.get("name") == "web" for item in unavailable): web_rows = _doctor_web_capability_rows() @@ -661,30 +489,18 @@ def _check_tool_availability(should_fix: bool) -> Finding: unavailable = [item for item in unavailable if item.get("name") != "web"] for tid in available: - info = TOOLSET_REQUIREMENTS.get(tid, {}) - check_ok(info.get("name", tid), _doctor_tool_availability_detail(tid)) - + check_ok(TOOLSET_REQUIREMENTS.get(tid, {}).get("name", tid), _doctor_tool_availability_detail(tid)) for status, label, detail in web_rows: - if status == "ok": - check_ok(label, detail) - else: - check_warn(label, detail) - + (check_ok if status == "ok" else check_warn)(label, detail) for item in unavailable: env_vars = item.get("missing_vars") or item.get("env_vars") or [] - if env_vars: - vars_str = ", ".join(env_vars) - check_warn(item["name"], f"(missing {vars_str})") - else: - check_warn(item["name"], "(system dependency not met)") + check_warn(item["name"], f"(missing {', '.join(env_vars)})" if env_vars else "(system dependency not met)") - # Count missing API-key requirements only for toolsets enabled in the - # current CLI platform. Default-off or explicitly disabled toolsets may - # still show warnings above, but should not pollute the final summary. + # Only toolsets enabled for the CLI count toward the summary; default-off or + # disabled toolsets may warn above but must not pollute it. api_disabled = _missing_api_key_toolsets_for_summary(unavailable) - web_not_ready = any(status != "ok" for status, _, _ in web_rows) - if api_disabled or web_not_ready: - issues.append("Run 'hermes setup' to configure missing API keys for full tool access") + if api_disabled or any(status != "ok" for status, _, _ in web_rows): + f.issues.append("Run 'hermes setup' to configure missing API keys for full tool access") except Exception as e: check_warn("Could not check tool availability", f"({e})") return f