mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
Fixes #26693 `hermes doctor` currently promotes invalid direct API keys into the final summary even when the matching OAuth path is already healthy. That makes the setup look more broken than it really is. This change keeps the failed API Connectivity row visible but stops treating it as a blocking summary issue when a healthy OAuth fallback already exists for the same provider family. Covered cases: - Gemini OAuth + invalid direct Gemini key - MiniMax OAuth + invalid direct MiniMax key Based on #26704 by @worlldz.
946 lines
34 KiB
Python
946 lines
34 KiB
Python
"""Tests for hermes_cli.doctor."""
|
|
|
|
import os
|
|
import sys
|
|
import types
|
|
import io
|
|
import contextlib
|
|
from argparse import Namespace
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.doctor as doctor
|
|
import hermes_cli.gateway as gateway_cli
|
|
from hermes_cli import doctor as doctor_mod
|
|
from hermes_cli.doctor import _has_provider_env_config
|
|
|
|
|
|
class TestDoctorPlatformHints:
|
|
def test_termux_package_hint(self, monkeypatch):
|
|
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
|
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
|
assert doctor._is_termux() is True
|
|
assert doctor._python_install_cmd() == "python -m pip install"
|
|
assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep"
|
|
|
|
def test_non_termux_package_hint_defaults_to_apt(self, monkeypatch):
|
|
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
|
monkeypatch.setenv("PREFIX", "/usr")
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
assert doctor._is_termux() is False
|
|
assert doctor._python_install_cmd() == "uv pip install"
|
|
assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep"
|
|
|
|
|
|
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_detects_custom_endpoint_without_openrouter_key(self):
|
|
content = "OPENAI_BASE_URL=http://localhost:8080/v1\n"
|
|
assert _has_provider_env_config(content)
|
|
|
|
def test_detects_kimi_cn_api_key(self):
|
|
content = "KIMI_CN_API_KEY=sk-test\n"
|
|
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 TestDoctorEnvFileEncoding:
|
|
"""Regression for #18637 (bug 3): `hermes doctor` crashed on Windows
|
|
Chinese locale (GBK) because `.env` was read with Path.read_text() 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 encoding="utf-8".
|
|
if self == env_path and encoding != "utf-8":
|
|
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))
|
|
|
|
|
|
class TestDoctorToolAvailabilityOverrides:
|
|
def test_marks_honcho_available_when_configured(self, monkeypatch):
|
|
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: True)
|
|
|
|
available, unavailable = doctor._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[{"name": "honcho", "env_vars": [], "tools": ["query_user_context"]}],
|
|
)
|
|
|
|
assert available == ["honcho"]
|
|
assert unavailable == []
|
|
|
|
def test_leaves_honcho_unavailable_when_not_configured(self, monkeypatch):
|
|
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False)
|
|
|
|
honcho_entry = {"name": "honcho", "env_vars": [], "tools": ["query_user_context"]}
|
|
available, unavailable = doctor._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[honcho_entry],
|
|
)
|
|
|
|
assert available == []
|
|
assert unavailable == [honcho_entry]
|
|
|
|
def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch):
|
|
monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False)
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
|
|
available, unavailable = doctor._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._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[kanban_entry],
|
|
)
|
|
|
|
assert available == []
|
|
assert unavailable == [kanban_entry]
|
|
|
|
def test_leaves_non_worker_kanban_failure_unavailable(self, monkeypatch):
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show", "not_a_kanban_tool"]}
|
|
|
|
available, unavailable = doctor._apply_doctor_tool_availability_overrides(
|
|
[],
|
|
[kanban_entry],
|
|
)
|
|
|
|
assert available == []
|
|
assert unavailable == [kanban_entry]
|
|
|
|
def test_kanban_doctor_detail_explains_worker_gate(self, monkeypatch):
|
|
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
|
|
|
assert doctor._doctor_tool_availability_detail("kanban") == "(runtime-gated; loaded only for dispatcher-spawned workers)"
|
|
|
|
|
|
class TestHonchoDoctorConfigDetection:
|
|
def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):
|
|
fake_config = SimpleNamespace(enabled=True, api_key="***")
|
|
|
|
monkeypatch.setattr(
|
|
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
|
lambda: fake_config,
|
|
)
|
|
|
|
assert doctor._honcho_is_configured_for_doctor()
|
|
|
|
def test_reports_not_configured_without_api_key(self, monkeypatch):
|
|
fake_config = SimpleNamespace(enabled=True, api_key="")
|
|
|
|
monkeypatch.setattr(
|
|
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
|
lambda: fake_config,
|
|
)
|
|
|
|
assert not doctor._honcho_is_configured_for_doctor()
|
|
|
|
|
|
def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path):
|
|
"""Doctor should present CLI-gated tools as available in CLI context."""
|
|
project_root = tmp_path / "project"
|
|
hermes_home = tmp_path / ".hermes"
|
|
project_root.mkdir()
|
|
hermes_home.mkdir()
|
|
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project_root)
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
|
|
seen = {}
|
|
|
|
def fake_check_tool_availability(*args, **kwargs):
|
|
seen["interactive"] = os.getenv("HERMES_INTERACTIVE")
|
|
raise SystemExit(0)
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=fake_check_tool_availability,
|
|
TOOLSET_REQUIREMENTS={},
|
|
)
|
|
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
|
|
|
with pytest.raises(SystemExit):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
|
|
assert seen["interactive"] == "1"
|
|
|
|
|
|
def test_check_gateway_service_linger_warns_when_disabled(monkeypatch, tmp_path, capsys):
|
|
unit_path = tmp_path / "hermes-gateway.service"
|
|
unit_path.write_text("[Unit]\n")
|
|
|
|
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
|
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
|
|
monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (False, ""))
|
|
|
|
issues = []
|
|
doctor._check_gateway_service_linger(issues)
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Gateway Service" in out
|
|
assert "Systemd linger disabled" in out
|
|
assert "loginctl enable-linger" in out
|
|
assert issues == [
|
|
"Enable linger for the gateway user service: sudo loginctl enable-linger $USER"
|
|
]
|
|
|
|
|
|
def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypatch, tmp_path, capsys):
|
|
unit_path = tmp_path / "missing.service"
|
|
|
|
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
|
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
|
|
|
|
issues = []
|
|
doctor._check_gateway_service_linger(issues)
|
|
|
|
out = capsys.readouterr().out
|
|
assert out == ""
|
|
assert issues == []
|
|
|
|
|
|
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(doctor_mod.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 runtime" in out
|
|
assert "python3.13" in out
|
|
assert "Vercel custom disk unsupported" in out
|
|
assert "Vercel auth incomplete" in out
|
|
assert "VERCEL_PROJECT_ID" in out
|
|
assert "Vercel auth mode: incomplete access token" in out
|
|
assert "Vercel auth present env: VERCEL_TOKEN, VERCEL_TEAM_ID" in out
|
|
assert "Vercel auth missing env: VERCEL_PROJECT_ID" in out
|
|
assert "super-secret-value" not in out
|
|
assert "snapshot filesystem only" in out
|
|
|
|
|
|
# ── 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=""):
|
|
"""Create a minimal HERMES_HOME with config.yaml."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
import yaml
|
|
config = {"memory": {"provider": provider}} if provider else {"memory": {}}
|
|
(home / "config.yaml").write_text(yaml.dump(config))
|
|
return home
|
|
|
|
def _run_doctor_and_capture(self, monkeypatch, tmp_path, provider=""):
|
|
"""Run doctor and capture stdout."""
|
|
home = self._make_hermes_home(tmp_path, provider)
|
|
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", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
|
except Exception:
|
|
pass
|
|
|
|
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_honcho_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
|
|
# Make honcho import fail
|
|
monkeypatch.setitem(
|
|
sys.modules, "plugins.memory.honcho.client", None
|
|
)
|
|
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="honcho")
|
|
assert "Memory Provider" in out
|
|
# Should show failure since honcho is set but not importable
|
|
assert "Built-in memory active" 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
|
|
|
|
|
|
def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
|
|
helper = TestDoctorMemoryProviderSection()
|
|
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
|
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
|
|
|
real_which = doctor_mod.shutil.which
|
|
|
|
def fake_which(cmd):
|
|
if cmd in {"docker", "node", "npm"}:
|
|
return None
|
|
return real_which(cmd)
|
|
|
|
monkeypatch.setattr(doctor_mod.shutil, "which", fake_which)
|
|
|
|
out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
|
|
|
|
assert "Docker backend is not available inside Termux" in out
|
|
assert "Node.js not found (browser tools are optional in the tested Termux path)" in out
|
|
assert "Install Node.js on Termux with: pkg install nodejs" in out
|
|
assert "Termux browser setup:" in out
|
|
assert "1) pkg install nodejs" in out
|
|
assert "2) npm install -g agent-browser" in out
|
|
assert "3) agent-browser install" in out
|
|
assert "Termux compatibility fallbacks:" in out
|
|
assert "use .[termux-all] for broad compatibility" in out
|
|
assert "Matrix E2EE extra is excluded on Termux" in out
|
|
assert "Local faster-whisper extra is excluded on Termux" in out
|
|
assert "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY)." in out
|
|
assert "docker not found (optional)" not in out
|
|
|
|
|
|
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 yaml
|
|
|
|
(home / "config.yaml").write_text(
|
|
yaml.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": {}},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
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", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_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_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", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_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
|
|
|
|
|
|
@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"),
|
|
],
|
|
)
|
|
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", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_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"}:
|
|
assert (
|
|
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
|
|
not in out
|
|
)
|
|
|
|
|
|
|
|
|
|
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", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
|
|
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 test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
|
|
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("TERMUX_VERSION", "0.118.3")
|
|
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
|
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
|
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
|
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
|
monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
|
|
|
|
fake_model_tools = types.SimpleNamespace(
|
|
check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
|
|
TOOLSET_REQUIREMENTS={
|
|
"terminal": {"name": "terminal"},
|
|
"browser": {"name": "browser"},
|
|
},
|
|
)
|
|
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: {})
|
|
except Exception:
|
|
pass
|
|
|
|
import io, contextlib
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
doctor_mod.run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "✓ browser" not in out
|
|
assert "browser" in out
|
|
assert "system dependency not met" in out
|
|
assert "agent-browser is not installed (expected in the tested Termux path)" in out
|
|
assert "npm install -g agent-browser && agent-browser install" 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: {})
|
|
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: {})
|
|
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: {})
|
|
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_no_token_and_not_gh_authenticated_shows_warn(self, monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
|
|
|
from hermes_cli.doctor import run_doctor, _DHH
|
|
import io, contextlib
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "No GITHUB_TOKEN" in out
|
|
assert "60 req/hr" in out
|
|
|
|
def test_token_env_present_shows_ok(self, monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123")
|
|
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
|
|
|
from hermes_cli.doctor import run_doctor
|
|
import io, contextlib
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "GitHub token configured" in out
|
|
|
|
def test_gh_authenticated_without_env_token_shows_ok(self, monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
# No GITHUB_TOKEN or GH_TOKEN
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
|
|
# Mock gh to return success
|
|
import shutil
|
|
real_which = shutil.which
|
|
def mock_which(cmd):
|
|
return "/usr/local/bin/gh" if cmd == "gh" else real_which(cmd)
|
|
monkeypatch.setattr(shutil, "which", mock_which)
|
|
|
|
call_log = []
|
|
def mock_run(cmd, **kwargs):
|
|
call_log.append(cmd)
|
|
if cmd[:2] == ["gh", "auth"]:
|
|
result = types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
else:
|
|
result = types.SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
return result
|
|
|
|
import subprocess
|
|
monkeypatch.setattr(subprocess, "run", mock_run)
|
|
|
|
from hermes_cli.doctor import run_doctor
|
|
import io, contextlib
|
|
|
|
buf = io.StringIO()
|
|
with contextlib.redirect_stdout(buf):
|
|
run_doctor(Namespace(fix=False))
|
|
out = buf.getvalue()
|
|
|
|
assert "gh auth" in str(call_log) or any(c[0] == "gh" for c in call_log), f"gh not called: {call_log}"
|
|
assert "GitHub authenticated via gh CLI" in out or "token configured" in out
|
|
|
|
|
|
def _run_doctor_with_healthy_oauth_fallback(
|
|
monkeypatch,
|
|
tmp_path,
|
|
*,
|
|
env_key: str,
|
|
bad_key: str,
|
|
failing_host: str,
|
|
gemini_oauth_status: dict,
|
|
minimax_oauth_status: dict,
|
|
) -> 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", lambda: {"logged_in": True})
|
|
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
|
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: gemini_oauth_status)
|
|
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_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", "gemini_oauth_status", "minimax_oauth_status", "unexpected_issue"),
|
|
[
|
|
(
|
|
"GOOGLE_API_KEY",
|
|
"bad-gemini-key",
|
|
"googleapis.com",
|
|
{"logged_in": True, "email": "user@example.com"},
|
|
{},
|
|
"Check GOOGLE_API_KEY in .env",
|
|
),
|
|
(
|
|
"MINIMAX_API_KEY",
|
|
"bad-minimax-key",
|
|
"minimax.io",
|
|
{},
|
|
{"logged_in": True, "region": "global"},
|
|
"Check MINIMAX_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,
|
|
gemini_oauth_status,
|
|
minimax_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,
|
|
gemini_oauth_status=gemini_oauth_status,
|
|
minimax_oauth_status=minimax_oauth_status,
|
|
)
|
|
|
|
assert "invalid API key" in out
|
|
assert unexpected_issue not in out
|