# 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
1612 lines
62 KiB
Python
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
|