refactor(doctor): split config and host-platform checks into doctor_config.py / doctor_platform.py

This commit is contained in:
Teknium
2026-09-02 15:56:21 -07:00
parent 687884416d
commit 9b05f0df55
4 changed files with 1619 additions and 1521 deletions

File diff suppressed because it is too large Load Diff

713
hermes_cli/doctor_config.py Normal file
View File

@@ -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.<name>`` 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 <valid_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:<name>" 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 <name>'"
),
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

View File

@@ -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.<name>`` 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 <name>`")
return
up_count = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}"))
check_ok(
f"Per-profile gateways: {up_count}/{len(profiles)} supervised up"
+ (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-<arch>/Hermes.app``) — the only layout whose
ad-hoc re-signed bundle can invalidate TCC grants. When multiple arch
trees coexist (stale cross-build), the newest wins, matching
``_desktop_packaged_executable``'s selection. ``/Applications/Hermes.app``
is deliberately not probed: it is the separately-signed Hermes-Setup
launcher (``com.nousresearch.hermes.setup``, certificate-anchored), whose
grants are stable by construction and unaffected by rebuilds.
"""
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

View File

@@ -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