mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
Merge remote-tracking branch 'origin/main' into sid/types-and-lints
# Conflicts: # gateway/platforms/base.py # gateway/platforms/qqbot/adapter.py # gateway/platforms/slack.py # hermes_cli/main.py # scripts/batch_runner.py # tools/skills_tool.py # uv.lock
This commit is contained in:
commit
a9ed7cb3b4
117 changed files with 7791 additions and 611 deletions
|
|
@ -921,17 +921,13 @@ class TestKimiMoonshotModelListIsolation:
|
|||
leaked = set(moonshot_models) & coding_plan_only
|
||||
assert not leaked, f"Moonshot list contains Coding Plan-only models: {leaked}"
|
||||
|
||||
def test_moonshot_list_contains_shared_models(self):
|
||||
def test_moonshot_list_non_empty(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
moonshot_models = _PROVIDER_MODELS["moonshot"]
|
||||
assert "kimi-k2.5" in moonshot_models
|
||||
assert "kimi-k2-thinking" in moonshot_models
|
||||
assert len(_PROVIDER_MODELS["moonshot"]) >= 1
|
||||
|
||||
def test_coding_plan_list_contains_plan_specific_models(self):
|
||||
def test_coding_plan_list_non_empty(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
coding_models = _PROVIDER_MODELS["kimi-coding"]
|
||||
assert "kimi-for-coding" in coding_models
|
||||
assert "kimi-k2-thinking-turbo" in coding_models
|
||||
assert len(_PROVIDER_MODELS["kimi-coding"]) >= 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -944,14 +940,12 @@ class TestHuggingFaceModels:
|
|||
def test_main_provider_models_has_huggingface(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
assert "huggingface" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["huggingface"]
|
||||
assert len(models) >= 6, "Expected at least 6 curated HF models"
|
||||
assert len(_PROVIDER_MODELS["huggingface"]) >= 1
|
||||
|
||||
def test_models_py_has_huggingface(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
assert "huggingface" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["huggingface"]
|
||||
assert len(models) >= 6
|
||||
assert len(_PROVIDER_MODELS["huggingface"]) >= 1
|
||||
|
||||
def test_model_lists_match(self):
|
||||
"""Model lists in main.py and models.py should be identical."""
|
||||
|
|
|
|||
|
|
@ -115,12 +115,12 @@ class TestArceeCredentials:
|
|||
|
||||
class TestArceeModelCatalog:
|
||||
def test_static_model_list(self):
|
||||
"""Arcee has a static _PROVIDER_MODELS catalog entry. Specific model
|
||||
names change with releases and don't belong in tests.
|
||||
"""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
assert "arcee" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["arcee"]
|
||||
assert "trinity-large-thinking" in models
|
||||
assert "trinity-large-preview" in models
|
||||
assert "trinity-mini" in models
|
||||
assert len(_PROVIDER_MODELS["arcee"]) >= 1
|
||||
|
||||
def test_canonical_provider_entry(self):
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS
|
||||
|
|
|
|||
|
|
@ -1011,3 +1011,466 @@ def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
|
|||
# Verify the auth store was NOT modified (no auto-import happened)
|
||||
after = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert "openai-codex" not in after.get("providers", {})
|
||||
|
||||
|
||||
def test_auth_remove_env_seeded_suppresses_shell_exported_var(tmp_path, monkeypatch, capsys):
|
||||
"""`hermes auth remove xai 1` must stick even when the env var is exported
|
||||
by the shell (not written into ~/.hermes/.env). Before PR for #13371 the
|
||||
removal silently restored on next load_pool() because _seed_from_env()
|
||||
re-read os.environ. Now env:<VAR> is suppressed in auth.json.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Simulate shell export (NOT written to .env)
|
||||
monkeypatch.setenv("XAI_API_KEY", "sk-xai-shell-export")
|
||||
(hermes_home / ".env").write_text("")
|
||||
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"xai": [{
|
||||
"id": "env-1",
|
||||
"label": "XAI_API_KEY",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "env:XAI_API_KEY",
|
||||
"access_token": "sk-xai-shell-export",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
}]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
auth_remove_command(SimpleNamespace(provider="xai", target="1"))
|
||||
|
||||
# Suppression marker written
|
||||
after = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert "env:XAI_API_KEY" in after.get("suppressed_sources", {}).get("xai", [])
|
||||
|
||||
# Diagnostic printed pointing at the shell
|
||||
out = capsys.readouterr().out
|
||||
assert "still set in your shell environment" in out
|
||||
assert "Cleared XAI_API_KEY from .env" not in out # wasn't in .env
|
||||
|
||||
# Fresh simulation: shell re-exports, reload pool
|
||||
monkeypatch.setenv("XAI_API_KEY", "sk-xai-shell-export")
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("xai")
|
||||
assert not pool.has_credentials(), "pool must stay empty — env:XAI_API_KEY suppressed"
|
||||
|
||||
|
||||
def test_auth_remove_env_seeded_dotenv_only_no_shell_hint(tmp_path, monkeypatch, capsys):
|
||||
"""When the env var lives only in ~/.hermes/.env (not the shell), the
|
||||
shell-hint should NOT be printed — avoid scaring the user about a
|
||||
non-existent shell export.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Key ONLY in .env, shell must not have it
|
||||
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
||||
(hermes_home / ".env").write_text("DEEPSEEK_API_KEY=sk-ds-only\n")
|
||||
# Mimic load_env() populating os.environ
|
||||
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-ds-only")
|
||||
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"deepseek": [{
|
||||
"id": "env-1",
|
||||
"label": "DEEPSEEK_API_KEY",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "env:DEEPSEEK_API_KEY",
|
||||
"access_token": "sk-ds-only",
|
||||
}]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
auth_remove_command(SimpleNamespace(provider="deepseek", target="1"))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Cleared DEEPSEEK_API_KEY from .env" in out
|
||||
assert "still set in your shell environment" not in out
|
||||
assert (hermes_home / ".env").read_text().strip() == ""
|
||||
|
||||
|
||||
def test_auth_add_clears_env_suppression_for_provider(tmp_path, monkeypatch):
|
||||
"""Re-adding a credential via `hermes auth add <provider>` clears any
|
||||
env:<VAR> suppression marker — strong signal the user wants auth back.
|
||||
Matches the Codex device_code re-link behaviour.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"xai": ["env:XAI_API_KEY"]},
|
||||
},
|
||||
)
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth import is_source_suppressed
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
assert is_source_suppressed("xai", "env:XAI_API_KEY") is True
|
||||
auth_add_command(SimpleNamespace(
|
||||
provider="xai", auth_type="api_key",
|
||||
api_key="sk-xai-manual", label="manual",
|
||||
))
|
||||
assert is_source_suppressed("xai", "env:XAI_API_KEY") is False
|
||||
|
||||
|
||||
def test_seed_from_env_respects_env_suppression(tmp_path, monkeypatch):
|
||||
"""_seed_from_env() must skip env:<VAR> sources that the user suppressed
|
||||
via `hermes auth remove`. This is the gate that prevents shell-exported
|
||||
keys from resurrecting removed credentials.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("XAI_API_KEY", "sk-xai-shell-export")
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"xai": ["env:XAI_API_KEY"]},
|
||||
}))
|
||||
|
||||
from agent.credential_pool import _seed_from_env
|
||||
|
||||
entries = []
|
||||
changed, active = _seed_from_env("xai", entries)
|
||||
assert changed is False
|
||||
assert entries == []
|
||||
assert active == set()
|
||||
|
||||
|
||||
def test_seed_from_env_respects_openrouter_suppression(tmp_path, monkeypatch):
|
||||
"""OpenRouter is the special-case branch in _seed_from_env; verify it
|
||||
honours suppression too.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-shell-export")
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"openrouter": ["env:OPENROUTER_API_KEY"]},
|
||||
}))
|
||||
|
||||
from agent.credential_pool import _seed_from_env
|
||||
|
||||
entries = []
|
||||
changed, active = _seed_from_env("openrouter", entries)
|
||||
assert changed is False
|
||||
assert entries == []
|
||||
assert active == set()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Unified credential-source stickiness — every source Hermes reads from has a
|
||||
# registered RemovalStep in agent.credential_sources, and every seeding path
|
||||
# gates on is_source_suppressed. Below: one test per source proving remove
|
||||
# sticks across a fresh load_pool() call.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_nous_suppression(tmp_path, monkeypatch):
|
||||
"""nous device_code must not re-seed from auth.json when suppressed."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {"nous": {"access_token": "tok", "refresh_token": "r", "expires_at": 9999999999}},
|
||||
"suppressed_sources": {"nous": ["device_code"]},
|
||||
}))
|
||||
|
||||
from agent.credential_pool import _seed_from_singletons
|
||||
entries = []
|
||||
changed, active = _seed_from_singletons("nous", entries)
|
||||
assert changed is False
|
||||
assert entries == []
|
||||
assert active == set()
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_copilot_suppression(tmp_path, monkeypatch):
|
||||
"""copilot gh_cli must not re-seed when suppressed."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"copilot": ["gh_cli"]},
|
||||
}))
|
||||
|
||||
# Stub resolve_copilot_token to return a live token
|
||||
import hermes_cli.copilot_auth as ca
|
||||
monkeypatch.setattr(ca, "resolve_copilot_token", lambda: ("ghp_fake", "gh auth token"))
|
||||
|
||||
from agent.credential_pool import _seed_from_singletons
|
||||
entries = []
|
||||
changed, active = _seed_from_singletons("copilot", entries)
|
||||
assert changed is False
|
||||
assert entries == []
|
||||
assert active == set()
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_qwen_suppression(tmp_path, monkeypatch):
|
||||
"""qwen-oauth qwen-cli must not re-seed from ~/.qwen/oauth_creds.json when suppressed."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"qwen-oauth": ["qwen-cli"]},
|
||||
}))
|
||||
|
||||
import hermes_cli.auth as ha
|
||||
monkeypatch.setattr(ha, "resolve_qwen_runtime_credentials", lambda **kw: {
|
||||
"api_key": "tok", "source": "qwen-cli", "base_url": "https://q",
|
||||
})
|
||||
|
||||
from agent.credential_pool import _seed_from_singletons
|
||||
entries = []
|
||||
changed, active = _seed_from_singletons("qwen-oauth", entries)
|
||||
assert changed is False
|
||||
assert entries == []
|
||||
assert active == set()
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeypatch):
|
||||
"""anthropic hermes_pkce must not re-seed from ~/.hermes/.anthropic_oauth.json when suppressed."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
import yaml
|
||||
(hermes_home / "config.yaml").write_text(yaml.dump({"model": {"provider": "anthropic", "model": "claude"}}))
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {"anthropic": ["hermes_pkce"]},
|
||||
}))
|
||||
|
||||
# Stub the readers so only hermes_pkce is "available"; claude_code returns None
|
||||
import agent.anthropic_adapter as aa
|
||||
monkeypatch.setattr(aa, "read_hermes_oauth_credentials", lambda: {
|
||||
"accessToken": "tok", "refreshToken": "r", "expiresAt": 9999999999000,
|
||||
})
|
||||
monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None)
|
||||
|
||||
from agent.credential_pool import _seed_from_singletons
|
||||
entries = []
|
||||
changed, active = _seed_from_singletons("anthropic", entries)
|
||||
# hermes_pkce suppressed, claude_code returns None → nothing should be seeded
|
||||
assert entries == []
|
||||
assert "hermes_pkce" not in active
|
||||
|
||||
|
||||
def test_seed_custom_pool_respects_config_suppression(tmp_path, monkeypatch):
|
||||
"""Custom provider config:<name> source must not re-seed when suppressed."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
import yaml
|
||||
(hermes_home / "config.yaml").write_text(yaml.dump({
|
||||
"model": {},
|
||||
"custom_providers": [
|
||||
{"name": "my", "base_url": "https://c.example.com", "api_key": "sk-custom"},
|
||||
],
|
||||
}))
|
||||
|
||||
from agent.credential_pool import _seed_custom_pool, get_custom_provider_pool_key
|
||||
pool_key = get_custom_provider_pool_key("https://c.example.com")
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {pool_key: ["config:my"]},
|
||||
}))
|
||||
|
||||
entries = []
|
||||
changed, active = _seed_custom_pool(pool_key, entries)
|
||||
assert changed is False
|
||||
assert entries == []
|
||||
assert "config:my" not in active
|
||||
|
||||
|
||||
def test_credential_sources_registry_has_expected_steps():
|
||||
"""Sanity check — the registry contains the expected RemovalSteps.
|
||||
|
||||
Guards against accidentally dropping a step during future refactors.
|
||||
If you add a new credential source, add it to the expected set below.
|
||||
"""
|
||||
from agent.credential_sources import _REGISTRY
|
||||
|
||||
descriptions = {step.description for step in _REGISTRY}
|
||||
expected = {
|
||||
"gh auth token / COPILOT_GITHUB_TOKEN / GH_TOKEN",
|
||||
"Any env-seeded credential (XAI_API_KEY, DEEPSEEK_API_KEY, etc.)",
|
||||
"~/.claude/.credentials.json",
|
||||
"~/.hermes/.anthropic_oauth.json",
|
||||
"auth.json providers.nous",
|
||||
"auth.json providers.openai-codex + ~/.codex/auth.json",
|
||||
"~/.qwen/oauth_creds.json",
|
||||
"Custom provider config.yaml api_key field",
|
||||
}
|
||||
assert descriptions == expected, f"Registry mismatch. Got: {descriptions}"
|
||||
|
||||
|
||||
def test_credential_sources_find_step_returns_none_for_manual():
|
||||
"""Manual entries have nothing external to clean up — no step registered."""
|
||||
from agent.credential_sources import find_removal_step
|
||||
assert find_removal_step("openrouter", "manual") is None
|
||||
assert find_removal_step("xai", "manual") is None
|
||||
|
||||
|
||||
def test_credential_sources_find_step_copilot_before_generic_env(tmp_path, monkeypatch):
|
||||
"""copilot env:GH_TOKEN must dispatch to the copilot step, not the
|
||||
generic env-var step. The copilot step handles the duplicate-source
|
||||
problem (same token seeded as both gh_cli and env:<VAR>); the generic
|
||||
env step would only suppress one of the variants.
|
||||
"""
|
||||
from agent.credential_sources import find_removal_step
|
||||
|
||||
step = find_removal_step("copilot", "env:GH_TOKEN")
|
||||
assert step is not None
|
||||
assert "copilot" in step.description.lower() or "gh" in step.description.lower()
|
||||
|
||||
# Generic step still matches any other provider's env var
|
||||
step = find_removal_step("xai", "env:XAI_API_KEY")
|
||||
assert step is not None
|
||||
assert "env-seeded" in step.description.lower()
|
||||
|
||||
|
||||
def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch):
|
||||
"""Removing any copilot source must suppress gh_cli + all env:* variants
|
||||
so the duplicate-seed paths don't resurrect the credential.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"copilot": [{
|
||||
"id": "c1",
|
||||
"label": "gh auth token",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "gh_cli",
|
||||
"access_token": "ghp_fake",
|
||||
}]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth import is_source_suppressed
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider="copilot", target="1"))
|
||||
|
||||
assert is_source_suppressed("copilot", "gh_cli")
|
||||
assert is_source_suppressed("copilot", "env:COPILOT_GITHUB_TOKEN")
|
||||
assert is_source_suppressed("copilot", "env:GH_TOKEN")
|
||||
assert is_source_suppressed("copilot", "env:GITHUB_TOKEN")
|
||||
|
||||
|
||||
def test_auth_add_clears_all_suppressions_including_non_env(tmp_path, monkeypatch):
|
||||
"""Re-adding a credential via `hermes auth add <provider>` clears ALL
|
||||
suppression markers for the provider, not just env:*. This matches
|
||||
the single "re-engage" semantic — the user wants auth back, period.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"suppressed_sources": {
|
||||
"copilot": ["gh_cli", "env:GH_TOKEN", "env:COPILOT_GITHUB_TOKEN"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth import is_source_suppressed
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
auth_add_command(SimpleNamespace(
|
||||
provider="copilot", auth_type="api_key",
|
||||
api_key="ghp-manual", label="m",
|
||||
))
|
||||
|
||||
assert not is_source_suppressed("copilot", "gh_cli")
|
||||
assert not is_source_suppressed("copilot", "env:GH_TOKEN")
|
||||
assert not is_source_suppressed("copilot", "env:COPILOT_GITHUB_TOKEN")
|
||||
|
||||
|
||||
def test_auth_remove_codex_manual_device_code_suppresses_canonical(tmp_path, monkeypatch):
|
||||
"""Removing a manual:device_code entry (from `hermes auth add openai-codex`)
|
||||
must suppress the canonical ``device_code`` key, not ``manual:device_code``.
|
||||
The re-seed gate in _seed_from_singletons checks ``device_code``.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"providers": {"openai-codex": {"tokens": {"access_token": "t", "refresh_token": "r"}}},
|
||||
"credential_pool": {
|
||||
"openai-codex": [{
|
||||
"id": "cdx",
|
||||
"label": "manual-codex",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "manual:device_code",
|
||||
"access_token": "t",
|
||||
}]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.auth import is_source_suppressed
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider="openai-codex", target="1"))
|
||||
assert is_source_suppressed("openai-codex", "device_code")
|
||||
|
|
|
|||
|
|
@ -459,7 +459,8 @@ class TestCustomProviderCompatibility:
|
|||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 21
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
|
||||
assert raw["providers"]["openai-direct"] == {
|
||||
"api": "https://api.openai.com/v1",
|
||||
"api_key": "test-key",
|
||||
|
|
@ -501,7 +502,8 @@ class TestCustomProviderCompatibility:
|
|||
assert compatible[0]["provider_key"] == "openai-direct"
|
||||
assert compatible[0]["api_mode"] == "codex_responses"
|
||||
|
||||
def test_compatible_custom_providers_prefers_api_then_url_then_base_url(self, tmp_path):
|
||||
def test_compatible_custom_providers_prefers_base_url_then_url_then_api(self, tmp_path):
|
||||
"""URL field precedence is base_url > url > api (PR #9332)."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(
|
||||
|
|
@ -526,7 +528,7 @@ class TestCustomProviderCompatibility:
|
|||
assert compatible == [
|
||||
{
|
||||
"name": "My Provider",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"base_url": "https://base.example.com/v1",
|
||||
"provider_key": "my-provider",
|
||||
}
|
||||
]
|
||||
|
|
@ -606,7 +608,8 @@ class TestInterimAssistantMessageConfig:
|
|||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 21
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
|
||||
assert raw["display"]["tool_progress"] == "off"
|
||||
assert raw["display"]["interim_assistant_messages"] is True
|
||||
|
||||
|
|
@ -626,7 +629,8 @@ class TestDiscordChannelPromptsConfig:
|
|||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 21
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
|
||||
assert raw["discord"]["auto_thread"] is True
|
||||
assert raw["discord"]["channel_prompts"] == {}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,18 +125,12 @@ class TestGeminiCredentials:
|
|||
# ── Model Catalog ──
|
||||
|
||||
class TestGeminiModelCatalog:
|
||||
def test_provider_models_exist(self):
|
||||
def test_provider_entry_exists(self):
|
||||
"""Gemini provider has a model catalog entry. Specific model names
|
||||
are data that changes with Google releases and don't belong in tests.
|
||||
"""
|
||||
assert "gemini" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["gemini"]
|
||||
assert "gemini-2.5-pro" in models
|
||||
assert "gemini-2.5-flash" in models
|
||||
assert "gemma-4-31b-it" not in models
|
||||
|
||||
def test_provider_models_has_3x(self):
|
||||
models = _PROVIDER_MODELS["gemini"]
|
||||
assert "gemini-3.1-pro-preview" in models
|
||||
assert "gemini-3-flash-preview" in models
|
||||
assert "gemini-3.1-flash-lite-preview" in models
|
||||
assert len(_PROVIDER_MODELS["gemini"]) >= 1
|
||||
|
||||
def test_provider_label(self):
|
||||
assert "gemini" in _PROVIDER_LABELS
|
||||
|
|
|
|||
|
|
@ -457,29 +457,62 @@ class TestValidateApiNotFound:
|
|||
assert "not found" in result["message"]
|
||||
|
||||
|
||||
# -- validate — API unreachable — reject with guidance ----------------
|
||||
# -- validate — API unreachable — soft-accept via catalog or warning --------
|
||||
|
||||
class TestValidateApiFallback:
|
||||
def test_any_model_rejected_when_api_down(self):
|
||||
result = _validate("anthropic/claude-opus-4.6", api_models=None)
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
"""When /models is unreachable, the validator must accept the model (with
|
||||
a warning) rather than reject it outright — otherwise provider switches
|
||||
fail in the gateway for any provider whose /models endpoint is down or
|
||||
doesn't exist (e.g. opencode-go returns 404 HTML).
|
||||
|
||||
def test_unknown_model_also_rejected_when_api_down(self):
|
||||
result = _validate("anthropic/claude-next-gen", api_models=None)
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert "could not reach" in result["message"].lower()
|
||||
Two paths:
|
||||
1. Provider has a curated catalog (``_PROVIDER_MODELS`` / live fetch):
|
||||
validate against it (recognized=True for known models,
|
||||
recognized=False with 'Note:' for unknown).
|
||||
2. Provider has no catalog: accept with a generic 'Note:' warning.
|
||||
|
||||
def test_zai_model_rejected_when_api_down(self):
|
||||
In both cases ``accepted`` and ``persist`` must be True so the gateway can
|
||||
write the ``_session_model_overrides`` entry.
|
||||
"""
|
||||
|
||||
def test_known_model_accepted_via_catalog_when_api_down(self):
|
||||
# Force the openrouter catalog lookup to return a deterministic list.
|
||||
with patch(
|
||||
"hermes_cli.models.provider_model_ids",
|
||||
return_value=["anthropic/claude-opus-4.6", "openai/gpt-5.4"],
|
||||
):
|
||||
result = _validate("anthropic/claude-opus-4.6", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["recognized"] is True
|
||||
|
||||
def test_unknown_model_accepted_with_note_when_api_down(self):
|
||||
with patch(
|
||||
"hermes_cli.models.provider_model_ids",
|
||||
return_value=["anthropic/claude-opus-4.6", "openai/gpt-5.4"],
|
||||
):
|
||||
result = _validate("anthropic/claude-next-gen", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["recognized"] is False
|
||||
# Message flags it as unverified against the catalog.
|
||||
assert "not found" in result["message"].lower() or "note" in result["message"].lower()
|
||||
|
||||
def test_zai_known_model_accepted_via_catalog_when_api_down(self):
|
||||
# glm-5 is in the zai curated catalog (_PROVIDER_MODELS["zai"]).
|
||||
result = _validate("glm-5", provider="zai", api_models=None)
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["recognized"] is True
|
||||
|
||||
def test_unknown_provider_rejected_when_api_down(self):
|
||||
result = _validate("some-model", provider="totally-unknown", api_models=None)
|
||||
assert result["accepted"] is False
|
||||
assert result["persist"] is False
|
||||
def test_unknown_provider_soft_accepted_when_api_down(self):
|
||||
# No catalog for unknown providers — soft-accept with a Note.
|
||||
with patch("hermes_cli.models.provider_model_ids", return_value=[]):
|
||||
result = _validate("some-model", provider="totally-unknown", api_models=None)
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["recognized"] is False
|
||||
assert "note" in result["message"].lower()
|
||||
|
||||
def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self):
|
||||
with patch(
|
||||
|
|
|
|||
|
|
@ -88,6 +88,131 @@ class TestFetchOpenRouterModels:
|
|||
|
||||
assert models == OPENROUTER_MODELS
|
||||
|
||||
def test_filters_out_models_without_tool_support(self, monkeypatch):
|
||||
"""Models whose supported_parameters omits 'tools' must not appear in the picker.
|
||||
|
||||
hermes-agent is tool-calling-first — surfacing a non-tool model leads to
|
||||
immediate runtime failures when the user selects it. Ported from
|
||||
Kilo-Org/kilocode#9068.
|
||||
"""
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
# opus-4.6 advertises tools → kept
|
||||
# nano-image has explicit supported_parameters that OMITS tools → dropped
|
||||
# qwen3.6-plus advertises tools → kept
|
||||
return (
|
||||
b'{"data":['
|
||||
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"},'
|
||||
b'"supported_parameters":["temperature","tools","tool_choice"]},'
|
||||
b'{"id":"google/gemini-3-pro-image-preview","pricing":{"prompt":"0.00001","completion":"0.00003"},'
|
||||
b'"supported_parameters":["temperature","response_format"]},'
|
||||
b'{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"},'
|
||||
b'"supported_parameters":["tools","temperature"]}'
|
||||
b']}'
|
||||
)
|
||||
|
||||
# Include the image-only id in the curated list so it has a chance to be surfaced.
|
||||
monkeypatch.setattr(
|
||||
_models_mod,
|
||||
"OPENROUTER_MODELS",
|
||||
[
|
||||
("anthropic/claude-opus-4.6", ""),
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("qwen/qwen3.6-plus", ""),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
||||
models = fetch_openrouter_models(force_refresh=True)
|
||||
|
||||
ids = [mid for mid, _ in models]
|
||||
assert "anthropic/claude-opus-4.6" in ids
|
||||
assert "qwen/qwen3.6-plus" in ids
|
||||
# Image-only model advertised supported_parameters WITHOUT tools → must be dropped.
|
||||
assert "google/gemini-3-pro-image-preview" not in ids
|
||||
|
||||
def test_permissive_when_supported_parameters_missing(self, monkeypatch):
|
||||
"""Models missing the supported_parameters field keep appearing in the picker.
|
||||
|
||||
Some OpenRouter-compatible gateways (Nous Portal, private mirrors, older
|
||||
catalog snapshots) don't populate supported_parameters. Treating missing
|
||||
as 'unknown → allow' prevents the picker from silently emptying on
|
||||
those gateways.
|
||||
"""
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
# No supported_parameters field at all on either entry.
|
||||
return (
|
||||
b'{"data":['
|
||||
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
|
||||
b'{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}'
|
||||
b']}'
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
|
||||
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
|
||||
models = fetch_openrouter_models(force_refresh=True)
|
||||
|
||||
ids = [mid for mid, _ in models]
|
||||
assert "anthropic/claude-opus-4.6" in ids
|
||||
assert "qwen/qwen3.6-plus" in ids
|
||||
|
||||
|
||||
class TestOpenRouterToolSupportHelper:
|
||||
"""Unit tests for _openrouter_model_supports_tools (Kilo port #9068)."""
|
||||
|
||||
def test_tools_in_supported_parameters(self):
|
||||
from hermes_cli.models import _openrouter_model_supports_tools
|
||||
assert _openrouter_model_supports_tools(
|
||||
{"id": "x", "supported_parameters": ["temperature", "tools"]}
|
||||
) is True
|
||||
|
||||
def test_tools_missing_from_supported_parameters(self):
|
||||
from hermes_cli.models import _openrouter_model_supports_tools
|
||||
assert _openrouter_model_supports_tools(
|
||||
{"id": "x", "supported_parameters": ["temperature", "response_format"]}
|
||||
) is False
|
||||
|
||||
def test_supported_parameters_absent_is_permissive(self):
|
||||
"""Missing field → allow (so older / non-OR gateways still work)."""
|
||||
from hermes_cli.models import _openrouter_model_supports_tools
|
||||
assert _openrouter_model_supports_tools({"id": "x"}) is True
|
||||
|
||||
def test_supported_parameters_none_is_permissive(self):
|
||||
from hermes_cli.models import _openrouter_model_supports_tools
|
||||
assert _openrouter_model_supports_tools({"id": "x", "supported_parameters": None}) is True
|
||||
|
||||
def test_supported_parameters_malformed_is_permissive(self):
|
||||
"""Malformed (non-list) value → allow rather than silently drop."""
|
||||
from hermes_cli.models import _openrouter_model_supports_tools
|
||||
assert _openrouter_model_supports_tools(
|
||||
{"id": "x", "supported_parameters": "tools,temperature"}
|
||||
) is True
|
||||
|
||||
def test_non_dict_item_is_permissive(self):
|
||||
from hermes_cli.models import _openrouter_model_supports_tools
|
||||
assert _openrouter_model_supports_tools(None) is True
|
||||
assert _openrouter_model_supports_tools("anthropic/claude-opus-4.6") is True
|
||||
|
||||
def test_empty_supported_parameters_list_drops_model(self):
|
||||
"""Explicit empty list → no tools → drop."""
|
||||
from hermes_cli.models import _openrouter_model_supports_tools
|
||||
assert _openrouter_model_supports_tools(
|
||||
{"id": "x", "supported_parameters": []}
|
||||
) is False
|
||||
|
||||
|
||||
class TestFindOpenrouterSlug:
|
||||
def test_exact_match(self):
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ def test_opencode_go_appears_when_api_key_set():
|
|||
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
|
||||
|
||||
assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set"
|
||||
assert opencode_go["models"] == ["kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
|
||||
assert opencode_go["models"] == ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
|
||||
# opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when
|
||||
# models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when
|
||||
# the API is unavailable, e.g. in CI).
|
||||
|
|
|
|||
133
tests/hermes_cli/test_opencode_go_validation_fallback.py
Normal file
133
tests/hermes_cli/test_opencode_go_validation_fallback.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Tests for the static-catalog fallback in validate_requested_model.
|
||||
|
||||
OpenCode Go and OpenCode Zen publish an OpenAI-compatible API at paths that do
|
||||
NOT expose ``/models`` (the path returns the marketing site's HTML 404). This
|
||||
caused ``validate_requested_model`` to return ``accepted=False`` for every
|
||||
model on those providers, which in turn made ``switch_model()`` fail and the
|
||||
gateway's ``/model <name> --provider opencode-go`` command never write to
|
||||
``_session_model_overrides``.
|
||||
|
||||
These tests cover the catalog-fallback path: when ``fetch_api_models`` returns
|
||||
``None``, the validator must consult ``provider_model_ids()`` for the provider
|
||||
(populated from ``_PROVIDER_MODELS``) rather than rejecting outright.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.models import validate_requested_model
|
||||
|
||||
|
||||
_UNREACHABLE_PROBE = {
|
||||
"models": None,
|
||||
"probed_url": "https://opencode.ai/zen/go/v1/models",
|
||||
"resolved_base_url": "https://opencode.ai/zen/go/v1",
|
||||
"suggested_base_url": None,
|
||||
"used_fallback": False,
|
||||
}
|
||||
|
||||
|
||||
def _patched(func):
|
||||
"""Decorator: force fetch_api_models / probe_api_models to simulate an
|
||||
unreachable /models endpoint, proving the catalog path is used."""
|
||||
def wrapper(*args, **kwargs):
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=None), \
|
||||
patch("hermes_cli.models.probe_api_models", return_value=_UNREACHABLE_PROBE):
|
||||
return func(*args, **kwargs)
|
||||
wrapper.__name__ = func.__name__
|
||||
return wrapper
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# opencode-go: curated catalog in _PROVIDER_MODELS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_patched
|
||||
def test_opencode_go_known_model_accepted():
|
||||
"""A model present in the opencode-go curated catalog must be accepted
|
||||
even when /models is unreachable."""
|
||||
result = validate_requested_model("kimi-k2.6", "opencode-go")
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["recognized"] is True
|
||||
assert result["message"] is None
|
||||
|
||||
|
||||
@_patched
|
||||
def test_opencode_go_known_model_case_insensitive():
|
||||
"""Catalog lookup is case-insensitive."""
|
||||
result = validate_requested_model("KIMI-K2.6", "opencode-go")
|
||||
assert result["accepted"] is True
|
||||
assert result["recognized"] is True
|
||||
|
||||
|
||||
@_patched
|
||||
def test_opencode_go_typo_auto_corrected():
|
||||
"""A close typo (>= 0.9 similarity) is auto-corrected to the catalog
|
||||
entry."""
|
||||
# 'kimi-k2.55' vs 'kimi-k2.5' ratio ≈ 0.95 — within the 0.9 cutoff.
|
||||
result = validate_requested_model("kimi-k2.55", "opencode-go")
|
||||
assert result["accepted"] is True
|
||||
assert result["recognized"] is True
|
||||
assert result.get("corrected_model") == "kimi-k2.5"
|
||||
|
||||
|
||||
@_patched
|
||||
def test_opencode_go_unknown_model_accepted_with_suggestion():
|
||||
"""An unknown model that has a medium-similarity match (>= 0.5 but < 0.9)
|
||||
is accepted with recognized=False and a 'similar models' hint. The key
|
||||
invariant: the gateway MUST be able to persist this override, so
|
||||
accepted/persist must both be True."""
|
||||
# 'kimi-k3-preview' vs 'kimi-k2.6' — similar enough to suggest, not to auto-correct.
|
||||
result = validate_requested_model("kimi-k3-preview", "opencode-go")
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["recognized"] is False
|
||||
assert "kimi-k3-preview" in result["message"]
|
||||
assert "curated catalog" in result["message"]
|
||||
|
||||
|
||||
@_patched
|
||||
def test_opencode_go_totally_unknown_model_still_accepted():
|
||||
"""A model with zero similarity to the catalog is still accepted (no
|
||||
suggestion line) so the user can try a model that hasn't made it into the
|
||||
curated list yet."""
|
||||
result = validate_requested_model("some-brand-new-model", "opencode-go")
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["recognized"] is False
|
||||
# No suggestion text (no close matches)
|
||||
assert "Similar models" not in result["message"]
|
||||
assert "opencode" in result["message"].lower() or "opencode go" in result["message"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# opencode-zen: same pattern as opencode-go
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_patched
|
||||
def test_opencode_zen_known_model_accepted():
|
||||
"""opencode-zen also uses _PROVIDER_MODELS; kimi-k2 is in its catalog."""
|
||||
result = validate_requested_model("kimi-k2", "opencode-zen")
|
||||
assert result["accepted"] is True
|
||||
assert result["recognized"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown provider with no catalog: soft-accept (honors the comment's intent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_patched
|
||||
def test_provider_without_catalog_accepts_with_warning():
|
||||
"""When a provider has no entry in _PROVIDER_MODELS and /models is
|
||||
unreachable, accept the model with a 'Note:' warning rather than reject.
|
||||
This matches the in-code comment: 'Accept and persist, but warn so typos
|
||||
don't silently break things.'"""
|
||||
# Use a made-up provider name that won't resolve to any catalog.
|
||||
result = validate_requested_model("some-model", "provider-that-does-not-exist")
|
||||
assert result["accepted"] is True
|
||||
assert result["persist"] is True
|
||||
assert result["recognized"] is False
|
||||
assert "Note:" in result["message"]
|
||||
|
|
@ -1412,3 +1412,90 @@ def test_named_custom_runtime_no_model_when_absent(monkeypatch):
|
|||
|
||||
resolved = rp.resolve_runtime_provider(requested="my-server")
|
||||
assert "model" not in resolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GHSA-76xc-57q6-vm5m — Ollama URL substring leak
|
||||
#
|
||||
# Same bug class as the previously-fixed GHSA-xf8p-v2cg-h7h5 (OpenRouter).
|
||||
# _resolve_openrouter_runtime's custom-endpoint branch selects OLLAMA_API_KEY
|
||||
# when the base_url "looks like" ollama.com. Previous implementation used
|
||||
# raw substring match; a custom base_url whose PATH or look-alike host
|
||||
# merely contained "ollama.com" leaked OLLAMA_API_KEY to that endpoint.
|
||||
# Fix: use base_url_host_matches (same helper as the OpenRouter sweep).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOllamaUrlSubstringLeak:
|
||||
"""Call-site regression tests for the fix in _resolve_openrouter_runtime."""
|
||||
|
||||
def _make_cfg(self, base_url):
|
||||
return {"base_url": base_url, "api_key": "", "provider": "custom"}
|
||||
|
||||
def test_ollama_key_not_leaked_to_path_injection(self, monkeypatch):
|
||||
"""http://127.0.0.1:9000/ollama.com/v1 — attacker endpoint with
|
||||
ollama.com in PATH. Must resolve to OPENAI_API_KEY, not OLLAMA_API_KEY."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-secret")
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "ol-SECRET-should-not-leak")
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
|
||||
"http://127.0.0.1:9000/ollama.com/v1"
|
||||
))
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
|
||||
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom")
|
||||
|
||||
assert "ol-SECRET" not in resolved["api_key"], (
|
||||
"OLLAMA_API_KEY must not be sent to an endpoint whose "
|
||||
"hostname is not ollama.com (GHSA-76xc-57q6-vm5m)"
|
||||
)
|
||||
assert resolved["api_key"] == "oa-secret"
|
||||
|
||||
def test_ollama_key_not_leaked_to_lookalike_host(self, monkeypatch):
|
||||
"""ollama.com.attacker.test — look-alike host. OLLAMA_API_KEY
|
||||
must not be sent."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "ol-SECRET-should-not-leak")
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
|
||||
"http://ollama.com.attacker.test:9000/v1"
|
||||
))
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
|
||||
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom")
|
||||
|
||||
assert "ol-SECRET" not in resolved["api_key"]
|
||||
assert resolved["api_key"] == "oa-secret"
|
||||
|
||||
def test_ollama_key_sent_to_genuine_ollama_com(self, monkeypatch):
|
||||
"""https://ollama.com/v1 — legit Ollama Cloud. OLLAMA_API_KEY
|
||||
should be used."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "ol-legit-key")
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
|
||||
"https://ollama.com/v1"
|
||||
))
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
|
||||
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom")
|
||||
|
||||
assert resolved["api_key"] == "ol-legit-key"
|
||||
|
||||
def test_ollama_key_sent_to_ollama_subdomain(self, monkeypatch):
|
||||
"""https://api.ollama.com/v1 — legit subdomain."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "oa-secret")
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "ol-legit-key")
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "custom")
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: self._make_cfg(
|
||||
"https://api.ollama.com/v1"
|
||||
))
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: None)
|
||||
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom")
|
||||
|
||||
assert resolved["api_key"] == "ol-legit-key"
|
||||
|
|
|
|||
148
tests/hermes_cli/test_web_server_host_header.py
Normal file
148
tests/hermes_cli/test_web_server_host_header.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Tests for GHSA-ppp5-vxwm-4cf7 — Host-header validation.
|
||||
|
||||
DNS rebinding defence: a victim browser that has the dashboard open
|
||||
could be tricked into fetching from an attacker-controlled hostname
|
||||
that TTL-flips to 127.0.0.1. Same-origin / CORS checks won't help —
|
||||
the browser now treats the attacker origin as same-origin. Validating
|
||||
the Host header at the application layer rejects the attack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_repo = str(Path(__file__).resolve().parents[1])
|
||||
if _repo not in sys.path:
|
||||
sys.path.insert(0, _repo)
|
||||
|
||||
|
||||
class TestHostHeaderValidator:
|
||||
"""Unit test the _is_accepted_host helper directly — cheaper and
|
||||
more thorough than spinning up the full FastAPI app."""
|
||||
|
||||
def test_loopback_bind_accepts_loopback_names(self):
|
||||
from hermes_cli.web_server import _is_accepted_host
|
||||
|
||||
for bound in ("127.0.0.1", "localhost", "::1"):
|
||||
for host_header in (
|
||||
"127.0.0.1", "127.0.0.1:9119",
|
||||
"localhost", "localhost:9119",
|
||||
"[::1]", "[::1]:9119",
|
||||
):
|
||||
assert _is_accepted_host(host_header, bound), (
|
||||
f"bound={bound} must accept host={host_header}"
|
||||
)
|
||||
|
||||
def test_loopback_bind_rejects_attacker_hostnames(self):
|
||||
"""The core rebinding defence: attacker-controlled hosts that
|
||||
TTL-flip to 127.0.0.1 must be rejected."""
|
||||
from hermes_cli.web_server import _is_accepted_host
|
||||
|
||||
for bound in ("127.0.0.1", "localhost"):
|
||||
for attacker in (
|
||||
"evil.example",
|
||||
"evil.example:9119",
|
||||
"rebind.attacker.test:80",
|
||||
"localhost.attacker.test", # subdomain trick
|
||||
"127.0.0.1.evil.test", # lookalike IP prefix
|
||||
"", # missing Host
|
||||
):
|
||||
assert not _is_accepted_host(attacker, bound), (
|
||||
f"bound={bound} must reject attacker host={attacker!r}"
|
||||
)
|
||||
|
||||
def test_zero_zero_bind_accepts_anything(self):
|
||||
"""0.0.0.0 means operator explicitly opted into all-interfaces
|
||||
(requires --insecure). No Host-layer defence is possible — rely
|
||||
on operator network controls."""
|
||||
from hermes_cli.web_server import _is_accepted_host
|
||||
|
||||
for host in ("10.0.0.5", "evil.example", "my-server.corp.net"):
|
||||
assert _is_accepted_host(host, "0.0.0.0")
|
||||
assert _is_accepted_host(host + ":9119", "0.0.0.0")
|
||||
|
||||
def test_explicit_non_loopback_bind_requires_exact_match(self):
|
||||
"""If the operator bound to a specific non-loopback hostname,
|
||||
the Host header must match exactly."""
|
||||
from hermes_cli.web_server import _is_accepted_host
|
||||
|
||||
assert _is_accepted_host("my-server.corp.net", "my-server.corp.net")
|
||||
assert _is_accepted_host("my-server.corp.net:9119", "my-server.corp.net")
|
||||
# Different host — reject
|
||||
assert not _is_accepted_host("evil.example", "my-server.corp.net")
|
||||
# Loopback — reject (we bound to a specific non-loopback name)
|
||||
assert not _is_accepted_host("localhost", "my-server.corp.net")
|
||||
|
||||
def test_case_insensitive_comparison(self):
|
||||
"""Host headers are case-insensitive per RFC — accept variations."""
|
||||
from hermes_cli.web_server import _is_accepted_host
|
||||
|
||||
assert _is_accepted_host("LOCALHOST", "127.0.0.1")
|
||||
assert _is_accepted_host("LocalHost:9119", "127.0.0.1")
|
||||
|
||||
|
||||
class TestHostHeaderMiddleware:
|
||||
"""End-to-end test via the FastAPI app — verify the middleware
|
||||
rejects bad Host headers with 400."""
|
||||
|
||||
def test_rebinding_request_rejected(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
# Simulate start_server having set the bound_host
|
||||
app.state.bound_host = "127.0.0.1"
|
||||
try:
|
||||
client = TestClient(app)
|
||||
# The TestClient sends Host: testserver by default — which is
|
||||
# NOT a loopback alias, so the middleware must reject it.
|
||||
resp = client.get(
|
||||
"/api/status",
|
||||
headers={"Host": "evil.example"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Invalid Host header" in resp.json()["detail"]
|
||||
finally:
|
||||
# Clean up so other tests don't inherit the bound_host
|
||||
if hasattr(app.state, "bound_host"):
|
||||
del app.state.bound_host
|
||||
|
||||
def test_legit_loopback_request_accepted(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
app.state.bound_host = "127.0.0.1"
|
||||
try:
|
||||
client = TestClient(app)
|
||||
# /api/status is in _PUBLIC_API_PATHS — passes auth — so the
|
||||
# only thing that can reject is the host header middleware
|
||||
resp = client.get(
|
||||
"/api/status",
|
||||
headers={"Host": "localhost:9119"},
|
||||
)
|
||||
# Either 200 (endpoint served) or some other non-400 —
|
||||
# just not the host-rejection 400
|
||||
assert resp.status_code != 400 or (
|
||||
"Invalid Host header" not in resp.json().get("detail", "")
|
||||
)
|
||||
finally:
|
||||
if hasattr(app.state, "bound_host"):
|
||||
del app.state.bound_host
|
||||
|
||||
def test_no_bound_host_skips_validation(self):
|
||||
"""If app.state.bound_host isn't set (e.g. running under test
|
||||
infra without calling start_server), middleware must pass through
|
||||
rather than crash."""
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
# Make sure bound_host isn't set
|
||||
if hasattr(app.state, "bound_host"):
|
||||
del app.state.bound_host
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/status")
|
||||
# Should get through to the status endpoint, not a 400
|
||||
assert resp.status_code != 400
|
||||
|
|
@ -136,13 +136,15 @@ class TestXiaomiModelCatalog:
|
|||
assert PROVIDER_TO_MODELS_DEV["xiaomi"] == "xiaomi"
|
||||
|
||||
def test_static_model_list_fallback(self):
|
||||
"""Static _PROVIDER_MODELS fallback must exist for model picker."""
|
||||
"""Static _PROVIDER_MODELS fallback must exist for model picker.
|
||||
|
||||
We only assert the provider key is present — the specific model
|
||||
names are data that changes with upstream releases and doesn't
|
||||
belong in tests.
|
||||
"""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
assert "xiaomi" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["xiaomi"]
|
||||
assert "mimo-v2-pro" in models
|
||||
assert "mimo-v2-omni" in models
|
||||
assert "mimo-v2-flash" in models
|
||||
assert len(_PROVIDER_MODELS["xiaomi"]) >= 1
|
||||
|
||||
def test_list_agentic_models_mock(self, monkeypatch):
|
||||
"""When models.dev returns Xiaomi data, list_agentic_models should return models."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue