refactor(doctor): split config and host-platform checks into doctor_config.py / doctor_platform.py
This commit is contained in:
1564
hermes_cli/doctor.py
1564
hermes_cli/doctor.py
File diff suppressed because it is too large
Load Diff
713
hermes_cli/doctor_config.py
Normal file
713
hermes_cli/doctor_config.py
Normal 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
|
||||
859
hermes_cli/doctor_platform.py
Normal file
859
hermes_cli/doctor_platform.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user