Merge main into bb/gui.

Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
Brooklyn Nicholson
2026-05-15 15:33:28 -05:00
415 changed files with 38391 additions and 20402 deletions
+153
View File
@@ -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 -2
View File
@@ -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
+48
View File
@@ -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
+193
View File
@@ -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()
+224
View File
@@ -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
+27
View File
@@ -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):
+378
View File
@@ -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()
+89
View File
@@ -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 ──────────────────────────────────────────────────────
-28
View File
@@ -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)
# ===================================================================
+512
View File
@@ -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",
-42
View File
@@ -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))
+31
View File
@@ -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"
+6
View File
@@ -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):
+237
View File
@@ -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