refactor(doctor): tighten platform + tools checks — shared helpers, backend dispatch table, compact docstrings

This commit is contained in:
Teknium
2026-09-02 16:19:08 -07:00
parent a8a7b7b849
commit 95804887c1
2 changed files with 495 additions and 937 deletions
+182 -440
View File
@@ -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.<name>`` keeps resolving (and monkeypatching) as before.
Split out of ``hermes_cli/doctor.py``, which re-exports every name so ``hermes_cli.doctor.<name>`` 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 <name>`")
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-<arch>/Hermes.app``), newest arch tree first.
Mirrors the install layout the self-updater produces
(``apps/desktop/release/mac-<arch>/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
+313 -497
View File
@@ -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.<name>`` keeps resolving (and monkeypatching) as before.
Split out of ``hermes_cli/doctor.py``, which re-exports every name so ``hermes_cli.doctor.<name>`` 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 <name>`) 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 <name>`) is read-only and
# safe, but `npm audit fix --workspace <name>` 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