refactor(hermes_cli): doctor — doctor_check on trivial wrappers, join wrapped message args, drop in-function blank lines
This commit is contained in:
@@ -96,7 +96,6 @@ def _check_auth_providers(should_fix: bool) -> Finding:
|
||||
_login_row("MiniMax OAuth", minimax_status, f"(logged in, region={minimax_status.get('region', 'global')})")
|
||||
except Exception as e:
|
||||
check_warn("Auth provider status", f"(could not check: {e})")
|
||||
|
||||
# xAI OAuth — separate try/except so an import failure here cannot
|
||||
# disrupt the already-printed Nous/Codex/MiniMax rows above.
|
||||
try:
|
||||
@@ -199,29 +198,23 @@ def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
ack_target = getattr(args, 'ack', None)
|
||||
|
||||
# Doctor runs from the interactive CLI, so CLI-gated tool checks (e.g. cronjob) see the same context.
|
||||
os.environ.setdefault("HERMES_INTERACTIVE", "1")
|
||||
|
||||
if ack_target:
|
||||
return _ack_advisory(ack_target)
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ 🩺 Hermes Doctor │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
total = Finding()
|
||||
for title, check in DOCTOR_CHECKS:
|
||||
if title:
|
||||
_section(title)
|
||||
total.merge(check(should_fix))
|
||||
|
||||
# Opt-in live probes run AFTER all static checks (`--live`: real network calls; bounded + read-only).
|
||||
try:
|
||||
from hermes_cli.doctor_live import maybe_run_live_checks
|
||||
maybe_run_live_checks(args, total.manual_issues)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_print_summary(should_fix, total)
|
||||
|
||||
@@ -140,9 +140,7 @@ def _check_mcp_security(should_fix: bool, f: Finding) -> None:
|
||||
continue
|
||||
suspicious += 1
|
||||
check_warn(f"MCP server '{name}' has suspicious stdio command", "; ".join(issues_found))
|
||||
f.manual_issues.append(
|
||||
f"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed."
|
||||
)
|
||||
f.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")
|
||||
|
||||
@@ -287,14 +285,10 @@ def _validate_model_config(config_path, issues: list) -> None:
|
||||
policy_id = str(runtime_provider or catalog_provider or "").strip().lower()
|
||||
accepts_vendor_slug = policy_id in _VENDOR_SLUG_PROVIDERS or policy_id == "custom" or policy_id.startswith("custom:")
|
||||
if default_model and "/" in default_model and policy_id and not 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_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.")
|
||||
|
||||
if runtime_provider and runtime_provider not in ("auto", "custom"):
|
||||
from hermes_cli.doctor import _DHH
|
||||
@@ -477,6 +471,4 @@ def _check_xai_retirement(should_fix: bool, f: Finding) -> None:
|
||||
for ref in retired_refs:
|
||||
check_warn(format_issue(ref))
|
||||
check_info(f"Migration guide: {MIGRATION_GUIDE_URL}")
|
||||
f.manual_issues.append(
|
||||
f"Update {len(retired_refs)} retired xAI model reference(s) in config.yaml — see {MIGRATION_GUIDE_URL}"
|
||||
)
|
||||
f.manual_issues.append(f"Update {len(retired_refs)} retired xAI model reference(s) in config.yaml — see {MIGRATION_GUIDE_URL}")
|
||||
|
||||
@@ -133,7 +133,6 @@ def _probe_audio(kind: str, config: dict, timeout: float) -> ProbeResult:
|
||||
provider = (((config.get(kind) or {}).get("provider")) or "").strip().lower()
|
||||
if provider in _LOCAL_AUDIO_PROVIDERS:
|
||||
return ProbeResult(name, "skip", f"(provider '{provider or 'local'}' — no remote backend to probe)")
|
||||
|
||||
entry = _AUDIO_PROBES.get(provider)
|
||||
if entry is None:
|
||||
return ProbeResult(name, "skip", f"(provider '{provider}' — no live probe implemented)")
|
||||
@@ -184,32 +183,26 @@ def run_live_checks(issues: List[str]) -> List[ProbeResult]:
|
||||
except (TypeError, ValueError):
|
||||
timeout = DEFAULT_PROBE_TIMEOUT
|
||||
timeout = max(1.0, timeout)
|
||||
|
||||
_section("Live Backend Probes (opt-in, real calls)")
|
||||
results: List[ProbeResult] = [
|
||||
_run_one(name, lambda n=name, spec=spec: _keyed_probe(n, *spec, timeout), issues)
|
||||
for name, spec in _KEYED_PROBES.items()
|
||||
]
|
||||
results.append(_run_one("Browser", lambda: _probe_browser(timeout), issues))
|
||||
|
||||
servers = config.get("mcp_servers") or {}
|
||||
if isinstance(servers, dict) and servers:
|
||||
for name in sorted(servers):
|
||||
entry = servers[name]
|
||||
|
||||
def _probe(n=name, e=entry) -> ProbeResult:
|
||||
if not isinstance(e, dict):
|
||||
return ProbeResult(f"MCP: {n}", "skip", "(malformed config entry)")
|
||||
return ProbeResult(f"MCP: {n}", "pass", f"({len(_probe_mcp_server(n, e, timeout))} tool(s))")
|
||||
|
||||
results.append(_run_one(f"MCP: {name}", _probe, issues))
|
||||
else:
|
||||
results.append(ProbeResult("MCP", "skip", "(no servers configured)"))
|
||||
_report(results[-1], issues)
|
||||
|
||||
for kind in ("tts", "stt"):
|
||||
results.append(_run_one(kind.upper(), lambda k=kind: _probe_audio(k, config, timeout), issues))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
|
||||
@@ -286,24 +286,17 @@ def check_macos_tcc_grants() -> None:
|
||||
check_warn("macOS TCC grant check", "(could not read code-signing requirement of the desktop bundle)")
|
||||
return
|
||||
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
|
||||
return 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.")
|
||||
check_ok("macOS TCC signing identity is stable",
|
||||
# --setup-tcc-identity or notarized build: strongest anchor
|
||||
"(certificate-anchored DR; grants survive rebuilds)" if "certificate" in dr.lower() else
|
||||
"(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."
|
||||
)
|
||||
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:
|
||||
@@ -389,12 +382,8 @@ def _check_security_advisories(should_fix: bool, f: Finding) -> None:
|
||||
for line in full_remediation_text(hit): # indented under the header as one section
|
||||
print(f" {color(line, Colors.YELLOW)}" if line else "")
|
||||
# Also into the action list so the summary block surfaces it.
|
||||
f.manual_issues.append(
|
||||
f"Resolve security advisory {hit.advisory.id}: "
|
||||
f"uninstall {hit.package}=={hit.installed_version} and "
|
||||
f"rotate credentials, then run "
|
||||
f"`hermes doctor --ack {hit.advisory.id}`."
|
||||
)
|
||||
f.manual_issues.append(f"Resolve security advisory {hit.advisory.id}: uninstall {hit.package}=={hit.installed_version} "
|
||||
f"and rotate credentials, then run `hermes doctor --ack {hit.advisory.id}`.")
|
||||
acked_ids = get_acked_ids() # acked-but-still-installed stays visible
|
||||
for h in all_hits:
|
||||
if h.advisory.id in acked_ids:
|
||||
@@ -441,10 +430,9 @@ def _check_python_environment(should_fix: bool) -> Finding:
|
||||
return f
|
||||
|
||||
|
||||
def _check_certificates(should_fix: bool) -> Finding:
|
||||
f = Finding()
|
||||
@doctor_check()
|
||||
def _check_certificates(should_fix: bool, f: Finding) -> None:
|
||||
check_certificates(should_fix=should_fix, issues=f.manual_issues)
|
||||
return f
|
||||
|
||||
|
||||
# (import name, display name, optional)
|
||||
@@ -469,11 +457,10 @@ def _check_required_packages(should_fix: bool) -> Finding:
|
||||
return f
|
||||
|
||||
|
||||
def _check_gateway_supervision(should_fix: bool) -> Finding:
|
||||
f = Finding()
|
||||
@doctor_check()
|
||||
def _check_gateway_supervision(should_fix: bool, f: Finding) -> None:
|
||||
_check_gateway_service_linger(f.issues)
|
||||
_check_s6_supervision(f.issues)
|
||||
return f
|
||||
|
||||
|
||||
def _check_command_installation(should_fix: bool) -> Finding:
|
||||
|
||||
@@ -61,11 +61,7 @@ def _render_state_db_stats(stats: dict, holders=None) -> list:
|
||||
"""
|
||||
lines: list = []
|
||||
stats = stats or {}
|
||||
|
||||
logical = stats.get("logical_size_bytes")
|
||||
wal = stats.get("wal_size_bytes")
|
||||
freelist = stats.get("freelist_count")
|
||||
|
||||
logical, wal, freelist = (stats.get(k) for k in ("logical_size_bytes", "wal_size_bytes", "freelist_count"))
|
||||
size_bits = _bits(
|
||||
(logical, lambda: f"logical size {_human_bytes(logical)}"),
|
||||
(stats.get("page_count"), lambda: f"{stats['page_count']:,} pages"),
|
||||
@@ -82,21 +78,15 @@ def _render_state_db_stats(stats: dict, holders=None) -> list:
|
||||
)
|
||||
if row_bits:
|
||||
lines.append(("info", ", ".join(row_bits), ""))
|
||||
|
||||
fts = stats.get("fts_tables")
|
||||
if fts:
|
||||
present = [t for t, ok in fts.items() if ok]
|
||||
lines.append(("info", "FTS tables: " + (", ".join(present) if present else "none"), ""))
|
||||
|
||||
deferral = stats.get("fts_rebuild_deferral")
|
||||
if isinstance(deferral, dict):
|
||||
attempts = deferral.get("attempts")
|
||||
pids = deferral.get("holder_pids") or []
|
||||
lines.append((
|
||||
"warn",
|
||||
f"state.db FTS repair is blocked after {attempts or '?'} deferral(s) by PID(s) {pids or 'unknown'}",
|
||||
"(stop the listed processes, then run 'hermes sessions optimize-storage' with the gateway stopped)",
|
||||
))
|
||||
lines.append(("warn", f"state.db FTS repair is blocked after {deferral.get('attempts') or '?'} deferral(s) "
|
||||
f"by PID(s) {deferral.get('holder_pids') or [] or 'unknown'}",
|
||||
"(stop the listed processes, then run 'hermes sessions optimize-storage' with the gateway stopped)"))
|
||||
|
||||
# Advisory: oversized database. Suggest auto_prune, and — when the v23 FTS rebuild is pending OR
|
||||
# the DB still carries the legacy inline trigram layout (fts_storage_version marker absent) —
|
||||
@@ -139,8 +129,7 @@ def _check_directory_structure(should_fix: bool) -> Finding:
|
||||
if soul_path.exists():
|
||||
content = soul_path.read_text(encoding="utf-8").strip()
|
||||
# Template comments only (no real content)?
|
||||
lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
|
||||
if lines:
|
||||
if any(l.strip() and not l.strip().startswith(("<!--", "-->", "#")) for l in content.splitlines()):
|
||||
check_ok(f"{_DHH}/SOUL.md exists (persona configured)")
|
||||
else:
|
||||
check_info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
|
||||
@@ -148,12 +137,8 @@ def _check_directory_structure(should_fix: bool) -> Finding:
|
||||
check_warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
|
||||
if should_fix:
|
||||
soul_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
soul_path.write_text(
|
||||
"# Hermes Agent Persona\n\n"
|
||||
"<!-- Edit this file to customize how Hermes communicates. -->\n\n"
|
||||
"You are Hermes, a helpful AI assistant.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
soul_path.write_text("# Hermes Agent Persona\n\n<!-- Edit this file to customize how Hermes communicates. -->\n\n"
|
||||
"You are Hermes, a helpful AI assistant.\n", encoding="utf-8")
|
||||
check_ok(f"Created {_DHH}/SOUL.md with basic template")
|
||||
f.fixed += 1
|
||||
|
||||
@@ -263,11 +248,8 @@ def _state_db_stats(issues: list, state_db_path: Path) -> None:
|
||||
continue
|
||||
check_warn(_text, _detail)
|
||||
if "auto_prune" in _detail:
|
||||
issues.append(
|
||||
"state.db is large — enable sessions.auto_prune in config.yaml"
|
||||
+ (" and run 'hermes sessions optimize-storage' offline (gateway stopped)"
|
||||
if "optimize-storage" in _detail else "")
|
||||
)
|
||||
issues.append("state.db is large — enable sessions.auto_prune in config.yaml"
|
||||
+ (" and run 'hermes sessions optimize-storage' offline (gateway stopped)" if "optimize-storage" in _detail else ""))
|
||||
except Exception as _stats_exc:
|
||||
check_info(f"state.db stats unavailable ({_stats_exc})")
|
||||
|
||||
|
||||
@@ -157,8 +157,7 @@ def _check_docker_backend(terminal_env: str, running_in_container: bool, issues:
|
||||
def _check_ssh_backend(issues: list[str]) -> None:
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST")
|
||||
if not ssh_host:
|
||||
_fail_and_issue("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)", "Set TERMINAL_SSH_HOST in .env", issues)
|
||||
return
|
||||
return _fail_and_issue("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)", "Set TERMINAL_SSH_HOST in .env", issues)
|
||||
ssh_user, ssh_port, ssh_key = (os.getenv(f"TERMINAL_SSH_{k}") for k in ("USER", "PORT", "KEY"))
|
||||
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"]
|
||||
if ssh_port:
|
||||
@@ -205,8 +204,7 @@ def _check_vercel_backend(issues: list[str]) -> None:
|
||||
elif auth_status.label.startswith("partial"):
|
||||
_fail_and_issue("Vercel auth incomplete", f"({auth_status.label})", "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", issues)
|
||||
else:
|
||||
_fail_and_issue("Vercel auth not configured", f"({auth_status.label})",
|
||||
"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", issues)
|
||||
_fail_and_issue("Vercel auth not configured", f"({auth_status.label})", "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", issues)
|
||||
for line in auth_status.detail_lines:
|
||||
check_info(f"Vercel auth {line}")
|
||||
persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}
|
||||
@@ -223,9 +221,8 @@ def _check_plugin_backend(terminal_env: str, issues: list[str]) -> None:
|
||||
except Exception:
|
||||
provider = None
|
||||
if provider is None:
|
||||
_fail_and_issue(f"Unknown terminal backend '{terminal_env}'", "(no built-in or plugin backend by that name)",
|
||||
"Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", issues)
|
||||
return
|
||||
return _fail_and_issue(f"Unknown terminal backend '{terminal_env}'", "(no built-in or plugin backend by that name)",
|
||||
"Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", issues)
|
||||
for ok, label, detail in provider.doctor_checks():
|
||||
_require(ok, (label, detail), (label, detail), detail.strip("()"), issues)
|
||||
|
||||
@@ -282,11 +279,8 @@ def _check_agent_browser(should_fix: bool) -> bool:
|
||||
# Almost always a dangling global symlink left by npm postinstall after `hermes update` wiped node_modules.
|
||||
check_warn("agent-browser found but not runnable", f"(broken symlink at {resolved}? run: npx agent-browser --version)")
|
||||
elif _is_termux():
|
||||
_termux_browser_hints(
|
||||
"agent-browser is not installed (expected in the tested Termux path)",
|
||||
"Install it manually later with: npm install -g agent-browser && agent-browser install",
|
||||
node_installed=True,
|
||||
)
|
||||
_termux_browser_hints("agent-browser is not installed (expected in the tested Termux path)",
|
||||
"Install it manually later with: npm install -g agent-browser && agent-browser install", node_installed=True)
|
||||
else:
|
||||
check_warn("agent-browser not installed", "(requires npm/npx on PATH)")
|
||||
return False
|
||||
@@ -350,11 +344,8 @@ def _check_node_and_browser(should_fix: bool) -> Finding:
|
||||
if _check_agent_browser(should_fix) and not _is_termux(): # Chromium check is not a tested Termux path
|
||||
_check_chromium()
|
||||
elif _is_termux():
|
||||
_termux_browser_hints(
|
||||
"Node.js not found (browser tools are optional in the tested Termux path)",
|
||||
"Install Node.js on Termux with: pkg install nodejs",
|
||||
node_installed=False,
|
||||
)
|
||||
_termux_browser_hints("Node.js not found (browser tools are optional in the tested Termux path)",
|
||||
"Install Node.js on Termux with: pkg install nodejs", node_installed=False)
|
||||
else:
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
_check_lightpanda()
|
||||
@@ -385,11 +376,8 @@ def _audit_one(npm_bin: str, npm_dir, label: str, audit_extra: list[str], issues
|
||||
if total == 0:
|
||||
check_ok(f"{label} deps", "(no known vulnerabilities)")
|
||||
elif critical > 0 or high > 0:
|
||||
if workspace_scoped:
|
||||
remedy = "build-tool advisory; clears via lockfile bump"
|
||||
else:
|
||||
flag = " --workspaces=false" if audit_extra == ["--workspaces=false"] else ""
|
||||
remedy = f"run: cd {npm_dir} && npm audit fix{flag}"
|
||||
flag = " --workspaces=false" if audit_extra == ["--workspaces=false"] else ""
|
||||
remedy = "build-tool advisory; clears via lockfile bump" if workspace_scoped else f"run: cd {npm_dir} && npm audit fix{flag}"
|
||||
check_warn(f"{label} deps", f"({critical} critical, {high} high, {moderate} moderate — {remedy})")
|
||||
if workspace_scoped:
|
||||
check_info(" ^ build-time tooling (not runtime); if manual npm remediation "
|
||||
@@ -426,7 +414,6 @@ def _check_npm_audit(should_fix: bool) -> Finding:
|
||||
# Workspace-scoped audits check the root node_modules; standalone dirs check their own.
|
||||
if ((PROJECT_ROOT if audit_extra else npm_dir) / "node_modules").exists():
|
||||
_audit_one(npm_bin, npm_dir, label, audit_extra, f.issues)
|
||||
|
||||
if _is_termux():
|
||||
check_info("Termux compatibility fallbacks:")
|
||||
for note in _TERMUX_INSTALL_ALL_FALLBACK_NOTES:
|
||||
|
||||
@@ -20,7 +20,6 @@ def _dotenv_key_names() -> set[str]:
|
||||
text = get_env_path().read_text(encoding="utf-8", errors="ignore")
|
||||
except (OSError, UnicodeError):
|
||||
return set()
|
||||
|
||||
names: set[str] = set()
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
@@ -150,7 +149,6 @@ def _config_overrides(config: dict) -> dict[str, str]:
|
||||
user_val = user_section.get(key)
|
||||
if user_val is not None and user_val != default_section.get(key):
|
||||
overrides[f"{section}.{key}"] = str(user_val)
|
||||
|
||||
user_toolsets = config.get("toolsets", [])
|
||||
if user_toolsets != DEFAULT_CONFIG.get("toolsets", []):
|
||||
overrides["toolsets"] = str(user_toolsets)
|
||||
|
||||
Reference in New Issue
Block a user