refactor(doctor): tighten platform + tools checks — shared helpers, backend dispatch table, compact docstrings
This commit is contained in:
+182
-440
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user