From 9b05f0df55193c56f1557d0e8b4c3e258a6aea6f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:56:21 -0700 Subject: [PATCH] refactor(doctor): split config and host-platform checks into doctor_config.py / doctor_platform.py --- hermes_cli/doctor.py | 1564 +------------------------------ hermes_cli/doctor_config.py | 713 ++++++++++++++ hermes_cli/doctor_platform.py | 859 +++++++++++++++++ tests/hermes_cli/test_doctor.py | 4 +- 4 files changed, 1619 insertions(+), 1521 deletions(-) create mode 100644 hermes_cli/doctor_config.py create mode 100644 hermes_cli/doctor_platform.py diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index c4c39f2be5..ae12dff462 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -11,13 +11,11 @@ import shutil import importlib.util from pathlib import Path -from hermes_cli.config import ( +from hermes_cli.config import ( # noqa: F401 (detect_install_method: tests patch doctor.detect_install_method) detect_install_method, get_env_path, get_hermes_home, get_project_root, - is_nix_install_method, - recommended_update_command_for_method, ) from hermes_cli.env_loader import load_hermes_dotenv from hermes_constants import display_hermes_home @@ -32,6 +30,49 @@ _env_path = get_env_path() load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env") from hermes_cli.colors import Colors, color +from hermes_cli.doctor_config import ( # noqa: F401 (re-exported; tests use hermes_cli.doctor.) + _DEPRECATED_COMPRESSION_SUMMARY_KEYS, + _DEPRECATED_CONFIG_KEYS, + _DEPRECATED_ENV_VARS, + _check_config_drift, + _check_config_file, + _check_env_file, + _check_mcp_security, + _check_xai_retirement, + _has_provider_env_config, + collect_deprecated_config_keys, + collect_deprecated_env_vars, + collect_relay_plugin_cutover_findings, + managed_scope_check, + report_deprecated_config_and_env, +) +from hermes_cli.doctor_platform import ( # noqa: F401 (re-exported; tests use hermes_cli.doctor.) + _SQLITE_HEADER_MAGIC, + _check_certificates, + _check_command_installation, + _check_gateway_service_linger, + _check_gateway_supervision, + _check_python_environment, + _check_required_packages, + _check_s6_supervision, + _check_security_advisories, + _check_version_consistency, + _desktop_app_bundle, + _format_db_size, + _hermes_database_paths, + _macos_desktop_dr, + _python_install_cmd, + _read_journal_mode, + _read_pyproject_version, + _report_database_journal_modes, + _sqlite_upgrade_hint, + _system_package_install_cmd, + _unreadable_reason, + check_certificates, + check_macos_full_disk_access, + check_macos_tcc_anchor, + check_macos_tcc_grants, +) from hermes_cli.doctor_report import ( # noqa: F401 (re-exported for doctor_live and tests) Finding, _fail_and_issue, @@ -86,166 +127,6 @@ _PROVIDER_ENV_HINTS = ( from hermes_constants import is_termux as _is_termux -def _python_install_cmd() -> str: - return "python -m pip install" if _is_termux() else "uv pip install" - - -def _system_package_install_cmd(pkg: str) -> str: - if _is_termux(): - return f"pkg install {pkg}" - if sys.platform == "darwin": - return f"brew install {pkg}" - return f"sudo apt install {pkg}" - - -def _sqlite_upgrade_hint(install_method: str | None = None) -> str: - """Return an actionable SQLite upgrade hint for this install layout.""" - 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" - elif is_nix_install_method(method): - # The Nix helper is prose guidance, not a literal shell command. - action = recommended_update_command_for_method(method) - elif method == "apt": - action = f"run `{recommended_update_command_for_method(method)}`" - else: - action = "run `hermes update`" - return ( - f"({action}; fixed versions: 3.51.3+ / 3.50.7 / 3.44.6 — " - "see https://sqlite.org/wal.html#walresetbug)" - ) - - -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. - 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. - 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 - - -_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. - """ - try: - db_path.stat() - except OSError as exc: - return str(exc) - if not os.access(db_path, os.R_OK): - return f"permission denied: {db_path}" - return "file could not be read" - - -def _read_journal_mode(db_path: Path) -> tuple[str | None, str | None]: - """Return (journal mode, error) from the file header 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. - """ - 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: - if has_live_connection(db_path): - return None, "database is open in this process" - return None, _unreadable_reason(db_path) - if len(header) == 0: - 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 - 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 - - try: - nbytes = db_path.stat().st_size - except OSError: - return "size unknown" - return _format_size(nbytes) - - -def _report_database_journal_modes( - hermes_home: Path | None = None, - version_info: tuple[int, ...] | None = None, -) -> None: - """List each database's journal mode; warn on WAL under a vulnerable SQLite.""" - from hermes_state import _wal_reset_repair_hint, is_sqlite_wal_reset_vulnerable - - vulnerable = is_sqlite_wal_reset_vulnerable(version_info) - home = hermes_home if hermes_home is not None else HERMES_HOME - try: - databases = _hermes_database_paths(home) - except Exception as exc: - check_warn(f"Could not list Hermes databases: {exc}") - return - exposed = [] - for name, path in databases: - if not path.is_file(): - continue - mode, error = _read_journal_mode(path) - 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)", - ) - 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)", - ) - else: - check_info(f"{name}: WAL journal mode ({size})") - elif vulnerable: - check_info(f"{name}: rollback journal mode ({size}, not exposed)") - else: - check_info(f"{name}: rollback journal mode ({size})") - if exposed: - check_info(f"To clear the exposure: {_wal_reset_repair_hint()}") - - def _safe_which(cmd: str) -> str | None: """shutil.which wrapper resilient to platform monkeypatching in tests.""" try: @@ -274,11 +155,6 @@ def _termux_install_all_fallback_notes() -> list[str]: ] -def _has_provider_env_config(content: str) -> bool: - """Return True when ~/.hermes/.env contains provider auth/base URL settings.""" - return any(key in content for key in _PROVIDER_ENV_HINTS) - - def _honcho_is_configured_for_doctor() -> bool: """Return True when Honcho is configured, even if this process has no active session.""" try: @@ -504,158 +380,6 @@ def _render_state_db_stats(stats: dict, holders=None) -> list: return lines -# Deprecated / legacy config keys still read for back-compat. Doctor surfaces -# them as non-failing warnings with the modern replacement — it does not -# auto-migrate or delete (migrations live in config.py version steps). -_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = ( - # (section, key, replacement) - ("display", "tool_progress_overrides", "display.platforms"), - ("delegation", "max_async_children", "delegation.max_concurrent_children"), -) - -# compression.summary_* → auxiliary.compression (model/provider/base_url) -_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = ( - "summary_model", - "summary_provider", - "summary_base_url", -) - -# Deprecated env vars (checked in the .env file, not process env, so config→env -# bridges like terminal.cwd → TERMINAL_CWD do not false-positive). -_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = ( - # HERMES_TOOL_PROGRESS is fully unsupported since the v12 config support - # floor removed its only consumer (the v3→4 migration) — it is silently - # ignored. HERMES_TOOL_PROGRESS_MODE is still read by the gateway as a - # back-compat fallback but remains deprecated. - ("HERMES_TOOL_PROGRESS", "display.tool_progress in config.yaml — ignored/unsupported since config floor v12"), - ("HERMES_TOOL_PROGRESS_MODE", "display.tool_progress in config.yaml"), - ("TERMINAL_CWD", "terminal.cwd in config.yaml"), - ("MESSAGING_CWD", "terminal.cwd in config.yaml"), - ("QQ_HOME_CHANNEL", "QQBOT_HOME_CHANNEL"), - ("QQ_HOME_CHANNEL_NAME", "QQBOT_HOME_CHANNEL_NAME"), -) - - -def collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]: - """Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*. - - Only keys that appear in the on-disk YAML are reported (raw file load, not - merged defaults). Empty containers still count — presence of the legacy - key is the signal that the user should migrate. - """ - findings: list[tuple[str, str]] = [] - if not isinstance(raw_config, dict): - return findings - - for section, key, replacement in _DEPRECATED_CONFIG_KEYS: - section_val = raw_config.get(section) - if isinstance(section_val, dict) and key in section_val: - findings.append((f"{section}.{key}", replacement)) - - compression = raw_config.get("compression") - if isinstance(compression, dict): - for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS: - if key in compression: - findings.append((f"compression.{key}", "auxiliary.compression")) - - return findings - - -def collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]: - """Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*. - - *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not - ``os.environ``, so bridged runtime vars do not trigger false positives. - """ - findings: list[tuple[str, str]] = [] - if not isinstance(env_map, dict): - return findings - for name, replacement in _DEPRECATED_ENV_VARS: - val = env_map.get(name) - if val is not None and str(val).strip() != "": - findings.append((name, replacement)) - return findings - - -def collect_relay_plugin_cutover_findings( - raw_config: dict | None, - env_map: dict | None, -) -> list[tuple[str, str]]: - """Return actionable findings for the removed Hermes Relay plugin.""" - from hermes_cli.relay_plugin_cutover import ( - LEGACY_RELAY_EXPORT_ENV_VARS, - RELAY_PLUGINS_CONFIG_ENV, - configured_legacy_relay_env_vars, - legacy_relay_plugin_keys, - ) - - findings: list[tuple[str, str]] = [] - if isinstance(raw_config, dict): - plugins = raw_config.get("plugins") - if isinstance(plugins, dict): - for key in legacy_relay_plugin_keys(plugins.get("enabled")): - findings.append( - ( - f"plugins.enabled: {key}", - f"remove it and configure {RELAY_PLUGINS_CONFIG_ENV}", - ) - ) - - effective_env = dict(env_map or {}) - # Fall through to process-level env ONLY when no explicit env_map was - # given: run_doctor passes None and wants live-process vars included, but - # callers (and tests) that hand in an explicit map are describing a - # complete environment — merging os.environ on top breaks hermeticity on - # any box that exports legacy relay vars (10-vs-2 findings, Aug 2026). - if env_map is None: - for name in (*LEGACY_RELAY_EXPORT_ENV_VARS, RELAY_PLUGINS_CONFIG_ENV): - if name not in effective_env and os.environ.get(name) is not None: - effective_env[name] = os.environ[name] - if not str(effective_env.get(RELAY_PLUGINS_CONFIG_ENV, "")).strip(): - for name in configured_legacy_relay_env_vars(effective_env): - findings.append( - ( - name, - f"move exporter settings to {RELAY_PLUGINS_CONFIG_ENV}; " - "this variable is now ignored", - ) - ) - return findings - - -def report_deprecated_config_and_env( - raw_config: dict | None = None, - env_map: dict | None = None, -) -> list[tuple[str, str]]: - """Emit non-failing doctor warnings for deprecated config keys and env vars. - - Returns the list of ``(legacy, replacement)`` findings that were reported - (empty when nothing deprecated is present). Does not mutate config/env and - does not append to the blocking ``issues`` list. - """ - deprecated = collect_deprecated_config_keys(raw_config) - deprecated.extend(collect_deprecated_env_vars(env_map)) - relay_cutover = collect_relay_plugin_cutover_findings(raw_config, env_map) - findings = deprecated + relay_cutover - if not findings: - check_ok("No deprecated config keys or env vars") - return findings - - for legacy, replacement in deprecated: - check_warn( - f"Deprecated: {legacy}", - f"(use {replacement} instead)", - ) - check_info(f"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)") - for legacy, replacement in relay_cutover: - check_warn( - f"Breaking Relay migration: {legacy}", - f"({replacement})", - ) - check_info(f"Migrate {legacy}: {replacement}") - return findings - - def _enabled_cli_toolsets_for_doctor() -> set[str] | None: """Return toolsets enabled for the CLI, or None if config resolution fails.""" try: @@ -682,1123 +406,12 @@ def _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict] ] -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. - """ - pyproject = PROJECT_ROOT / "pyproject.toml" - try: - text = pyproject.read_text(encoding="utf-8") - except OSError: - return None - in_project = False - for raw in text.splitlines(): - line = raw.strip() - if line.startswith("[") and line.endswith("]"): - 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("\"'") - return value or None - return None - - -def _check_version_consistency(issues: list[str]) -> None: - """Verify pyproject.toml version matches hermes_cli.__version__. - - 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. - """ - try: - from hermes_cli import __version__ as init_version - except Exception: - 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})") - else: - _fail_and_issue( - "Version mismatch between source files", - f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})", - "Re-sync version files (e.g. run 'hermes update', or set " - "hermes_cli/__init__.py __version__ to match pyproject.toml)", - issues, - ) - - -def _check_s6_supervision(issues: list[str]) -> None: - """Inside a container under our s6 /init, surface what s6 sees. - - 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`` - """ - try: - 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"): - if mgr.is_running(static): - check_ok(f"{static}: up") - else: - check_info(f"{static}: down (expected if not enabled via env)") - - profiles = mgr.list_profile_gateways() - if not profiles: - check_info("No per-profile gateways registered yet — create one with `hermes profile create `") - return - - up_count = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}")) - check_ok( - f"Per-profile gateways: {up_count}/{len(profiles)} supervised up" - + (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "") - ) - - -def check_certificates(should_fix: bool = False, issues: "list | None" = None) -> None: - """Verify the certifi CA bundle is loadable. - - 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. - """ - try: - from agent.ssl_guard import verify_ca_bundle_with_fallback - from agent.errors import SSLConfigurationError - except Exception as e: - check_warn("SSL certificate check skipped", str(e)) - return - - try: - verify_ca_bundle_with_fallback() - check_ok("SSL CA certificate bundle is valid") - return - except SSLConfigurationError as e: - first_error = str(e) - except Exception as e: - check_warn("SSL certificate check skipped", str(e)) - return - - 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`" - ) - 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, - ) - 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" - ) - 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" - ) - return - - # Drop any cached certifi module so where() re-resolves the new bundle. - 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) - importlib.invalidate_caches() - - try: - verify_ca_bundle_with_fallback() - 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." - ) - - -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``. - """ - try: - 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(): - return - - _section("Gateway Service") - linger_enabled, linger_detail = get_systemd_linger_status() - if linger_enabled is True: - check_ok("Systemd linger enabled", "(gateway service survives logout)") - elif linger_enabled is False: - check_warn("Systemd linger disabled", "(gateway may stop after logout)") - check_info("Run: sudo loginctl enable-linger $USER") - issues.append("Enable linger for the gateway user service: sudo loginctl enable-linger $USER") - else: - check_warn("Could not verify systemd linger", f"({linger_detail})") - - -def managed_scope_check() -> None: - """Report the active managed scope (resolved dir + pinned key counts). - - Silent when no managed scope is present. When the managed directory was - resolved from the HERMES_MANAGED_DIR override (rather than the system - default), that is surfaced too — a redirected scope is the documented - foot-gun (see docs/design/managed-scope.md §7) and an operator should see it. - """ - try: - from hermes_cli import managed_scope - managed_dir = managed_scope.get_managed_dir() - except Exception: # noqa: BLE001 — diagnostics must never crash - return - if managed_dir is None: - return - n_cfg = len(managed_scope.managed_config_keys()) - n_env = len(managed_scope.load_managed_env()) - check_ok( - f"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) " - f"pinned by {managed_dir}" - ) - if os.environ.get("HERMES_MANAGED_DIR", "").strip(): - check_info(f"managed dir set via HERMES_MANAGED_DIR={managed_dir}") - - -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. - """ - if sys.platform != "darwin": - return - app = _desktop_app_bundle() - if app is 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)", - ) - 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", - "the desktop bundle's designated requirement is cdhash-pinned " - "(pre-#73681 build) — rebuilds invalidate all permission grants. " - "Run `hermes update` to get the stable identifier-pinned signing " - "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)", - ) - else: - check_ok( - "macOS TCC signing identity is stable", - "(identifier-pinned DR; grants survive rebuilds — for the strongest " - "anchor, see `hermes desktop --setup-tcc-identity`)", - ) - check_info( - "If macOS still re-prompts for permissions (toggle shows ON): the stored " - "grant is stale — run `tccutil reset ScreenCapture com.nousresearch.hermes` " - "(repeat per affected service), toggle it ON in System Settings, then " - "fully quit & relaunch Hermes once." - ) - - -def _desktop_app_bundle() -> Path | None: - """Locate the locally-built desktop app bundle, if any. - - Mirrors the install layout the self-updater produces - (``apps/desktop/release/mac-/Hermes.app``) — the only layout whose - ad-hoc re-signed bundle can invalidate TCC grants. When multiple arch - trees coexist (stale cross-build), the newest wins, matching - ``_desktop_packaged_executable``'s selection. ``/Applications/Hermes.app`` - is deliberately not probed: it is the separately-signed Hermes-Setup - launcher (``com.nousresearch.hermes.setup``, certificate-anchored), whose - grants are stable by construction and unaffected by rebuilds. - """ - root = Path(__file__).resolve().parents[1] - release_dir = root / "apps" / "desktop" / "release" - candidates = [p for p in release_dir.glob("mac*/Hermes.app") if p.is_dir()] - if not candidates: - return None - return max(candidates, key=lambda p: p.stat().st_mtime) - - -def _macos_desktop_dr(app: Path) -> str | None: - """Return the bundle's designated requirement string, or None on failure.""" - 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, - ) - 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 - return (proc.stdout or "") + (proc.stderr or "") - - -def check_macos_tcc_anchor(should_fix: bool = False) -> None: - """Report (and optionally install) the dylib-complete TCC anchor (#95596). - - 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. - """ - try: - from hermes_cli import macos_tcc_anchor as tcc - - status, detail = tcc.tcc_anchor_state() - if status == "skip": - return - if status == "active": - check_ok("macOS TCC anchor active", f"({detail})") - return - if should_fix: - anchored = tcc.ensure_tcc_anchor() - 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 check failed", f"({e})") - - -def check_macos_full_disk_access() -> None: - """One-grant guidance: Full Disk Access silences every folder prompt. - - 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. - """ - 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)", - ) - 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." - ) - - def _memory_store_flags(hermes_home: Path) -> tuple: from tools.memory_tool import get_builtin_memory_store_flags return get_builtin_memory_store_flags({"memory": _doctor_memory_config(hermes_home)}) -def _check_security_advisories(should_fix: bool) -> Finding: - """Compromised-package advisories; funnels remediation into manual issues.""" - f = Finding() - manual_issues = f.manual_issues - try: - 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: - check_ok("No active security advisories") - 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_mcp_security(should_fix: bool) -> Finding: - """Flag mcp_servers entries with suspicious stdio commands.""" - f = Finding() - manual_issues = f.manual_issues - try: - from hermes_cli.config import load_config - from hermes_cli.mcp_security import validate_mcp_server_entry - - servers = load_config().get("mcp_servers") or {} - suspicious = 0 - if isinstance(servers, dict): - for name, entry in sorted(servers.items()): - if not isinstance(entry, dict): - continue - issues_found = validate_mcp_server_entry(name, entry) - if not issues_found: - continue - suspicious += 1 - check_warn(f"MCP server '{name}' has suspicious stdio command", "; ".join(issues_found)) - manual_issues.append( - f"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed." - ) - if suspicious == 0: - check_ok("No suspicious MCP stdio commands") - except Exception as e: - check_warn(f"MCP security check failed: {e}") - return f - - -def _check_python_environment(should_fix: bool) -> Finding: - """Interpreter, linked SQLite, venv, macOS TCC anchors, 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}") - 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)") - else: - _fail_and_issue( - f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", - "(3.10+ required)", - "Upgrade Python to 3.10+", - 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. - 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 - ) - 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(), - ) - else: - check_ok(f"SQLite {_sqlite_ver}") - if _sqlite_src_short: - check_info(f"SQLite source id: {_sqlite_src_short}") - _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: - 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_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) - 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: - 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: - try: - __import__(module) - check_ok(name, "(optional)") - except ImportError: - check_warn(name, "(optional, not installed)") - return f - - -def _check_env_file(should_fix: bool) -> Finding: - """Managed scope plus ~/.hermes/.env presence and provider credentials.""" - f = Finding() - issues = f.issues - managed_scope_check() - # Check ~/.hermes/.env (primary location for user config) - env_path = HERMES_HOME / '.env' - if env_path.exists(): - check_ok(f"{_DHH}/.env file exists") - - # Prefer UTF-8 (.env is written as UTF-8 elsewhere). Fall back to - # latin-1 for Windows Notepad/cp1252 files that are not valid UTF-8 — - # matches hermes_cli.env_loader._load_dotenv_with_fallback. - try: - content = env_path.read_text(encoding="utf-8") - except UnicodeDecodeError: - content = env_path.read_text(encoding="latin-1") - if _has_provider_env_config(content): - check_ok("API key or custom endpoint configured") - else: - check_warn(f"No API key found in {_DHH}/.env") - issues.append("Run 'hermes setup' to configure API keys") - else: - # Also check project root as fallback - fallback_env = PROJECT_ROOT / '.env' - if fallback_env.exists(): - check_ok(".env file exists (in project directory)") - else: - check_fail(f"{_DHH}/.env file missing") - if should_fix: - env_path.parent.mkdir(parents=True, exist_ok=True) - env_path.touch() - # .env holds API keys — restrict to owner-only access from - # creation. touch() obeys umask which is commonly 0o022, - # leaving the file world-readable; tighten explicitly. - try: - os.chmod(str(env_path), 0o600) - except OSError: - pass - check_ok(f"Created empty {_DHH}/.env") - check_info("Run 'hermes setup' to configure API keys") - f.fixed += 1 - else: - check_info("Run 'hermes setup' to create one") - issues.append("Run 'hermes setup' to create .env") - return f - - -def _check_config_file(should_fix: bool) -> Finding: - """config.yaml presence; validate model.provider / model.default and credentials.""" - f = Finding() - issues = f.issues - # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback) - config_path = HERMES_HOME / 'config.yaml' - if config_path.exists(): - check_ok(f"{_DHH}/config.yaml exists") - - # Validate model.provider and model.default values - try: - # Raw-file diagnostic: inspects what the user actually wrote. - from hermes_cli.config import read_user_config_raw - cfg = read_user_config_raw(config_path) - model_section = cfg.get("model") or {} - provider_raw = (model_section.get("provider") or "").strip() - provider = provider_raw.lower() - default_model = (model_section.get("default") or model_section.get("model") or "").strip() - - known_providers: set = set() - try: - from hermes_cli.auth import ( - PROVIDER_REGISTRY, - resolve_provider as _resolve_auth_provider, - ) - known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto", "moa"} - except Exception: - _resolve_auth_provider = None - pass - try: - from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers - from hermes_cli.providers import ( - custom_provider_aliases as _custom_provider_aliases, - normalize_provider as _normalize_catalog_provider, - resolve_provider_full as _resolve_provider_full, - ) - except Exception: - _compatible_custom_providers = None - _custom_provider_aliases = None - _normalize_catalog_provider = None - _resolve_provider_full = None - - custom_providers = [] - if _compatible_custom_providers is not None: - try: - custom_providers = _compatible_custom_providers(cfg) - except Exception: - custom_providers = [] - - user_providers = cfg.get("providers") - if isinstance(user_providers, dict): - from hermes_cli.config import is_provider_enabled - known_providers.update( - str(name).strip().lower() - for name, prov_cfg in user_providers.items() - if str(name).strip() and is_provider_enabled(prov_cfg) - ) - for entry in custom_providers: - if not isinstance(entry, dict): - continue - name = str(entry.get("name") or "").strip() - provider_key = str(entry.get("provider_key") or "").strip() - if name and _custom_provider_aliases is not None: - known_providers.update( - _custom_provider_aliases(name, provider_key) - ) - - valid_provider_ids = set(known_providers) - provider_ids_to_accept = {provider} if provider else set() - if _normalize_catalog_provider is not None: - for known_provider in known_providers: - try: - valid_provider_ids.add(_normalize_catalog_provider(known_provider)) - except Exception: - continue - - runtime_provider = provider - if ( - provider - and _resolve_auth_provider is not None - and provider not in {"auto", "custom"} - ): - try: - runtime_provider = _resolve_auth_provider(provider) - provider_ids_to_accept.add(runtime_provider) - except Exception: - runtime_provider = provider - - catalog_provider = provider - if ( - provider - and _resolve_provider_full is not None - and provider not in {"auto", "custom"} - ): - provider_def = _resolve_provider_full(provider, user_providers, custom_providers) - catalog_provider = provider_def.id if provider_def is not None else None - if catalog_provider is not None: - provider_ids_to_accept.add(catalog_provider) - - if provider and provider != "auto": - if catalog_provider is None or ( - known_providers - and not (provider_ids_to_accept & valid_provider_ids) - ): - known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)" - _fail_and_issue( - f"model.provider '{provider_raw}' is not a recognised provider", - f"(known: {known_list})", - ( - f"model.provider '{provider_raw}' is unknown. " - f"Valid providers: {known_list}. " - f"Fix: run 'hermes config set model.provider '" - ), - issues, - ) - - # Warn if model is set to a provider-prefixed name on a provider that doesn't use them. - # Vendor/model slugs are valid on aggregator-style providers and on any custom - # provider — bare "custom" or a named "custom:" that fronts an OpenAI-compatible - # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix. - provider_for_policy = runtime_provider or catalog_provider - provider_policy_id = str(provider_for_policy or "").strip().lower() - providers_accepting_vendor_slugs = { - "openrouter", - "auto", - "ai-gateway", - "kilocode", - "opencode-zen", - "huggingface", - "lmstudio", - "nous", - "nvidia", - # Fireworks' native model IDs are slash-form - # (accounts/fireworks/models/... and .../routers/...), so a "/" - # is expected, not an aggregator vendor prefix. - "fireworks", - # DeepInfra is an aggregator-style gateway: its catalog - # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…, - # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …). - "deepinfra", - } - provider_accepts_vendor_slug = ( - provider_policy_id in providers_accepting_vendor_slugs - or provider_policy_id == "custom" - or provider_policy_id.startswith("custom:") - ) - if ( - default_model - and "/" in default_model - and provider_policy_id - and not provider_accepts_vendor_slug - ): - check_warn( - f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'", - "(vendor-prefixed slugs belong to aggregators like openrouter)", - ) - issues.append( - f"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. " - "Either set model.provider to 'openrouter', or drop the vendor prefix." - ) - - # Check credentials for the configured provider. - # Limit to API-key providers in PROVIDER_REGISTRY — other provider - # types (OAuth, SDK, anthropic/custom/auto) have their own env-var - # checks elsewhere in doctor, and get_auth_status() returns a bare - # {logged_in: False} for anything it doesn't explicitly dispatch, - # which would produce false positives. - if runtime_provider and runtime_provider not in ("auto", "custom"): - try: - if runtime_provider == "openrouter": - from hermes_cli.config import get_env_value - - configured = bool( - str(get_env_value("OPENROUTER_API_KEY") or "").strip() - or str(get_env_value("OPENAI_API_KEY") or "").strip() - ) - else: - from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status - - pconfig = PROVIDER_REGISTRY.get(runtime_provider) - configured = True - if pconfig and getattr(pconfig, "auth_type", "") == "api_key": - status = get_auth_status(runtime_provider) or {} - configured = bool( - status.get("configured") - or status.get("logged_in") - or status.get("api_key") - ) - if not configured: - _fail_and_issue( - f"model.provider '{runtime_provider}' is set but no API key is configured", - "(check ~/.hermes/.env or run 'hermes setup')", - ( - f"No credentials found for provider '{runtime_provider}'. " - f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " - f"or switch providers with 'hermes config set model.provider '" - ), - issues, - ) - except Exception: - pass - - except Exception as e: - check_warn("Could not validate model/provider config", f"({e})") - else: - fallback_config = PROJECT_ROOT / 'cli-config.yaml' - if fallback_config.exists(): - check_ok("cli-config.yaml exists (in project directory)") - else: - if should_fix: - config_path.parent.mkdir(parents=True, exist_ok=True) - example_config = PROJECT_ROOT / 'cli-config.yaml.example' - if example_config.exists(): - shutil.copy2(str(example_config), str(config_path)) - check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example") - else: - from hermes_cli.config import DEFAULT_CONFIG, save_config - save_config(DEFAULT_CONFIG) - check_ok(f"Created {_DHH}/config.yaml from defaults") - f.fixed += 1 - else: - check_warn("config.yaml not found", "(using defaults)") - return f - - -def _check_config_drift(should_fix: bool) -> Finding: - """Config version, stale root keys, HERMES_MAX_ITERATIONS ghost, deprecations, structure.""" - f = Finding() - issues, manual_issues = f.issues, f.manual_issues - # Check config version and stale keys - config_path = HERMES_HOME / 'config.yaml' - if config_path.exists(): - try: - from hermes_cli.config import check_config_version, migrate_config - current_ver, latest_ver = check_config_version() - if current_ver < latest_ver: - check_warn( - f"Config version outdated (v{current_ver} → v{latest_ver})", - "(new settings available)" - ) - if should_fix: - try: - migrate_config(interactive=False, quiet=False) - check_ok("Config migrated to latest version") - f.fixed += 1 - except Exception as mig_err: - check_warn(f"Auto-migration failed: {mig_err}") - issues.append("Run 'hermes setup' to migrate config") - else: - issues.append("Run 'hermes doctor --fix' or 'hermes setup' to migrate config") - else: - check_ok(f"Config version up to date (v{current_ver})") - except Exception: - pass - - # Detect stale root-level model keys (known bug source — PR #4329) - try: - # Raw-file diagnostic: stale-key detection must see the raw file. - from hermes_cli.config import read_user_config_raw - raw_config = read_user_config_raw(config_path) - stale_root_keys = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)] - if stale_root_keys: - check_warn( - f"Stale root-level config keys: {', '.join(stale_root_keys)}", - "(should be under 'model:' section)" - ) - if should_fix: - # Coerce scalar/None ``model:`` into a dict before mutation — - # ``setdefault("model", {})`` would return an existing scalar - # and then ``model_section[k] = ...`` would raise TypeError. - raw_model = raw_config.get("model") - if isinstance(raw_model, dict): - model_section = raw_model - elif isinstance(raw_model, str) and raw_model.strip(): - model_section = {"default": raw_model.strip()} - raw_config["model"] = model_section - else: - model_section = {} - raw_config["model"] = model_section - for k in stale_root_keys: - if not model_section.get(k): - model_section[k] = raw_config.pop(k) - else: - raw_config.pop(k) - from hermes_cli.config import atomic_config_write - atomic_config_write(config_path, raw_config) - check_ok("Migrated stale root-level keys into model section") - f.fixed += 1 - else: - issues.append("Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'") - except Exception: - pass - - # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing - # agent.max_turns in config.yaml (issue #17534). The setup wizard - # used to dual-write the iteration budget to both stores; users who - # later edit only config.yaml are left with a .env ghost. The gateway - # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns - # at startup, but if that bridge bails (any earlier config-parse - # error), the stale .env value silently wins and the agent runs at the - # wrong budget — e.g. config says 400 but the activity line reads N/90. - # Read the .env FILE directly (load_env), not get_env_value/os.environ, - # which the startup bridge may already have overridden. - try: - from hermes_cli.config import load_env, read_user_config_raw, remove_env_value - # Raw-file diagnostic: drift check against the raw file. - raw_config = read_user_config_raw(config_path) - agent_cfg = raw_config.get("agent") - cfg_max_turns = ( - agent_cfg.get("max_turns") - if isinstance(agent_cfg, dict) - else None - ) - # Legacy root-level key counts too. - if cfg_max_turns is None: - cfg_max_turns = raw_config.get("max_turns") - env_ghost = load_env().get("HERMES_MAX_ITERATIONS") - drift = ( - cfg_max_turns is not None - and env_ghost is not None - and str(cfg_max_turns).strip() != str(env_ghost).strip() - ) - if drift: - check_warn( - f"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows " - f"agent.max_turns={cfg_max_turns} in config.yaml", - "(stale ghost from an earlier `hermes setup` run)", - ) - if should_fix: - if remove_env_value("HERMES_MAX_ITERATIONS"): - check_ok( - "Removed stale HERMES_MAX_ITERATIONS from .env " - f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)" - ) - f.fixed += 1 - else: - check_warn("Could not remove HERMES_MAX_ITERATIONS from .env") - manual_issues.append( - "Manually delete the HERMES_MAX_ITERATIONS line from " - f"{_DHH}/.env — config.yaml agent.max_turns is authoritative." - ) - else: - issues.append( - "Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — " - "run 'hermes doctor --fix'" - ) - except Exception: - pass - - # Surface deprecated/legacy config keys and env vars (warn-only). - # Migrations may still live in config.py version steps; doctor does - # not auto-delete here — only tells the user the modern replacement. - try: - from hermes_cli.config import load_env as _load_env_depr - from hermes_cli.config import read_user_config_raw as _read_raw_depr - - # Raw-file diagnostic: deprecation sweep inspects the raw file. - _raw_for_depr = _read_raw_depr(config_path) - # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD - # from terminal.cwd) does not false-positive. - try: - _env_for_depr = _load_env_depr() - except Exception: - _env_for_depr = {} - report_deprecated_config_and_env(_raw_for_depr, _env_for_depr) - except Exception: - pass - - # Validate config structure (catches malformed custom_providers, etc.) - try: - from hermes_cli.config import validate_config_structure - config_issues = validate_config_structure() - if config_issues: - _section("Config Structure") - for ci in config_issues: - if ci.severity == "error": - check_fail(ci.message) - else: - check_warn(ci.message) - # Show the hint indented - for hint_line in ci.hint.splitlines(): - check_info(hint_line) - issues.append(ci.message) - except Exception: - pass - - if not config_path.exists(): - # No config.yaml — still surface deprecated env vars from .env. - try: - from hermes_cli.config import load_env as _load_env_depr - - try: - _env_for_depr = _load_env_depr() - except Exception: - _env_for_depr = {} - report_deprecated_config_and_env({}, _env_for_depr) - except Exception: - pass - return f - - -def _check_xai_retirement(should_fix: bool) -> Finding: - f = Finding() - manual_issues = f.manual_issues - try: - from hermes_cli.config import load_config - from hermes_cli.xai_retirement import ( - MIGRATION_GUIDE_URL, - find_retired_xai_refs, - format_issue, - ) - - _xai_cfg = load_config() - retired_refs = find_retired_xai_refs(_xai_cfg) - if not retired_refs: - check_ok("No retired xAI models in config") - else: - for ref in retired_refs: - check_warn(format_issue(ref)) - check_info(f"Migration guide: {MIGRATION_GUIDE_URL}") - manual_issues.append( - f"Update {len(retired_refs)} retired xAI model reference(s) " - f"in config.yaml — see {MIGRATION_GUIDE_URL}" - ) - except Exception as _xai_check_err: - check_warn("xAI retirement check skipped", f"({_xai_check_err})") - return f - - def _check_auth_providers(should_fix: bool) -> Finding: """Refresh-free OAuth status snapshot (doctor must never trigger a token refresh).""" f = Finding() @@ -2109,95 +722,6 @@ def _check_state_db(should_fix: bool) -> Finding: return f -def _check_gateway_supervision(should_fix: bool) -> Finding: - f = Finding() - issues = f.issues - _check_gateway_service_linger(issues) - _check_s6_supervision(issues) - return f - - -def _check_command_installation(should_fix: bool) -> Finding: - """Venv entry point and the ~/.local/bin (or $PREFIX/bin) symlink; skipped on Windows.""" - 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 - - # 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]')" - ) - 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'") - return f - - def _check_git_and_rg(should_fix: bool) -> Finding: f = Finding() # Git diff --git a/hermes_cli/doctor_config.py b/hermes_cli/doctor_config.py new file mode 100644 index 0000000000..8524601787 --- /dev/null +++ b/hermes_cli/doctor_config.py @@ -0,0 +1,713 @@ +"""Configuration-file checks for hermes doctor: .env, config.yaml validation, drift, deprecations. + +Split out of ``hermes_cli/doctor.py``; every moved name is re-imported there, so +``hermes_cli.doctor.`` keeps resolving (and monkeypatching) as before. +""" + +from __future__ import annotations + +import os +import shutil +from hermes_cli.doctor_report import ( + Finding, + _fail_and_issue, + _section, + check_fail, + check_info, + check_ok, + check_warn, +) + + +def _has_provider_env_config(content: str) -> bool: + """Return True when ~/.hermes/.env contains provider auth/base URL settings.""" + from hermes_cli.doctor import _PROVIDER_ENV_HINTS + return any(key in content for key in _PROVIDER_ENV_HINTS) + + +# Deprecated / legacy config keys still read for back-compat. Doctor surfaces +# them as non-failing warnings with the modern replacement — it does not +# auto-migrate or delete (migrations live in config.py version steps). +_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = ( + # (section, key, replacement) + ("display", "tool_progress_overrides", "display.platforms"), + ("delegation", "max_async_children", "delegation.max_concurrent_children"), +) + + +# compression.summary_* → auxiliary.compression (model/provider/base_url) +_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = ( + "summary_model", + "summary_provider", + "summary_base_url", +) + + +# Deprecated env vars (checked in the .env file, not process env, so config→env +# bridges like terminal.cwd → TERMINAL_CWD do not false-positive). +_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = ( + # HERMES_TOOL_PROGRESS is fully unsupported since the v12 config support + # floor removed its only consumer (the v3→4 migration) — it is silently + # ignored. HERMES_TOOL_PROGRESS_MODE is still read by the gateway as a + # back-compat fallback but remains deprecated. + ("HERMES_TOOL_PROGRESS", "display.tool_progress in config.yaml — ignored/unsupported since config floor v12"), + ("HERMES_TOOL_PROGRESS_MODE", "display.tool_progress in config.yaml"), + ("TERMINAL_CWD", "terminal.cwd in config.yaml"), + ("MESSAGING_CWD", "terminal.cwd in config.yaml"), + ("QQ_HOME_CHANNEL", "QQBOT_HOME_CHANNEL"), + ("QQ_HOME_CHANNEL_NAME", "QQBOT_HOME_CHANNEL_NAME"), +) + + +def collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]: + """Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*. + + Only keys that appear in the on-disk YAML are reported (raw file load, not + merged defaults). Empty containers still count — presence of the legacy + key is the signal that the user should migrate. + """ + findings: list[tuple[str, str]] = [] + if not isinstance(raw_config, dict): + return findings + + for section, key, replacement in _DEPRECATED_CONFIG_KEYS: + section_val = raw_config.get(section) + if isinstance(section_val, dict) and key in section_val: + findings.append((f"{section}.{key}", replacement)) + + compression = raw_config.get("compression") + if isinstance(compression, dict): + for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS: + if key in compression: + findings.append((f"compression.{key}", "auxiliary.compression")) + + return findings + + +def collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]: + """Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*. + + *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not + ``os.environ``, so bridged runtime vars do not trigger false positives. + """ + findings: list[tuple[str, str]] = [] + if not isinstance(env_map, dict): + return findings + for name, replacement in _DEPRECATED_ENV_VARS: + val = env_map.get(name) + if val is not None and str(val).strip() != "": + findings.append((name, replacement)) + return findings + + +def collect_relay_plugin_cutover_findings( + raw_config: dict | None, + env_map: dict | None, +) -> list[tuple[str, str]]: + """Return actionable findings for the removed Hermes Relay plugin.""" + from hermes_cli.relay_plugin_cutover import ( + LEGACY_RELAY_EXPORT_ENV_VARS, + RELAY_PLUGINS_CONFIG_ENV, + configured_legacy_relay_env_vars, + legacy_relay_plugin_keys, + ) + + findings: list[tuple[str, str]] = [] + if isinstance(raw_config, dict): + plugins = raw_config.get("plugins") + if isinstance(plugins, dict): + for key in legacy_relay_plugin_keys(plugins.get("enabled")): + findings.append( + ( + f"plugins.enabled: {key}", + f"remove it and configure {RELAY_PLUGINS_CONFIG_ENV}", + ) + ) + + effective_env = dict(env_map or {}) + # Fall through to process-level env ONLY when no explicit env_map was + # given: run_doctor passes None and wants live-process vars included, but + # callers (and tests) that hand in an explicit map are describing a + # complete environment — merging os.environ on top breaks hermeticity on + # any box that exports legacy relay vars (10-vs-2 findings, Aug 2026). + if env_map is None: + for name in (*LEGACY_RELAY_EXPORT_ENV_VARS, RELAY_PLUGINS_CONFIG_ENV): + if name not in effective_env and os.environ.get(name) is not None: + effective_env[name] = os.environ[name] + if not str(effective_env.get(RELAY_PLUGINS_CONFIG_ENV, "")).strip(): + for name in configured_legacy_relay_env_vars(effective_env): + findings.append( + ( + name, + f"move exporter settings to {RELAY_PLUGINS_CONFIG_ENV}; " + "this variable is now ignored", + ) + ) + return findings + + +def report_deprecated_config_and_env( + raw_config: dict | None = None, + env_map: dict | None = None, +) -> list[tuple[str, str]]: + """Emit non-failing doctor warnings for deprecated config keys and env vars. + + Returns the list of ``(legacy, replacement)`` findings that were reported + (empty when nothing deprecated is present). Does not mutate config/env and + does not append to the blocking ``issues`` list. + """ + deprecated = collect_deprecated_config_keys(raw_config) + deprecated.extend(collect_deprecated_env_vars(env_map)) + relay_cutover = collect_relay_plugin_cutover_findings(raw_config, env_map) + findings = deprecated + relay_cutover + if not findings: + check_ok("No deprecated config keys or env vars") + return findings + + for legacy, replacement in deprecated: + check_warn( + f"Deprecated: {legacy}", + f"(use {replacement} instead)", + ) + check_info(f"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)") + for legacy, replacement in relay_cutover: + check_warn( + f"Breaking Relay migration: {legacy}", + f"({replacement})", + ) + check_info(f"Migrate {legacy}: {replacement}") + return findings + + +def managed_scope_check() -> None: + """Report the active managed scope (resolved dir + pinned key counts). + + Silent when no managed scope is present. When the managed directory was + resolved from the HERMES_MANAGED_DIR override (rather than the system + default), that is surfaced too — a redirected scope is the documented + foot-gun (see docs/design/managed-scope.md §7) and an operator should see it. + """ + try: + from hermes_cli import managed_scope + managed_dir = managed_scope.get_managed_dir() + except Exception: # noqa: BLE001 — diagnostics must never crash + return + if managed_dir is None: + return + n_cfg = len(managed_scope.managed_config_keys()) + n_env = len(managed_scope.load_managed_env()) + check_ok( + f"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) " + f"pinned by {managed_dir}" + ) + if os.environ.get("HERMES_MANAGED_DIR", "").strip(): + check_info(f"managed dir set via HERMES_MANAGED_DIR={managed_dir}") + + +def _check_mcp_security(should_fix: bool) -> Finding: + """Flag mcp_servers entries with suspicious stdio commands.""" + f = Finding() + manual_issues = f.manual_issues + try: + from hermes_cli.config import load_config + from hermes_cli.mcp_security import validate_mcp_server_entry + + servers = load_config().get("mcp_servers") or {} + suspicious = 0 + if isinstance(servers, dict): + for name, entry in sorted(servers.items()): + if not isinstance(entry, dict): + continue + issues_found = validate_mcp_server_entry(name, entry) + if not issues_found: + continue + suspicious += 1 + check_warn(f"MCP server '{name}' has suspicious stdio command", "; ".join(issues_found)) + manual_issues.append( + f"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed." + ) + if suspicious == 0: + check_ok("No suspicious MCP stdio commands") + except Exception as e: + check_warn(f"MCP security check failed: {e}") + return f + + +def _check_env_file(should_fix: bool) -> Finding: + """Managed scope plus ~/.hermes/.env presence and provider credentials.""" + from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH + f = Finding() + issues = f.issues + managed_scope_check() + # Check ~/.hermes/.env (primary location for user config) + env_path = HERMES_HOME / '.env' + if env_path.exists(): + check_ok(f"{_DHH}/.env file exists") + + # Prefer UTF-8 (.env is written as UTF-8 elsewhere). Fall back to + # latin-1 for Windows Notepad/cp1252 files that are not valid UTF-8 — + # matches hermes_cli.env_loader._load_dotenv_with_fallback. + try: + content = env_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + content = env_path.read_text(encoding="latin-1") + if _has_provider_env_config(content): + check_ok("API key or custom endpoint configured") + else: + check_warn(f"No API key found in {_DHH}/.env") + issues.append("Run 'hermes setup' to configure API keys") + else: + # Also check project root as fallback + fallback_env = PROJECT_ROOT / '.env' + if fallback_env.exists(): + check_ok(".env file exists (in project directory)") + else: + check_fail(f"{_DHH}/.env file missing") + if should_fix: + env_path.parent.mkdir(parents=True, exist_ok=True) + env_path.touch() + # .env holds API keys — restrict to owner-only access from + # creation. touch() obeys umask which is commonly 0o022, + # leaving the file world-readable; tighten explicitly. + try: + os.chmod(str(env_path), 0o600) + except OSError: + pass + check_ok(f"Created empty {_DHH}/.env") + check_info("Run 'hermes setup' to configure API keys") + f.fixed += 1 + else: + check_info("Run 'hermes setup' to create one") + issues.append("Run 'hermes setup' to create .env") + return f + + +def _check_config_file(should_fix: bool) -> Finding: + """config.yaml presence; validate model.provider / model.default and credentials.""" + from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH + f = Finding() + issues = f.issues + # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback) + config_path = HERMES_HOME / 'config.yaml' + if config_path.exists(): + check_ok(f"{_DHH}/config.yaml exists") + + # Validate model.provider and model.default values + try: + # Raw-file diagnostic: inspects what the user actually wrote. + from hermes_cli.config import read_user_config_raw + cfg = read_user_config_raw(config_path) + model_section = cfg.get("model") or {} + provider_raw = (model_section.get("provider") or "").strip() + provider = provider_raw.lower() + default_model = (model_section.get("default") or model_section.get("model") or "").strip() + + known_providers: set = set() + try: + from hermes_cli.auth import ( + PROVIDER_REGISTRY, + resolve_provider as _resolve_auth_provider, + ) + known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto", "moa"} + except Exception: + _resolve_auth_provider = None + pass + try: + from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers + from hermes_cli.providers import ( + custom_provider_aliases as _custom_provider_aliases, + normalize_provider as _normalize_catalog_provider, + resolve_provider_full as _resolve_provider_full, + ) + except Exception: + _compatible_custom_providers = None + _custom_provider_aliases = None + _normalize_catalog_provider = None + _resolve_provider_full = None + + custom_providers = [] + if _compatible_custom_providers is not None: + try: + custom_providers = _compatible_custom_providers(cfg) + except Exception: + custom_providers = [] + + user_providers = cfg.get("providers") + if isinstance(user_providers, dict): + from hermes_cli.config import is_provider_enabled + known_providers.update( + str(name).strip().lower() + for name, prov_cfg in user_providers.items() + if str(name).strip() and is_provider_enabled(prov_cfg) + ) + for entry in custom_providers: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "").strip() + provider_key = str(entry.get("provider_key") or "").strip() + if name and _custom_provider_aliases is not None: + known_providers.update( + _custom_provider_aliases(name, provider_key) + ) + + valid_provider_ids = set(known_providers) + provider_ids_to_accept = {provider} if provider else set() + if _normalize_catalog_provider is not None: + for known_provider in known_providers: + try: + valid_provider_ids.add(_normalize_catalog_provider(known_provider)) + except Exception: + continue + + runtime_provider = provider + if ( + provider + and _resolve_auth_provider is not None + and provider not in {"auto", "custom"} + ): + try: + runtime_provider = _resolve_auth_provider(provider) + provider_ids_to_accept.add(runtime_provider) + except Exception: + runtime_provider = provider + + catalog_provider = provider + if ( + provider + and _resolve_provider_full is not None + and provider not in {"auto", "custom"} + ): + provider_def = _resolve_provider_full(provider, user_providers, custom_providers) + catalog_provider = provider_def.id if provider_def is not None else None + if catalog_provider is not None: + provider_ids_to_accept.add(catalog_provider) + + if provider and provider != "auto": + if catalog_provider is None or ( + known_providers + and not (provider_ids_to_accept & valid_provider_ids) + ): + known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)" + _fail_and_issue( + f"model.provider '{provider_raw}' is not a recognised provider", + f"(known: {known_list})", + ( + f"model.provider '{provider_raw}' is unknown. " + f"Valid providers: {known_list}. " + f"Fix: run 'hermes config set model.provider '" + ), + issues, + ) + + # Warn if model is set to a provider-prefixed name on a provider that doesn't use them. + # Vendor/model slugs are valid on aggregator-style providers and on any custom + # provider — bare "custom" or a named "custom:" that fronts an OpenAI-compatible + # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix. + provider_for_policy = runtime_provider or catalog_provider + provider_policy_id = str(provider_for_policy or "").strip().lower() + providers_accepting_vendor_slugs = { + "openrouter", + "auto", + "ai-gateway", + "kilocode", + "opencode-zen", + "huggingface", + "lmstudio", + "nous", + "nvidia", + # Fireworks' native model IDs are slash-form + # (accounts/fireworks/models/... and .../routers/...), so a "/" + # is expected, not an aggregator vendor prefix. + "fireworks", + # DeepInfra is an aggregator-style gateway: its catalog + # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…, + # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …). + "deepinfra", + } + provider_accepts_vendor_slug = ( + provider_policy_id in providers_accepting_vendor_slugs + or provider_policy_id == "custom" + or provider_policy_id.startswith("custom:") + ) + if ( + default_model + and "/" in default_model + and provider_policy_id + and not provider_accepts_vendor_slug + ): + check_warn( + f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'", + "(vendor-prefixed slugs belong to aggregators like openrouter)", + ) + issues.append( + f"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. " + "Either set model.provider to 'openrouter', or drop the vendor prefix." + ) + + # Check credentials for the configured provider. + # Limit to API-key providers in PROVIDER_REGISTRY — other provider + # types (OAuth, SDK, anthropic/custom/auto) have their own env-var + # checks elsewhere in doctor, and get_auth_status() returns a bare + # {logged_in: False} for anything it doesn't explicitly dispatch, + # which would produce false positives. + if runtime_provider and runtime_provider not in ("auto", "custom"): + try: + if runtime_provider == "openrouter": + from hermes_cli.config import get_env_value + + configured = bool( + str(get_env_value("OPENROUTER_API_KEY") or "").strip() + or str(get_env_value("OPENAI_API_KEY") or "").strip() + ) + else: + from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status + + pconfig = PROVIDER_REGISTRY.get(runtime_provider) + configured = True + if pconfig and getattr(pconfig, "auth_type", "") == "api_key": + status = get_auth_status(runtime_provider) or {} + configured = bool( + status.get("configured") + or status.get("logged_in") + or status.get("api_key") + ) + if not configured: + _fail_and_issue( + f"model.provider '{runtime_provider}' is set but no API key is configured", + "(check ~/.hermes/.env or run 'hermes setup')", + ( + f"No credentials found for provider '{runtime_provider}'. " + f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " + f"or switch providers with 'hermes config set model.provider '" + ), + issues, + ) + except Exception: + pass + + except Exception as e: + check_warn("Could not validate model/provider config", f"({e})") + else: + fallback_config = PROJECT_ROOT / 'cli-config.yaml' + if fallback_config.exists(): + check_ok("cli-config.yaml exists (in project directory)") + else: + if should_fix: + config_path.parent.mkdir(parents=True, exist_ok=True) + example_config = PROJECT_ROOT / 'cli-config.yaml.example' + if example_config.exists(): + shutil.copy2(str(example_config), str(config_path)) + check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example") + else: + from hermes_cli.config import DEFAULT_CONFIG, save_config + save_config(DEFAULT_CONFIG) + check_ok(f"Created {_DHH}/config.yaml from defaults") + f.fixed += 1 + else: + check_warn("config.yaml not found", "(using defaults)") + return f + + +def _check_config_drift(should_fix: bool) -> Finding: + """Config version, stale root keys, HERMES_MAX_ITERATIONS ghost, deprecations, structure.""" + from hermes_cli.doctor import HERMES_HOME, _DHH + f = Finding() + issues, manual_issues = f.issues, f.manual_issues + # Check config version and stale keys + config_path = HERMES_HOME / 'config.yaml' + if config_path.exists(): + try: + from hermes_cli.config import check_config_version, migrate_config + current_ver, latest_ver = check_config_version() + if current_ver < latest_ver: + check_warn( + f"Config version outdated (v{current_ver} → v{latest_ver})", + "(new settings available)" + ) + if should_fix: + try: + migrate_config(interactive=False, quiet=False) + check_ok("Config migrated to latest version") + f.fixed += 1 + except Exception as mig_err: + check_warn(f"Auto-migration failed: {mig_err}") + issues.append("Run 'hermes setup' to migrate config") + else: + issues.append("Run 'hermes doctor --fix' or 'hermes setup' to migrate config") + else: + check_ok(f"Config version up to date (v{current_ver})") + except Exception: + pass + + # Detect stale root-level model keys (known bug source — PR #4329) + try: + # Raw-file diagnostic: stale-key detection must see the raw file. + from hermes_cli.config import read_user_config_raw + raw_config = read_user_config_raw(config_path) + stale_root_keys = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)] + if stale_root_keys: + check_warn( + f"Stale root-level config keys: {', '.join(stale_root_keys)}", + "(should be under 'model:' section)" + ) + if should_fix: + # Coerce scalar/None ``model:`` into a dict before mutation — + # ``setdefault("model", {})`` would return an existing scalar + # and then ``model_section[k] = ...`` would raise TypeError. + raw_model = raw_config.get("model") + if isinstance(raw_model, dict): + model_section = raw_model + elif isinstance(raw_model, str) and raw_model.strip(): + model_section = {"default": raw_model.strip()} + raw_config["model"] = model_section + else: + model_section = {} + raw_config["model"] = model_section + for k in stale_root_keys: + if not model_section.get(k): + model_section[k] = raw_config.pop(k) + else: + raw_config.pop(k) + from hermes_cli.config import atomic_config_write + atomic_config_write(config_path, raw_config) + check_ok("Migrated stale root-level keys into model section") + f.fixed += 1 + else: + issues.append("Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'") + except Exception: + pass + + # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing + # agent.max_turns in config.yaml (issue #17534). The setup wizard + # used to dual-write the iteration budget to both stores; users who + # later edit only config.yaml are left with a .env ghost. The gateway + # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns + # at startup, but if that bridge bails (any earlier config-parse + # error), the stale .env value silently wins and the agent runs at the + # wrong budget — e.g. config says 400 but the activity line reads N/90. + # Read the .env FILE directly (load_env), not get_env_value/os.environ, + # which the startup bridge may already have overridden. + try: + from hermes_cli.config import load_env, read_user_config_raw, remove_env_value + # Raw-file diagnostic: drift check against the raw file. + raw_config = read_user_config_raw(config_path) + agent_cfg = raw_config.get("agent") + cfg_max_turns = ( + agent_cfg.get("max_turns") + if isinstance(agent_cfg, dict) + else None + ) + # Legacy root-level key counts too. + if cfg_max_turns is None: + cfg_max_turns = raw_config.get("max_turns") + env_ghost = load_env().get("HERMES_MAX_ITERATIONS") + drift = ( + cfg_max_turns is not None + and env_ghost is not None + and str(cfg_max_turns).strip() != str(env_ghost).strip() + ) + if drift: + check_warn( + f"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows " + f"agent.max_turns={cfg_max_turns} in config.yaml", + "(stale ghost from an earlier `hermes setup` run)", + ) + if should_fix: + if remove_env_value("HERMES_MAX_ITERATIONS"): + check_ok( + "Removed stale HERMES_MAX_ITERATIONS from .env " + f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)" + ) + f.fixed += 1 + else: + check_warn("Could not remove HERMES_MAX_ITERATIONS from .env") + manual_issues.append( + "Manually delete the HERMES_MAX_ITERATIONS line from " + f"{_DHH}/.env — config.yaml agent.max_turns is authoritative." + ) + else: + issues.append( + "Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — " + "run 'hermes doctor --fix'" + ) + except Exception: + pass + + # Surface deprecated/legacy config keys and env vars (warn-only). + # Migrations may still live in config.py version steps; doctor does + # not auto-delete here — only tells the user the modern replacement. + try: + from hermes_cli.config import load_env as _load_env_depr + from hermes_cli.config import read_user_config_raw as _read_raw_depr + + # Raw-file diagnostic: deprecation sweep inspects the raw file. + _raw_for_depr = _read_raw_depr(config_path) + # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD + # from terminal.cwd) does not false-positive. + try: + _env_for_depr = _load_env_depr() + except Exception: + _env_for_depr = {} + report_deprecated_config_and_env(_raw_for_depr, _env_for_depr) + except Exception: + pass + + # Validate config structure (catches malformed custom_providers, etc.) + try: + from hermes_cli.config import validate_config_structure + config_issues = validate_config_structure() + if config_issues: + _section("Config Structure") + for ci in config_issues: + if ci.severity == "error": + check_fail(ci.message) + else: + check_warn(ci.message) + # Show the hint indented + for hint_line in ci.hint.splitlines(): + check_info(hint_line) + issues.append(ci.message) + except Exception: + pass + + if not config_path.exists(): + # No config.yaml — still surface deprecated env vars from .env. + try: + from hermes_cli.config import load_env as _load_env_depr + + try: + _env_for_depr = _load_env_depr() + except Exception: + _env_for_depr = {} + report_deprecated_config_and_env({}, _env_for_depr) + except Exception: + pass + return f + + +def _check_xai_retirement(should_fix: bool) -> Finding: + f = Finding() + manual_issues = f.manual_issues + try: + from hermes_cli.config import load_config + from hermes_cli.xai_retirement import ( + MIGRATION_GUIDE_URL, + find_retired_xai_refs, + format_issue, + ) + + _xai_cfg = load_config() + retired_refs = find_retired_xai_refs(_xai_cfg) + if not retired_refs: + check_ok("No retired xAI models in config") + else: + for ref in retired_refs: + check_warn(format_issue(ref)) + check_info(f"Migration guide: {MIGRATION_GUIDE_URL}") + manual_issues.append( + f"Update {len(retired_refs)} retired xAI model reference(s) " + f"in config.yaml — see {MIGRATION_GUIDE_URL}" + ) + except Exception as _xai_check_err: + check_warn("xAI retirement check skipped", f"({_xai_check_err})") + return f diff --git a/hermes_cli/doctor_platform.py b/hermes_cli/doctor_platform.py new file mode 100644 index 0000000000..0351810216 --- /dev/null +++ b/hermes_cli/doctor_platform.py @@ -0,0 +1,859 @@ +"""Host-platform checks for hermes doctor: interpreter, SQLite, certificates, macOS TCC, gateway supervision, command install. + +Split out of ``hermes_cli/doctor.py``; every moved name is re-imported there, so +``hermes_cli.doctor.`` keeps resolving (and monkeypatching) as before. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +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_constants import is_termux as _is_termux + + +def _python_install_cmd() -> str: + return "python -m pip install" if _is_termux() else "uv pip install" + + +def _system_package_install_cmd(pkg: str) -> str: + if _is_termux(): + return f"pkg install {pkg}" + if sys.platform == "darwin": + return f"brew install {pkg}" + return f"sudo apt install {pkg}" + + +def _sqlite_upgrade_hint(install_method: str | None = None) -> str: + """Return an actionable SQLite upgrade hint for this install layout.""" + 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" + elif is_nix_install_method(method): + # The Nix helper is prose guidance, not a literal shell command. + action = recommended_update_command_for_method(method) + elif method == "apt": + action = f"run `{recommended_update_command_for_method(method)}`" + else: + action = "run `hermes update`" + return ( + f"({action}; fixed versions: 3.51.3+ / 3.50.7 / 3.44.6 — " + "see https://sqlite.org/wal.html#walresetbug)" + ) + + +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. + 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. + 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 + + +_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. + """ + try: + db_path.stat() + except OSError as exc: + return str(exc) + if not os.access(db_path, os.R_OK): + return f"permission denied: {db_path}" + return "file could not be read" + + +def _read_journal_mode(db_path: Path) -> tuple[str | None, str | None]: + """Return (journal mode, error) from the file header 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. + """ + 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: + if has_live_connection(db_path): + return None, "database is open in this process" + return None, _unreadable_reason(db_path) + if len(header) == 0: + 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 + 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 + + try: + nbytes = db_path.stat().st_size + except OSError: + return "size unknown" + return _format_size(nbytes) + + +def _report_database_journal_modes( + hermes_home: Path | None = None, + version_info: tuple[int, ...] | None = None, +) -> None: + """List each database's journal mode; warn on WAL under a vulnerable SQLite.""" + from hermes_cli.doctor import HERMES_HOME + from hermes_state import _wal_reset_repair_hint, is_sqlite_wal_reset_vulnerable + + vulnerable = is_sqlite_wal_reset_vulnerable(version_info) + home = hermes_home if hermes_home is not None else HERMES_HOME + try: + databases = _hermes_database_paths(home) + except Exception as exc: + check_warn(f"Could not list Hermes databases: {exc}") + return + exposed = [] + for name, path in databases: + if not path.is_file(): + continue + mode, error = _read_journal_mode(path) + 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)", + ) + 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)", + ) + else: + check_info(f"{name}: WAL journal mode ({size})") + elif vulnerable: + check_info(f"{name}: rollback journal mode ({size}, not exposed)") + else: + check_info(f"{name}: rollback journal mode ({size})") + if exposed: + check_info(f"To clear the exposure: {_wal_reset_repair_hint()}") + + +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. + """ + from hermes_cli.doctor import PROJECT_ROOT + pyproject = PROJECT_ROOT / "pyproject.toml" + try: + text = pyproject.read_text(encoding="utf-8") + except OSError: + return None + in_project = False + for raw in text.splitlines(): + line = raw.strip() + if line.startswith("[") and line.endswith("]"): + 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("\"'") + return value or None + return None + + +def _check_version_consistency(issues: list[str]) -> None: + """Verify pyproject.toml version matches hermes_cli.__version__. + + 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. + """ + try: + from hermes_cli import __version__ as init_version + except Exception: + 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})") + else: + _fail_and_issue( + "Version mismatch between source files", + f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})", + "Re-sync version files (e.g. run 'hermes update', or set " + "hermes_cli/__init__.py __version__ to match pyproject.toml)", + issues, + ) + + +def _check_s6_supervision(issues: list[str]) -> None: + """Inside a container under our s6 /init, surface what s6 sees. + + 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`` + """ + try: + 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"): + if mgr.is_running(static): + check_ok(f"{static}: up") + else: + check_info(f"{static}: down (expected if not enabled via env)") + + profiles = mgr.list_profile_gateways() + if not profiles: + check_info("No per-profile gateways registered yet — create one with `hermes profile create `") + return + + up_count = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}")) + check_ok( + f"Per-profile gateways: {up_count}/{len(profiles)} supervised up" + + (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "") + ) + + +def check_certificates(should_fix: bool = False, issues: "list | None" = None) -> None: + """Verify the certifi CA bundle is loadable. + + 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. + """ + try: + from agent.ssl_guard import verify_ca_bundle_with_fallback + from agent.errors import SSLConfigurationError + except Exception as e: + check_warn("SSL certificate check skipped", str(e)) + return + + try: + verify_ca_bundle_with_fallback() + check_ok("SSL CA certificate bundle is valid") + return + except SSLConfigurationError as e: + first_error = str(e) + except Exception as e: + check_warn("SSL certificate check skipped", str(e)) + return + + 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`" + ) + 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, + ) + 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" + ) + 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" + ) + return + + # Drop any cached certifi module so where() re-resolves the new bundle. + 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) + importlib.invalidate_caches() + + try: + verify_ca_bundle_with_fallback() + 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." + ) + + +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``. + """ + try: + 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(): + return + + _section("Gateway Service") + linger_enabled, linger_detail = get_systemd_linger_status() + if linger_enabled is True: + check_ok("Systemd linger enabled", "(gateway service survives logout)") + elif linger_enabled is False: + check_warn("Systemd linger disabled", "(gateway may stop after logout)") + check_info("Run: sudo loginctl enable-linger $USER") + issues.append("Enable linger for the gateway user service: sudo loginctl enable-linger $USER") + else: + check_warn("Could not verify systemd linger", f"({linger_detail})") + + +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. + """ + from hermes_cli.doctor import _desktop_app_bundle, _macos_desktop_dr + if sys.platform != "darwin": + return + app = _desktop_app_bundle() + if app is 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)", + ) + 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", + "the desktop bundle's designated requirement is cdhash-pinned " + "(pre-#73681 build) — rebuilds invalidate all permission grants. " + "Run `hermes update` to get the stable identifier-pinned signing " + "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)", + ) + else: + check_ok( + "macOS TCC signing identity is stable", + "(identifier-pinned DR; grants survive rebuilds — for the strongest " + "anchor, see `hermes desktop --setup-tcc-identity`)", + ) + check_info( + "If macOS still re-prompts for permissions (toggle shows ON): the stored " + "grant is stale — run `tccutil reset ScreenCapture com.nousresearch.hermes` " + "(repeat per affected service), toggle it ON in System Settings, then " + "fully quit & relaunch Hermes once." + ) + + +def _desktop_app_bundle() -> Path | None: + """Locate the locally-built desktop app bundle, if any. + + Mirrors the install layout the self-updater produces + (``apps/desktop/release/mac-/Hermes.app``) — the only layout whose + ad-hoc re-signed bundle can invalidate TCC grants. When multiple arch + trees coexist (stale cross-build), the newest wins, matching + ``_desktop_packaged_executable``'s selection. ``/Applications/Hermes.app`` + is deliberately not probed: it is the separately-signed Hermes-Setup + launcher (``com.nousresearch.hermes.setup``, certificate-anchored), whose + grants are stable by construction and unaffected by rebuilds. + """ + root = Path(__file__).resolve().parents[1] + release_dir = root / "apps" / "desktop" / "release" + candidates = [p for p in release_dir.glob("mac*/Hermes.app") if p.is_dir()] + if not candidates: + return None + return max(candidates, key=lambda p: p.stat().st_mtime) + + +def _macos_desktop_dr(app: Path) -> str | None: + """Return the bundle's designated requirement string, or None on failure.""" + 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, + ) + 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 + return (proc.stdout or "") + (proc.stderr or "") + + +def check_macos_tcc_anchor(should_fix: bool = False) -> None: + """Report (and optionally install) the dylib-complete TCC anchor (#95596). + + 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. + """ + try: + from hermes_cli import macos_tcc_anchor as tcc + + status, detail = tcc.tcc_anchor_state() + if status == "skip": + return + if status == "active": + check_ok("macOS TCC anchor active", f"({detail})") + return + if should_fix: + anchored = tcc.ensure_tcc_anchor() + 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 check failed", f"({e})") + + +def check_macos_full_disk_access() -> None: + """One-grant guidance: Full Disk Access silences every folder prompt. + + 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. + """ + 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)", + ) + 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." + ) + + +def _check_security_advisories(should_fix: bool) -> Finding: + """Compromised-package advisories; funnels remediation into manual issues.""" + f = Finding() + manual_issues = f.manual_issues + try: + 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: + check_ok("No active security advisories") + 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.""" + 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}") + 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)") + else: + _fail_and_issue( + f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", + "(3.10+ required)", + "Upgrade Python to 3.10+", + 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. + 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 + ) + 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(), + ) + else: + check_ok(f"SQLite {_sqlite_ver}") + if _sqlite_src_short: + check_info(f"SQLite source id: {_sqlite_src_short}") + _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: + 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_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) + 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: + 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: + try: + __import__(module) + check_ok(name, "(optional)") + except ImportError: + check_warn(name, "(optional, not installed)") + return f + + +def _check_gateway_supervision(should_fix: bool) -> Finding: + f = Finding() + issues = f.issues + _check_gateway_service_linger(issues) + _check_s6_supervision(issues) + return f + + +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 + + # 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]')" + ) + 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'") + return f diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 267388f369..70c7f0efe3 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -47,7 +47,9 @@ class TestDoctorPlatformHints: assert "hermes update" not in hint def test_sqlite_upgrade_hint_preserves_nix_guidance_as_prose(self): - guidance = doctor.recommended_update_command_for_method("nix") + from hermes_cli.config import recommended_update_command_for_method + + guidance = recommended_update_command_for_method("nix") hint = doctor._sqlite_upgrade_hint("nix") assert guidance in hint