Merge main into bb/gui.
Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
@@ -1099,6 +1099,159 @@ class TestHuggingFaceModels:
|
||||
assert _PROVIDER_LABELS["huggingface"] == "Hugging Face"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# NovitaAI provider tests (added by feat/add-novita-provider)
|
||||
# =============================================================================
|
||||
|
||||
class TestNovitaProvider:
|
||||
"""Tests for NovitaAI — an OpenAI-compatible multi-model aggregator."""
|
||||
|
||||
def test_novita_profile_loads(self):
|
||||
from providers import get_provider_profile
|
||||
profile = get_provider_profile("novita")
|
||||
assert profile is not None
|
||||
assert profile.name == "novita"
|
||||
assert profile.display_name == "NovitaAI"
|
||||
assert profile.base_url == "https://api.novita.ai/openai/v1"
|
||||
assert "NOVITA_API_KEY" in profile.env_vars
|
||||
|
||||
def test_novita_aliases(self):
|
||||
from providers import get_provider_profile
|
||||
profile = get_provider_profile("novita")
|
||||
assert "novita-ai" in profile.aliases
|
||||
assert "novitaai" in profile.aliases
|
||||
|
||||
def test_novita_alias_resolves(self):
|
||||
assert resolve_provider("novita-ai") == "novita"
|
||||
assert resolve_provider("novitaai") == "novita"
|
||||
|
||||
def test_novita_in_provider_registry(self):
|
||||
"""Auto-registration from ProviderProfile should expose Novita."""
|
||||
assert "novita" in PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["novita"]
|
||||
assert pconfig.auth_type == "api_key"
|
||||
assert pconfig.id == "novita"
|
||||
assert pconfig.inference_base_url == "https://api.novita.ai/openai/v1"
|
||||
assert pconfig.api_key_env_vars == ("NOVITA_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "NOVITA_BASE_URL"
|
||||
|
||||
def test_novita_aliases_in_registry(self):
|
||||
assert "novita-ai" in PROVIDER_REGISTRY
|
||||
assert "novitaai" in PROVIDER_REGISTRY
|
||||
|
||||
def test_main_provider_models_has_novita(self):
|
||||
from hermes_cli.main import _PROVIDER_MODELS
|
||||
assert "novita" in _PROVIDER_MODELS
|
||||
assert len(_PROVIDER_MODELS["novita"]) >= 1
|
||||
|
||||
def test_models_py_has_novita(self):
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
assert "novita" in _PROVIDER_MODELS
|
||||
assert len(_PROVIDER_MODELS["novita"]) >= 1
|
||||
|
||||
def test_novita_model_lists_match(self):
|
||||
"""Model lists in main.py and models.py should be identical."""
|
||||
from hermes_cli.main import _PROVIDER_MODELS as main_models
|
||||
from hermes_cli.models import _PROVIDER_MODELS as models_models
|
||||
assert main_models["novita"] == models_models["novita"]
|
||||
|
||||
def test_novita_models_use_org_name_format(self):
|
||||
"""Novita models should use org/name format."""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
for model in _PROVIDER_MODELS["novita"]:
|
||||
assert "/" in model, f"Novita model {model!r} missing org/ prefix"
|
||||
|
||||
def test_novita_aliases_in_models_py(self):
|
||||
from hermes_cli.models import _PROVIDER_ALIASES
|
||||
assert _PROVIDER_ALIASES.get("novita-ai") == "novita"
|
||||
assert _PROVIDER_ALIASES.get("novitaai") == "novita"
|
||||
|
||||
def test_novita_label(self):
|
||||
from hermes_cli.models import _PROVIDER_LABELS
|
||||
assert "novita" in _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS["novita"] == "NovitaAI"
|
||||
|
||||
def test_novita_in_provider_prefixes(self):
|
||||
from agent.model_metadata import _PROVIDER_PREFIXES
|
||||
assert "novita" in _PROVIDER_PREFIXES
|
||||
|
||||
def test_novita_url_to_provider(self):
|
||||
from agent.model_metadata import _URL_TO_PROVIDER
|
||||
assert _URL_TO_PROVIDER.get("api.novita.ai") == "novita"
|
||||
|
||||
def test_context_size_in_context_length_keys(self):
|
||||
"""Novita /v1/models uses 'context_size' as the context length key."""
|
||||
from agent.model_metadata import _CONTEXT_LENGTH_KEYS
|
||||
assert "context_size" in _CONTEXT_LENGTH_KEYS
|
||||
|
||||
def test_novita_pricing_unit_conversion(self):
|
||||
"""Novita returns prices in 0.0001 USD per Mtok; divide by 10_000 * 1_000_000."""
|
||||
from agent.model_metadata import _extract_pricing
|
||||
# Sample shape from real Novita /v1/models response
|
||||
payload = {
|
||||
"id": "deepseek/deepseek-v3-0324",
|
||||
"input_token_price_per_m": 2690, # = $0.269 / Mtok
|
||||
"output_token_price_per_m": 4000, # = $0.400 / Mtok
|
||||
}
|
||||
result = _extract_pricing(payload)
|
||||
# Resulting strings represent per-token prices in dollars.
|
||||
assert "prompt" in result
|
||||
assert "completion" in result
|
||||
assert float(result["prompt"]) == 2690 / 10_000 / 1_000_000
|
||||
assert float(result["completion"]) == 4000 / 10_000 / 1_000_000
|
||||
|
||||
def test_novita_pricing_cache(self, monkeypatch):
|
||||
"""_fetch_novita_pricing should cache results in _pricing_cache."""
|
||||
from hermes_cli import models as models_mod
|
||||
monkeypatch.setenv("NOVITA_API_KEY", "sk-test-key")
|
||||
monkeypatch.setenv("NOVITA_BASE_URL", "https://api.novita.ai/openai/v1")
|
||||
models_mod._pricing_cache.pop("https://api.novita.ai/openai/v1", None)
|
||||
|
||||
call_count = {"n": 0}
|
||||
fake_payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "x/y",
|
||||
"input_token_price_per_m": 1000,
|
||||
"output_token_price_per_m": 2000,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
class _FakeResp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
import json as _json
|
||||
return _json.dumps(fake_payload).encode()
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
call_count["n"] += 1
|
||||
return _FakeResp()
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_mod.urllib.request, "urlopen", fake_urlopen
|
||||
)
|
||||
|
||||
# First call hits the network.
|
||||
first = models_mod._fetch_novita_pricing()
|
||||
assert "x/y" in first
|
||||
assert call_count["n"] == 1
|
||||
|
||||
# Second call returns cached result without re-hitting the network.
|
||||
second = models_mod._fetch_novita_pricing()
|
||||
assert second == first
|
||||
assert call_count["n"] == 1
|
||||
|
||||
# force_refresh bypasses the cache.
|
||||
models_mod._fetch_novita_pricing(force_refresh=True)
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MiniMax OAuth provider tests (added by feat/minimax-oauth-provider)
|
||||
# =============================================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ All Bedrock API calls are mocked — no real AWS credentials needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -26,6 +28,19 @@ import pytest
|
||||
# Shared helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _mock_botocore_session(*, return_value=None):
|
||||
"""Patch botocore.session even when botocore is not installed."""
|
||||
botocore_mod = ModuleType("botocore")
|
||||
session_mod = ModuleType("botocore.session")
|
||||
session_mod.get_session = MagicMock(return_value=return_value)
|
||||
botocore_mod.session = session_mod
|
||||
with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}):
|
||||
yield session_mod.get_session
|
||||
|
||||
|
||||
_EU_MODELS = [
|
||||
{"id": "eu.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (EU)", "provider": "inference-profile"},
|
||||
{"id": "eu.anthropic.claude-haiku-4-5-20251015-v1:0", "name": "Claude Haiku 4.5 (EU)", "provider": "inference-profile"},
|
||||
@@ -276,7 +291,7 @@ class TestBedrockRegionRouting:
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
|
||||
patch("botocore.session.get_session", return_value=mock_session):
|
||||
_mock_botocore_session(return_value=mock_session):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
@@ -310,7 +325,7 @@ class TestBedrockRegionRouting:
|
||||
mock_session = MagicMock()
|
||||
mock_session.get_config_variable.return_value = "eu-central-1"
|
||||
|
||||
with patch("botocore.session.get_session", return_value=mock_session):
|
||||
with _mock_botocore_session(return_value=mock_session):
|
||||
region = resolve_bedrock_region()
|
||||
|
||||
assert region == "us-west-2", "env var should override botocore profile"
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
"""Tests for the codex MCP plugin migration helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.codex_runtime_plugin_migration import (
|
||||
MIGRATION_MARKER,
|
||||
MIGRATION_END_MARKER,
|
||||
MigrationReport,
|
||||
_build_hermes_tools_mcp_entry,
|
||||
_format_toml_value,
|
||||
_looks_like_test_tempdir,
|
||||
_strip_existing_managed_block,
|
||||
_strip_unmanaged_plugin_tables,
|
||||
_translate_one_server,
|
||||
migrate,
|
||||
render_codex_toml_section,
|
||||
)
|
||||
|
||||
|
||||
# ---- per-server translation ----
|
||||
|
||||
class TestTranslateOneServer:
|
||||
def test_stdio_basic(self):
|
||||
cfg, skipped = _translate_one_server("filesystem", {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"env": {"FOO": "bar"},
|
||||
})
|
||||
assert cfg == {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"env": {"FOO": "bar"},
|
||||
}
|
||||
assert skipped == []
|
||||
|
||||
def test_stdio_with_cwd(self):
|
||||
cfg, _ = _translate_one_server("custom", {
|
||||
"command": "/usr/bin/myserver",
|
||||
"cwd": "/var/lib/mcp",
|
||||
})
|
||||
assert cfg["cwd"] == "/var/lib/mcp"
|
||||
|
||||
def test_http_basic(self):
|
||||
cfg, skipped = _translate_one_server("api", {
|
||||
"url": "https://x.example/mcp",
|
||||
"headers": {"Authorization": "Bearer abc"},
|
||||
})
|
||||
assert cfg == {
|
||||
"url": "https://x.example/mcp",
|
||||
"http_headers": {"Authorization": "Bearer abc"},
|
||||
}
|
||||
assert skipped == []
|
||||
|
||||
def test_sse_falls_under_streamable_http_with_warning(self):
|
||||
cfg, skipped = _translate_one_server("sse_server", {
|
||||
"url": "http://localhost:8000/sse",
|
||||
"transport": "sse",
|
||||
})
|
||||
assert cfg["url"] == "http://localhost:8000/sse"
|
||||
assert any("sse" in s.lower() for s in skipped)
|
||||
|
||||
def test_timeouts_translate(self):
|
||||
cfg, _ = _translate_one_server("x", {
|
||||
"command": "y",
|
||||
"timeout": 180,
|
||||
"connect_timeout": 30,
|
||||
})
|
||||
assert cfg["tool_timeout_sec"] == 180.0
|
||||
assert cfg["startup_timeout_sec"] == 30.0
|
||||
|
||||
def test_non_numeric_timeout_skipped(self):
|
||||
cfg, skipped = _translate_one_server("x", {
|
||||
"command": "y",
|
||||
"timeout": "not-a-number",
|
||||
})
|
||||
assert "tool_timeout_sec" not in cfg
|
||||
assert any("timeout" in s and "numeric" in s for s in skipped)
|
||||
|
||||
def test_disabled_server_emits_enabled_false(self):
|
||||
cfg, _ = _translate_one_server("x", {
|
||||
"command": "y",
|
||||
"enabled": False,
|
||||
})
|
||||
assert cfg["enabled"] is False
|
||||
|
||||
def test_enabled_true_omitted(self):
|
||||
cfg, _ = _translate_one_server("x", {"command": "y", "enabled": True})
|
||||
assert "enabled" not in cfg # codex defaults to true
|
||||
|
||||
def test_command_and_url_prefers_stdio_warns(self):
|
||||
cfg, skipped = _translate_one_server("x", {
|
||||
"command": "y", "url": "http://z",
|
||||
})
|
||||
assert "command" in cfg
|
||||
assert "url" not in cfg
|
||||
assert any("url" in s for s in skipped)
|
||||
|
||||
def test_no_transport_returns_none(self):
|
||||
cfg, skipped = _translate_one_server("broken", {"description": "x"})
|
||||
assert cfg is None
|
||||
assert "no command or url" in skipped[0]
|
||||
|
||||
def test_sampling_dropped_with_warning(self):
|
||||
cfg, skipped = _translate_one_server("x", {
|
||||
"command": "y",
|
||||
"sampling": {"enabled": True, "model": "gemini-3-flash"},
|
||||
})
|
||||
assert "sampling" not in cfg
|
||||
assert any("sampling" in s for s in skipped)
|
||||
|
||||
def test_unknown_keys_warned(self):
|
||||
cfg, skipped = _translate_one_server("x", {
|
||||
"command": "y",
|
||||
"totally_made_up_key": "value",
|
||||
})
|
||||
assert "totally_made_up_key" not in cfg
|
||||
assert any("totally_made_up_key" in s for s in skipped)
|
||||
|
||||
def test_non_dict_input(self):
|
||||
cfg, skipped = _translate_one_server("x", "notadict") # type: ignore[arg-type]
|
||||
assert cfg is None
|
||||
|
||||
|
||||
# ---- TOML rendering ----
|
||||
|
||||
class TestTomlValueFormatter:
|
||||
def test_string_quoted(self):
|
||||
assert _format_toml_value("hello") == '"hello"'
|
||||
|
||||
def test_string_with_quotes_escaped(self):
|
||||
assert _format_toml_value('a"b') == '"a\\"b"'
|
||||
|
||||
def test_bool(self):
|
||||
assert _format_toml_value(True) == "true"
|
||||
assert _format_toml_value(False) == "false"
|
||||
|
||||
def test_int(self):
|
||||
assert _format_toml_value(42) == "42"
|
||||
|
||||
def test_float(self):
|
||||
assert _format_toml_value(180.0) == "180.0"
|
||||
|
||||
def test_list_of_strings(self):
|
||||
assert _format_toml_value(["a", "b"]) == '["a", "b"]'
|
||||
|
||||
def test_inline_table(self):
|
||||
out = _format_toml_value({"FOO": "bar"})
|
||||
assert out == '{ FOO = "bar" }'
|
||||
|
||||
def test_empty_inline_table(self):
|
||||
assert _format_toml_value({}) == "{}"
|
||||
|
||||
def test_string_with_newline_escaped(self):
|
||||
"""TOML basic strings don't allow literal newlines — a path or
|
||||
env var containing a newline must use \\n. Otherwise codex would
|
||||
refuse to load the config."""
|
||||
out = _format_toml_value("line one\nline two")
|
||||
assert "\n" not in out # no raw newline in output
|
||||
assert "\\n" in out
|
||||
|
||||
def test_string_with_tab_escaped(self):
|
||||
out = _format_toml_value("col1\tcol2")
|
||||
assert "\t" not in out
|
||||
assert "\\t" in out
|
||||
|
||||
def test_string_with_other_controls_escaped(self):
|
||||
for raw, expected in [
|
||||
("\r", "\\r"),
|
||||
("\f", "\\f"),
|
||||
("\b", "\\b"),
|
||||
]:
|
||||
out = _format_toml_value(f"x{raw}y")
|
||||
assert raw not in out, f"{raw!r} should be escaped"
|
||||
assert expected in out, f"{expected!r} should be in output"
|
||||
|
||||
def test_windows_path_escaped_correctly(self):
|
||||
out = _format_toml_value(r"C:\Users\Alice\.codex")
|
||||
# Each backslash should be doubled
|
||||
assert out == r'"C:\\Users\\Alice\\.codex"'
|
||||
|
||||
def test_atomic_write_no_temp_leak_on_success(self, tmp_path):
|
||||
"""The atomic-write path uses tempfile.mkstemp + rename. On
|
||||
success the temp file should not be left behind."""
|
||||
migrate({"mcp_servers": {"x": {"command": "y"}}},
|
||||
codex_home=tmp_path,
|
||||
discover_plugins=False,
|
||||
expose_hermes_tools=False,
|
||||
default_permission_profile=None)
|
||||
# config.toml should exist
|
||||
assert (tmp_path / "config.toml").exists()
|
||||
# And no .config.toml.* temp files left behind
|
||||
leftover = [p.name for p in tmp_path.iterdir()
|
||||
if p.name.startswith(".config.toml.")]
|
||||
assert leftover == [], f"temp file leaked after migration: {leftover}"
|
||||
|
||||
def test_atomic_write_cleanup_on_rename_failure(self, tmp_path, monkeypatch):
|
||||
"""If rename fails partway through (out of disk, permissions,
|
||||
crash), the temp file must be cleaned up. Otherwise repeated
|
||||
failed migrations would pile up .config.toml.* files."""
|
||||
from pathlib import Path as _Path
|
||||
original_replace = _Path.replace
|
||||
|
||||
def failing_replace(self, target):
|
||||
raise OSError("simulated disk full")
|
||||
|
||||
monkeypatch.setattr(_Path, "replace", failing_replace)
|
||||
report = migrate(
|
||||
{"mcp_servers": {"x": {"command": "y"}}},
|
||||
codex_home=tmp_path,
|
||||
discover_plugins=False,
|
||||
expose_hermes_tools=False,
|
||||
default_permission_profile=None,
|
||||
)
|
||||
# Error surfaced
|
||||
assert any("simulated disk full" in e for e in report.errors)
|
||||
# And no leaked temp file
|
||||
leftover = [p.name for p in tmp_path.iterdir()
|
||||
if p.name.startswith(".config.toml.")]
|
||||
assert leftover == [], f"temp files leaked: {leftover}"
|
||||
|
||||
def test_unsupported_type_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_format_toml_value(object())
|
||||
|
||||
|
||||
class TestRenderToml:
|
||||
def test_starts_with_marker(self):
|
||||
out = render_codex_toml_section({})
|
||||
assert out.startswith(MIGRATION_MARKER)
|
||||
|
||||
def test_empty_servers_emits_placeholder(self):
|
||||
out = render_codex_toml_section({})
|
||||
assert "no MCP servers" in out
|
||||
|
||||
def test_servers_sorted_alphabetically(self):
|
||||
out = render_codex_toml_section({
|
||||
"zoo": {"command": "z"},
|
||||
"alpha": {"command": "a"},
|
||||
"middle": {"command": "m"},
|
||||
})
|
||||
# Find the section header positions and confirm order
|
||||
a_pos = out.find("[mcp_servers.alpha]")
|
||||
m_pos = out.find("[mcp_servers.middle]")
|
||||
z_pos = out.find("[mcp_servers.zoo]")
|
||||
assert 0 < a_pos < m_pos < z_pos
|
||||
|
||||
def test_server_with_args_and_env(self):
|
||||
out = render_codex_toml_section({
|
||||
"fs": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "filesystem"],
|
||||
"env": {"PATH": "/usr/bin"},
|
||||
}
|
||||
})
|
||||
assert "[mcp_servers.fs]" in out
|
||||
assert 'command = "npx"' in out
|
||||
assert 'args = ["-y", "filesystem"]' in out
|
||||
# Env emitted as inline table
|
||||
assert 'env = { PATH = "/usr/bin" }' in out
|
||||
|
||||
|
||||
# ---- existing-block stripping ----
|
||||
|
||||
class TestStripExistingManagedBlock:
|
||||
def test_no_managed_block_unchanged(self):
|
||||
text = "[other]\nfoo = 1\n"
|
||||
assert _strip_existing_managed_block(text) == text
|
||||
|
||||
def test_strips_managed_block_alone(self):
|
||||
text = (
|
||||
f"{MIGRATION_MARKER}\n"
|
||||
"\n"
|
||||
"[mcp_servers.fs]\n"
|
||||
'command = "npx"\n'
|
||||
)
|
||||
assert _strip_existing_managed_block(text).strip() == ""
|
||||
|
||||
def test_preserves_user_content_above_managed_block(self):
|
||||
text = (
|
||||
"[model]\n"
|
||||
'name = "gpt-5.5"\n'
|
||||
"\n"
|
||||
f"{MIGRATION_MARKER}\n"
|
||||
"[mcp_servers.fs]\n"
|
||||
'command = "x"\n'
|
||||
)
|
||||
out = _strip_existing_managed_block(text)
|
||||
assert "[model]" in out
|
||||
assert 'name = "gpt-5.5"' in out
|
||||
assert "mcp_servers.fs" not in out
|
||||
|
||||
def test_preserves_unrelated_section_after_managed_block(self):
|
||||
text = (
|
||||
f"{MIGRATION_MARKER}\n"
|
||||
"[mcp_servers.fs]\n"
|
||||
'command = "x"\n'
|
||||
"\n"
|
||||
"[providers]\n"
|
||||
'foo = "bar"\n'
|
||||
)
|
||||
out = _strip_existing_managed_block(text)
|
||||
assert "mcp_servers.fs" not in out
|
||||
assert "[providers]" in out
|
||||
assert 'foo = "bar"' in out
|
||||
|
||||
|
||||
# ---- end-to-end migrate(, expose_hermes_tools=False) ----
|
||||
|
||||
class TestMigrate:
|
||||
def test_no_servers_no_plugins_no_perms_writes_placeholder(self, tmp_path):
|
||||
report = migrate({}, codex_home=tmp_path,
|
||||
discover_plugins=False,
|
||||
default_permission_profile=None, expose_hermes_tools=False)
|
||||
assert report.written
|
||||
text = (tmp_path / "config.toml").read_text()
|
||||
assert MIGRATION_MARKER in text
|
||||
assert "no MCP servers" in text or "no MCP servers, plugins, or permissions" in text
|
||||
|
||||
def test_no_servers_still_writes_permissions_default(self, tmp_path):
|
||||
"""Even with zero MCP servers, enabling the runtime should write the
|
||||
default permissions profile so users don't get prompted on every
|
||||
write attempt. This is the fix for quirk #2."""
|
||||
report = migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
|
||||
assert report.written
|
||||
text = (tmp_path / "config.toml").read_text()
|
||||
# Codex's schema: top-level `default_permissions` keying a built-in
|
||||
# profile name (prefixed with ":"). NOT a [permissions] section
|
||||
# (which is for *user-defined* profiles with structured fields).
|
||||
assert 'default_permissions = ":workspace"' in text
|
||||
assert report.wrote_permissions_default == ":workspace"
|
||||
|
||||
def test_explicit_none_permissions_skips_block(self, tmp_path):
|
||||
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
|
||||
codex_home=tmp_path,
|
||||
discover_plugins=False,
|
||||
default_permission_profile=None, expose_hermes_tools=False)
|
||||
text = (tmp_path / "config.toml").read_text()
|
||||
assert "default_permissions" not in text
|
||||
assert "[permissions]" not in text
|
||||
assert report.wrote_permissions_default is None
|
||||
|
||||
def test_plugin_discovery_writes_plugin_blocks(self, tmp_path, monkeypatch):
|
||||
"""Discovered curated plugins land as [plugins."<name>@<marketplace>"]
|
||||
blocks. This is what OpenClaw calls 'migrate native codex plugins.'"""
|
||||
from hermes_cli import codex_runtime_plugin_migration as crpm
|
||||
|
||||
def fake_query(codex_home=None, timeout=8.0):
|
||||
return [
|
||||
{"name": "google-calendar", "marketplace": "openai-curated",
|
||||
"enabled": True},
|
||||
{"name": "github", "marketplace": "openai-curated",
|
||||
"enabled": True},
|
||||
], None
|
||||
monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query)
|
||||
|
||||
report = migrate({}, codex_home=tmp_path, discover_plugins=True)
|
||||
text = (tmp_path / "config.toml").read_text()
|
||||
assert '[plugins."github@openai-curated"]' in text
|
||||
assert '[plugins."google-calendar@openai-curated"]' in text
|
||||
assert "enabled = true" in text
|
||||
assert "google-calendar@openai-curated" in report.migrated_plugins
|
||||
assert "github@openai-curated" in report.migrated_plugins
|
||||
|
||||
def test_plugin_discovery_skips_unavailable_plugins(self):
|
||||
"""Plugins where codex reports availability != AVAILABLE should
|
||||
be skipped — they're broken/uninstallable on codex's side, so
|
||||
migrating them would write config that fails at activation
|
||||
time. Cf. openclaw#80815."""
|
||||
from hermes_cli.codex_runtime_plugin_migration import _query_codex_plugins
|
||||
from unittest.mock import patch
|
||||
|
||||
# Fake a plugin/list response where one plugin is unavailable
|
||||
fake_response = {
|
||||
"marketplaces": [{
|
||||
"name": "openai-curated",
|
||||
"plugins": [
|
||||
{"name": "good-plugin", "installed": True,
|
||||
"enabled": True, "availability": "AVAILABLE"},
|
||||
{"name": "broken-plugin", "installed": True,
|
||||
"enabled": True, "availability": "UNAVAILABLE"},
|
||||
{"name": "auth-pending", "installed": True,
|
||||
"enabled": True, "availability": "REQUIRES_AUTH"},
|
||||
# Plugin without availability field — pass through
|
||||
# (older codex versions or marketplaces that don't
|
||||
# set it should still work).
|
||||
{"name": "legacy-plugin", "installed": True,
|
||||
"enabled": True},
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kw): pass
|
||||
def initialize(self, **kw): pass
|
||||
def request(self, method, params, timeout=None):
|
||||
return fake_response
|
||||
def close(self): pass
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): pass
|
||||
|
||||
with patch("agent.transports.codex_app_server.CodexAppServerClient",
|
||||
FakeClient):
|
||||
plugins, err = _query_codex_plugins()
|
||||
|
||||
assert err is None
|
||||
names = [p["name"] for p in plugins]
|
||||
assert "good-plugin" in names
|
||||
assert "legacy-plugin" in names # no field → don't skip
|
||||
assert "broken-plugin" not in names
|
||||
assert "auth-pending" not in names
|
||||
|
||||
def test_plugin_discovery_failure_non_fatal(self, tmp_path, monkeypatch):
|
||||
"""If codex isn't installed or RPC fails, MCP migration still
|
||||
completes. The error surfaces in the report but doesn't abort."""
|
||||
from hermes_cli import codex_runtime_plugin_migration as crpm
|
||||
|
||||
def fake_query_fails(codex_home=None, timeout=8.0):
|
||||
return [], "codex CLI not available"
|
||||
monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query_fails)
|
||||
|
||||
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
|
||||
codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
|
||||
assert report.written
|
||||
assert report.migrated == ["x"]
|
||||
assert report.plugin_query_error == "codex CLI not available"
|
||||
assert report.migrated_plugins == []
|
||||
|
||||
def test_discover_plugins_false_skips_query(self, tmp_path, monkeypatch):
|
||||
"""Tests and restricted environments can opt out of the subprocess
|
||||
spawn entirely."""
|
||||
from hermes_cli import codex_runtime_plugin_migration as crpm
|
||||
|
||||
called = {"yes": False}
|
||||
def boom(*a, **kw):
|
||||
called["yes"] = True
|
||||
return [], None
|
||||
monkeypatch.setattr(crpm, "_query_codex_plugins", boom)
|
||||
|
||||
migrate({"mcp_servers": {"x": {"command": "y"}}},
|
||||
codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
|
||||
assert called["yes"] is False
|
||||
|
||||
def test_dry_run_skips_plugin_query(self, tmp_path, monkeypatch):
|
||||
"""Dry run should never spawn codex. Even with discover_plugins=True
|
||||
the query is skipped because dry_run takes precedence."""
|
||||
from hermes_cli import codex_runtime_plugin_migration as crpm
|
||||
|
||||
called = {"yes": False}
|
||||
def boom(*a, **kw):
|
||||
called["yes"] = True
|
||||
return [], None
|
||||
monkeypatch.setattr(crpm, "_query_codex_plugins", boom)
|
||||
|
||||
migrate({"mcp_servers": {"x": {"command": "y"}}},
|
||||
codex_home=tmp_path, dry_run=True, discover_plugins=True, expose_hermes_tools=False)
|
||||
assert called["yes"] is False
|
||||
|
||||
def test_re_run_replaces_plugin_block(self, tmp_path, monkeypatch):
|
||||
"""Plugin blocks are managed and re-runs should replace them
|
||||
cleanly — same idempotency contract as MCP servers."""
|
||||
from hermes_cli import codex_runtime_plugin_migration as crpm
|
||||
|
||||
# First run: only github
|
||||
monkeypatch.setattr(crpm, "_query_codex_plugins",
|
||||
lambda codex_home=None, timeout=8.0: (
|
||||
[{"name": "github", "marketplace": "openai-curated", "enabled": True}],
|
||||
None,
|
||||
))
|
||||
migrate({}, codex_home=tmp_path, discover_plugins=True,
|
||||
default_permission_profile=None, expose_hermes_tools=False)
|
||||
first = (tmp_path / "config.toml").read_text()
|
||||
assert "github@openai-curated" in first
|
||||
|
||||
# Second run: only canva (github went away)
|
||||
monkeypatch.setattr(crpm, "_query_codex_plugins",
|
||||
lambda codex_home=None, timeout=8.0: (
|
||||
[{"name": "canva", "marketplace": "openai-curated", "enabled": True}],
|
||||
None,
|
||||
))
|
||||
migrate({}, codex_home=tmp_path, discover_plugins=True,
|
||||
default_permission_profile=None, expose_hermes_tools=False)
|
||||
second = (tmp_path / "config.toml").read_text()
|
||||
assert "github@openai-curated" not in second
|
||||
assert "canva@openai-curated" in second
|
||||
|
||||
def test_expose_hermes_tools_writes_callback_mcp_entry(self, tmp_path):
|
||||
"""When expose_hermes_tools=True (production default), an
|
||||
[mcp_servers.hermes-tools] entry is written so codex calls back
|
||||
into Hermes for browser/web/delegate_task/vision/memory tools.
|
||||
|
||||
This is the fix for 'all other tools that codex doesn't provide
|
||||
should be useable by hermes' — quirk #7."""
|
||||
report = migrate({}, codex_home=tmp_path,
|
||||
discover_plugins=False,
|
||||
default_permission_profile=None,
|
||||
expose_hermes_tools=True)
|
||||
text = (tmp_path / "config.toml").read_text()
|
||||
assert "[mcp_servers.hermes-tools]" in text
|
||||
assert "hermes_tools_mcp_server" in text
|
||||
# Must include startup + tool timeouts so codex doesn't give up
|
||||
assert "startup_timeout_sec" in text
|
||||
assert "tool_timeout_sec" in text
|
||||
# And the entry is reported
|
||||
assert "hermes-tools" in report.migrated
|
||||
|
||||
def test_expose_hermes_tools_disabled_skips_entry(self, tmp_path):
|
||||
"""expose_hermes_tools=False suppresses the callback registration."""
|
||||
migrate({}, codex_home=tmp_path,
|
||||
discover_plugins=False,
|
||||
default_permission_profile=None,
|
||||
expose_hermes_tools=False)
|
||||
text = (tmp_path / "config.toml").read_text()
|
||||
assert "[mcp_servers.hermes-tools]" not in text
|
||||
assert "hermes_tools_mcp_server" not in text
|
||||
|
||||
def test_dry_run_doesnt_write(self, tmp_path):
|
||||
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
|
||||
codex_home=tmp_path, dry_run=True, expose_hermes_tools=False)
|
||||
assert report.dry_run is True
|
||||
assert not (tmp_path / "config.toml").exists()
|
||||
assert "x" in report.migrated
|
||||
|
||||
def test_full_migration_round_trip(self, tmp_path):
|
||||
hermes_cfg = {
|
||||
"mcp_servers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
},
|
||||
"github": {
|
||||
"url": "https://api.github.com/mcp",
|
||||
"headers": {"Authorization": "Bearer x"},
|
||||
},
|
||||
}
|
||||
}
|
||||
report = migrate(hermes_cfg, codex_home=tmp_path, expose_hermes_tools=False)
|
||||
assert report.written
|
||||
text = (tmp_path / "config.toml").read_text()
|
||||
assert "[mcp_servers.filesystem]" in text
|
||||
assert "[mcp_servers.github]" in text
|
||||
assert 'command = "npx"' in text
|
||||
assert 'url = "https://api.github.com/mcp"' in text
|
||||
|
||||
def test_idempotent_re_run_replaces_managed_block(self, tmp_path):
|
||||
# First migration
|
||||
migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False)
|
||||
first_text = (tmp_path / "config.toml").read_text()
|
||||
assert "[mcp_servers.a]" in first_text
|
||||
# Second migration with different servers
|
||||
migrate({"mcp_servers": {"b": {"command": "y"}}}, codex_home=tmp_path, expose_hermes_tools=False)
|
||||
second_text = (tmp_path / "config.toml").read_text()
|
||||
assert "[mcp_servers.a]" not in second_text
|
||||
assert "[mcp_servers.b]" in second_text
|
||||
|
||||
def test_preserves_user_codex_config_above_marker(self, tmp_path):
|
||||
target = tmp_path / "config.toml"
|
||||
target.write_text(
|
||||
"[model]\n"
|
||||
'profile = "default"\n'
|
||||
"\n"
|
||||
"[providers.openai]\n"
|
||||
'api_key = "sk-test"\n'
|
||||
)
|
||||
migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False)
|
||||
new_text = target.read_text()
|
||||
# User's codex config preserved
|
||||
assert "[model]" in new_text
|
||||
assert 'profile = "default"' in new_text
|
||||
assert "[providers.openai]" in new_text
|
||||
# And new MCP block inserted without breaking user tables
|
||||
assert "[mcp_servers.a]" in new_text
|
||||
assert MIGRATION_MARKER in new_text
|
||||
|
||||
def test_managed_root_keys_stay_top_level_when_config_ends_in_table(self, tmp_path):
|
||||
"""TOML has no explicit 'leave current table' syntax. If Hermes appends
|
||||
root keys like default_permissions after a user table such as [features],
|
||||
Codex parses them as features.default_permissions and rejects the config.
|
||||
The managed block must therefore be inserted before the first table."""
|
||||
import tomllib
|
||||
|
||||
target = tmp_path / "config.toml"
|
||||
target.write_text(
|
||||
'model = "gpt-5.5"\n'
|
||||
"\n"
|
||||
"[features]\n"
|
||||
"terminal_resize_reflow = true\n"
|
||||
)
|
||||
migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
|
||||
new_text = target.read_text()
|
||||
parsed = tomllib.loads(new_text)
|
||||
assert parsed["default_permissions"] == ":workspace"
|
||||
assert "default_permissions" not in parsed["features"]
|
||||
assert new_text.index(MIGRATION_MARKER) < new_text.index("[features]")
|
||||
|
||||
def test_preserves_user_mcp_server_outside_managed_block(self, tmp_path):
|
||||
"""Quirk #6: when a user adds their own MCP server entry directly
|
||||
to ~/.codex/config.toml outside Hermes' managed block, re-running
|
||||
migration must preserve it. Tested both above and below the
|
||||
managed block."""
|
||||
target = tmp_path / "config.toml"
|
||||
target.write_text(
|
||||
"[mcp_servers.user-above]\n"
|
||||
'command = "/usr/bin/above-server"\n'
|
||||
'args = ["--above"]\n'
|
||||
)
|
||||
# First migrate — adds managed block below user content
|
||||
migrate({"mcp_servers": {"hermes-mcp": {"command": "npx"}}},
|
||||
codex_home=tmp_path, discover_plugins=False,
|
||||
expose_hermes_tools=False)
|
||||
text = target.read_text()
|
||||
assert "user-above" in text, "user MCP server above managed block got nuked"
|
||||
assert 'command = "/usr/bin/above-server"' in text
|
||||
|
||||
# Append another user entry below the managed block
|
||||
target.write_text(
|
||||
text + "\n[mcp_servers.user-below]\ncommand = \"below-server\"\n"
|
||||
)
|
||||
# Re-migrate — both should survive
|
||||
migrate({"mcp_servers": {"hermes-mcp": {"command": "npx"}}},
|
||||
codex_home=tmp_path, discover_plugins=False,
|
||||
expose_hermes_tools=False)
|
||||
final = target.read_text()
|
||||
assert "user-above" in final
|
||||
assert "user-below" in final
|
||||
# And our managed block is still there with the new content
|
||||
assert "[mcp_servers.hermes-mcp]" in final
|
||||
|
||||
def test_skipped_keys_reported(self, tmp_path):
|
||||
report = migrate({
|
||||
"mcp_servers": {
|
||||
"x": {
|
||||
"command": "y",
|
||||
"sampling": {"enabled": True}, # codex has no equivalent
|
||||
}
|
||||
}
|
||||
}, codex_home=tmp_path, expose_hermes_tools=False)
|
||||
assert "x" in report.skipped_keys_per_server
|
||||
assert any("sampling" in s for s in report.skipped_keys_per_server["x"])
|
||||
|
||||
def test_invalid_mcp_servers_value(self, tmp_path):
|
||||
report = migrate({"mcp_servers": "notadict"}, codex_home=tmp_path, expose_hermes_tools=False)
|
||||
assert any("not a dict" in e for e in report.errors)
|
||||
|
||||
def test_server_without_transport_skipped_with_error(self, tmp_path):
|
||||
report = migrate({
|
||||
"mcp_servers": {"broken": {"description": "no command/url"}}
|
||||
}, codex_home=tmp_path, expose_hermes_tools=False)
|
||||
assert "broken" not in report.migrated
|
||||
assert any("broken" in e for e in report.errors)
|
||||
|
||||
def test_summary_reports_migration_count(self, tmp_path):
|
||||
report = migrate({
|
||||
"mcp_servers": {"a": {"command": "x"}, "b": {"command": "y"}}
|
||||
}, codex_home=tmp_path, expose_hermes_tools=False)
|
||||
summary = report.summary()
|
||||
assert "Migrated 2 MCP server(s)" in summary
|
||||
assert "- a" in summary
|
||||
assert "- b" in summary
|
||||
|
||||
|
||||
# ---- Bug B: duplicate [plugins.X] tables ----
|
||||
|
||||
|
||||
class TestStripUnmanagedPluginTables:
|
||||
"""Regression tests for issue #26250 Bug B.
|
||||
|
||||
When codex itself writes ``[plugins."<name>@<marketplace>"]`` tables
|
||||
(via the user running ``codex plugins enable`` directly), re-running
|
||||
``hermes codex-runtime migrate`` would re-emit them inside the managed
|
||||
block and the resulting duplicate-table-header would crash codex.
|
||||
"""
|
||||
|
||||
def test_strips_plugin_tables_outside_managed_block(self):
|
||||
text = (
|
||||
'model = "gpt-5.5"\n'
|
||||
"\n"
|
||||
"[mcp_servers.user-thing]\n"
|
||||
'command = "x"\n'
|
||||
"\n"
|
||||
'[plugins."tasks@openai-curated"]\n'
|
||||
"enabled = true\n"
|
||||
"\n"
|
||||
'[plugins."web-search@openai-curated"]\n'
|
||||
"enabled = true\n"
|
||||
"\n"
|
||||
"[features]\n"
|
||||
"terminal_resize_reflow = true\n"
|
||||
)
|
||||
stripped = _strip_unmanaged_plugin_tables(text)
|
||||
assert "[plugins." not in stripped
|
||||
# Non-plugin content preserved
|
||||
assert "[mcp_servers.user-thing]" in stripped
|
||||
assert "[features]" in stripped
|
||||
assert "terminal_resize_reflow = true" in stripped
|
||||
|
||||
def test_preserves_content_when_no_plugin_tables(self):
|
||||
text = (
|
||||
'model = "gpt-5.5"\n'
|
||||
"\n"
|
||||
"[mcp_servers.x]\n"
|
||||
'command = "y"\n'
|
||||
)
|
||||
assert _strip_unmanaged_plugin_tables(text) == text
|
||||
|
||||
def test_multi_line_array_in_plugin_table_does_not_leak(self):
|
||||
"""A multi-line TOML array inside a [plugins.X] table whose
|
||||
continuation lines start with ``[`` (e.g. nested arrays) must NOT
|
||||
prematurely exit the strip region — otherwise array fragments
|
||||
leak into top-level output and produce invalid TOML on the next
|
||||
codex startup. Regression guard for #26260 review.
|
||||
"""
|
||||
text = (
|
||||
'[plugins."tasks@openai-curated"]\n'
|
||||
"allowed = [\n"
|
||||
' "a",\n'
|
||||
' ["nested"],\n'
|
||||
"]\n"
|
||||
"[features]\n"
|
||||
"x = 1\n"
|
||||
)
|
||||
stripped = _strip_unmanaged_plugin_tables(text)
|
||||
# Everything inside the plugin table — including the multi-line
|
||||
# array's continuation lines starting with `[` — should be gone.
|
||||
assert '["nested"]' not in stripped
|
||||
assert "allowed" not in stripped
|
||||
# Sibling user table survives intact.
|
||||
assert "[features]" in stripped
|
||||
assert "x = 1" in stripped
|
||||
# Result is still valid TOML.
|
||||
import tomllib
|
||||
tomllib.loads(stripped)
|
||||
|
||||
def test_migrate_dedups_codex_owned_plugin_tables(self, tmp_path, monkeypatch):
|
||||
"""End-to-end: codex's pre-existing [plugins.X] tables get replaced by
|
||||
the managed block's re-emission rather than duplicated."""
|
||||
target = tmp_path / "config.toml"
|
||||
target.write_text(
|
||||
"[mcp_servers.user-server]\n"
|
||||
'command = "x"\n'
|
||||
"\n"
|
||||
'[plugins."tasks@openai-curated"]\n'
|
||||
"enabled = true\n"
|
||||
)
|
||||
|
||||
# Simulate codex's plugin/list reporting the same plugin tasks@openai-curated.
|
||||
def fake_query(codex_home=None, timeout=8.0):
|
||||
return (
|
||||
[{"name": "tasks", "marketplace": "openai-curated", "enabled": True}],
|
||||
None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_runtime_plugin_migration._query_codex_plugins",
|
||||
fake_query,
|
||||
)
|
||||
migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
|
||||
new_text = target.read_text()
|
||||
# Only ONE [plugins."tasks@openai-curated"] header should remain — inside
|
||||
# the managed block — not the original outside-the-block copy.
|
||||
assert new_text.count('[plugins."tasks@openai-curated"]') == 1
|
||||
# And the surviving one is inside our managed section.
|
||||
managed_start = new_text.index(MIGRATION_MARKER)
|
||||
managed_end = new_text.index(MIGRATION_END_MARKER)
|
||||
plugin_idx = new_text.index('[plugins."tasks@openai-curated"]')
|
||||
assert managed_start < plugin_idx < managed_end
|
||||
# File parses cleanly as TOML (the original duplicate-key error is gone).
|
||||
import tomllib
|
||||
tomllib.loads(new_text)
|
||||
|
||||
def test_migrate_preserves_plugin_tables_when_plugin_list_fails(self, tmp_path, monkeypatch):
|
||||
"""If plugin/list RPC fails, we can't re-emit plugins authoritatively,
|
||||
so we must NOT strip the user's existing [plugins.X] tables — that
|
||||
would silently lose them."""
|
||||
target = tmp_path / "config.toml"
|
||||
target.write_text(
|
||||
'[plugins."tasks@openai-curated"]\n'
|
||||
"enabled = true\n"
|
||||
)
|
||||
|
||||
def fake_query(codex_home=None, timeout=8.0):
|
||||
return ([], "plugin/list query failed: codex not installed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_runtime_plugin_migration._query_codex_plugins",
|
||||
fake_query,
|
||||
)
|
||||
migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
|
||||
new_text = target.read_text()
|
||||
# User's plugin table preserved verbatim — we can't re-emit it.
|
||||
assert '[plugins."tasks@openai-curated"]' in new_text
|
||||
|
||||
|
||||
# ---- Bug C: HERMES_HOME tempdir leak into ~/.codex/config.toml ----
|
||||
|
||||
|
||||
class TestHermesHomeLeakGuard:
|
||||
"""Regression tests for issue #26250 Bug C.
|
||||
|
||||
Previously ``_build_hermes_tools_mcp_entry()`` read ``HERMES_HOME``
|
||||
directly from ``os.environ``, so a pytest ``monkeypatch.setenv`` would
|
||||
leak a transient tempdir path into the user's real ``~/.codex/config.toml``
|
||||
once codex spawned the hermes-tools MCP subprocess.
|
||||
"""
|
||||
|
||||
def test_tempdir_detector_recognizes_pytest_paths(self):
|
||||
assert _looks_like_test_tempdir(
|
||||
"/private/var/folders/abc/pytest-of-kshitij/pytest-137/popen-gw2/test_X/hermes_test"
|
||||
)
|
||||
assert _looks_like_test_tempdir(
|
||||
"/tmp/pytest-of-user/pytest-12/test_X/hermes"
|
||||
)
|
||||
assert _looks_like_test_tempdir(
|
||||
"/private/var/folders/zz/T/pytest-of-bob/pytest-1"
|
||||
)
|
||||
|
||||
def test_tempdir_detector_accepts_real_hermes_home(self):
|
||||
assert not _looks_like_test_tempdir("/Users/alice/.hermes")
|
||||
assert not _looks_like_test_tempdir("/home/bob/.hermes")
|
||||
assert not _looks_like_test_tempdir("/opt/hermes")
|
||||
assert not _looks_like_test_tempdir("")
|
||||
|
||||
def test_pytest_tempdir_not_burned_into_mcp_env(self, monkeypatch):
|
||||
"""The headline regression: even when HERMES_HOME points at a pytest
|
||||
tempdir, _build_hermes_tools_mcp_entry() must NOT propagate it."""
|
||||
monkeypatch.setenv(
|
||||
"HERMES_HOME",
|
||||
"/private/var/folders/xx/pytest-of-user/pytest-99/test_x/hermes_test",
|
||||
)
|
||||
entry = _build_hermes_tools_mcp_entry()
|
||||
env = entry.get("env", {})
|
||||
assert "HERMES_HOME" not in env, (
|
||||
f"pytest-tempdir HERMES_HOME leaked into codex MCP entry: "
|
||||
f"{env.get('HERMES_HOME')!r}"
|
||||
)
|
||||
|
||||
def test_real_hermes_home_propagates(self, monkeypatch, tmp_path):
|
||||
"""A legitimate HERMES_HOME (not a tempdir path) DOES propagate so the
|
||||
MCP subprocess sees the same config as the parent CLI."""
|
||||
# Use a path that looks real — under /Users or /home, not /var/folders.
|
||||
# We can't easily create one in the test, so just use a stable path
|
||||
# outside any tempdir-detector needle. The detector checks for tempdir
|
||||
# markers, not for path existence.
|
||||
real_path = "/Users/alice/.hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", real_path)
|
||||
entry = _build_hermes_tools_mcp_entry()
|
||||
env = entry.get("env", {})
|
||||
assert env.get("HERMES_HOME") == real_path
|
||||
|
||||
def test_unset_hermes_home_omits_env_key(self, monkeypatch):
|
||||
"""When HERMES_HOME is unset in the environment, the MCP entry MUST
|
||||
NOT bake in a resolved-default path. The codex subprocess should
|
||||
inherit whatever HERMES_HOME its launcher (systemd, gateway, shell)
|
||||
sets at runtime, rather than being pinned to migrate-time defaults.
|
||||
Regression guard for issue #26250 follow-up review."""
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
entry = _build_hermes_tools_mcp_entry()
|
||||
env = entry.get("env", {})
|
||||
assert "HERMES_HOME" not in env, (
|
||||
f"HERMES_HOME should not be set when env var is unset, got: "
|
||||
f"{env.get('HERMES_HOME')!r}"
|
||||
)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests for the /codex-runtime slash-command shared logic.
|
||||
|
||||
These cover the pure-Python state machine; CLI and gateway handlers are
|
||||
tested separately because they involve config persistence and prompt
|
||||
formatting that's surface-specific."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import codex_runtime_switch as crs
|
||||
|
||||
|
||||
class TestParseArgs:
|
||||
@pytest.mark.parametrize("arg,expected", [
|
||||
("", None),
|
||||
(" ", None),
|
||||
("auto", "auto"),
|
||||
("codex_app_server", "codex_app_server"),
|
||||
("on", "codex_app_server"),
|
||||
("off", "auto"),
|
||||
("codex", "codex_app_server"),
|
||||
("default", "auto"),
|
||||
("hermes", "auto"),
|
||||
("ENABLE", "codex_app_server"), # case-insensitive
|
||||
("DiSaBlE", "auto"),
|
||||
])
|
||||
def test_valid_args(self, arg, expected):
|
||||
value, errors = crs.parse_args(arg)
|
||||
assert errors == []
|
||||
assert value == expected
|
||||
|
||||
def test_invalid_arg_returns_error(self):
|
||||
value, errors = crs.parse_args("turbo")
|
||||
assert value is None
|
||||
assert errors and "Unknown runtime" in errors[0]
|
||||
|
||||
|
||||
class TestGetCurrentRuntime:
|
||||
def test_default_when_unset(self):
|
||||
assert crs.get_current_runtime({}) == "auto"
|
||||
assert crs.get_current_runtime({"model": {}}) == "auto"
|
||||
assert crs.get_current_runtime({"model": {"openai_runtime": ""}}) == "auto"
|
||||
|
||||
def test_unrecognized_falls_back_to_auto(self):
|
||||
assert crs.get_current_runtime(
|
||||
{"model": {"openai_runtime": "garbage"}}
|
||||
) == "auto"
|
||||
|
||||
def test_explicit_codex(self):
|
||||
assert crs.get_current_runtime(
|
||||
{"model": {"openai_runtime": "codex_app_server"}}
|
||||
) == "codex_app_server"
|
||||
|
||||
def test_handles_non_dict_config(self):
|
||||
assert crs.get_current_runtime(None) == "auto" # type: ignore[arg-type]
|
||||
assert crs.get_current_runtime("notadict") == "auto" # type: ignore[arg-type]
|
||||
assert crs.get_current_runtime({"model": "notadict"}) == "auto"
|
||||
|
||||
|
||||
class TestSetRuntime:
|
||||
def test_creates_model_section_if_missing(self):
|
||||
cfg = {}
|
||||
old = crs.set_runtime(cfg, "codex_app_server")
|
||||
assert old == "auto"
|
||||
assert cfg["model"]["openai_runtime"] == "codex_app_server"
|
||||
|
||||
def test_returns_previous_value(self):
|
||||
cfg = {"model": {"openai_runtime": "codex_app_server"}}
|
||||
old = crs.set_runtime(cfg, "auto")
|
||||
assert old == "codex_app_server"
|
||||
assert cfg["model"]["openai_runtime"] == "auto"
|
||||
|
||||
def test_invalid_value_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
crs.set_runtime({}, "garbage")
|
||||
|
||||
|
||||
class TestApply:
|
||||
def test_read_only_call_reports_state(self):
|
||||
cfg = {"model": {"openai_runtime": "codex_app_server"}}
|
||||
with patch.object(crs, "check_codex_binary_ok",
|
||||
return_value=(True, "0.130.0")):
|
||||
r = crs.apply(cfg, None)
|
||||
assert r.success
|
||||
assert r.new_value == "codex_app_server"
|
||||
assert r.old_value == "codex_app_server"
|
||||
assert "codex_app_server" in r.message
|
||||
assert "0.130.0" in r.message
|
||||
|
||||
def test_no_change_when_already_set(self):
|
||||
cfg = {"model": {"openai_runtime": "auto"}}
|
||||
r = crs.apply(cfg, "auto")
|
||||
assert r.success
|
||||
assert r.message == "openai_runtime already set to auto"
|
||||
|
||||
def test_enable_blocked_when_codex_missing(self):
|
||||
cfg = {}
|
||||
with patch.object(crs, "check_codex_binary_ok",
|
||||
return_value=(False, "codex not found")):
|
||||
r = crs.apply(cfg, "codex_app_server")
|
||||
assert r.success is False
|
||||
assert "Cannot enable" in r.message
|
||||
assert "npm i -g @openai/codex" in r.message
|
||||
# Config NOT mutated on failure
|
||||
assert cfg.get("model", {}).get("openai_runtime") in (None, "")
|
||||
|
||||
def test_enable_succeeds_when_codex_present(self):
|
||||
cfg = {}
|
||||
persisted = {}
|
||||
|
||||
def persist(c):
|
||||
persisted.update(c)
|
||||
|
||||
# Patch migrate so this test doesn't reach into the user's real
|
||||
# ~/.codex/config.toml. See issue #26250 Bug C — without this patch,
|
||||
# crs.apply() invokes the real migrate() which writes to
|
||||
# Path.home() / ".codex" using whatever HERMES_HOME the running pytest
|
||||
# session has set, leaking pytest tempdir paths into the user's
|
||||
# codex config.
|
||||
with patch.object(crs, "check_codex_binary_ok",
|
||||
return_value=(True, "0.130.0")), \
|
||||
patch("hermes_cli.codex_runtime_plugin_migration.migrate"):
|
||||
r = crs.apply(cfg, "codex_app_server", persist_callback=persist)
|
||||
assert r.success
|
||||
assert r.new_value == "codex_app_server"
|
||||
assert r.old_value == "auto"
|
||||
assert r.requires_new_session is True
|
||||
assert "via MCP" in r.message # hermes-tools callback message
|
||||
assert cfg["model"]["openai_runtime"] == "codex_app_server"
|
||||
assert persisted["model"]["openai_runtime"] == "codex_app_server"
|
||||
|
||||
def test_disable_does_not_check_binary(self):
|
||||
cfg = {"model": {"openai_runtime": "codex_app_server"}}
|
||||
with patch.object(crs, "check_codex_binary_ok") as bin_check:
|
||||
r = crs.apply(cfg, "auto")
|
||||
assert r.success
|
||||
# Binary check is irrelevant when disabling — should not be called
|
||||
# with the codex_app_server enable-gate signature.
|
||||
assert r.new_value == "auto"
|
||||
assert r.old_value == "codex_app_server"
|
||||
|
||||
def test_persist_callback_failure_reported(self):
|
||||
cfg = {}
|
||||
|
||||
def persist_boom(c):
|
||||
raise IOError("disk full")
|
||||
|
||||
with patch.object(crs, "check_codex_binary_ok",
|
||||
return_value=(True, "0.130.0")):
|
||||
r = crs.apply(cfg, "codex_app_server", persist_callback=persist_boom)
|
||||
assert r.success is False
|
||||
assert "persist failed" in r.message
|
||||
assert "disk full" in r.message
|
||||
|
||||
def test_enable_triggers_mcp_migration(self):
|
||||
"""Enabling codex_app_server should auto-migrate Hermes mcp_servers
|
||||
to ~/.codex/config.toml so the spawned subprocess sees them."""
|
||||
cfg = {
|
||||
"mcp_servers": {
|
||||
"filesystem": {"command": "npx", "args": ["-y", "fs-server"]},
|
||||
}
|
||||
}
|
||||
|
||||
with patch.object(crs, "check_codex_binary_ok",
|
||||
return_value=(True, "0.130.0")), \
|
||||
patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
|
||||
mig.return_value.migrated = ["filesystem", "hermes-tools"]
|
||||
mig.return_value.migrated_plugins = []
|
||||
mig.return_value.plugin_query_error = None
|
||||
mig.return_value.wrote_permissions_default = ":workspace"
|
||||
mig.return_value.errors = []
|
||||
mig.return_value.target_path = "/fake/.codex/config.toml"
|
||||
r = crs.apply(cfg, "codex_app_server")
|
||||
assert r.success
|
||||
assert mig.called # migration was triggered
|
||||
# User MCP servers are reported (excluding internal hermes-tools)
|
||||
assert "Migrated 1 MCP server" in r.message
|
||||
assert "filesystem" in r.message
|
||||
# Permissions default surfaces
|
||||
assert "Default sandbox: :workspace" in r.message
|
||||
# Hermes tool callback announcement
|
||||
assert "via MCP" in r.message
|
||||
|
||||
def test_disable_does_not_trigger_migration(self):
|
||||
"""Switching back to auto must not write to ~/.codex/."""
|
||||
cfg = {
|
||||
"model": {"openai_runtime": "codex_app_server"},
|
||||
"mcp_servers": {"x": {"command": "y"}},
|
||||
}
|
||||
with patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
|
||||
r = crs.apply(cfg, "auto")
|
||||
assert r.success
|
||||
assert not mig.called # disabling does not migrate
|
||||
|
||||
def test_migration_failure_does_not_block_enable(self):
|
||||
"""If MCP migration raises, the runtime change still proceeds —
|
||||
users can manually re-run migration later."""
|
||||
cfg = {"mcp_servers": {"x": {"command": "y"}}}
|
||||
with patch.object(crs, "check_codex_binary_ok",
|
||||
return_value=(True, "0.130.0")), \
|
||||
patch("hermes_cli.codex_runtime_plugin_migration.migrate",
|
||||
side_effect=RuntimeError("disk full")):
|
||||
r = crs.apply(cfg, "codex_app_server")
|
||||
assert r.success # change still applied
|
||||
assert r.new_value == "codex_app_server"
|
||||
assert "MCP migration skipped" in r.message
|
||||
assert "disk full" in r.message
|
||||
|
||||
def test_binary_check_cached_within_apply(self):
|
||||
"""check_codex_binary_ok is invoked at most once per apply() call.
|
||||
|
||||
The enable path has three sites that need the version (state report,
|
||||
enable gate, success message). Without caching, a single
|
||||
/codex-runtime invocation spawns `codex --version` three times.
|
||||
Regression guard against a refactor that drops the cache.
|
||||
"""
|
||||
cfg = {}
|
||||
with patch.object(crs, "check_codex_binary_ok",
|
||||
return_value=(True, "0.130.0")) as bin_check, \
|
||||
patch("hermes_cli.codex_runtime_plugin_migration.migrate"):
|
||||
r = crs.apply(cfg, "codex_app_server")
|
||||
assert r.success
|
||||
assert bin_check.call_count == 1, (
|
||||
f"check_codex_binary_ok was called {bin_check.call_count} time(s); "
|
||||
"should be cached and called exactly once per apply()"
|
||||
)
|
||||
|
||||
def test_binary_check_cached_on_read_only_call(self):
|
||||
"""Read-only call (new_value=None) calls the binary check exactly
|
||||
once and reuses the result for the message."""
|
||||
cfg = {"model": {"openai_runtime": "codex_app_server"}}
|
||||
with patch.object(crs, "check_codex_binary_ok",
|
||||
return_value=(True, "0.130.0")) as bin_check:
|
||||
crs.apply(cfg, None)
|
||||
assert bin_check.call_count == 1
|
||||
@@ -140,6 +140,54 @@ class TestGenerateZsh:
|
||||
# gateway has subcommands so a _cmds array must be generated
|
||||
assert "gateway_cmds" in out
|
||||
|
||||
def test_registers_compdef_instead_of_invoking_completion_function(self):
|
||||
out = generate_zsh(_make_parser())
|
||||
assert 'compdef _hermes hermes' in out
|
||||
assert '_hermes "$@"' not in out
|
||||
|
||||
def test_preserves_valid_zsh_arguments_alias_syntax(self):
|
||||
out = generate_zsh(_make_parser())
|
||||
assert "'(-)'{-h,--help}'[Show help and exit]'" in out
|
||||
assert "'(-)'{-V,--version}'[Show version and exit]'" in out
|
||||
assert "'(-)'{-p,--profile}'[Profile name]:profile:_hermes_profiles'" in out
|
||||
assert "'(-h --help){-h,--help}[Show help and exit]'" not in out
|
||||
assert '"(-h --help)"{-h,--help}"[Show help and exit]"' not in out
|
||||
|
||||
def test_valid_zsh_syntax(self):
|
||||
if not shutil.which("zsh"):
|
||||
pytest.skip("zsh not installed")
|
||||
out = generate_zsh(_make_parser())
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".zsh", delete=False) as f:
|
||||
f.write(out)
|
||||
path = f.name
|
||||
try:
|
||||
result = subprocess.run(["zsh", "-n", path], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_zsh_eval_style_source_registers_after_compinit(self):
|
||||
if not shutil.which("zsh"):
|
||||
pytest.skip("zsh not installed")
|
||||
out = generate_zsh(_make_parser())
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".zsh", delete=False) as f:
|
||||
f.write(out)
|
||||
path = f.name
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"zsh",
|
||||
"-fc",
|
||||
f"autoload -Uz compinit && compinit -D; source {path}; [[ ${{_comps[hermes]}} == _hermes ]]",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stderr == ""
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Fish output
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests for the load_env() process-level cache.
|
||||
|
||||
The cache exists to keep `hermes tools` → "All Platforms" fast: every
|
||||
`get_env_value()` lookup used to re-read and re-sanitise the entire
|
||||
.env file, racking up hundreds of ms across one menu render. The
|
||||
cache is keyed on (path, mtime, size); writers (save_env_value /
|
||||
remove_env_value / sanitise_env_file) call invalidate_env_cache().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _write_env(path: Path, contents: str) -> None:
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
|
||||
|
||||
def test_load_env_caches_on_repeat_calls():
|
||||
"""Repeated load_env() calls on the same file return the cached dict."""
|
||||
from hermes_cli.config import invalidate_env_cache, load_env
|
||||
|
||||
invalidate_env_cache()
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".env", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("OPENAI_API_KEY=sk-first\n")
|
||||
env_path = Path(f.name)
|
||||
|
||||
try:
|
||||
with patch("hermes_cli.config.get_env_path", return_value=env_path):
|
||||
first = load_env()
|
||||
# Even if a writer outside our cache mutates the file, an
|
||||
# mtime/size match means the cache still wins. We simulate that
|
||||
# by writing identical bytes back — sanity check that the cache
|
||||
# is keyed structurally, not on a counter.
|
||||
second = load_env()
|
||||
|
||||
assert first == second
|
||||
assert first.get("OPENAI_API_KEY") == "sk-first"
|
||||
finally:
|
||||
env_path.unlink(missing_ok=True)
|
||||
invalidate_env_cache()
|
||||
|
||||
|
||||
def test_load_env_invalidates_on_mtime_bump():
|
||||
"""Editing the file (mtime changes) invalidates the cache."""
|
||||
from hermes_cli.config import invalidate_env_cache, load_env
|
||||
|
||||
invalidate_env_cache()
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".env", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("OPENAI_API_KEY=sk-old\n")
|
||||
env_path = Path(f.name)
|
||||
|
||||
try:
|
||||
with patch("hermes_cli.config.get_env_path", return_value=env_path):
|
||||
first = load_env()
|
||||
assert first.get("OPENAI_API_KEY") == "sk-old"
|
||||
|
||||
# Rewrite file with new contents and bump mtime to make sure
|
||||
# the FS records the change even on coarse-mtime filesystems.
|
||||
_write_env(env_path, "OPENAI_API_KEY=sk-new\n")
|
||||
future = env_path.stat().st_mtime + 5.0
|
||||
os.utime(env_path, (future, future))
|
||||
|
||||
second = load_env()
|
||||
assert second.get("OPENAI_API_KEY") == "sk-new", (
|
||||
"load_env() returned stale value after file change"
|
||||
)
|
||||
finally:
|
||||
env_path.unlink(missing_ok=True)
|
||||
invalidate_env_cache()
|
||||
|
||||
|
||||
def test_invalidate_env_cache_forces_reread():
|
||||
"""invalidate_env_cache() forces the next load_env() to hit the disk.
|
||||
|
||||
This is the belt-and-braces knob for writers (save_env_value, etc.)
|
||||
on filesystems where mtime resolution might miss a same-second write.
|
||||
"""
|
||||
from hermes_cli.config import invalidate_env_cache, load_env
|
||||
|
||||
invalidate_env_cache()
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".env", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("OPENAI_API_KEY=sk-old\n")
|
||||
env_path = Path(f.name)
|
||||
|
||||
try:
|
||||
with patch("hermes_cli.config.get_env_path", return_value=env_path):
|
||||
assert load_env().get("OPENAI_API_KEY") == "sk-old"
|
||||
|
||||
# Rewrite WITHOUT bumping mtime — simulates same-second write.
|
||||
mtime_before = env_path.stat().st_mtime
|
||||
_write_env(env_path, "OPENAI_API_KEY=sk-new\n")
|
||||
os.utime(env_path, (mtime_before, mtime_before))
|
||||
|
||||
# Without invalidation, cache hit might return stale.
|
||||
invalidate_env_cache()
|
||||
|
||||
assert load_env().get("OPENAI_API_KEY") == "sk-new"
|
||||
finally:
|
||||
env_path.unlink(missing_ok=True)
|
||||
invalidate_env_cache()
|
||||
|
||||
|
||||
def test_save_env_value_invalidates_cache(tmp_path, monkeypatch):
|
||||
"""save_env_value() invalidates the cache so subsequent reads see the update."""
|
||||
from hermes_cli import config as config_mod
|
||||
from hermes_cli.config import invalidate_env_cache, load_env, save_env_value
|
||||
|
||||
invalidate_env_cache()
|
||||
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("EXISTING_KEY=old\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path)
|
||||
monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None)
|
||||
monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None)
|
||||
monkeypatch.setattr(config_mod, "is_managed", lambda: False)
|
||||
|
||||
try:
|
||||
# Prime the cache.
|
||||
first = load_env()
|
||||
assert first.get("EXISTING_KEY") == "old"
|
||||
|
||||
save_env_value("NEW_KEY", "shiny")
|
||||
|
||||
# Same-second writes on coarse-mtime filesystems would normally
|
||||
# let stale cache survive; invalidate_env_cache() inside the
|
||||
# writer makes the next read see the new key.
|
||||
result = load_env()
|
||||
assert result.get("NEW_KEY") == "shiny"
|
||||
assert result.get("EXISTING_KEY") == "old"
|
||||
finally:
|
||||
monkeypatch.delenv("NEW_KEY", raising=False)
|
||||
invalidate_env_cache()
|
||||
|
||||
|
||||
def test_remove_env_value_invalidates_cache(tmp_path, monkeypatch):
|
||||
"""remove_env_value() invalidates the cache so the removed key disappears."""
|
||||
from hermes_cli import config as config_mod
|
||||
from hermes_cli.config import (
|
||||
invalidate_env_cache,
|
||||
load_env,
|
||||
remove_env_value,
|
||||
save_env_value,
|
||||
)
|
||||
|
||||
invalidate_env_cache()
|
||||
|
||||
env_path = tmp_path / ".env"
|
||||
monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path)
|
||||
monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None)
|
||||
monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None)
|
||||
monkeypatch.setattr(config_mod, "is_managed", lambda: False)
|
||||
|
||||
save_env_value("DOOMED_KEY", "value")
|
||||
assert load_env().get("DOOMED_KEY") == "value"
|
||||
|
||||
try:
|
||||
removed = remove_env_value("DOOMED_KEY")
|
||||
assert removed is True
|
||||
assert "DOOMED_KEY" not in load_env()
|
||||
finally:
|
||||
monkeypatch.delenv("DOOMED_KEY", raising=False)
|
||||
invalidate_env_cache()
|
||||
|
||||
|
||||
def test_load_env_handles_missing_file():
|
||||
"""A nonexistent .env returns {} and caches the empty result."""
|
||||
from hermes_cli.config import invalidate_env_cache, load_env
|
||||
|
||||
invalidate_env_cache()
|
||||
|
||||
nonexistent = Path(tempfile.gettempdir()) / "hermes-test-no-such-env-xyz123.env"
|
||||
nonexistent.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
with patch("hermes_cli.config.get_env_path", return_value=nonexistent):
|
||||
assert load_env() == {}
|
||||
assert load_env() == {} # cached
|
||||
finally:
|
||||
invalidate_env_cache()
|
||||
@@ -514,3 +514,227 @@ class TestJudgeParseFailureAutoPause:
|
||||
reloaded = load_goal("parse-fail-sid-4")
|
||||
assert reloaded is not None
|
||||
assert reloaded.consecutive_parse_failures == 2
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# /subgoal — user-added criteria
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGoalStateSubgoalsBackcompat:
|
||||
def test_old_state_meta_row_loads_without_subgoals(self):
|
||||
"""A goal serialized BEFORE the subgoals field existed must
|
||||
round-trip with an empty list, not crash."""
|
||||
import json
|
||||
from hermes_cli.goals import GoalState
|
||||
|
||||
legacy = json.dumps({
|
||||
"goal": "do a thing",
|
||||
"status": "active",
|
||||
"turns_used": 2,
|
||||
"max_turns": 20,
|
||||
"created_at": 1.0,
|
||||
"last_turn_at": 2.0,
|
||||
"consecutive_parse_failures": 0,
|
||||
})
|
||||
state = GoalState.from_json(legacy)
|
||||
assert state.goal == "do a thing"
|
||||
assert state.subgoals == []
|
||||
|
||||
def test_subgoals_round_trip(self):
|
||||
from hermes_cli.goals import GoalState
|
||||
state = GoalState(goal="g", subgoals=["a", "b", "c"])
|
||||
rt = GoalState.from_json(state.to_json())
|
||||
assert rt.subgoals == ["a", "b", "c"]
|
||||
|
||||
|
||||
class TestGoalManagerSubgoals:
|
||||
def test_add_subgoal(self, hermes_home):
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sub-add")
|
||||
mgr.set("main goal")
|
||||
text = mgr.add_subgoal(" use bullet points ")
|
||||
assert text == "use bullet points"
|
||||
assert mgr.state.subgoals == ["use bullet points"]
|
||||
|
||||
def test_add_subgoal_requires_active_goal(self, hermes_home):
|
||||
import pytest
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sub-noactive")
|
||||
with pytest.raises(RuntimeError):
|
||||
mgr.add_subgoal("oops")
|
||||
|
||||
def test_add_empty_subgoal_rejected(self, hermes_home):
|
||||
import pytest
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sub-empty")
|
||||
mgr.set("g")
|
||||
with pytest.raises(ValueError):
|
||||
mgr.add_subgoal(" ")
|
||||
|
||||
def test_remove_subgoal(self, hermes_home):
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sub-remove")
|
||||
mgr.set("g")
|
||||
mgr.add_subgoal("first")
|
||||
mgr.add_subgoal("second")
|
||||
mgr.add_subgoal("third")
|
||||
removed = mgr.remove_subgoal(2)
|
||||
assert removed == "second"
|
||||
assert mgr.state.subgoals == ["first", "third"]
|
||||
|
||||
def test_remove_subgoal_out_of_range(self, hermes_home):
|
||||
import pytest
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sub-oob")
|
||||
mgr.set("g")
|
||||
mgr.add_subgoal("only")
|
||||
with pytest.raises(IndexError):
|
||||
mgr.remove_subgoal(5)
|
||||
with pytest.raises(IndexError):
|
||||
mgr.remove_subgoal(0)
|
||||
|
||||
def test_clear_subgoals(self, hermes_home):
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sub-clear")
|
||||
mgr.set("g")
|
||||
mgr.add_subgoal("a")
|
||||
mgr.add_subgoal("b")
|
||||
prev = mgr.clear_subgoals()
|
||||
assert prev == 2
|
||||
assert mgr.state.subgoals == []
|
||||
|
||||
def test_subgoals_persist_across_reloads(self, hermes_home):
|
||||
"""Subgoals stored in SessionDB survive a fresh GoalManager."""
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sub-persist")
|
||||
mgr.set("g")
|
||||
mgr.add_subgoal("first")
|
||||
mgr.add_subgoal("second")
|
||||
|
||||
mgr2 = GoalManager(session_id="sub-persist")
|
||||
assert mgr2.state.subgoals == ["first", "second"]
|
||||
|
||||
|
||||
class TestContinuationPromptWithSubgoals:
|
||||
def test_empty_subgoals_uses_original_template(self, hermes_home):
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="cp-empty")
|
||||
mgr.set("ship the feature")
|
||||
prompt = mgr.next_continuation_prompt()
|
||||
assert prompt is not None
|
||||
assert "ship the feature" in prompt
|
||||
assert "Additional criteria" not in prompt
|
||||
|
||||
def test_with_subgoals_includes_them(self, hermes_home):
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="cp-with")
|
||||
mgr.set("ship the feature")
|
||||
mgr.add_subgoal("write tests")
|
||||
mgr.add_subgoal("update docs")
|
||||
prompt = mgr.next_continuation_prompt()
|
||||
assert prompt is not None
|
||||
assert "ship the feature" in prompt
|
||||
assert "Additional criteria" in prompt
|
||||
assert "1. write tests" in prompt
|
||||
assert "2. update docs" in prompt
|
||||
|
||||
|
||||
class TestJudgeGoalWithSubgoals:
|
||||
def test_judge_uses_subgoals_template_when_provided(self, hermes_home):
|
||||
"""judge_goal switches templates when subgoals is non-empty.
|
||||
|
||||
We don't actually call the model — we patch the aux client to
|
||||
capture the prompt that would be sent.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from hermes_cli import goals
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeMsg:
|
||||
content = '{"done": true, "reason": "all done"}'
|
||||
class _FakeChoice:
|
||||
message = _FakeMsg()
|
||||
class _FakeResp:
|
||||
choices = [_FakeChoice()]
|
||||
class _FakeClient:
|
||||
class chat:
|
||||
class completions:
|
||||
@staticmethod
|
||||
def create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _FakeResp()
|
||||
|
||||
with patch.object(goals, "get_text_auxiliary_client",
|
||||
return_value=(_FakeClient, "fake-model"), create=True), \
|
||||
patch.object(goals, "get_auxiliary_extra_body",
|
||||
return_value=None, create=True), \
|
||||
patch("agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(_FakeClient, "fake-model")), \
|
||||
patch("agent.auxiliary_client.get_auxiliary_extra_body",
|
||||
return_value=None):
|
||||
verdict, reason, parse_failed = goals.judge_goal(
|
||||
"ship the feature",
|
||||
"ok shipped",
|
||||
subgoals=["write tests", "update docs"],
|
||||
)
|
||||
|
||||
# The aux client was called with a prompt that includes the subgoals.
|
||||
sent_messages = captured.get("messages") or []
|
||||
user_msg = next((m["content"] for m in sent_messages if m["role"] == "user"), "")
|
||||
assert "Additional criteria" in user_msg
|
||||
assert "1. write tests" in user_msg
|
||||
assert "2. update docs" in user_msg
|
||||
assert "every additional criterion" in user_msg
|
||||
assert verdict == "done"
|
||||
|
||||
def test_judge_uses_original_template_when_no_subgoals(self, hermes_home):
|
||||
from unittest.mock import patch
|
||||
from hermes_cli import goals
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeMsg:
|
||||
content = '{"done": true, "reason": "ok"}'
|
||||
class _FakeChoice:
|
||||
message = _FakeMsg()
|
||||
class _FakeResp:
|
||||
choices = [_FakeChoice()]
|
||||
class _FakeClient:
|
||||
class chat:
|
||||
class completions:
|
||||
@staticmethod
|
||||
def create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _FakeResp()
|
||||
|
||||
with patch("agent.auxiliary_client.get_text_auxiliary_client",
|
||||
return_value=(_FakeClient, "fake-model")), \
|
||||
patch("agent.auxiliary_client.get_auxiliary_extra_body",
|
||||
return_value=None):
|
||||
goals.judge_goal("ship it", "done", subgoals=None)
|
||||
|
||||
sent_messages = captured.get("messages") or []
|
||||
user_msg = next((m["content"] for m in sent_messages if m["role"] == "user"), "")
|
||||
assert "Additional criteria" not in user_msg
|
||||
assert "ship it" in user_msg
|
||||
|
||||
|
||||
class TestStatusLineSubgoalCount:
|
||||
def test_status_line_no_subgoals(self, hermes_home):
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sl-empty")
|
||||
mgr.set("ship it")
|
||||
line = mgr.status_line()
|
||||
assert "ship it" in line
|
||||
assert "subgoal" not in line.lower()
|
||||
|
||||
def test_status_line_with_subgoals(self, hermes_home):
|
||||
from hermes_cli.goals import GoalManager
|
||||
mgr = GoalManager(session_id="sl-with")
|
||||
mgr.set("ship it")
|
||||
mgr.add_subgoal("a")
|
||||
mgr.add_subgoal("b")
|
||||
line = mgr.status_line()
|
||||
assert "2 subgoals" in line
|
||||
|
||||
@@ -103,6 +103,33 @@ class TestPluginPickerInjection:
|
||||
visible = tools_config._visible_providers(browser, {})
|
||||
assert all(p.get("image_gen_plugin_name") is None for p in visible)
|
||||
|
||||
def test_post_setup_propagated_when_declared(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
image_gen_registry.register_provider(_FakeProvider(
|
||||
"xai_img",
|
||||
schema={
|
||||
"name": "xAI Grok Imagine",
|
||||
"badge": "paid",
|
||||
"tag": "grok image",
|
||||
"env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
},
|
||||
))
|
||||
|
||||
rows = tools_config._plugin_image_gen_providers()
|
||||
match = next(r for r in rows if r.get("image_gen_plugin_name") == "xai_img")
|
||||
assert match["post_setup"] == "xai_grok"
|
||||
|
||||
def test_post_setup_omitted_when_not_declared(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
image_gen_registry.register_provider(_FakeProvider("plain_img"))
|
||||
|
||||
rows = tools_config._plugin_image_gen_providers()
|
||||
match = next(r for r in rows if r.get("image_gen_plugin_name") == "plain_img")
|
||||
assert "post_setup" not in match
|
||||
|
||||
|
||||
class TestPluginCatalog:
|
||||
def test_plugin_catalog_returns_models(self):
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Behavior tests for hermes_cli.inventory.
|
||||
|
||||
Locks the invariants the three migrated consumers (web_server.py
|
||||
/api/model/options, tui_gateway model.options, tui_gateway model.save_key)
|
||||
depend on:
|
||||
|
||||
- load_picker_context() reproduces the inline 17-LOC config-slice exactly.
|
||||
- with_overrides() is truthy-only (empty agent attrs must not clobber).
|
||||
- build_models_payload() returns a stable {providers, model, provider}
|
||||
shape and delegates curation to list_authenticated_providers (does not
|
||||
call provider_model_ids per row).
|
||||
- canonical_order keys on slug membership, not is_user_defined — section
|
||||
3 of list_authenticated_providers sets is_user_defined=True for
|
||||
canonical slugs in the providers: dict, and that flag must NOT demote
|
||||
them to the tail.
|
||||
- picker_hints adds authenticated/auth_type/key_env/warning per row,
|
||||
matching the TUI ModelPickerDialog shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.inventory import (
|
||||
ConfigContext,
|
||||
build_models_payload,
|
||||
load_picker_context,
|
||||
)
|
||||
|
||||
|
||||
# ─── load_picker_context ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cfg(model=None, providers=None, custom_providers=None) -> dict:
|
||||
return {
|
||||
"model": model if model is not None else {},
|
||||
"providers": providers if providers is not None else {},
|
||||
"custom_providers": custom_providers if custom_providers is not None else [],
|
||||
}
|
||||
|
||||
|
||||
def test_load_picker_context_full_dict():
|
||||
cfg = _cfg(
|
||||
model={
|
||||
"default": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
},
|
||||
providers={"openrouter": {}},
|
||||
custom_providers=[{"name": "Ollama", "base_url": "http://localhost:11434/v1"}],
|
||||
)
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg):
|
||||
ctx = load_picker_context()
|
||||
assert ctx.current_model == "anthropic/claude-sonnet-4.6"
|
||||
assert ctx.current_provider == "openrouter"
|
||||
assert ctx.current_base_url == "https://openrouter.ai/api/v1"
|
||||
assert "openrouter" in ctx.user_providers
|
||||
# custom_providers comes from get_compatible_custom_providers, which
|
||||
# merges legacy list + v12+ keyed providers — both present here means
|
||||
# at least one row.
|
||||
assert isinstance(ctx.custom_providers, list)
|
||||
|
||||
|
||||
def test_load_picker_context_falls_back_to_name_when_default_missing():
|
||||
cfg = _cfg(model={"name": "gpt-5.4", "provider": "openai"})
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg):
|
||||
ctx = load_picker_context()
|
||||
assert ctx.current_model == "gpt-5.4"
|
||||
assert ctx.current_provider == "openai"
|
||||
|
||||
|
||||
def test_load_picker_context_string_model_legacy_shape():
|
||||
"""config.model can be a bare string in older configs."""
|
||||
cfg = {"model": "some-model", "providers": {}, "custom_providers": []}
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg):
|
||||
ctx = load_picker_context()
|
||||
assert ctx.current_model == "some-model"
|
||||
assert ctx.current_provider == ""
|
||||
assert ctx.current_base_url == ""
|
||||
|
||||
|
||||
def test_load_picker_context_empty_config():
|
||||
cfg = _cfg()
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg):
|
||||
ctx = load_picker_context()
|
||||
assert ctx.current_provider == ""
|
||||
assert ctx.current_model == ""
|
||||
assert ctx.current_base_url == ""
|
||||
assert ctx.user_providers == {}
|
||||
assert ctx.custom_providers == []
|
||||
|
||||
|
||||
# ─── with_overrides ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _empty_ctx(provider="orig", model="orig-model", base_url="orig-url"):
|
||||
return ConfigContext(
|
||||
current_provider=provider,
|
||||
current_model=model,
|
||||
current_base_url=base_url,
|
||||
user_providers={},
|
||||
custom_providers=[],
|
||||
)
|
||||
|
||||
|
||||
def test_with_overrides_truthy_only_strings():
|
||||
"""Empty strings must NOT clobber disk config — TUI calls this with
|
||||
empty getattr(agent, 'provider', '') when no agent is spawned yet."""
|
||||
ctx = _empty_ctx()
|
||||
overlaid = ctx.with_overrides(
|
||||
current_provider="",
|
||||
current_model="",
|
||||
current_base_url="",
|
||||
)
|
||||
assert overlaid.current_provider == "orig"
|
||||
assert overlaid.current_model == "orig-model"
|
||||
assert overlaid.current_base_url == "orig-url"
|
||||
|
||||
|
||||
def test_with_overrides_truthy_value_replaces():
|
||||
ctx = _empty_ctx()
|
||||
overlaid = ctx.with_overrides(current_provider="anthropic")
|
||||
assert overlaid.current_provider == "anthropic"
|
||||
assert overlaid.current_model == "orig-model" # untouched
|
||||
|
||||
|
||||
def test_with_overrides_no_args_returns_self_or_equivalent():
|
||||
ctx = _empty_ctx()
|
||||
assert ctx.with_overrides() == ctx
|
||||
|
||||
|
||||
# ─── build_models_payload ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _list_auth_returning(rows: list[dict]):
|
||||
"""Patch list_authenticated_providers to return a fixed row list."""
|
||||
return patch(
|
||||
"hermes_cli.model_switch.list_authenticated_providers",
|
||||
return_value=rows,
|
||||
)
|
||||
|
||||
|
||||
def test_build_models_payload_returns_expected_shape():
|
||||
rows = [
|
||||
{"slug": "openrouter", "name": "OpenRouter", "models": ["m1"],
|
||||
"total_models": 1, "is_current": True, "is_user_defined": False,
|
||||
"source": "built-in"},
|
||||
]
|
||||
ctx = _empty_ctx(provider="openrouter", model="m1", base_url="")
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(ctx)
|
||||
assert set(payload.keys()) == {"providers", "model", "provider"}
|
||||
assert payload["model"] == "m1"
|
||||
assert payload["provider"] == "openrouter"
|
||||
assert payload["providers"] == rows
|
||||
|
||||
|
||||
def test_build_models_payload_does_not_call_provider_model_ids():
|
||||
"""Curated lists must come from list_authenticated_providers, not
|
||||
provider_model_ids — that would pull TTS/embeddings/etc.
|
||||
"""
|
||||
rows = [{"slug": "nous", "name": "Nous", "models": ["hermes-4-405b"],
|
||||
"total_models": 1, "is_current": False, "is_user_defined": False,
|
||||
"source": "built-in"}]
|
||||
ctx = _empty_ctx()
|
||||
with _list_auth_returning(rows), \
|
||||
patch("hermes_cli.models.provider_model_ids") as mock_pm:
|
||||
build_models_payload(ctx)
|
||||
mock_pm.assert_not_called()
|
||||
|
||||
|
||||
def test_include_unconfigured_appends_canonical_skeletons():
|
||||
"""include_unconfigured=True adds CANONICAL_PROVIDERS rows that
|
||||
list_authenticated_providers didn't emit. Skeleton rows have empty
|
||||
models and source='canonical'."""
|
||||
rows = [
|
||||
{"slug": "openrouter", "name": "OpenRouter", "models": ["m1"],
|
||||
"total_models": 1, "is_current": True, "is_user_defined": False,
|
||||
"source": "built-in"},
|
||||
]
|
||||
ctx = _empty_ctx(provider="openrouter")
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(ctx, include_unconfigured=True)
|
||||
# All canonical providers other than openrouter should appear as
|
||||
# skeleton rows.
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS
|
||||
|
||||
seen_slugs = {r["slug"] for r in payload["providers"]}
|
||||
for entry in CANONICAL_PROVIDERS:
|
||||
assert entry.slug in seen_slugs, f"missing {entry.slug}"
|
||||
# Skeletons have empty models and source='canonical'.
|
||||
skeletons = [r for r in payload["providers"]
|
||||
if r.get("source") == "canonical"]
|
||||
assert all(r["models"] == [] for r in skeletons)
|
||||
assert all(r["total_models"] == 0 for r in skeletons)
|
||||
|
||||
|
||||
def test_include_unconfigured_skips_already_present_slugs():
|
||||
"""If list_authenticated_providers already returned a row for a
|
||||
canonical slug, include_unconfigured must NOT duplicate it."""
|
||||
rows = [
|
||||
{"slug": "openrouter", "name": "OpenRouter", "models": ["m1"],
|
||||
"total_models": 1, "is_current": True, "is_user_defined": False,
|
||||
"source": "built-in"},
|
||||
]
|
||||
ctx = _empty_ctx()
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(ctx, include_unconfigured=True)
|
||||
or_rows = [r for r in payload["providers"] if r["slug"] == "openrouter"]
|
||||
assert len(or_rows) == 1
|
||||
assert or_rows[0]["models"] == ["m1"] # the authenticated row, not skeleton
|
||||
|
||||
|
||||
# ─── picker_hints ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_picker_hints_marks_authed_rows_authenticated():
|
||||
rows = [
|
||||
{"slug": "openrouter", "name": "OpenRouter", "models": ["m1"],
|
||||
"total_models": 1, "is_current": True, "is_user_defined": False,
|
||||
"source": "built-in"},
|
||||
]
|
||||
ctx = _empty_ctx()
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(ctx, picker_hints=True)
|
||||
assert payload["providers"][0]["authenticated"] is True
|
||||
|
||||
|
||||
def test_picker_hints_adds_warning_to_skeleton_rows():
|
||||
"""Skeleton rows (unconfigured canonical providers) must carry the
|
||||
setup hint the picker UI displays."""
|
||||
rows = []
|
||||
ctx = _empty_ctx()
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(
|
||||
ctx, include_unconfigured=True, picker_hints=True,
|
||||
)
|
||||
skeleton_rows = [r for r in payload["providers"]
|
||||
if r.get("source") == "canonical"]
|
||||
assert skeleton_rows, "test setup: expected at least one skeleton row"
|
||||
for row in skeleton_rows:
|
||||
assert row["authenticated"] is False
|
||||
assert "auth_type" in row
|
||||
assert "warning" in row
|
||||
# api_key providers get "paste X to activate" / others get the
|
||||
# hermes model fallback.
|
||||
assert (
|
||||
row["warning"].startswith("paste ")
|
||||
or row["warning"].startswith("run `hermes model`")
|
||||
)
|
||||
|
||||
|
||||
def test_picker_hints_api_key_warning_format():
|
||||
"""For api_key providers with a defined env var, the warning must
|
||||
point to that env var."""
|
||||
rows = []
|
||||
ctx = _empty_ctx()
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(
|
||||
ctx, include_unconfigured=True, picker_hints=True,
|
||||
)
|
||||
# anthropic uses api_key + ANTHROPIC_API_KEY.
|
||||
anthropic = next(
|
||||
r for r in payload["providers"] if r["slug"] == "anthropic"
|
||||
)
|
||||
assert "ANTHROPIC_API_KEY" in anthropic["warning"]
|
||||
assert anthropic["warning"].startswith("paste ")
|
||||
|
||||
|
||||
# ─── canonical_order ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_canonical_order_uses_slug_not_is_user_defined_flag():
|
||||
"""Section 3 of list_authenticated_providers sets is_user_defined=True
|
||||
for canonical slugs that appear in the providers: config dict.
|
||||
canonical_order MUST key on slug membership, not the flag — otherwise
|
||||
canonical providers configured via the keyed schema get demoted to
|
||||
the tail.
|
||||
"""
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS
|
||||
|
||||
canonical_slug = CANONICAL_PROVIDERS[2].slug # any canonical
|
||||
rows = [
|
||||
# A truly-custom row (correct: is_user_defined=True)
|
||||
{"slug": "custom:Ollama", "name": "Ollama", "models": [],
|
||||
"total_models": 0, "is_current": False, "is_user_defined": True,
|
||||
"source": "user-config"},
|
||||
# A canonical row that the substrate flagged as user-defined
|
||||
# because the user configured it via providers: dict.
|
||||
{"slug": canonical_slug, "name": "x", "models": ["m1"],
|
||||
"total_models": 1, "is_current": False, "is_user_defined": True,
|
||||
"source": "built-in"},
|
||||
]
|
||||
ctx = _empty_ctx()
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(ctx, canonical_order=True)
|
||||
slugs = [r["slug"] for r in payload["providers"]]
|
||||
# Canonical-slug row must come BEFORE truly-custom rows, regardless
|
||||
# of is_user_defined.
|
||||
canonical_idx = slugs.index(canonical_slug)
|
||||
custom_idx = slugs.index("custom:Ollama")
|
||||
assert canonical_idx < custom_idx, (
|
||||
f"canonical {canonical_slug} demoted to tail "
|
||||
f"(canonical_idx={canonical_idx} > custom_idx={custom_idx})"
|
||||
)
|
||||
|
||||
|
||||
def test_canonical_order_with_unconfigured_preserves_full_universe():
|
||||
"""Combined picker call: include_unconfigured + picker_hints +
|
||||
canonical_order is the production TUI shape. Verify the result
|
||||
has CANONICAL_PROVIDERS in declaration order, hints applied,
|
||||
custom rows trailing.
|
||||
"""
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS
|
||||
|
||||
rows = [
|
||||
{"slug": "custom:Ollama", "name": "Ollama", "models": [],
|
||||
"total_models": 0, "is_current": False, "is_user_defined": True,
|
||||
"source": "user-config"},
|
||||
]
|
||||
ctx = _empty_ctx()
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(
|
||||
ctx,
|
||||
include_unconfigured=True,
|
||||
picker_hints=True,
|
||||
canonical_order=True,
|
||||
)
|
||||
slugs = [r["slug"] for r in payload["providers"]]
|
||||
# First row: first canonical provider in declaration order.
|
||||
assert slugs[0] == CANONICAL_PROVIDERS[0].slug
|
||||
# Custom row trails canonical universe.
|
||||
assert slugs.index("custom:Ollama") >= len(CANONICAL_PROVIDERS)
|
||||
|
||||
|
||||
# ─── Integration: end-to-end through real load_picker_context ──────────
|
||||
|
||||
|
||||
def test_end_to_end_with_real_context_no_credentials_leak(monkeypatch):
|
||||
"""Full pipeline: real load_picker_context + real
|
||||
list_authenticated_providers. Verify no credential string ever
|
||||
appears in the returned payload, even with picker_hints=True."""
|
||||
canary = "sk-canary-XYZ-must-not-appear"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", canary)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", canary)
|
||||
cfg = _cfg(model={"provider": "openrouter"})
|
||||
with patch("hermes_cli.config.load_config", return_value=cfg):
|
||||
ctx = load_picker_context()
|
||||
payload = build_models_payload(
|
||||
ctx, include_unconfigured=True, picker_hints=True,
|
||||
)
|
||||
import json as _json
|
||||
|
||||
assert canary not in _json.dumps(payload)
|
||||
|
||||
|
||||
def test_payload_shape_compatible_with_modelpickerdialog_frontend():
|
||||
"""Frontend (web/src/components/ModelPickerDialog.tsx) reads:
|
||||
name, slug, models, total_models, is_current, warning, authenticated.
|
||||
Verify every authenticated/skeleton row exposes those keys.
|
||||
"""
|
||||
rows = [
|
||||
{"slug": "openrouter", "name": "OpenRouter", "models": ["m1"],
|
||||
"total_models": 1, "is_current": True, "is_user_defined": False,
|
||||
"source": "built-in"},
|
||||
]
|
||||
ctx = _empty_ctx()
|
||||
with _list_auth_returning(rows):
|
||||
payload = build_models_payload(
|
||||
ctx, include_unconfigured=True, picker_hints=True,
|
||||
)
|
||||
required_keys = {"name", "slug", "models", "total_models", "is_current",
|
||||
"authenticated"}
|
||||
for row in payload["providers"]:
|
||||
missing = required_keys - row.keys()
|
||||
assert not missing, f"row {row['slug']} missing keys: {missing}"
|
||||
@@ -177,6 +177,40 @@ class TestProviderPersistsAfterModelSave:
|
||||
assert model.get("api_mode") == "codex_responses"
|
||||
assert config["agent"]["reasoning_effort"] == "high"
|
||||
|
||||
def test_named_custom_provider_preserves_explicit_api_mode(self, config_home):
|
||||
"""Named custom providers should re-activate with their saved api_mode."""
|
||||
import yaml
|
||||
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
provider_info = {
|
||||
"name": "Packy",
|
||||
"base_url": "https://packy.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"model": "gpt-5.4",
|
||||
"api_mode": "codex_responses",
|
||||
}
|
||||
|
||||
# Patch fetch_api_models so the named custom flow returns one model;
|
||||
# patch simple_term_menu to force the input() fallback; patch input to
|
||||
# auto-select the first model from the fallback prompt.
|
||||
from unittest.mock import MagicMock
|
||||
fake_menu_module = MagicMock()
|
||||
fake_menu_module.TerminalMenu.side_effect = OSError("no tty in test")
|
||||
with patch("hermes_cli.auth._save_model_choice"), \
|
||||
patch("hermes_cli.auth.deactivate_provider"), \
|
||||
patch("hermes_cli.models.fetch_api_models", return_value=["gpt-5.4"]), \
|
||||
patch.dict("sys.modules", {"simple_term_menu": fake_menu_module}), \
|
||||
patch("builtins.input", return_value="1"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model.get("provider") == "custom"
|
||||
assert model.get("base_url") == "https://packy.example.com/v1"
|
||||
assert model.get("api_mode") == "codex_responses"
|
||||
|
||||
def test_copilot_acp_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_copilot_acp should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_copilot_acp
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for the get_nous_auth_status() process-level cache.
|
||||
|
||||
The cache avoids re-validating Nous credentials on every menu paint —
|
||||
`hermes tools` → "All Platforms" used to fire ~31 OAuth refresh POSTs
|
||||
against portal.nousresearch.com during one render. The cache is keyed
|
||||
on auth.json mtime so login/logout flows invalidate naturally; tests
|
||||
and other writers can also call invalidate_nous_auth_status_cache().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _seed_auth_file(tmp_path):
|
||||
"""Drop a placeholder auth.json into the test HERMES_HOME.
|
||||
|
||||
The exact content doesn't matter for cache-key purposes — only that
|
||||
the file exists and we can mutate it to bump mtime.
|
||||
"""
|
||||
auth = tmp_path / "auth.json"
|
||||
auth.write_text(json.dumps({"providers": {}}), encoding="utf-8")
|
||||
return auth
|
||||
|
||||
|
||||
def test_get_nous_auth_status_caches_consecutive_calls(tmp_path, monkeypatch):
|
||||
"""A second call within the TTL skips re-computing the snapshot."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_seed_auth_file(tmp_path)
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_compute():
|
||||
call_count["n"] += 1
|
||||
return {"logged_in": False, "source": "auth_store", "call": call_count["n"]}
|
||||
|
||||
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
|
||||
first = auth_mod.get_nous_auth_status()
|
||||
second = auth_mod.get_nous_auth_status()
|
||||
third = auth_mod.get_nous_auth_status()
|
||||
|
||||
assert call_count["n"] == 1, (
|
||||
f"_compute_nous_auth_status was called {call_count['n']}× — "
|
||||
"cache is not deduplicating within TTL."
|
||||
)
|
||||
# Each call returns a copy so callers can't mutate the cached dict.
|
||||
assert first == second == third
|
||||
first["mutated"] = True
|
||||
assert "mutated" not in auth_mod.get_nous_auth_status()
|
||||
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
|
||||
|
||||
def test_get_nous_auth_status_invalidates_on_auth_file_mtime(tmp_path, monkeypatch):
|
||||
"""Touching auth.json (login/logout) forces a re-compute."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
auth_path = _seed_auth_file(tmp_path)
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_compute():
|
||||
call_count["n"] += 1
|
||||
return {"logged_in": False, "source": "auth_store", "call": call_count["n"]}
|
||||
|
||||
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
|
||||
auth_mod.get_nous_auth_status()
|
||||
# Bump mtime forward so coarse-resolution filesystems still record
|
||||
# a change.
|
||||
future = auth_path.stat().st_mtime + 5.0
|
||||
os.utime(auth_path, (future, future))
|
||||
auth_mod.get_nous_auth_status()
|
||||
|
||||
assert call_count["n"] == 2, (
|
||||
"auth.json mtime change should invalidate the cache, but only "
|
||||
f"{call_count['n']} compute call(s) happened."
|
||||
)
|
||||
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
|
||||
|
||||
def test_invalidate_nous_auth_status_cache_forces_recompute(tmp_path, monkeypatch):
|
||||
"""Explicit invalidate forces the next call to re-compute."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_seed_auth_file(tmp_path)
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_compute():
|
||||
call_count["n"] += 1
|
||||
return {"logged_in": False, "source": "auth_store"}
|
||||
|
||||
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
|
||||
auth_mod.get_nous_auth_status()
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
auth_mod.get_nous_auth_status()
|
||||
|
||||
assert call_count["n"] == 2
|
||||
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
|
||||
|
||||
def test_get_nous_auth_status_caches_failure_path(tmp_path, monkeypatch):
|
||||
"""Logged-out snapshots are cached too — that's where the cost was.
|
||||
|
||||
Teknium's case: ~31 cache misses per `hermes tools` "All Platforms"
|
||||
menu paint, all returning logged_in=False after a failed refresh POST.
|
||||
The whole point of the cache is to memoise that failure path too.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_seed_auth_file(tmp_path)
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_compute():
|
||||
call_count["n"] += 1
|
||||
return {"logged_in": False, "source": "auth_store", "error": "refresh failed"}
|
||||
|
||||
with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute):
|
||||
for _ in range(10):
|
||||
auth_mod.get_nous_auth_status()
|
||||
|
||||
assert call_count["n"] == 1, (
|
||||
f"Logged-out snapshots must cache; got {call_count['n']} computes for 10 calls."
|
||||
)
|
||||
|
||||
auth_mod.invalidate_nous_auth_status_cache()
|
||||
@@ -538,6 +538,95 @@ class TestPreToolCallBlocking:
|
||||
assert get_pre_tool_call_block_message("terminal", {}) == "first blocker"
|
||||
|
||||
|
||||
class TestThreadToolWhitelist:
|
||||
"""Tests for the thread-local tool whitelist used by background review forks."""
|
||||
|
||||
def test_allowed_tool_passes_through_to_hooks(self, monkeypatch):
|
||||
from hermes_cli.plugins import (
|
||||
set_thread_tool_whitelist,
|
||||
clear_thread_tool_whitelist,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [],
|
||||
)
|
||||
set_thread_tool_whitelist({"memory", "skill_manage"})
|
||||
try:
|
||||
assert get_pre_tool_call_block_message("memory", {}) is None
|
||||
finally:
|
||||
clear_thread_tool_whitelist()
|
||||
|
||||
def test_disallowed_tool_blocked_with_message(self, monkeypatch):
|
||||
from hermes_cli.plugins import (
|
||||
set_thread_tool_whitelist,
|
||||
clear_thread_tool_whitelist,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [],
|
||||
)
|
||||
set_thread_tool_whitelist(
|
||||
{"memory"}, deny_msg_fmt="denied: {tool_name}"
|
||||
)
|
||||
try:
|
||||
msg = get_pre_tool_call_block_message("terminal", {})
|
||||
assert msg == "denied: terminal"
|
||||
finally:
|
||||
clear_thread_tool_whitelist()
|
||||
|
||||
def test_clear_restores_unrestricted_behavior(self, monkeypatch):
|
||||
from hermes_cli.plugins import (
|
||||
set_thread_tool_whitelist,
|
||||
clear_thread_tool_whitelist,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [],
|
||||
)
|
||||
set_thread_tool_whitelist({"memory"})
|
||||
clear_thread_tool_whitelist()
|
||||
# After clearing, any tool should pass through to plugin hooks (which
|
||||
# return [] here, so result is None).
|
||||
assert get_pre_tool_call_block_message("terminal", {}) is None
|
||||
|
||||
def test_whitelist_is_thread_local(self, monkeypatch):
|
||||
"""Setting a whitelist in one thread must NOT leak into another."""
|
||||
import threading
|
||||
|
||||
from hermes_cli.plugins import (
|
||||
set_thread_tool_whitelist,
|
||||
clear_thread_tool_whitelist,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [],
|
||||
)
|
||||
|
||||
# Main thread: install a restrictive whitelist.
|
||||
set_thread_tool_whitelist({"memory"})
|
||||
try:
|
||||
assert get_pre_tool_call_block_message("terminal", {}) is not None
|
||||
|
||||
# Worker thread: should NOT inherit main thread's whitelist.
|
||||
result = {}
|
||||
|
||||
def worker():
|
||||
result["msg"] = get_pre_tool_call_block_message("terminal", {})
|
||||
|
||||
t = threading.Thread(target=worker)
|
||||
t.start()
|
||||
t.join()
|
||||
assert result["msg"] is None, (
|
||||
"thread-local whitelist leaked across threads"
|
||||
)
|
||||
finally:
|
||||
clear_thread_tool_whitelist()
|
||||
|
||||
|
||||
# ── TestPluginContext ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@ from hermes_cli.profiles import (
|
||||
rename_profile,
|
||||
export_profile,
|
||||
import_profile,
|
||||
generate_bash_completion,
|
||||
generate_zsh_completion,
|
||||
_get_profiles_root,
|
||||
_get_default_hermes_home,
|
||||
seed_profile_skills,
|
||||
@@ -1013,32 +1011,6 @@ class TestProfileIsolation:
|
||||
assert (beta_dir / "skills").is_dir()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestCompletion
|
||||
# ===================================================================
|
||||
|
||||
class TestCompletion:
|
||||
"""Tests for bash/zsh completion generators."""
|
||||
|
||||
def test_bash_completion_contains_complete(self):
|
||||
script = generate_bash_completion()
|
||||
assert len(script) > 0
|
||||
assert "complete" in script
|
||||
|
||||
def test_zsh_completion_contains_compdef(self):
|
||||
script = generate_zsh_completion()
|
||||
assert len(script) > 0
|
||||
assert "compdef" in script
|
||||
|
||||
def test_bash_completion_has_hermes_profiles_function(self):
|
||||
script = generate_bash_completion()
|
||||
assert "_hermes_profiles" in script
|
||||
|
||||
def test_zsh_completion_has_hermes_function(self):
|
||||
script = generate_zsh_completion()
|
||||
assert "_hermes" in script
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestGetProfilesRoot / TestGetDefaultHermesHome (internal helpers)
|
||||
# ===================================================================
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Tests for the `hermes proxy` subcommand and its upstream adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.proxy.adapters import ADAPTERS, get_adapter
|
||||
from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential
|
||||
from hermes_cli.proxy.adapters.nous_portal import NousPortalAdapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_lists_nous():
|
||||
assert "nous" in ADAPTERS
|
||||
|
||||
|
||||
def test_get_adapter_returns_instance():
|
||||
adapter = get_adapter("nous")
|
||||
assert isinstance(adapter, NousPortalAdapter)
|
||||
assert isinstance(adapter, UpstreamAdapter)
|
||||
|
||||
|
||||
def test_get_adapter_case_insensitive():
|
||||
assert isinstance(get_adapter("NOUS"), NousPortalAdapter)
|
||||
assert isinstance(get_adapter(" Nous "), NousPortalAdapter)
|
||||
|
||||
|
||||
def test_get_adapter_unknown_provider_raises():
|
||||
with pytest.raises(ValueError, match="anthropic"):
|
||||
get_adapter("anthropic") # not yet implemented
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NousPortalAdapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_auth_store(hermes_home: Path, nous_state: Dict[str, Any]) -> Path:
|
||||
"""Write an auth.json with the given nous state into a hermetic HERMES_HOME."""
|
||||
auth_path = hermes_home / "auth.json"
|
||||
auth_path.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {"nous": nous_state},
|
||||
}))
|
||||
return auth_path
|
||||
|
||||
|
||||
def test_nous_adapter_metadata():
|
||||
adapter = NousPortalAdapter()
|
||||
assert adapter.name == "nous"
|
||||
assert adapter.display_name == "Nous Portal"
|
||||
assert "/chat/completions" in adapter.allowed_paths
|
||||
assert "/embeddings" in adapter.allowed_paths
|
||||
assert "/completions" in adapter.allowed_paths
|
||||
assert "/models" in adapter.allowed_paths
|
||||
|
||||
|
||||
def test_nous_adapter_not_authenticated_when_no_auth_file(tmp_path, monkeypatch):
|
||||
# HERMES_HOME is already set by conftest, but make doubly sure
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
adapter = NousPortalAdapter()
|
||||
assert not adapter.is_authenticated()
|
||||
|
||||
|
||||
def test_nous_adapter_not_authenticated_when_provider_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
}))
|
||||
assert not NousPortalAdapter().is_authenticated()
|
||||
|
||||
|
||||
def test_nous_adapter_authenticated_with_agent_key(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_auth_store(tmp_path, {
|
||||
"agent_key": "ov-test-key",
|
||||
"agent_key_expires_at": "2099-01-01T00:00:00Z",
|
||||
"inference_base_url": "https://inference-api.nousresearch.com/v1",
|
||||
})
|
||||
assert NousPortalAdapter().is_authenticated()
|
||||
|
||||
|
||||
def test_nous_adapter_authenticated_with_refresh_token_only(tmp_path, monkeypatch):
|
||||
"""If access_token+refresh_token exist but no agent_key yet, we can still mint."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_auth_store(tmp_path, {
|
||||
"access_token": "access-tok",
|
||||
"refresh_token": "refresh-tok",
|
||||
})
|
||||
assert NousPortalAdapter().is_authenticated()
|
||||
|
||||
|
||||
def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_auth_store(tmp_path, {
|
||||
"access_token": "access-tok",
|
||||
"refresh_token": "refresh-tok",
|
||||
"client_id": "hermes-cli",
|
||||
"portal_base_url": "https://portal.nousresearch.com",
|
||||
"inference_base_url": "https://inference-api.nousresearch.com/v1",
|
||||
})
|
||||
|
||||
refreshed_state = {
|
||||
"access_token": "access-tok",
|
||||
"refresh_token": "refresh-tok",
|
||||
"client_id": "hermes-cli",
|
||||
"portal_base_url": "https://portal.nousresearch.com",
|
||||
"inference_base_url": "https://inference-api.nousresearch.com/v1",
|
||||
"agent_key": "minted-bearer",
|
||||
"agent_key_expires_at": "2099-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
||||
return_value=refreshed_state,
|
||||
) as mock_refresh:
|
||||
adapter = NousPortalAdapter()
|
||||
cred = adapter.get_credential()
|
||||
|
||||
mock_refresh.assert_called_once()
|
||||
assert cred.bearer == "minted-bearer"
|
||||
assert cred.base_url == "https://inference-api.nousresearch.com/v1"
|
||||
assert cred.expires_at == "2099-01-01T00:00:00Z"
|
||||
assert cred.token_type == "Bearer"
|
||||
|
||||
# Verify state was persisted back
|
||||
stored = json.loads((tmp_path / "auth.json").read_text())
|
||||
assert stored["providers"]["nous"]["agent_key"] == "minted-bearer"
|
||||
|
||||
|
||||
def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
adapter = NousPortalAdapter()
|
||||
with pytest.raises(RuntimeError, match="hermes login nous"):
|
||||
adapter.get_credential()
|
||||
|
||||
|
||||
def test_nous_adapter_get_credential_raises_on_refresh_failure(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_auth_store(tmp_path, {
|
||||
"access_token": "access-tok",
|
||||
"refresh_token": "refresh-tok",
|
||||
})
|
||||
|
||||
with patch(
|
||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
||||
side_effect=RuntimeError("Refresh session has been revoked"),
|
||||
):
|
||||
adapter = NousPortalAdapter()
|
||||
with pytest.raises(RuntimeError, match="Refresh session has been revoked"):
|
||||
adapter.get_credential()
|
||||
|
||||
|
||||
def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path, monkeypatch):
|
||||
"""If the refresh helper succeeds but produces no agent_key, we surface a clear error."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_auth_store(tmp_path, {
|
||||
"access_token": "access-tok",
|
||||
"refresh_token": "refresh-tok",
|
||||
})
|
||||
|
||||
with patch(
|
||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
||||
return_value={"access_token": "a", "refresh_token": "r"},
|
||||
):
|
||||
adapter = NousPortalAdapter()
|
||||
with pytest.raises(RuntimeError, match="did not return a usable agent_key"):
|
||||
adapter.get_credential()
|
||||
|
||||
|
||||
def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch):
|
||||
"""Two parallel get_credential() calls must serialize through the lock."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_write_auth_store(tmp_path, {
|
||||
"access_token": "a", "refresh_token": "r",
|
||||
})
|
||||
|
||||
call_log: list = []
|
||||
in_flight = threading.Event()
|
||||
overlap_detected = threading.Event()
|
||||
counter = [0]
|
||||
counter_lock = threading.Lock()
|
||||
|
||||
def serializing_refresh(state, **kwargs):
|
||||
# If another thread is already inside refresh, the lock is broken.
|
||||
if in_flight.is_set():
|
||||
overlap_detected.set()
|
||||
in_flight.set()
|
||||
try:
|
||||
call_log.append(threading.current_thread().ident)
|
||||
# Simulate refresh latency so any race window is exposed.
|
||||
import time
|
||||
time.sleep(0.05)
|
||||
with counter_lock:
|
||||
counter[0] += 1
|
||||
idx = counter[0]
|
||||
return {
|
||||
**state,
|
||||
"agent_key": f"key-{idx}",
|
||||
"agent_key_expires_at": "2099-01-01T00:00:00Z",
|
||||
"inference_base_url": "https://inference-api.nousresearch.com/v1",
|
||||
}
|
||||
finally:
|
||||
in_flight.clear()
|
||||
|
||||
adapter = NousPortalAdapter()
|
||||
results: list = []
|
||||
errors: list = []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
results.append(adapter.get_credential().bearer)
|
||||
except Exception as exc: # pragma: no cover - shouldn't happen
|
||||
errors.append(exc)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
||||
side_effect=serializing_refresh,
|
||||
):
|
||||
threads = [threading.Thread(target=worker) for _ in range(3)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"workers errored: {errors}"
|
||||
assert len(results) == 3
|
||||
assert len(call_log) == 3
|
||||
assert not overlap_detected.is_set(), "refresh calls overlapped — lock is broken"
|
||||
assert all(r.startswith("key-") for r in results)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server: path filtering + forwarding
|
||||
#
|
||||
# We run the proxy AND a fake upstream as real aiohttp servers on ephemeral
|
||||
# ports. Avoids pytest-aiohttp's fixtures (extra dependency for one test file).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
aiohttp = pytest.importorskip("aiohttp")
|
||||
from aiohttp import web # noqa: E402
|
||||
|
||||
from hermes_cli.proxy.server import create_app # noqa: E402
|
||||
|
||||
|
||||
class FakeAdapter(UpstreamAdapter):
|
||||
"""A test adapter that returns a fixed credential without touching disk."""
|
||||
|
||||
def __init__(self, base_url: str, bearer: str = "test-bearer",
|
||||
allowed=None, raise_on_credential=False):
|
||||
self._base_url = base_url
|
||||
self._bearer = bearer
|
||||
self._allowed = frozenset(allowed or ["/chat/completions"])
|
||||
self._raise = raise_on_credential
|
||||
self.calls = 0
|
||||
|
||||
@property
|
||||
def name(self): return "fake"
|
||||
|
||||
@property
|
||||
def display_name(self): return "Fake Provider"
|
||||
|
||||
@property
|
||||
def allowed_paths(self): return self._allowed
|
||||
|
||||
def is_authenticated(self): return True
|
||||
|
||||
def get_credential(self):
|
||||
self.calls += 1
|
||||
if self._raise:
|
||||
raise RuntimeError("simulated auth failure")
|
||||
return UpstreamCredential(
|
||||
bearer=self._bearer, base_url=self._base_url,
|
||||
expires_at="2099-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
|
||||
async def _start_runner(app: "web.Application"):
|
||||
"""Spin up an aiohttp app on an ephemeral localhost port. Returns (runner, base_url)."""
|
||||
runner = web.AppRunner(app, access_log=None)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, host="127.0.0.1", port=0)
|
||||
await site.start()
|
||||
sockets = list(site._server.sockets) # type: ignore[union-attr]
|
||||
port = sockets[0].getsockname()[1]
|
||||
return runner, f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
def _build_fake_upstream(captured: Dict[str, Any]) -> "web.Application":
|
||||
async def echo(request):
|
||||
body = await request.read()
|
||||
captured["requests"].append({
|
||||
"method": request.method,
|
||||
"path": request.path,
|
||||
"auth": request.headers.get("Authorization"),
|
||||
"body": body.decode("utf-8") if body else "",
|
||||
})
|
||||
return web.json_response({"echoed": True, "path": request.path})
|
||||
|
||||
async def sse(request):
|
||||
resp = web.StreamResponse(
|
||||
status=200, headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
await resp.prepare(request)
|
||||
for chunk in [b"data: hello\n\n", b"data: world\n\n", b"data: [DONE]\n\n"]:
|
||||
await resp.write(chunk)
|
||||
await resp.write_eof()
|
||||
return resp
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_route("*", "/v1/chat/completions", echo)
|
||||
app.router.add_route("*", "/v1/embeddings", echo)
|
||||
app.router.add_route("*", "/v1/sse", sse)
|
||||
return app
|
||||
|
||||
|
||||
def test_server_forwards_chat_completions():
|
||||
async def run():
|
||||
captured: Dict[str, Any] = {"requests": []}
|
||||
upstream_runner, upstream_base = await _start_runner(_build_fake_upstream(captured))
|
||||
adapter = FakeAdapter(f"{upstream_base}/v1", bearer="real-portal-key")
|
||||
proxy_runner, proxy_base = await _start_runner(create_app(adapter))
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{proxy_base}/v1/chat/completions",
|
||||
json={"model": "Hermes-4-70B",
|
||||
"messages": [{"role": "user", "content": "hi"}]},
|
||||
headers={"Authorization": "Bearer client-dummy-key"},
|
||||
) as resp:
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["echoed"] is True
|
||||
|
||||
assert len(captured["requests"]) == 1
|
||||
req = captured["requests"][0]
|
||||
assert req["auth"] == "Bearer real-portal-key"
|
||||
assert "Hermes-4-70B" in req["body"]
|
||||
finally:
|
||||
await proxy_runner.cleanup()
|
||||
await upstream_runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_server_rejects_disallowed_path():
|
||||
async def run():
|
||||
adapter = FakeAdapter("http://unused.example/v1", allowed=["/chat/completions"])
|
||||
runner, base = await _start_runner(create_app(adapter))
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{base}/v1/random/endpoint") as resp:
|
||||
assert resp.status == 404
|
||||
body = await resp.json()
|
||||
assert body["error"]["type"] == "path_not_allowed"
|
||||
assert "/chat/completions" in body["error"]["message"]
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_server_returns_401_when_adapter_fails():
|
||||
async def run():
|
||||
adapter = FakeAdapter("http://unused.example/v1", raise_on_credential=True)
|
||||
runner, base = await _start_runner(create_app(adapter))
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(f"{base}/v1/chat/completions", json={}) as resp:
|
||||
assert resp.status == 401
|
||||
body = await resp.json()
|
||||
assert body["error"]["type"] == "upstream_auth_failed"
|
||||
assert "simulated auth failure" in body["error"]["message"]
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_server_health_endpoint():
|
||||
async def run():
|
||||
adapter = FakeAdapter("http://unused.example/v1")
|
||||
runner, base = await _start_runner(create_app(adapter))
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{base}/health") as resp:
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["upstream"] == "Fake Provider"
|
||||
assert body["authenticated"] is True
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_server_streams_sse():
|
||||
async def run():
|
||||
captured: Dict[str, Any] = {"requests": []}
|
||||
upstream_runner, upstream_base = await _start_runner(_build_fake_upstream(captured))
|
||||
adapter = FakeAdapter(f"{upstream_base}/v1", allowed=["/sse"])
|
||||
proxy_runner, proxy_base = await _start_runner(create_app(adapter))
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{proxy_base}/v1/sse") as resp:
|
||||
assert resp.status == 200
|
||||
chunks = []
|
||||
async for chunk in resp.content.iter_any():
|
||||
chunks.append(chunk)
|
||||
full = b"".join(chunks)
|
||||
assert b"data: hello" in full
|
||||
assert b"data: [DONE]" in full
|
||||
finally:
|
||||
await proxy_runner.cleanup()
|
||||
await upstream_runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_server_strips_client_auth_header():
|
||||
"""The client's Authorization header MUST NOT reach the upstream."""
|
||||
async def run():
|
||||
captured: Dict[str, Any] = {"requests": []}
|
||||
upstream_runner, upstream_base = await _start_runner(_build_fake_upstream(captured))
|
||||
adapter = FakeAdapter(f"{upstream_base}/v1", bearer="ours")
|
||||
proxy_runner, proxy_base = await _start_runner(create_app(adapter))
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{proxy_base}/v1/chat/completions",
|
||||
json={},
|
||||
headers={"Authorization": "Bearer SHOULD_NOT_LEAK"},
|
||||
) as resp:
|
||||
await resp.read()
|
||||
assert captured["requests"][0]["auth"] == "Bearer ours"
|
||||
assert "SHOULD_NOT_LEAK" not in captured["requests"][0]["auth"]
|
||||
finally:
|
||||
await proxy_runner.cleanup()
|
||||
await upstream_runner.cleanup()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cmd_proxy_status_runs(capsys, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
from hermes_cli.proxy.cli import cmd_proxy_status
|
||||
|
||||
args = MagicMock()
|
||||
rc = cmd_proxy_status(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "nous" in out
|
||||
assert "Nous Portal" in out
|
||||
assert "not logged in" in out
|
||||
|
||||
|
||||
def test_cmd_proxy_providers_runs(capsys):
|
||||
from hermes_cli.proxy.cli import cmd_proxy_list_providers
|
||||
|
||||
args = MagicMock()
|
||||
rc = cmd_proxy_list_providers(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "nous" in out
|
||||
assert "Nous Portal" in out
|
||||
|
||||
|
||||
def test_cmd_proxy_start_refuses_unknown_provider(capsys):
|
||||
from hermes_cli.proxy.cli import cmd_proxy_start
|
||||
|
||||
args = MagicMock()
|
||||
args.provider = "no-such-provider"
|
||||
args.host = None
|
||||
args.port = None
|
||||
rc = cmd_proxy_start(args)
|
||||
assert rc == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "no-such-provider" in err
|
||||
|
||||
|
||||
def test_cmd_proxy_start_refuses_when_unauthenticated(capsys, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
from hermes_cli.proxy.cli import cmd_proxy_start
|
||||
|
||||
args = MagicMock()
|
||||
args.provider = "nous"
|
||||
args.host = None
|
||||
args.port = None
|
||||
rc = cmd_proxy_start(args)
|
||||
assert rc == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "hermes login nous" in err
|
||||
@@ -39,8 +39,6 @@ class TestExplicitAllowlist:
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"WANDB_API_KEY",
|
||||
"TINKER_API_KEY",
|
||||
"HONCHO_API_KEY",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"BROWSERBASE_API_KEY",
|
||||
|
||||
@@ -573,48 +573,6 @@ def test_vercel_setup_prefills_project_and_team_from_link_file(tmp_path, monkeyp
|
||||
assert defaults[" Vercel team ID"] == "linked-team"
|
||||
|
||||
|
||||
def test_offer_launch_chat_relaunches_via_bin(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
from hermes_cli import relaunch as relaunch_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/local/bin/hermes")
|
||||
|
||||
exec_calls = []
|
||||
|
||||
def fake_execvp(path, argv):
|
||||
exec_calls.append((path, argv))
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_mod._offer_launch_chat()
|
||||
|
||||
assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])]
|
||||
|
||||
|
||||
def test_offer_launch_chat_falls_back_to_module(monkeypatch):
|
||||
from hermes_cli import setup as setup_mod
|
||||
from hermes_cli import relaunch as relaunch_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None)
|
||||
|
||||
exec_calls = []
|
||||
|
||||
def fake_execvp(path, argv):
|
||||
exec_calls.append((path, argv))
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_mod._offer_launch_chat()
|
||||
|
||||
assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])]
|
||||
|
||||
|
||||
def test_setup_slack_saves_home_channel(monkeypatch):
|
||||
"""_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one."""
|
||||
saved = {}
|
||||
|
||||
@@ -18,4 +18,3 @@ def test_setup_hermes_script_has_termux_path():
|
||||
assert ".[termux]" in content
|
||||
assert "constraints-termux.txt" in content
|
||||
assert "$PREFIX/bin" in content
|
||||
assert "Skipping tinker-atropos on Termux" in content
|
||||
|
||||
@@ -262,7 +262,6 @@ class TestSetupWizardOpenclawIntegration:
|
||||
patch.object(setup_mod, "setup_tools"),
|
||||
patch.object(setup_mod, "save_config"),
|
||||
patch.object(setup_mod, "_print_setup_summary"),
|
||||
patch.object(setup_mod, "_offer_launch_chat"),
|
||||
):
|
||||
setup_mod.run_setup_wizard(args)
|
||||
|
||||
@@ -294,7 +293,6 @@ class TestSetupWizardOpenclawIntegration:
|
||||
patch.object(setup_mod, "setup_tools"),
|
||||
patch.object(setup_mod, "save_config"),
|
||||
patch.object(setup_mod, "_print_setup_summary"),
|
||||
patch.object(setup_mod, "_offer_launch_chat"),
|
||||
):
|
||||
setup_mod.run_setup_wizard(args)
|
||||
|
||||
@@ -327,7 +325,6 @@ class TestSetupWizardOpenclawIntegration:
|
||||
patch.object(setup_mod, "setup_tools"),
|
||||
patch.object(setup_mod, "save_config"),
|
||||
patch.object(setup_mod, "_print_setup_summary"),
|
||||
patch.object(setup_mod, "_offer_launch_chat"),
|
||||
):
|
||||
setup_mod.run_setup_wizard(args)
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ def _enter_existing_install_patches(stack, **extra):
|
||||
("hermes_cli.setup.get_env_value", {"return_value": None}),
|
||||
("hermes_cli.auth.get_active_provider", {"return_value": "openrouter"}),
|
||||
("hermes_cli.setup._print_setup_summary", {}),
|
||||
("hermes_cli.setup._offer_launch_chat", {}),
|
||||
("hermes_cli.setup._offer_openclaw_migration", {"return_value": False}),
|
||||
]:
|
||||
stack.enter_context(patch(target, **kwargs))
|
||||
|
||||
@@ -199,6 +199,37 @@ class TestUserSkins:
|
||||
# Should inherit defaults for unspecified colors
|
||||
assert skin.get_color("banner_border") == "#CD7F32" # from default
|
||||
|
||||
def test_load_user_skin_invalid_section_types_fall_back_to_defaults(self, tmp_path, monkeypatch):
|
||||
from hermes_cli.skin_engine import load_skin
|
||||
|
||||
skins_dir = tmp_path / "skins"
|
||||
skins_dir.mkdir()
|
||||
import yaml
|
||||
|
||||
(skins_dir / "broken.yaml").write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"name": "broken",
|
||||
"colors": ["not", "a", "mapping"],
|
||||
"spinner": "invalid",
|
||||
"branding": ["also", "invalid"],
|
||||
"tool_emojis": ["invalid"],
|
||||
"tool_prefix": "!",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir)
|
||||
|
||||
skin = load_skin("broken")
|
||||
|
||||
assert skin.name == "broken"
|
||||
assert skin.get_color("banner_title") == "#FFD700"
|
||||
assert skin.get_branding("agent_name") == "Hermes Agent"
|
||||
assert skin.spinner.get("waiting_faces", []) == []
|
||||
assert skin.tool_emojis == {}
|
||||
assert skin.tool_prefix == "!"
|
||||
|
||||
def test_list_skins_includes_user_skins(self, tmp_path, monkeypatch):
|
||||
from hermes_cli.skin_engine import list_skins
|
||||
skins_dir = tmp_path / "skins"
|
||||
|
||||
@@ -83,6 +83,12 @@ def test_get_platform_tools_default_telegram_includes_messaging():
|
||||
assert "messaging" in enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_default_whatsapp_includes_web():
|
||||
enabled = _get_platform_tools({}, "whatsapp")
|
||||
|
||||
assert "web" in enabled
|
||||
|
||||
|
||||
def test_get_platform_tools_homeassistant_platform_keeps_homeassistant_toolset():
|
||||
enabled = _get_platform_tools({}, "homeassistant")
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ def _setup_update_mocks(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(hermes_config, "get_missing_config_fields", lambda: [])
|
||||
monkeypatch.setattr(hermes_config, "check_config_version", lambda: (5, 5))
|
||||
monkeypatch.setattr(hermes_config, "migrate_config", lambda **kw: {"env_added": [], "config_added": []})
|
||||
monkeypatch.setattr(hermes_main, "_refresh_active_lazy_features", lambda: None)
|
||||
|
||||
|
||||
def test_cmd_update_retries_optional_extras_individually_when_all_fails(monkeypatch, tmp_path, capsys):
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Tests for plugin video_gen providers in the tools picker.
|
||||
|
||||
Covers the reconfigure path that previously failed to write
|
||||
``video_gen.provider`` when a user picked an xAI/etc. plugin backend
|
||||
through Reconfigure tool → Video Generation. The first-time configure
|
||||
path already handled it; the reconfigure path forgot to mirror it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import video_gen_registry
|
||||
from agent.video_gen_provider import VideoGenProvider
|
||||
|
||||
|
||||
class _FakeVideoProvider(VideoGenProvider):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
available: bool = True,
|
||||
schema: Optional[Dict[str, Any]] = None,
|
||||
models: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
self._name = name
|
||||
self._available = available
|
||||
self._schema = schema or {
|
||||
"name": name.title(),
|
||||
"badge": "test",
|
||||
"tag": f"{name} test tag",
|
||||
"env_vars": [{"key": f"{name.upper()}_API_KEY", "prompt": f"{name} key"}],
|
||||
}
|
||||
self._models = models or [
|
||||
{
|
||||
"id": f"{name}-video-v1",
|
||||
"display": f"{name} v1",
|
||||
"speed": "~10s",
|
||||
"strengths": "test",
|
||||
"price": "$",
|
||||
},
|
||||
]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
def list_models(self):
|
||||
return list(self._models)
|
||||
|
||||
def default_model(self):
|
||||
return self._models[0]["id"] if self._models else None
|
||||
|
||||
def get_setup_schema(self):
|
||||
return dict(self._schema)
|
||||
|
||||
def generate(self, prompt, **kw):
|
||||
return {"success": True, "video": f"{self._name}://{prompt}"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
video_gen_registry._reset_for_tests()
|
||||
yield
|
||||
video_gen_registry._reset_for_tests()
|
||||
|
||||
|
||||
class TestReconfigureWritesProvider:
|
||||
"""Regression tests for the video_gen reconfigure path.
|
||||
|
||||
Before the fix, _reconfigure_provider() handled image_gen_plugin_name
|
||||
in both the no-env-vars branch and the post-env-vars branch but
|
||||
missed video_gen_plugin_name in both. Picking xAI via Reconfigure
|
||||
tool → Video Generation silently no-op'd: the env var was already
|
||||
set, the env-var loop ran (Enter to keep), and the function fell
|
||||
through without ever writing config["video_gen"]["provider"].
|
||||
"""
|
||||
|
||||
def test_reconfigure_with_env_vars_already_set_writes_provider(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""Env vars present and user accepts current value → still writes
|
||||
video_gen.provider via the post-env-vars branch."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
video_gen_registry.register_provider(_FakeVideoProvider("xai_fake"))
|
||||
|
||||
# Picker prompts replaced — no TTY in tests.
|
||||
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
|
||||
# User presses Enter to keep the existing key.
|
||||
monkeypatch.setattr(tools_config, "_prompt", lambda *a, **kw: "")
|
||||
# Pretend the env var is already set so the reconfigure path
|
||||
# hits the "Kept current" branch.
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
"get_env_value",
|
||||
lambda key: "sk-fake" if key == "XAI_FAKE_API_KEY" else "",
|
||||
)
|
||||
|
||||
config: dict = {}
|
||||
provider_row = {
|
||||
"name": "xAI",
|
||||
"env_vars": [{"key": "XAI_FAKE_API_KEY", "prompt": "xAI key"}],
|
||||
"video_gen_plugin_name": "xai_fake",
|
||||
}
|
||||
|
||||
tools_config._reconfigure_provider(provider_row, config)
|
||||
|
||||
assert config["video_gen"]["provider"] == "xai_fake"
|
||||
assert config["video_gen"]["model"] == "xai_fake-video-v1"
|
||||
assert config["video_gen"]["use_gateway"] is False
|
||||
|
||||
def test_reconfigure_with_no_env_vars_writes_provider(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""No env vars at all (managed-style plugin) → writes
|
||||
video_gen.provider via the no-env-vars early-return branch."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
video_gen_registry.register_provider(_FakeVideoProvider(
|
||||
"noenv_video",
|
||||
schema={
|
||||
"name": "NoEnvVideo",
|
||||
"badge": "free",
|
||||
"tag": "",
|
||||
"env_vars": [],
|
||||
},
|
||||
))
|
||||
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
|
||||
|
||||
config: dict = {}
|
||||
provider_row = {
|
||||
"name": "NoEnvVideo",
|
||||
"env_vars": [],
|
||||
"video_gen_plugin_name": "noenv_video",
|
||||
}
|
||||
|
||||
tools_config._reconfigure_provider(provider_row, config)
|
||||
|
||||
assert config["video_gen"]["provider"] == "noenv_video"
|
||||
assert config["video_gen"]["model"] == "noenv_video-video-v1"
|
||||
assert config["video_gen"]["use_gateway"] is False
|
||||
|
||||
|
||||
class TestPluginVideoProvidersRow:
|
||||
"""Tests for _plugin_video_gen_providers row contents."""
|
||||
|
||||
def test_post_setup_propagated_when_declared(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
video_gen_registry.register_provider(_FakeVideoProvider(
|
||||
"xai_video",
|
||||
schema={
|
||||
"name": "xAI Grok Imagine",
|
||||
"badge": "paid",
|
||||
"tag": "grok video",
|
||||
"env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
},
|
||||
))
|
||||
|
||||
rows = tools_config._plugin_video_gen_providers()
|
||||
match = next(r for r in rows if r.get("video_gen_plugin_name") == "xai_video")
|
||||
assert match["post_setup"] == "xai_grok"
|
||||
|
||||
def test_post_setup_omitted_when_not_declared(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
video_gen_registry.register_provider(_FakeVideoProvider("plain_video"))
|
||||
|
||||
rows = tools_config._plugin_video_gen_providers()
|
||||
match = next(r for r in rows if r.get("video_gen_plugin_name") == "plain_video")
|
||||
assert "post_setup" not in match
|
||||
|
||||
|
||||
class TestVideoPluginProviderActive:
|
||||
"""Tests for _is_provider_active recognizing video_gen_plugin_name."""
|
||||
|
||||
def test_active_when_video_gen_provider_matches(self):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
config = {"video_gen": {"provider": "xai"}}
|
||||
row = {"name": "xAI Grok Imagine", "video_gen_plugin_name": "xai"}
|
||||
|
||||
assert tools_config._is_provider_active(row, config) is True
|
||||
|
||||
def test_inactive_when_video_gen_provider_differs(self):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
config = {"video_gen": {"provider": "fal"}}
|
||||
row = {"name": "xAI Grok Imagine", "video_gen_plugin_name": "xai"}
|
||||
|
||||
assert tools_config._is_provider_active(row, config) is False
|
||||
|
||||
def test_inactive_when_video_gen_section_missing(self):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
row = {"name": "xAI Grok Imagine", "video_gen_plugin_name": "xai"}
|
||||
assert tools_config._is_provider_active(row, {}) is False
|
||||
|
||||
def test_detect_active_index_picks_video_plugin_match(self, monkeypatch):
|
||||
"""When xAI is the configured video_gen provider, the picker should
|
||||
default to the xAI row even if FAL_KEY happens to be set in env.
|
||||
|
||||
Regression: previously _detect_active_provider_index() saw
|
||||
_is_provider_active(xai) return False (no video_gen branch),
|
||||
skipped xAI (empty env_vars), and matched the FAL row via the
|
||||
env-var fallback — so the picker visually defaulted to FAL even
|
||||
though the user picked xAI. The xAI row uses empty env_vars
|
||||
because authentication is handled via xAI Grok OAuth (post_setup
|
||||
hook).
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
"get_env_value",
|
||||
lambda key: "fal-key" if key == "FAL_KEY" else "",
|
||||
)
|
||||
|
||||
config = {"video_gen": {"provider": "xai"}}
|
||||
providers = [
|
||||
{"name": "xAI Grok Imagine", "env_vars": [], "video_gen_plugin_name": "xai"},
|
||||
{
|
||||
"name": "FAL.ai",
|
||||
"env_vars": [{"key": "FAL_KEY", "prompt": "FAL"}],
|
||||
"video_gen_plugin_name": "fal",
|
||||
},
|
||||
]
|
||||
|
||||
assert tools_config._detect_active_provider_index(providers, config) == 0
|
||||
Reference in New Issue
Block a user