Files
hermes-agent/tests/hermes_cli/test_doctor.py
ethernet 16652eea18 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	gateway/config.py
#	gateway/config_loader.py
#	gateway/readiness.py
#	hermes_cli/managed_scope.py
#	hermes_cli/plugin_python_deps.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/update_cmd_maint.py
#	plugin-catalog/hindsight.yaml
#	plugins/plugin_loader.py
#	providers/__init__.py
#	scripts/run_tests.sh
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_gateway_streaming_nested_config.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/tools/test_lazy_deps.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tools/lazy_deps.py
2026-09-23 15:26:34 -04:00

1612 lines
62 KiB
Python

"""Tests for hermes_cli.doctor."""
import importlib.util
import os
import subprocess
import sys
import types
import io
import contextlib
from argparse import Namespace
from pathlib import Path
from types import SimpleNamespace
import pytest
import hermes_cli.doctor as doctor
import hermes_constants
from hermes_cli import config as config_mod
import hermes_cli.gateway as gateway_cli
from hermes_cli import doctor as doctor_mod
from hermes_cli.doctor_config import _has_provider_env_config
from hermes_cli.doctor_report import Finding
import shutil
from hermes_cli import doctor_tools
from hermes_cli import doctor_state
from hermes_cli import doctor_platform
from hermes_cli import doctor_config
from tools import browser_tool_install as bt_install
@pytest.fixture(autouse=True)
def _no_browser_downloads(monkeypatch):
"""Unrelated doctor --fix tests must not start an installer worker.
Browser acquisition/readback is exercised with real temporary PM facts in
test_browser_pm; tests here may replace this boundary deliberately.
"""
def refuse(*args, **kwargs):
raise RuntimeError("PM downloads disabled in doctor unit tests")
monkeypatch.setattr("pm.client._request", refuse)
def _tls_out_normalized(out: str) -> str:
"""Doctor print matcher for TLS rows: key on words, not spacing."""
return " ".join(out.lower().split())
def test_check_certificates_exercises_real_tls_policy(monkeypatch, capsys):
"""install_truststore True: the check exercises the actual policy
(agent.ssl_verify platform trust store), reports TLS as configured, and
never presents context construction as certificate verification."""
monkeypatch.setattr("agent.ssl_verify.install_truststore", lambda: True)
doctor_platform.check_certificates()
out = _tls_out_normalized(capsys.readouterr().out)
assert "✓" in out
assert "tls" in out
assert "skipped" not in out
assert "verification active" not in out # configuration, not proof of validation
assert "platform trust" in out or "configured" in out
def test_check_certificates_fallback_names_openssl_without_platform_trust_claim(monkeypatch, capsys):
"""install_truststore False + a constructible context is an OpenSSL fallback:
the output names the actual fallback, stays available, and no ✓ row claims
the platform trust store is active."""
monkeypatch.setattr("agent.ssl_verify.install_truststore", lambda: False)
doctor_platform.check_certificates()
raw = capsys.readouterr().out
out = _tls_out_normalized(raw)
assert "⚠" in raw
assert "trust store" in out
assert "openssl" in out # names the actual fallback
assert "verification active" not in out # construction proves nothing verified
assert "available" in out # status is context available, not verification proven
assert not any(
line.startswith("✓") and "platform trust" in line
for line in raw.splitlines()
)
class TestDoctorPlatformHints:
def test_sqlite_upgrade_hint_recreates_docker_containers(self, monkeypatch):
monkeypatch.setattr(config_mod, "detect_install_method", lambda _root: "docker")
hint = doctor_platform._sqlite_upgrade_hint()
assert "docker pull nousresearch/hermes-agent:latest" in hint
assert "hermes update" not in hint
def test_sqlite_upgrade_hint_apt_stamp_names_termux_external_update(self):
# The "apt" install method is Termux APT by contract (config.py
# _UPDATE_COMMAND_BY_METHOD). Deployment kinds are first-class: doctor
# reports the correct external update command instead of routing an
# out-of-place method through the in-place `hermes update` path.
hint = doctor_platform._sqlite_upgrade_hint("apt")
assert "run `pkg upgrade hermes-agent`" in hint
assert "hermes update" not in hint
def test_sqlite_upgrade_hint_preserves_nix_guidance_as_prose(self):
from hermes_cli.config import recommended_update_command_for_method
guidance = recommended_update_command_for_method("nix")
hint = doctor_platform._sqlite_upgrade_hint("nix")
assert guidance in hint
assert f"run `{guidance}`" not in hint
assert "hermes update" not in hint
class TestProviderEnvDetection:
def test_detects_openai_api_key(self):
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
assert _has_provider_env_config(content)
def test_returns_false_when_no_provider_settings(self):
content = "TERMINAL_ENV=local\n"
assert not _has_provider_env_config(content)
class TestDoctorToolAvailabilitySummary:
def test_missing_api_key_summary_ignores_disabled_toolsets(self, monkeypatch):
unavailable = [
{"name": "rl", "missing_vars": ["TINKER_API_KEY"]},
{"name": "web", "missing_vars": ["EXA_API_KEY"]},
]
monkeypatch.setattr(doctor_tools, "_enabled_cli_toolsets_for_doctor", lambda: {"web"})
filtered = doctor_tools._missing_api_key_toolsets_for_summary(unavailable)
assert [item["name"] for item in filtered] == ["web"]
def test_image_gen_without_provider_reports_setup_hint_not_system_dependency(self, monkeypatch):
"""image_gen declares no single env var (FAL / managed Nous / plugin providers); an
unconfigured backend is a setup problem and must say so, and it counts toward the
'run hermes setup' summary like any missing key (#9516)."""
unavailable = [{"name": "image_gen", "env_vars": [], "tools": ["image_generate"]},
{"name": "homeassistant", "env_vars": [], "tools": []}]
monkeypatch.setattr(doctor_tools, "_enabled_cli_toolsets_for_doctor", lambda: {"image_gen"})
monkeypatch.setattr(doctor_tools, "_apply_doctor_tool_availability_overrides", lambda a, u: (a, u))
monkeypatch.setattr(doctor_tools, "_doctor_web_capability_rows", lambda: [])
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda: ([], unavailable),
TOOLSET_REQUIREMENTS={"image_gen": {"name": "image_gen"}, "homeassistant": {"name": "homeassistant"}},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
f = doctor_tools._check_tool_availability(False)
out = buf.getvalue()
image_line = next(line for line in out.splitlines() if "image_gen" in line)
assert "hermes tools" in image_line and "system dependency" not in image_line and "unavailable" in image_line
assert "system dependency not met" in next(line for line in out.splitlines() if "homeassistant" in line)
assert any("hermes setup" in issue for issue in f.issues)
def test_web_capability_rows_warn_when_selected_provider_not_ready(self, monkeypatch):
"""#78412: selected firecrawl with is_available=False must warn."""
class _Unavailable:
name = "firecrawl"
def is_available(self):
return False
unavailable = _Unavailable()
monkeypatch.setattr(
"agent.web_search_registry.get_active_search_provider",
lambda: unavailable,
)
monkeypatch.setattr(
"agent.web_search_registry.get_active_extract_provider",
lambda: unavailable,
)
rows = doctor_tools._doctor_web_capability_rows()
assert rows
assert all(status == "warn" for status, _, _ in rows)
def test_web_capability_rows_ok_when_provider_ready(self, monkeypatch):
class _Ready:
name = "ddgs"
def is_available(self):
return True
ready = _Ready()
monkeypatch.setattr(
"agent.web_search_registry.get_active_search_provider",
lambda: ready,
)
monkeypatch.setattr(
"agent.web_search_registry.get_active_extract_provider",
lambda: ready,
)
rows = doctor_tools._doctor_web_capability_rows()
assert rows and all(status == "ok" for status, _, _ in rows)
class TestDoctorEnvFileEncoding:
"""Regression for #18637 (bug 3): `hermes doctor` crashed on Windows
Chinese locale (GBK) because `.env` was read with Path.read_text(encoding="utf-8") which
defaults to the system locale encoding, not UTF-8."""
def test_doctor_reads_env_as_utf8_even_when_locale_is_not_utf8(
self, monkeypatch, tmp_path
):
import pathlib
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# Write a UTF-8 .env containing an em dash (U+2014 = e2 80 94). The
# 0x94 byte is exactly the one the issue reporter hit: it's invalid
# as a GBK trailing byte in this position, so locale-default reads
# raise UnicodeDecodeError on Chinese Windows.
env_path = hermes_home / ".env"
env_path.write_text(
"OPENAI_API_KEY=sk-test # em-dash here — should not crash\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
orig_read_text = pathlib.Path.read_text
def gbk_like_read_text(self, encoding=None, errors=None, **kwargs):
# Simulate a GBK locale: refuse to decode this specific UTF-8
# .env unless the caller pins a UTF-8 codec. utf-8-sig is the
# sanctioned read encoding (Windows tools BOM-prefix files it
# touches); it decodes BOM-less UTF-8 identically.
if self == env_path and encoding not in ("utf-8", "utf-8-sig"):
raise UnicodeDecodeError(
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
)
return orig_read_text(self, encoding=encoding, errors=errors, **kwargs)
monkeypatch.setattr(pathlib.Path, "read_text", gbk_like_read_text)
# Short-circuit the expensive tool-availability probe — we only
# need doctor to reach the .env read without crashing.
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
# Run doctor. If the .env read still uses locale encoding, this
# raises UnicodeDecodeError and the test fails.
with pytest.raises(SystemExit):
doctor_mod.run_doctor(Namespace(fix=False))
def test_doctor_reads_invalid_utf8_env_via_latin1_fallback(
self, monkeypatch, tmp_path
):
"""cp1252/latin-1 .env with ASCII provider hints must not abort doctor."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
env_path = hermes_home / ".env"
# 0xff is invalid UTF-8; latin-1 decodes it. Keep an ASCII provider key
# so the scan still reports a configured endpoint/key.
env_path.write_bytes(b"OPENAI_API_KEY=sk-test\xff\n")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
with pytest.raises(SystemExit):
doctor_mod.run_doctor(Namespace(fix=False))
class TestDoctorToolAvailabilityOverrides:
def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch):
monkeypatch.setattr(doctor_state, "_honcho_is_configured_for_doctor", lambda: False)
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
available, unavailable = doctor_tools._apply_doctor_tool_availability_overrides(
[],
[{"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}],
)
assert available == ["kanban"]
assert unavailable == []
def test_leaves_kanban_unavailable_when_worker_env_is_set(self, monkeypatch):
monkeypatch.setenv("HERMES_KANBAN_TASK", "probe")
kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}
available, unavailable = doctor_tools._apply_doctor_tool_availability_overrides(
[],
[kanban_entry],
)
assert available == []
assert unavailable == [kanban_entry]
def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "2048")
monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value")
monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
monkeypatch.setenv("VERCEL_TEAM_ID", "team")
monkeypatch.setattr(importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "VERCEL_PROJECT_ID" in out # names the missing auth var
assert "super-secret-value" not in out # never echoes the token value
# ── Memory provider section (doctor should only check the *active* provider) ──
class TestDoctorMemoryProviderSection:
"""The ◆ Memory Provider section should respect memory.provider config."""
def _make_hermes_home(self, tmp_path, provider="", memory_config=None):
"""Create a minimal HERMES_HOME with config.yaml."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
import hermes_yaml as yaml
config = dict(memory_config or {})
if provider:
config["provider"] = provider
config = {"memory": config}
(home / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
return home
def _run_doctor_and_capture(
self,
monkeypatch,
tmp_path,
provider="",
*,
memory_config=None,
stale_builtin_files=False,
):
"""Run doctor and capture stdout."""
home = self._make_hermes_home(tmp_path, provider, memory_config)
if stale_builtin_files:
memories = home / "memories"
memories.mkdir()
(memories / "MEMORY.md").write_text("stale memory", encoding="utf-8")
(memories / "USER.md").write_text("stale user", encoding="utf-8")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
# Stub tool availability (returns empty) so doctor runs past it
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
# Stub auth checks to avoid real API calls
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
# Keep doctor from probing the AMBIENT gh CLI. A PATH lookup that
# resolves (any dev box has gh) makes doctor shell out to
# `gh auth status`, which both leaks the runner's real auth state
# into the assertion surface and -- on Windows -- dies with WinError 5
# when gh resolves to a Store/MSIX reparse-point shim. The gh-
# specific doctor behaviors have their own dedicated tests below,
# which mock gh explicitly.
real_which = shutil.which
monkeypatch.setattr(
shutil,
"which",
lambda cmd: None if cmd == "gh" else real_which(cmd),
)
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
return buf.getvalue()
def test_no_provider_shows_builtin_ok(self, monkeypatch, tmp_path):
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
assert "Memory Provider" in out
assert "Built-in memory active" in out
# Should NOT mention Honcho or Mem0 errors
assert "Honcho API key" not in out
assert "Mem0" not in out
def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
# Make mem0 import fail
monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None)
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
assert "Memory Provider" in out
assert "Built-in memory active" not in out
@pytest.mark.parametrize("memory_enabled", [False, True])
def test_stale_builtin_files_reported_only_when_store_enabled(
self, monkeypatch, tmp_path, memory_enabled
):
# #100668: disabled built-in stores must not surface stale files as active.
out = self._run_doctor_and_capture(
monkeypatch,
tmp_path,
provider="mnemosyne",
memory_config={
"memory_enabled": memory_enabled,
"user_profile_enabled": False,
},
stale_builtin_files=True,
)
assert ("MEMORY.md exists" in out) is memory_enabled
assert "USER.md exists" not in out
assert ("Built-in memory files disabled by config" in out) is not memory_enabled
def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
import hermes_yaml as yaml
(home / "config.yaml").write_text(
yaml.safe_dump(
{
"model": {
"provider": "volcengine-plan",
"default": "doubao-seed-2.0-code",
},
"providers": {
"volcengine-plan": {
"name": "volcengine-plan",
"base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
"default_model": "doubao-seed-2.0-code",
"models": {"doubao-seed-2.0-code": {}},
}
},
}
)
, encoding="utf-8")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'volcengine-plan' is not a recognised provider" not in out
def test_run_doctor_accepts_stable_key_when_provider_name_differs(
monkeypatch, tmp_path
):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom:local-127.0.0.1:11434\n"
" default: qwen3.5:9b\n"
"providers:\n"
" local-127.0.0.1:11434:\n"
" name: Local Ollama\n"
" base_url: http://127.0.0.1:11434/v1\n"
" default_model: qwen3.5:9b\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert (
"model.provider 'custom:local-127.0.0.1:11434' is not a recognised provider"
not in out
)
assert "model.provider 'custom:local-127.0.0.1:11434' is unknown" not in out
def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom\n"
" default: local-model\n"
" base_url: http://localhost:8000/v1\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'custom' is not a recognised provider" not in out
def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: openrouter\n"
" default: openai/gpt-4.1-mini\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'openrouter' is set but no API key is configured" in out
@pytest.mark.parametrize(
("provider", "default_model"),
[
("ai-gateway", "anthropic/claude-sonnet-4.6"),
("opencode-zen", "anthropic/claude-sonnet-4.6"),
("kilocode", "anthropic/claude-sonnet-4.6"),
("kimi-coding", "kimi-k2"),
("nvidia", "qwen/qwen3.5-122b-a10b"),
("moa", "anthropic/claude-sonnet-4.6"),
],
)
def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
monkeypatch, tmp_path, provider, default_model
):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
f" provider: {provider}\n"
f" default: {default_model}\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert f"model.provider '{provider}' is not a recognised provider" not in out
assert f"model.provider '{provider}' is unknown" not in out
if provider in {"ai-gateway", "opencode-zen", "kilocode", "nvidia"}:
assert (
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
not in out
)
def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom:hpc-ai\n"
" default: deepseek/deepseek-v4-flash\n"
"custom_providers:\n"
" - name: hpc-ai\n"
" base_url: https://hpc-ai.example/v1\n"
" api_key: test-key\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'custom:hpc-ai' is not a recognised provider" not in out
assert "model.provider 'custom:hpc-ai' is unknown" not in out
assert (
"model.default 'deepseek/deepseek-v4-flash' uses a vendor/model slug but provider is "
"'custom:hpc-ai'"
not in out
)
assert "Either set model.provider to 'openrouter', or drop the vendor prefix." not in out
@pytest.mark.parametrize(
("base_url", "expects_warning"),
[
("http://localhost:20128/v1", False),
("https://api.openai.com/v1", True),
],
)
def test_run_doctor_vendor_slug_policy_for_openai_api_endpoint(
monkeypatch, tmp_path, base_url, expects_warning
):
"""openai-api behind a custom router owns a vendor/model namespace (#69912); the real
OpenAI endpoint keeps the warning."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: openai-api\n"
" default: nvidia/z-ai/glm-5.2\n"
f" base_url: {base_url}\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
warning = (
"model.default 'nvidia/z-ai/glm-5.2' uses a vendor/model slug "
"but provider is 'openai-api'"
)
assert (warning in buf.getvalue()) is expects_warning
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
(home / "config.yaml").write_text(
"model:\n"
" provider: kimi-coding-cn\n"
" default: kimi-k2.6\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
def _doctor_env_for_agent_browser(monkeypatch, tmp_path):
"""Shared fixture setup for the agent-browser npx-resolution
branch in run_doctor (hermes_cli/doctor.py ~1557-1605)."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(
shutil,
"which",
lambda cmd: "/usr/bin/node" if cmd in {"node", "npm"} else None,
)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
def test_run_doctor_reports_installed_agent_browser(monkeypatch, tmp_path):
_doctor_env_for_agent_browser(monkeypatch, tmp_path)
monkeypatch.setattr(bt_install, "_find_agent_browser", lambda **_kw: "/pm/agent-browser")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "agent-browser" in out
assert "/pm/agent-browser" in out
assert "agent-browser not installed" not in out
def test_doctor_fix_does_not_claim_success_without_published_binary(monkeypatch, tmp_path):
from hermes_cli import doctor_tools
_doctor_env_for_agent_browser(monkeypatch, tmp_path)
def missing(**kwargs):
raise FileNotFoundError("no published browser")
monkeypatch.setattr(bt_install, "_find_agent_browser", missing)
monkeypatch.setattr("pm.ensure", lambda *a, **kw: None)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
assert doctor_tools._check_agent_browser(True) is False
out = buf.getvalue()
assert "agent-browser install failed" in out
assert "no published browser" in out
def test_doctor_fix_reports_pm_install_failure(monkeypatch, tmp_path):
from hermes_cli import doctor_tools
import pm
_doctor_env_for_agent_browser(monkeypatch, tmp_path)
def missing(**kwargs):
raise FileNotFoundError("no published browser")
def refuse(*args, **kwargs):
raise pm.InstallError("agent-browser", "offline")
monkeypatch.setattr(bt_install, "_find_agent_browser", missing)
monkeypatch.setattr(pm, "ensure", refuse)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
assert doctor_tools._check_agent_browser(True) is False
out = buf.getvalue()
assert "agent-browser install failed" in out
assert "offline" in out
def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
(home / ".env").write_text("KIMI_CN_API_KEY=sk-test\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test")
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
calls = []
def fake_get(url, headers=None, timeout=None):
calls.append((url, headers, timeout))
return types.SimpleNamespace(status_code=200)
import httpx
monkeypatch.setattr(httpx, "get", fake_get)
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "API key or custom endpoint configured" in out
assert "Kimi / Moonshot (China)" in out
assert "str expected, not NoneType" not in out
assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls)
def test_run_doctor_dashscope_retries_china_endpoint_after_intl_unauthorized(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
(home / ".env").write_text("DASHSCOPE_API_KEY=sk-test\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test")
monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except ImportError:
pass
calls = []
def fake_get(url, headers=None, timeout=None):
calls.append((url, headers, timeout))
status = 200 if "dashscope.aliyuncs.com" in url else 401
return types.SimpleNamespace(status_code=status)
import httpx
monkeypatch.setattr(httpx, "get", fake_get)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "Alibaba/DashScope" in out
assert "invalid API key" not in out
assert any(
url == "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models"
for url, _, _ in calls
)
assert any(
url == "https://dashscope.aliyuncs.com/compatible-mode/v1/models"
for url, _, _ in calls
)
@pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"])
def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
(home / ".env").write_text("OPENCODE_GO_API_KEY=***\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test")
if base_url:
monkeypatch.setenv("OPENCODE_GO_BASE_URL", base_url)
else:
monkeypatch.delenv("OPENCODE_GO_BASE_URL", raising=False)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except ImportError:
pass
calls = []
def fake_get(url, headers=None, timeout=None):
calls.append((url, headers, timeout))
return types.SimpleNamespace(status_code=200)
import httpx
monkeypatch.setattr(httpx, "get", fake_get)
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert any(
"OpenCode Go" in line and "(key configured)" in line
for line in out.splitlines()
)
assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls)
assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls)
class TestGitHubTokenCheck:
"""Tests for GitHub token / gh auth detection in doctor."""
def test_gh_authenticated_on_gh_without_authenticated_json_field(self, monkeypatch):
"""gh 2.98+ dropped the `authenticated` field from `gh auth status --json`,
so that invocation exits 1 even for a logged-in user. A logged-in user on
such a gh must still be reported as authenticated."""
from hermes_cli import doctor_state
def gh_2_98(cmd, **kwargs):
assert cmd[:3] == ["gh", "auth", "status"], cmd
if "--json" in cmd and "authenticated" in cmd:
return types.SimpleNamespace(returncode=1, stdout=b"", stderr=b"unknown JSON field")
return types.SimpleNamespace(returncode=0, stdout=b"", stderr=b"Logged in to github.com")
monkeypatch.setattr(subprocess, "run", gh_2_98)
assert doctor_state._gh_authenticated() is True
def _run_doctor_with_healthy_oauth_fallback(
monkeypatch,
tmp_path,
*,
env_key: str,
bad_key: str,
failing_host: str,
minimax_oauth_status: dict,
xai_oauth_status: dict | None = None,
) -> str:
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: nous\n"
" default: moonshotai/kimi-k2.6\n",
encoding="utf-8",
)
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setenv(env_key, bad_key)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
monkeypatch.delenv("MINIMAX_CN_API_KEY", raising=False)
monkeypatch.setenv(env_key, bad_key)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status)
_xai_status = xai_oauth_status if xai_oauth_status is not None else {}
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: _xai_status)
def fake_get(url, headers=None, timeout=None):
status = 401 if failing_host in url else 200
return types.SimpleNamespace(status_code=status)
import httpx
monkeypatch.setattr(httpx, "get", fake_get)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
return buf.getvalue()
@pytest.mark.parametrize(
("env_key", "bad_key", "failing_host", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"),
[
(
"MINIMAX_API_KEY",
"bad-minimax-key",
"minimax.io",
{"logged_in": True, "region": "global"},
None,
"Check MINIMAX_API_KEY in .env",
),
(
"XAI_API_KEY",
"bad-xai-key",
"api.x.ai",
{},
{"logged_in": True, "auth_mode": "oauth_pkce"},
"Check XAI_API_KEY in .env",
),
],
)
def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
monkeypatch,
tmp_path,
env_key,
bad_key,
failing_host,
minimax_oauth_status,
xai_oauth_status,
unexpected_issue,
):
out = _run_doctor_with_healthy_oauth_fallback(
monkeypatch,
tmp_path,
env_key=env_key,
bad_key=bad_key,
failing_host=failing_host,
minimax_oauth_status=minimax_oauth_status,
xai_oauth_status=xai_oauth_status,
)
assert "invalid API key" in out
assert unexpected_issue not in out
# ---------------------------------------------------------------------------
# ◆ Auth Providers — xAI OAuth display in run_doctor()
# ---------------------------------------------------------------------------
class TestDoctorXaiOAuthStatus:
"""The ◆ Auth Providers section must show xAI OAuth login state.
xAI OAuth is checked in a *separate* try/except block so that an import
failure (or runtime exception) cannot silence the Nous / Codex / Gemini /
MiniMax rows that were already printed above it.
"""
def _run(self, monkeypatch, tmp_path, *, xai_auth_fn) -> str:
"""Run doctor with a controlled xAI auth callable; return stdout."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
return buf.getvalue()
def test_logged_in_does_not_emit_not_logged_in_on_xai_line(self, monkeypatch, tmp_path):
out = self._run(
monkeypatch, tmp_path,
xai_auth_fn=lambda: {"logged_in": True},
)
assert "xAI OAuth" in out
# The xAI OAuth line itself must say "(logged in)", not "(not logged in)".
xai_line = next(l for l in out.splitlines() if "xAI OAuth" in l)
assert "(logged in)" in xai_line
assert "(not logged in)" not in xai_line
def test_import_failure_does_not_affect_other_providers(self, monkeypatch, tmp_path):
"""Nous / Codex / Gemini / MiniMax rows must survive an xAI import failure."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "Nous Portal auth" in out
assert "logged in" in out
def test_function_raises_does_not_crash_doctor(self, monkeypatch, tmp_path):
"""A runtime exception from get_xai_oauth_auth_status must be swallowed."""
def _raise():
raise RuntimeError("simulated xAI status failure")
out = self._run(monkeypatch, tmp_path, xai_auth_fn=_raise)
assert "Auth Providers" in out
# ---------------------------------------------------------------------------
# ◆ Auth Providers — codex CLI import hint placement (issue #27975)
# ---------------------------------------------------------------------------
class TestDoctorStaleMaxIterationsDrift:
"""Regression for #17534: a stale HERMES_MAX_ITERATIONS in .env shadows
agent.max_turns in config.yaml. The repro symptom is config.yaml saying
400 while the gateway activity line reads N/90. Doctor must detect the
drift, and `--fix` must remove the .env ghost (config.yaml wins).
The detector reads the .env FILE directly, NOT os.environ — the gateway
startup bridge can already have overridden os.environ to the config value,
so the ghost is only visible in the file.
"""
def _run_config_section(self, monkeypatch, tmp_path, *, fix, ghost, cfg_turns,
os_environ_value=None):
import contextlib
import io
from argparse import Namespace
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(parents=True)
(hermes_home / "config.yaml").write_text(
f"agent:\n max_turns: {cfg_turns}\n", encoding="utf-8"
)
env_lines = ["OPENAI_API_KEY=sk-test\n"]
if ghost is not None:
env_lines.append(f"HERMES_MAX_ITERATIONS={ghost}\n")
(hermes_home / ".env").write_text("".join(env_lines), encoding="utf-8")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
monkeypatch.setattr(doctor_mod, "get_hermes_home", lambda: hermes_home)
# Point the config helpers at the temp home.
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
if os_environ_value is not None:
# Simulate the gateway bridge having already overridden os.environ.
monkeypatch.setenv("HERMES_MAX_ITERATIONS", str(os_environ_value))
else:
monkeypatch.delenv("HERMES_MAX_ITERATIONS", raising=False)
# Short-circuit at the Tool Availability stage — the drift check runs
# well before it in the Configuration Files section.
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
buf = io.StringIO()
with contextlib.redirect_stdout(buf), pytest.raises(SystemExit):
doctor_mod.run_doctor(Namespace(fix=fix))
return buf.getvalue(), hermes_home
def test_detects_drift_warn_only(self, monkeypatch, tmp_path):
out, hermes_home = self._run_config_section(
monkeypatch, tmp_path, fix=False, ghost=90, cfg_turns=400,
os_environ_value=400, # bridge contaminated os.environ
)
assert "HERMES_MAX_ITERATIONS=90" in out
assert "shadows" in out
# Warn-only must NOT mutate .env.
assert "HERMES_MAX_ITERATIONS=90" in (hermes_home / ".env").read_text(encoding="utf-8")
def test_fix_removes_ghost(self, monkeypatch, tmp_path):
out, hermes_home = self._run_config_section(
monkeypatch, tmp_path, fix=True, ghost=90, cfg_turns=400,
os_environ_value=400,
)
assert "Removed stale HERMES_MAX_ITERATIONS" in out
env_after = (hermes_home / ".env").read_text(encoding="utf-8")
assert "HERMES_MAX_ITERATIONS" not in env_after
assert "OPENAI_API_KEY=sk-test" in env_after # other keys preserved
def test_no_drift_when_ghost_absent(self, monkeypatch, tmp_path):
out, _ = self._run_config_section(
monkeypatch, tmp_path, fix=False, ghost=None, cfg_turns=400,
)
assert "shadows" not in out
class TestDoctorLegacyCustomProvidersResidue:
"""A legacy ``custom_providers`` list entry without a ``providers:`` twin lives on in the retired list
store; doctor must name it and point at the move. Twins (URL modulo trailing slash /
case) and non-list values are not this step's business."""
def _run(self, tmp_path, yaml_text):
cfg = tmp_path / "config.yaml"
cfg.write_text(yaml_text, encoding="utf-8")
finding = doctor_config.Finding()
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_config._drift_legacy_custom_providers(finding, False, cfg)
return buf.getvalue(), finding
def test_orphan_entry_is_flagged_with_repair_instruction(self, tmp_path):
out, finding = self._run(tmp_path, (
"custom_providers:\n - name: Local (8283)\n base_url: http://127.0.0.1:8283/v1\n"
"providers:\n other:\n api: http://127.0.0.1:8290/v1\n"))
assert "Local (8283)" in out
assert finding.manual_issues and "providers.<key>.api: http://127.0.0.1:8283/v1" in finding.manual_issues[0]
assert finding.fixed == 0 and finding.issues == [] # warn-only: no --fix rewrite of config.yaml
def test_twin_and_scalar_are_silent(self, tmp_path):
out, finding = self._run(tmp_path, (
"custom_providers:\n - name: Local\n base_url: http://127.0.0.1:8283/V1/\n"
"providers:\n local:\n api: http://127.0.0.1:8283/v1\n"))
assert out == "" and finding.manual_issues == []
out, finding = self._run(tmp_path, "custom_providers: oops\n")
assert out == "" and finding.manual_issues == []
class TestDoctorDeprecatedConfigAndEnv:
"""Doctor must surface deprecated/legacy config keys and env vars with
modern replacements as non-failing warnings — without auto-migrating.
"""
def test_collect_deprecated_env_vars_ignores_empty(self):
assert doctor_config.collect_deprecated_env_vars({"TERMINAL_CWD": " "}) == []
assert doctor_config.collect_deprecated_env_vars({}) == []
assert doctor_config.collect_deprecated_env_vars(None) == []
class TestStagedRuntimeVenv:
"""doctor_platform._check_python_environment distinguishes staged
dependencies (pm's Venv().venv_dir() resolved state) from the active
interpreter: under no-boot-through-venv bundled installs
sys.prefix == sys.base_prefix by design, so "not active" must not read
as "missing dependencies" when pm has staged the runtime venv."""
@staticmethod
def _stub_venv(monkeypatch, venv_dir):
class _StubVenv:
def venv_dir(self):
return venv_dir
monkeypatch.setattr("pm.packages.Venv", _StubVenv)
# --- _staged_venv_dir: pm authority + provisioned-venv marker ---
def test_resolved_path_without_venv_marker_is_not_staged(self, tmp_path, monkeypatch):
empty = tmp_path / "venv"
empty.mkdir()
self._stub_venv(monkeypatch, empty)
assert doctor_platform._staged_venv_dir() is None
def test_unreadable_pm_degrades_to_none(self, monkeypatch):
class _BrokenVenv:
def venv_dir(self):
raise RuntimeError("pm unavailable")
monkeypatch.setattr("pm.packages.Venv", _BrokenVenv)
assert doctor_platform._staged_venv_dir() is None
# --- the check's staged-vs-active rows ---
def test_nothing_staged_keeps_legacy_interpreter_probe(self, monkeypatch, capsys):
monkeypatch.setattr(doctor_platform, "_staged_venv_dir", lambda: None)
monkeypatch.setattr(doctor_platform.sys, "prefix", "/usr")
monkeypatch.setattr(doctor_platform.sys, "base_prefix", "/usr")
doctor_platform._check_python_environment(False)
out = capsys.readouterr().out
assert "Runtime venv staged" not in out
assert "Not in virtual environment" in out
def test_run_doctor_reports_shadowed_lightpanda_engine(monkeypatch, tmp_path):
helper = TestDoctorMemoryProviderSection()
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True)
monkeypatch.setattr(
"tools.browser_tool_lightpanda_fallback.lightpanda_engine_status",
lambda: (False, "cloud provider Browserbase is selected"),
)
out = helper._run_doctor_and_capture(monkeypatch, tmp_path)
assert "browser.engine=lightpanda is shadowed" in out
assert "Browserbase" in out
def test_run_doctor_warns_when_lightpanda_binary_missing(monkeypatch, tmp_path):
helper = TestDoctorMemoryProviderSection()
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True)
monkeypatch.setattr("tools.browser_tool_lightpanda_fallback.lightpanda_engine_status", lambda: (True, "Browser Use mode"))
monkeypatch.setattr("tools.browser_lightpanda.find_lightpanda_binary", lambda: None)
out = helper._run_doctor_and_capture(monkeypatch, tmp_path)
assert "Lightpanda selected but binary not found" in out
def test_docker_daemon_probe_uses_version_not_info(monkeypatch):
"""`docker info` needs the /info endpoint, which socket proxies commonly block, so doctor reported
"daemon not running" against a working DOCKER_HOST (#72927). `docker version` (/version) is what the
backend itself probes with."""
from hermes_cli import doctor_tools
calls: list = []
monkeypatch.setattr(doctor_tools, "find_docker", lambda: "/usr/bin/docker")
monkeypatch.setattr(doctor_tools, "_run_ok", lambda cmd, timeout, **kw: calls.append(cmd) or True)
monkeypatch.setattr(doctor_tools, "_require", lambda *a, **k: None)
doctor_tools._check_docker_backend("docker", False, [])
assert calls == [["/usr/bin/docker", "version"]]
def test_doctor_reports_auxiliary_blocks_that_do_not_resolve(tmp_path, monkeypatch):
"""A routed auxiliary.<task> block that the runtime resolver rejects is a doctor finding, not a
silent fall-back to the main model (#116055); a resolvable one is not flagged."""
import hermes_yaml as yaml
from hermes_cli import doctor_config
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text(yaml.safe_dump({"auxiliary": {
"background_review": {"provider": "no-such-provider", "model": "m"},
"compression": {"provider": "openai", "model": "gpt-x", "base_url": "https://gateway.example/v1", "api_key": "gw"},
}}))
issues = []
doctor_config._validate_auxiliary_config(cfg_file, issues)
assert len(issues) == 1 and "auxiliary.background_review" in issues[0] and "no-such-provider" in issues[0]
@pytest.mark.platforms("macos")
class TestMacOSTCCGrants:
"""macOS TCC grant persistence check (#86385): a cdhash-pinned DR (pre-#73681
local builds) silently resets Screen Recording/Accessibility grants on every
rebuild while the Settings toggle stays ON."""
@staticmethod
def _darwin_bundle(monkeypatch, tmp_path, dr):
monkeypatch.setattr(doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app")
if dr is not ...:
monkeypatch.setattr(doctor_platform, "_macos_desktop_dr", lambda app: dr)
def test_silent_without_desktop_bundle(self, monkeypatch, capsys):
monkeypatch.setattr(doctor_platform, "_desktop_app_bundle", lambda: None)
doctor_platform.check_macos_tcc_grants()
assert capsys.readouterr().out == ""
def test_warns_on_cdhash_pinned_dr(self, monkeypatch, capsys, tmp_path):
self._darwin_bundle(
monkeypatch, tmp_path,
'designated => identifier "com.nousresearch.hermes" and cdhash H"97e692f3890f781fa0ad5ad6cb9d769cfaf42628"',
)
doctor_platform.check_macos_tcc_grants()
out = capsys.readouterr().out
assert "TCC grants will reset after every update" in out
assert "hermes update" in out
assert "signing identity is stable" not in out
def test_identifier_dr_is_stable_with_upgrade_hint_and_repair_info(self, monkeypatch, capsys, tmp_path):
self._darwin_bundle(monkeypatch, tmp_path, 'designated => identifier "com.nousresearch.hermes"')
doctor_platform.check_macos_tcc_grants()
out = capsys.readouterr().out
assert "TCC signing identity is stable" in out
assert "--setup-tcc-identity" in out
assert "tccutil reset ScreenCapture com.nousresearch.hermes" in out
def test_certificate_anchored_dr_is_stable_without_upgrade_hint(self, monkeypatch, capsys, tmp_path):
self._darwin_bundle(
monkeypatch, tmp_path,
'designated => identifier "com.nousresearch.hermes" and certificate root = H"aabbcc"',
)
doctor_platform.check_macos_tcc_grants()
out = capsys.readouterr().out
assert "TCC signing identity is stable" in out
assert "--setup-tcc-identity" not in out
assert "tccutil reset ScreenCapture com.nousresearch.hermes" in out
@pytest.mark.parametrize("failure", ["none", "empty", "timeout", "no_codesign"])
def test_unreadable_dr_warns_and_never_claims_stable(self, monkeypatch, capsys, tmp_path, failure):
"""codesign failing, hanging, missing or printing nothing degrades to a
warning; an empty DR must not false-positive as a stable identity."""
if failure in ("none", "empty"):
self._darwin_bundle(monkeypatch, tmp_path, None if failure == "none" else "")
else:
self._darwin_bundle(monkeypatch, tmp_path, ...)
if failure == "timeout":
monkeypatch.setattr(shutil, "which", lambda _name: "/usr/bin/codesign")
def _timeout(*args, **kwargs):
raise subprocess.TimeoutExpired(cmd=["codesign"], timeout=15)
monkeypatch.setattr(subprocess, "run", _timeout)
else:
monkeypatch.setattr(shutil, "which", lambda _name: None)
doctor_platform.check_macos_tcc_grants()
out = capsys.readouterr().out
assert "could not read code-signing requirement" in out
assert "stable" not in out