Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	cli.py
#	hermes_cli/main.py
#	run_agent.py
#	tests/hermes_cli/test_cmd_update.py
#	tools/mcp_tool.py
#	web/src/lib/gatewayClient.ts
This commit is contained in:
Brooklyn Nicholson
2026-05-18 01:26:56 -05:00
260 changed files with 24547 additions and 13573 deletions
+10
View File
@@ -207,6 +207,16 @@ class TestBuildToolStart:
assert result.content is None
assert result.raw_input is None
def test_build_tool_start_for_browser_navigate(self):
"""browser_navigate should emit a polished start event."""
args = {"url": "https://x.com"}
result = build_tool_start("tc-browser-start", "browser_navigate", args)
assert isinstance(result, ToolCallStart)
assert result.title == "navigate: https://x.com"
assert result.kind == "fetch"
assert result.content[0].content.text == '{\n "url": "https://x.com"\n}'
assert result.raw_input is None
def test_build_tool_start_for_search(self):
"""search_files should include pattern in content."""
args = {"pattern": "TODO", "target": "content"}
+1 -1
View File
@@ -91,7 +91,7 @@ def main():
if msg.get("method") == "workspace/didChangeWatchedFiles":
continue
if msg.get("method") in ("textDocument/didOpen", "textDocument/didChange"):
if msg.get("method") in {"textDocument/didOpen", "textDocument/didChange"}:
params = msg.get("params") or {}
td = params.get("textDocument") or {}
uri = td.get("uri", "")
@@ -87,10 +87,10 @@ def test_install_npm_works_without_extras(tmp_path, monkeypatch):
cmd = captured["cmd"]
assert "pyright" in cmd
# Should not blow up when extra_pkgs is omitted/None
install_targets = [c for c in cmd if not c.startswith("-") and c not in (
install_targets = [c for c in cmd if not c.startswith("-") and c not in {
"install", "--prefix", str(install_mod.hermes_lsp_bin_dir().parent),
"/usr/bin/npm",
)]
}]
assert install_targets == ["pyright"]
+9 -2
View File
@@ -157,6 +157,13 @@ class TestBuildAnthropicClient:
class TestReadClaudeCodeCredentials:
@pytest.fixture(autouse=True)
def no_keychain(self, monkeypatch):
monkeypatch.setattr(
"agent.anthropic_adapter._read_claude_code_credentials_from_keychain",
lambda: None,
)
def test_reads_valid_credentials(self, tmp_path, monkeypatch):
cred_file = tmp_path / ".claude" / ".credentials.json"
cred_file.parent.mkdir(parents=True)
@@ -1651,7 +1658,7 @@ class TestThinkingBlockSignatureManagement:
_, result = convert_messages_to_anthropic(messages)
assistant = next(m for m in result if m["role"] == "assistant")
for block in assistant["content"]:
if block.get("type") in ("thinking", "redacted_thinking"):
if block.get("type") in {"thinking", "redacted_thinking"}:
assert "cache_control" not in block
def test_thinking_stripped_from_merged_consecutive_assistants(self):
@@ -1741,7 +1748,7 @@ class TestThinkingBlockSignatureManagement:
# First two: no thinking blocks
for a in assistants[:2]:
assert not any(
b.get("type") in ("thinking", "redacted_thinking")
b.get("type") in {"thinking", "redacted_thinking"}
for b in a["content"]
if isinstance(b, dict)
)
+187 -4
View File
@@ -673,6 +673,8 @@ class TestGetTextAuxiliaryClient:
def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self):
with patch("agent.auxiliary_client._resolve_custom_runtime",
return_value=("https://api.openai.com/v1", "sk-test", "codex_responses")), \
patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
patch("agent.auxiliary_client._resolve_nous_runtime_api", return_value=None), \
patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.3-codex"), \
patch("agent.auxiliary_client.OpenAI") as mock_openai:
client, model = get_text_auxiliary_client()
@@ -923,6 +925,44 @@ class TestIsPaymentError:
exc = Exception("connection reset")
assert _is_payment_error(exc) is False
# ── Daily / monthly quota exhaustion (#26803) ────────────────────────────
def test_429_quota_exceeded(self):
"""Cloud provider quota exhaustion (e.g. Vertex AI) is a payment error."""
exc = Exception("RESOURCE_EXHAUSTED: quota exceeded for project")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_too_many_tokens_per_day(self):
"""Bedrock / LiteLLM daily token limit is a payment error."""
exc = Exception("Too many tokens per day: 1000000 used, 1000000 limit")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_daily_limit_phrase(self):
"""Generic 'daily limit' phrasing is a payment error."""
exc = Exception("You have exceeded your daily limit.")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_resource_exhausted_grpc(self):
"""Vertex AI gRPC RESOURCE_EXHAUSTED maps to payment error."""
exc = Exception("resource exhausted")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_daily_quota_phrase(self):
"""'daily quota' phrasing is a payment error."""
exc = Exception("Daily quota of 500 requests reached.")
exc.status_code = 429
assert _is_payment_error(exc) is True
def test_429_transient_rate_limit_not_quota(self):
"""Transient 429 rate limit without quota keywords is NOT a payment error."""
exc = Exception("Rate limit exceeded. Retry after 10s.")
exc.status_code = 429
assert _is_payment_error(exc) is False
class TestIsRateLimitError:
"""_is_rate_limit_error detects 429 rate-limit errors warranting fallback."""
@@ -1111,6 +1151,140 @@ class TestCallLlmPaymentFallback:
# Fallback client should have been used
assert fallback_client.chat.completions.create.called
class TestAuxiliaryFallbackLayering:
"""Explicit-provider users get layered fallback: configured_chain → main agent → warn."""
def _make_payment_err(self):
exc = Exception("Payment Required: insufficient credits")
exc.status_code = 402
return exc
def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog):
"""When a user has fallback_chain configured, it's tried BEFORE the main agent model."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create.side_effect = self._make_payment_err()
chain_client = MagicMock()
chain_client.chat.completions.create.return_value = MagicMock(choices=[
MagicMock(message=MagicMock(content="from configured chain"))
])
main_called = MagicMock()
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "glm-4v-flash")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("glm", "glm-4v-flash", None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(chain_client, "gpt-4o-mini", "fallback_chain[0](openai)")), \
patch("agent.auxiliary_client._try_main_agent_model_fallback",
side_effect=main_called):
result = call_llm(
task="vision",
messages=[{"role": "user", "content": "hello"}],
)
assert chain_client.chat.completions.create.called
# Main agent fallback should NOT have been consulted — chain succeeded first
main_called.assert_not_called()
def test_explicit_provider_falls_back_to_main_when_chain_exhausted(self, monkeypatch):
"""If configured fallback_chain returns nothing, main agent model is tried next."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create.side_effect = self._make_payment_err()
main_client = MagicMock()
main_client.chat.completions.create.return_value = MagicMock(choices=[
MagicMock(message=MagicMock(content="from main agent"))
])
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "glm-4v-flash")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("glm", "glm-4v-flash", None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, "")), \
patch("agent.auxiliary_client._try_main_agent_model_fallback",
return_value=(main_client, "claude-sonnet-4", "main-agent(openrouter)")):
result = call_llm(
task="vision",
messages=[{"role": "user", "content": "hello"}],
)
assert main_client.chat.completions.create.called
def test_warning_emitted_when_all_fallbacks_exhausted(self, monkeypatch, caplog):
"""When chain AND main model both fail, a user-visible warning fires before re-raise."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
primary_client = MagicMock()
primary_client.chat.completions.create.side_effect = self._make_payment_err()
with patch("agent.auxiliary_client._get_cached_client",
return_value=(primary_client, "glm-4v-flash")), \
patch("agent.auxiliary_client._resolve_task_provider_model",
return_value=("glm", "glm-4v-flash", None, None, None)), \
patch("agent.auxiliary_client._try_configured_fallback_chain",
return_value=(None, None, "")), \
patch("agent.auxiliary_client._try_main_agent_model_fallback",
return_value=(None, None, "")), \
caplog.at_level("WARNING", logger="agent.auxiliary_client"):
with pytest.raises(Exception, match="Payment Required"):
call_llm(
task="vision",
messages=[{"role": "user", "content": "hello"}],
)
assert any(
"all fallbacks exhausted" in r.message for r in caplog.records
), f"Expected exhaustion warning, got: {[r.message for r in caplog.records]}"
class TestTryMainAgentModelFallback:
"""_try_main_agent_model_fallback resolves the user's main provider+model as a safety net."""
def test_returns_none_when_main_provider_is_auto(self):
from agent.auxiliary_client import _try_main_agent_model_fallback
with patch("agent.auxiliary_client._read_main_provider", return_value="auto"), \
patch("agent.auxiliary_client._read_main_model", return_value="some-model"):
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
assert client is None and model is None and label == ""
def test_returns_none_when_failed_provider_equals_main(self):
"""If the thing that failed IS the main model, no point retrying it."""
from agent.auxiliary_client import _try_main_agent_model_fallback
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"):
client, model, label = _try_main_agent_model_fallback("openrouter", task="vision")
assert client is None and label == ""
def test_resolves_main_provider_client(self):
from agent.auxiliary_client import _try_main_agent_model_fallback
fake_client = MagicMock()
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \
patch("agent.auxiliary_client._is_provider_unhealthy", return_value=False), \
patch("agent.auxiliary_client.resolve_provider_client",
return_value=(fake_client, "anthropic/claude-sonnet-4")):
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
assert client is fake_client
assert model == "anthropic/claude-sonnet-4"
assert label == "main-agent(openrouter)"
def test_skips_when_main_provider_is_unhealthy(self):
from agent.auxiliary_client import _try_main_agent_model_fallback
with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \
patch("agent.auxiliary_client._is_provider_unhealthy", return_value=True):
client, model, label = _try_main_agent_model_fallback("glm", task="vision")
assert client is None
# ---------------------------------------------------------------------------
# Gate: _resolve_api_key_provider must skip anthropic when not configured
# ---------------------------------------------------------------------------
@@ -2349,10 +2523,13 @@ class TestAuxiliaryClientPoisonedCacheEviction:
def test_call_llm_evicts_on_connection_error_with_explicit_provider(self):
"""Connection error on an explicit provider must drop the cached client.
This is the exact reporter scenario: ``auxiliary.compression.provider:
main`` (resolves to ``openai-codex``) → no fallback chain runs (not
auto), but the cached client was poisoned by a prior timeout and must
be evicted so the next call rebuilds.
Reporter scenario: ``auxiliary.compression.provider: main`` (resolves
to ``openai-codex``). After #26803, capacity errors (payment/quota/
connection) DO trigger fallback even on explicit providers — so we
also stub ``_try_payment_fallback`` to ``(None, None, "")`` so the
connection error re-raises after eviction instead of escaping into
a real network call. The contract under test is cache eviction,
not the fallback gate.
"""
from agent.auxiliary_client import _client_cache, _client_cache_lock
@@ -2372,6 +2549,9 @@ class TestAuxiliaryClientPoisonedCacheEviction:
), patch(
"agent.auxiliary_client._get_cached_client",
return_value=(poisoned, "gpt-5.5"),
), patch(
"agent.auxiliary_client._try_payment_fallback",
return_value=(None, None, ""),
):
with pytest.raises(ConnectionError):
call_llm(
@@ -2405,6 +2585,9 @@ class TestAuxiliaryClientPoisonedCacheEviction:
), patch(
"agent.auxiliary_client._get_cached_client",
return_value=(poisoned, "gpt-5.5"),
), patch(
"agent.auxiliary_client._try_payment_fallback",
return_value=(None, None, ""),
):
with pytest.raises(ConnectionError):
await async_call_llm(
+1 -1
View File
@@ -371,7 +371,7 @@ class TestResolveVisionMainFirst:
provider, client, model = resolve_vision_provider_client()
assert client is fallback_client
assert provider in ("openrouter", "nous")
assert provider in {"openrouter", "nous"}
def test_explicit_provider_override_still_wins(self):
"""Explicit config override bypasses main-first policy."""
+3 -3
View File
@@ -1046,7 +1046,7 @@ class TestCompressWithClient:
for i in range(1, len(result)):
r1 = result[i - 1].get("role")
r2 = result[i].get("role")
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
def test_double_collision_merges_summary_into_tail(self):
@@ -1087,7 +1087,7 @@ class TestCompressWithClient:
for i in range(1, len(result)):
r1 = result[i - 1].get("role")
r2 = result[i].get("role")
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
# The summary text should be merged into the first tail message
@@ -1164,7 +1164,7 @@ class TestCompressWithClient:
for i in range(1, len(result)):
r1 = result[i - 1].get("role")
r2 = result[i].get("role")
if r1 in ("user", "assistant") and r2 in ("user", "assistant"):
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
# The summary should be merged into the first tail message (assistant at index 5)
+184
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import base64
import json
import time
from datetime import datetime, timezone
import pytest
@@ -14,6 +16,14 @@ def _write_auth_store(tmp_path, payload: dict) -> None:
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
def _jwt_with_claims(claims: dict) -> str:
def _part(payload: dict) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig"
def test_fill_first_selection_skips_recently_exhausted_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
@@ -510,6 +520,180 @@ def test_load_pool_migrates_nous_provider_state(tmp_path, monkeypatch):
assert entry.agent_key == "agent-key"
def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
expires_at = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc).isoformat()
token = _jwt_with_claims({
"sub": "test-user",
"scope": ["inference:invoke", "inference:mint_agent_key"],
"exp": int(time.time() + 3600),
})
_write_auth_store(
tmp_path,
{
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"token_type": "Bearer",
"scope": "inference:invoke inference:mint_agent_key",
"access_token": token,
"refresh_token": "refresh-token",
"expires_at": expires_at,
"agent_key": token,
"agent_key_expires_at": expires_at,
}
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("nous")
entry = pool.select()
assert entry is not None
assert entry.source == "device_code"
assert entry.agent_key == token
assert entry.runtime_api_key == token
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
pool_entry = auth_payload["credential_pool"]["nous"][0]
assert pool_entry["agent_key"] == token
assert pool_entry["agent_key_expires_at"] == expires_at
def test_nous_pool_terminal_refresh_removes_device_code_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared"))
_write_auth_store(
tmp_path,
{
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"token_type": "Bearer",
"scope": "inference:mint_agent_key",
"access_token": "access-token",
"refresh_token": "refresh-token",
"expires_at": "2026-03-24T12:00:00+00:00",
"agent_key": "agent-key",
"agent_key_expires_at": "2026-03-24T13:30:00+00:00",
}
},
},
)
from agent.credential_pool import PooledCredential, load_pool
from hermes_cli import auth as auth_mod
from hermes_cli.auth import AuthError
refresh_calls = {"count": 0}
def _terminal_refresh_failure(*_args, **_kwargs):
refresh_calls["count"] += 1
raise AuthError(
"Refresh session has been revoked",
provider="nous",
code="invalid_grant",
relogin_required=True,
)
pool = load_pool("nous")
selected = pool.select()
assert selected is not None
assert selected.source == "device_code"
pool.add_entry(PooledCredential.from_dict("nous", {
"id": "legacy-seeded",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "old-access-token",
"refresh_token": "old-refresh-token",
"agent_key": "old-agent-key",
}))
pool.add_entry(PooledCredential.from_dict("nous", {
"id": "manual-key",
"source": "manual",
"auth_type": "api_key",
"access_token": "manual-nous-key",
}))
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", _terminal_refresh_failure)
assert pool.try_refresh_current() is None
assert [entry.id for entry in pool.entries()] == ["manual-key"]
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
nous_state = auth_payload["providers"]["nous"]
assert not nous_state.get("refresh_token")
assert not nous_state.get("access_token")
assert not nous_state.get("agent_key")
assert nous_state["last_auth_error"]["code"] == "invalid_grant"
assert [entry["id"] for entry in auth_payload["credential_pool"]["nous"]] == ["manual-key"]
assert pool.try_refresh_current() is None
assert refresh_calls["count"] == 1
def test_load_pool_removes_nous_device_code_when_singleton_quarantined(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"last_auth_error": {"code": "invalid_grant"},
}
},
"credential_pool": {
"nous": [
{
"id": "seeded-current",
"source": "device_code",
"auth_type": "oauth",
"access_token": "stale-access",
"refresh_token": "stale-refresh",
"agent_key": "stale-agent",
},
{
"id": "seeded-legacy",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "older-stale-access",
},
{
"id": "manual-key",
"source": "manual",
"auth_type": "api_key",
"access_token": "manual-nous-key",
},
]
},
},
)
from agent.credential_pool import load_pool
pool = load_pool("nous")
assert [entry.id for entry in pool.entries()] == ["manual-key"]
auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
assert [entry["id"] for entry in auth_payload["credential_pool"]["nous"]] == ["manual-key"]
def test_load_pool_removes_stale_file_backed_singleton_entry(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
@@ -191,7 +191,7 @@ class TestDeepSeekAnthropicPreservesThinking:
if not isinstance(m.get("content"), list):
continue
for b in m["content"]:
if isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking"):
if isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}:
assert "cache_control" not in b
def test_openai_compat_deepseek_base_is_not_matched(self) -> None:
+10
View File
@@ -746,6 +746,16 @@ class TestGetModelContextLength:
mock_fetch.return_value = {}
assert get_model_context_length("qwen3-coder") == 262144
@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen3_6_plus_context_length(self, mock_fetch):
"""qwen3.6-plus has a 1M context window, not the generic 128K Qwen default."""
mock_fetch.return_value = {}
assert get_model_context_length("qwen3.6-plus") == 1048576
# Provider-prefixed variants must resolve to the same explicit entry
# via the longest-substring fallback (no portal/OR cache available).
assert get_model_context_length("qwen/qwen3.6-plus") == 1048576
assert get_model_context_length("dashscope/qwen3.6-plus") == 1048576
@patch("agent.model_metadata.fetch_model_metadata")
def test_qwen_generic_context_length(self, mock_fetch):
"""Generic qwen models still get the 128K default."""
+24
View File
@@ -100,6 +100,30 @@ class TestParseResponse:
)
assert r is None
def test_block_action_without_message_uses_default(self):
"""Block is honored even when message/reason is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"action": "block"}')
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}
def test_block_decision_without_reason_uses_default(self):
"""Block is honored even when reason/message is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"decision": "block"}')
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}
def test_block_action_empty_message_uses_default(self):
"""Empty string message falls back to default, not empty string."""
r = shell_hooks._parse_response(
"pre_tool_call", '{"action": "block", "message": ""}',
)
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}
def test_block_action_non_string_message_uses_default(self):
"""Non-string message (e.g. integer) falls back to default."""
r = shell_hooks._parse_response(
"pre_tool_call", '{"action": "block", "message": 42}',
)
assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE}
# ── _serialize_payload ────────────────────────────────────────────────────
@@ -241,3 +241,58 @@ class TestSpawnEnvIsolation:
assert captured["env"].get("CODEX_HOME") == "/tmp/profile/codex"
# And HOME still passes through unchanged
assert captured["env"].get("HOME") == "/users/alice"
def test_kanban_worker_adds_only_kanban_writable_root(self, monkeypatch):
"""Codex-runtime Kanban workers need to write board state outside
their scratch/worktree workspace, but should not fall back to
danger-full-access. Hermes passes a narrow app-server config override
for the Kanban root only.
"""
import subprocess
from agent.transports import codex_app_server as cas
captured = {}
class FakePopen:
def __init__(self, cmd, *args, **kwargs):
captured["cmd"] = list(cmd)
captured["env"] = kwargs.get("env", {}).copy()
self.stdin = None
self.stdout = None
self.stderr = None
self.pid = 1
self.returncode = None
def poll(self):
return None
def terminate(self):
pass
def wait(self, timeout=None):
return 0
def kill(self):
pass
monkeypatch.setattr(subprocess, "Popen", FakePopen)
monkeypatch.setenv("HOME", "/users/alice")
monkeypatch.setenv("HERMES_HOME", "/users/alice/.hermes/profiles/backend-worker")
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_smoke")
monkeypatch.setenv(
"HERMES_KANBAN_DB",
"/users/alice/.hermes/kanban/boards/smoke/kanban.db",
)
client = cas.CodexAppServerClient(codex_bin="codex")
client._closed = True
cmd = captured["cmd"]
assert cmd[:2] == ["codex", "app-server"]
assert 'sandbox_mode="workspace-write"' in cmd
assert (
'sandbox_workspace_write.writable_roots=["/users/alice/.hermes/kanban/boards/smoke"]'
in cmd
)
assert "sandbox_workspace_write.network_access=false" in cmd
assert all("danger" not in part for part in cmd)
@@ -9,10 +9,12 @@ from __future__ import annotations
import threading
import time
from unittest.mock import patch
from typing import Any, Optional
import pytest
import agent.transports.codex_app_server_session as session_mod
from agent.transports.codex_app_server_session import (
CodexAppServerSession,
TurnResult,
@@ -344,6 +346,23 @@ class TestRunTurn:
assert r.interrupted is True
assert r.error and "timed out" in r.error
def test_deadline_uses_monotonic_clock(self):
client = FakeClient()
s = make_session(client)
monotonic_values = iter([1000.0, 999.0, 999.0, 1001.0])
with patch.object(
session_mod.time,
"monotonic",
side_effect=lambda: next(monotonic_values),
):
r = s.run_turn(
"never finishes",
turn_timeout=0.1,
notification_poll_timeout=0.0,
)
assert r.interrupted is True
assert r.error and "timed out" in r.error
def test_failed_turn_records_error_from_turn_completed(self):
client = FakeClient()
client.queue_notification(
@@ -666,6 +685,35 @@ class TestSessionRetirement:
# Confirm we issued turn/interrupt to free codex compute
assert any(method == "turn/interrupt" for (method, _) in client.requests)
def test_post_tool_watchdog_uses_monotonic_clock(self):
client = FakeClient()
client.queue_notification(
"item/completed",
item={
"type": "commandExecution", "id": "ex1",
"command": "echo hi", "cwd": "/tmp",
"status": "completed", "aggregatedOutput": "hi",
"exitCode": 0, "commandActions": [],
},
threadId="t", turnId="tu1",
)
s = make_session(client)
monotonic_values = iter([1000.0, 999.0, 999.0, 999.0, 1000.2])
with patch.object(
session_mod.time,
"monotonic",
side_effect=lambda: next(monotonic_values),
):
r = s.run_turn(
"tool then silence",
turn_timeout=5.0,
notification_poll_timeout=0.0,
post_tool_quiet_timeout=0.15,
)
assert r.interrupted is True
assert r.should_retire is True
assert r.error and "silent" in r.error
def test_post_tool_watchdog_resets_on_further_activity(self):
"""A tool completion followed by an agent message should NOT trip
the watchdog further activity = codex still alive."""
+1 -1
View File
@@ -99,7 +99,7 @@ class TestVerboseAndToolProgress:
def test_tool_progress_mode_is_string(self):
cli = _make_cli()
assert isinstance(cli.tool_progress_mode, str)
assert cli.tool_progress_mode in ("off", "new", "all", "verbose")
assert cli.tool_progress_mode in {"off", "new", "all", "verbose"}
class TestBusyInputMode:
+4 -4
View File
@@ -70,7 +70,7 @@ class TestHandleReasoningCommand(unittest.TestCase):
stub = self._make_cli(show_reasoning=False)
# Simulate /reasoning show
arg = "show"
if arg in ("show", "on"):
if arg in {"show", "on"}:
stub.show_reasoning = True
stub.agent.reasoning_callback = lambda x: None
self.assertTrue(stub.show_reasoning)
@@ -79,7 +79,7 @@ class TestHandleReasoningCommand(unittest.TestCase):
stub = self._make_cli(show_reasoning=True)
# Simulate /reasoning hide
arg = "hide"
if arg in ("hide", "off"):
if arg in {"hide", "off"}:
stub.show_reasoning = False
stub.agent.reasoning_callback = None
self.assertFalse(stub.show_reasoning)
@@ -88,14 +88,14 @@ class TestHandleReasoningCommand(unittest.TestCase):
def test_on_enables_display(self):
stub = self._make_cli(show_reasoning=False)
arg = "on"
if arg in ("show", "on"):
if arg in {"show", "on"}:
stub.show_reasoning = True
self.assertTrue(stub.show_reasoning)
def test_off_disables_display(self):
stub = self._make_cli(show_reasoning=True)
arg = "off"
if arg in ("hide", "off"):
if arg in {"hide", "off"}:
stub.show_reasoning = False
self.assertFalse(stub.show_reasoning)
+1
View File
@@ -187,6 +187,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"HERMES_BACKGROUND_NOTIFICATIONS",
"HERMES_EXEC_ASK",
"HERMES_HOME_MODE",
"HERMES_AGENT_USE_LEGACY_SESSION_KEYS",
# Kanban path/board pins must never leak from a developer shell or
# dispatched worker into tests; otherwise tests can write fake tasks to
# the real ~/.hermes/kanban.db instead of the per-test HERMES_HOME.
+2 -2
View File
@@ -68,7 +68,7 @@ def test_create_job_no_agent_stores_field(hermes_env):
assert job["no_agent"] is True
assert job["script"] == "watchdog.sh"
# Prompt can be empty/None for no_agent jobs.
assert job["prompt"] in (None, "")
assert job["prompt"] in {None, ""}
def test_create_job_default_is_not_no_agent(hermes_env):
@@ -148,7 +148,7 @@ def test_cronjob_tool_update_toggles_no_agent(hermes_env):
off = json.loads(cronjob(action="update", job_id=job_id, no_agent=False, prompt="run"))
assert off["success"] is True
assert off["job"].get("no_agent") in (False, None)
assert off["job"].get("no_agent") in {False, None}
on = json.loads(cronjob(action="update", job_id=job_id, no_agent=True))
assert on["success"] is True
+1 -1
View File
@@ -269,7 +269,7 @@ def _scan_for_plugin_adapter_antipattern(source: str) -> list[str]:
and isinstance(func.value.value, ast.Name)
and func.value.value.id == "sys"
and func.value.attr == "path"
and func.attr in ("insert", "append", "extend")
and func.attr in {"insert", "append", "extend"}
):
target_name = f"sys.path.{func.attr}"
@@ -16,8 +16,8 @@ def _would_warn():
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS")
)
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
os.getenv(v, "").lower() in ("true", "1", "yes")
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} or any(
os.getenv(v, "").lower() in {"true", "1", "yes"}
for v in ("TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS",
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
+92
View File
@@ -445,7 +445,12 @@ class TestHealthEndpoint:
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health")
assert resp.status == 200
assert resp.headers.get("Content-Security-Policy") == "default-src 'none'; frame-ancestors 'none'"
assert resp.headers.get("Permissions-Policy") == "camera=(), microphone=(), geolocation=()"
assert resp.headers.get("Strict-Transport-Security") == "max-age=31536000; includeSubDomains"
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
assert resp.headers.get("X-Frame-Options") == "DENY"
assert resp.headers.get("X-XSS-Protection") == "0"
assert resp.headers.get("Referrer-Policy") == "no-referrer"
@pytest.mark.asyncio
@@ -704,6 +709,37 @@ class TestChatCompletionsEndpoint:
assert "[DONE]" in body
assert "Hello!" in body
@pytest.mark.asyncio
async def test_stream_string_false_returns_json_completion(self, adapter):
"""Quoted false must not route chat completions into SSE mode."""
mock_result = {
"final_response": "Hello! How can I help you today?",
"messages": [],
"api_calls": 1,
}
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
mock_result,
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
resp = await cli.post(
"/v1/chat/completions",
json={
"model": "hermes-agent",
"messages": [{"role": "user", "content": "Hello"}],
"stream": "false",
},
)
assert resp.status == 200
assert "text/event-stream" not in resp.headers.get("Content-Type", "")
data = await resp.json()
assert data["object"] == "chat.completion"
assert data["choices"][0]["message"]["content"] == mock_result["final_response"]
@pytest.mark.asyncio
async def test_stream_task_done_callback_enqueues_eos_for_chat_completions(self, adapter):
"""Regression guard for #24451: completion callback must signal SSE EOS."""
@@ -1655,6 +1691,31 @@ class TestResponsesEndpoint:
# The response has an ID but it shouldn't be retrievable
assert adapter._response_store.get(data["id"]) is None
@pytest.mark.asyncio
async def test_store_string_false_does_not_store(self, adapter):
"""Quoted false must preserve ephemeral store=false semantics."""
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
mock_result,
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
)
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": "Hello",
"store": "false",
},
)
assert resp.status == 200
data = await resp.json()
assert adapter._response_store.get(data["id"]) is None
@pytest.mark.asyncio
async def test_instructions_inherited_from_previous(self, adapter):
"""If no instructions provided, carry forward from previous response."""
@@ -1749,6 +1810,37 @@ class TestResponsesStreaming:
assert "Hello" in body
assert " world" in body
@pytest.mark.asyncio
async def test_stream_string_false_returns_json_response(self, adapter):
"""Quoted false must not route Responses API requests into SSE mode."""
mock_result = {
"final_response": "Paris is the capital of France.",
"messages": [],
"api_calls": 1,
}
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
mock_result,
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
)
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": "What is the capital of France?",
"stream": "false",
},
)
assert resp.status == 200
assert "text/event-stream" not in resp.headers.get("Content-Type", "")
data = await resp.json()
assert data["object"] == "response"
assert data["output"][0]["content"][0]["text"] == mock_result["final_response"]
@pytest.mark.asyncio
async def test_stream_task_done_callback_enqueues_eos_for_responses(self, adapter):
"""Regression guard for #24451 on /v1/responses streaming path."""
+22
View File
@@ -335,6 +335,28 @@ class TestRunEvents:
"approval_not_pending",
}
@pytest.mark.asyncio
async def test_approval_string_false_does_not_resolve_all(self, adapter):
"""Quoted false must not fan out approval resolution across the queue."""
app = _create_runs_app(adapter)
run_id = "run_bool_parse"
adapter._run_statuses[run_id] = {"run_id": run_id, "status": "running"}
adapter._run_approval_sessions[run_id] = "session-123"
async with TestClient(TestServer(app)) as cli:
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
approval_resp = await cli.post(
f"/v1/runs/{run_id}/approval",
json={"choice": "once", "all": "false"},
)
assert approval_resp.status == 200
mock_resolve.assert_called_once_with(
"session-123",
"once",
resolve_all=False,
)
@pytest.mark.asyncio
async def test_events_not_found_returns_404(self, adapter):
app = _create_runs_app(adapter)
+1
View File
@@ -316,6 +316,7 @@ class TestRunBackgroundTask:
assert mock_adapter.send.call_args.kwargs["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20197",
"telegram_reply_to_message_id": "463",
}
+5
View File
@@ -101,6 +101,11 @@ class TestBlueBubblesHelpers:
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("**Hello** `world`") == "Hello world"
def test_format_message_preserves_underscores_in_identifiers(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
text = "Use /api_v2 with FEATURE_FLAG_NAME and config_file.json"
assert adapter.format_message(text) == text
def test_strip_markdown_headers(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("## Heading\ntext") == "Heading\ntext"
+2 -2
View File
@@ -44,7 +44,7 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
val = terminal_cfg[cfg_key]
# Skip cwd placeholder values — don't overwrite already-resolved
# TERMINAL_CWD. Mirrors the fix in gateway/run.py.
if cfg_key == "cwd" and str(val) in (".", "auto", "cwd"):
if cfg_key == "cwd" and str(val) in {".", "auto", "cwd"}:
continue
# Expand shell tilde so subprocess.Popen never receives a literal
# "~/" which the kernel rejects.
@@ -70,7 +70,7 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
# --- Replicate lines 144-147: MESSAGING_CWD fallback ---
configured_cwd = env.get("TERMINAL_CWD", "")
if not configured_cwd or configured_cwd in (".", "auto", "cwd"):
if not configured_cwd or configured_cwd in {".", "auto", "cwd"}:
messaging_cwd = env.get("MESSAGING_CWD") or "/root" # Path.home() for root
env["TERMINAL_CWD"] = messaging_cwd
@@ -48,7 +48,7 @@ class TestDiscordSystemMessageFilter(unittest.TestCase):
return False
# System message filter (the fix being tested)
if message.type not in (discord.MessageType.default, discord.MessageType.reply):
if message.type not in {discord.MessageType.default, discord.MessageType.reply}:
return False
return True # message accepted
+1 -1
View File
@@ -2740,7 +2740,7 @@ class _FakeAiohttpSession:
def _install_fake_aiohttp(monkeypatch, session):
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda timeout=None: session,
ClientSession=lambda timeout=None, **kwargs: session,
ClientTimeout=lambda total=None: None,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
+204
View File
@@ -2257,6 +2257,210 @@ class TestMatrixOnRoomMessageFilter:
ev = self._mk_event(sender="@alice:example.org", body="hello bot")
await self.adapter._on_room_message(ev)
self.adapter._handle_text_message.assert_awaited_once()
class TestMatrixClockSkewWarning:
"""Clock-skew detector for #12614.
Reporter's host clock was set ~2 hours ahead of real time. The grace
filter `event_ts < startup_ts - 5` then drops every live event because
server timestamps look "older than startup". When this happens well
after startup (>30s), the adapter logs a one-shot WARNING pointing the
user at NTP instead of failing silently.
"""
def setup_method(self):
self.adapter = _make_adapter()
self.adapter._user_id = "@bot:example.org"
self.adapter._handle_text_message = AsyncMock()
self.adapter._handle_media_message = AsyncMock()
@staticmethod
def _mk_event(sender, ts_ms, event_id=None):
ev = MagicMock()
ev.room_id = "!room:example.org"
ev.sender = sender
ev.event_id = event_id or f"$evt-{sender}-{ts_ms}"
ev.timestamp = ts_ms
ev.server_timestamp = ts_ms
ev.content = {"msgtype": "m.text", "body": "hi"}
return ev
@pytest.mark.asyncio
async def test_late_drops_emit_one_shot_clock_skew_warning(self, caplog):
import logging
import time as _t
# Simulate the reporter's environment: host clock is ~2 hours ahead
# of server time. Startup happened "in the future" relative to the
# real-world events we're now receiving.
now = _t.time()
self.adapter._startup_ts = now - 60 # bot started 60s ago (wall clock)
# Server events are dated 2h before startup_ts (skewed clock).
skewed_event_ts_ms = int((self.adapter._startup_ts - 7200) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i in range(5):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=skewed_event_ts_ms
)
await self.adapter._on_room_message(ev)
# Handler should never be invoked — all events failed the grace check.
self.adapter._handle_text_message.assert_not_called()
# Exactly one WARNING from THIS logger should be emitted. Filter by
# logger name so unrelated stdlib/library warnings can't satisfy the
# assertion.
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
and r.levelname == "WARNING"
and "set-ntp" in r.getMessage()
]
assert len(skew_warnings) == 1, (
f"expected exactly 1 clock-skew warning, got {len(skew_warnings)}"
)
msg = skew_warnings[0].getMessage()
assert "7200" in msg, f"skew value missing from message: {msg!r}"
# Pin the counter so a regression in the gating logic (e.g. warning
# at threshold 1 or 5, or not stopping after warn) is caught.
assert self.adapter._late_grace_drops == 3
assert self.adapter._clock_skew_warned is True
@pytest.mark.asyncio
async def test_initial_sync_drops_do_not_warn(self, caplog):
"""During the first 30s after startup, old events are normal backfill."""
import logging
import time as _t
now = _t.time()
# Startup was 1s ago — we're still in the initial-sync window.
self.adapter._startup_ts = now - 1
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i in range(5):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
)
await self.adapter._on_room_message(ev)
# Backfill drops are silent — no clock-skew warning fired.
assert self.adapter._clock_skew_warned is False
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
and "set-ntp" in r.getMessage()
]
assert skew_warnings == []
@pytest.mark.asyncio
async def test_fewer_than_three_late_drops_do_not_warn(self, caplog):
"""A single delayed backfill event after 30s shouldn't trigger NTP advice."""
import logging
import time as _t
now = _t.time()
self.adapter._startup_ts = now - 120 # extra slack vs the 30s gate
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i in range(2): # only 2 late drops — under the threshold
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
)
await self.adapter._on_room_message(ev)
assert self.adapter._late_grace_drops == 2
assert self.adapter._clock_skew_warned is False
@pytest.mark.asyncio
async def test_varied_backfill_skews_do_not_warn(self, caplog):
"""Backfill from a freshly-invited room delivers events of varied age.
A genuine clock-skew bug produces drops with a *constant* offset
(every event is ~X seconds older than wall clock). Joining an old
room post-startup delivers events spanning hours-to-days; those
skews vary wildly and must NOT trigger the NTP warning.
"""
import logging
import time as _t
now = _t.time()
self.adapter._startup_ts = now - 120
# Each event has a different age, ranging from 1h to 30d ago.
ages_in_hours = [1, 24, 168, 720, 4] # 1h, 1d, 1w, 30d, 4h
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i, hrs in enumerate(ages_in_hours):
ts_ms = int((self.adapter._startup_ts - hrs * 3600) * 1000)
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=ts_ms
)
await self.adapter._on_room_message(ev)
# The varied-skew guard should keep the counter from reaching 3.
assert self.adapter._late_grace_drops < 3
assert self.adapter._clock_skew_warned is False
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
and "set-ntp" in r.getMessage()
]
assert skew_warnings == []
@pytest.mark.asyncio
async def test_state_reset_allows_warning_to_fire_again(self, caplog):
"""After the reset block at top of connect() runs, the warning is rearmed.
Reconnect lifecycle: the user fixes NTP, restarts the bot, and the
new connect() call resets _late_grace_drops / _clock_skew_warned at
the top. This test exercises the rearm path by:
1. Tripping the warning once (state: warned=True).
2. Running the same reset block connect() runs.
3. Tripping the warning a second time the second warning should
fire because the state was cleared.
"""
import logging
import time as _t
now = _t.time()
self.adapter._startup_ts = now - 60
skewed_ms = int((self.adapter._startup_ts - 7200) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i in range(3):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=skewed_ms,
event_id=f"$first-{i}",
)
await self.adapter._on_room_message(ev)
assert self.adapter._clock_skew_warned is True
# Mirror the reset block in connect() (matrix.py around line 855).
self.adapter._startup_ts = _t.time() - 60
self.adapter._late_grace_drops = 0
self.adapter._late_grace_skew = 0.0
self.adapter._clock_skew_warned = False
# Same skewed-clock scenario should warn AGAIN after reset.
skewed_ms2 = int((self.adapter._startup_ts - 7200) * 1000)
for i in range(3):
ev = self._mk_event(
sender=f"@bob{i}:example.org", ts_ms=skewed_ms2,
event_id=f"$second-{i}",
)
await self.adapter._on_room_message(ev)
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
and "set-ntp" in r.getMessage()
]
assert len(skew_warnings) == 2, (
f"expected 2 warnings (one per connect cycle), got {len(skew_warnings)}"
)
# ---------------------------------------------------------------------------
# DM auto-thread
# ---------------------------------------------------------------------------
@@ -76,12 +76,12 @@ def test_checker_returns_true_when_configured(platform, checker, monkeypatch):
elif platform == Platform.SMS:
monkeypatch.setenv("TWILIO_ACCOUNT_SID", "ACtest")
mock_config.extra = {}
elif platform in (
elif platform in {
Platform.API_SERVER,
Platform.WEBHOOK,
Platform.MSGRAPH_WEBHOOK,
Platform.WHATSAPP,
):
}:
mock_config.extra = {}
elif platform == Platform.FEISHU:
mock_config.extra = {"app_id": "app"}
+1 -1
View File
@@ -1076,7 +1076,7 @@ class TestBuildApprovalKeyboard:
parsed = parse_approval_button_data(btn.action.data)
assert parsed is not None
assert parsed[0] == session_key
assert parsed[1] in ("allow-once", "allow-always", "deny")
assert parsed[1] in {"allow-once", "allow-always", "deny"}
class TestBuildUpdatePromptKeyboard:
+10 -1
View File
@@ -33,7 +33,16 @@ async def test_restart_command_while_busy_requests_drain_without_interrupt(monke
result = await runner._handle_message(event)
assert result == t("gateway.draining", count=1)
expected = t("gateway.draining", count=1)
assert result == expected
# Guard against the silent-degradation regression in #22266: if the i18n
# catalog cannot be resolved (e.g. xdist workers losing the locales path)
# then ``t("gateway.draining", count=1)`` returns the bare key
# ``"gateway.draining"`` instead of the formatted English string, and both
# sides of the equality above would still match. Assert on the catalog
# output explicitly so a broken locale resolution fails loudly here.
assert expected != "gateway.draining"
assert "Draining" in expected and "1" in expected
running_agent.interrupt.assert_not_called()
runner.request_restart.assert_called_once_with(detached=True, via_service=False)
+1 -1
View File
@@ -89,7 +89,7 @@ def _build_agent_history(history: list) -> list:
agent_history: list = []
for msg in history:
role = msg.get("role")
if not role or role in ("session_meta", "system"):
if not role or role in {"session_meta", "system"}:
continue
has_tool_calls = "tool_calls" in msg
has_tool_call_id = "tool_call_id" in msg
+1 -1
View File
@@ -108,7 +108,7 @@ async def test_finalize_before_reset(mock_invoke_hook):
await runner._handle_reset_command(_make_event("/new"))
calls = [c for c in mock_invoke_hook.call_args_list
if c[0][0] in ("on_session_finalize", "on_session_reset")]
if c[0][0] in {"on_session_finalize", "on_session_reset"}]
hook_names = [c[0][0] for c in calls]
assert hook_names == ["on_session_finalize", "on_session_reset"]
@@ -187,7 +187,7 @@ fallback_providers:
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
def fake_resolve_runtime_provider(*, requested=None, explicit_base_url=None, explicit_api_key=None):
if requested in (None, "", "openai-codex"):
if requested in {None, "", "openai-codex"}:
from hermes_cli.auth import AuthError
raise AuthError("No Codex credentials stored. Run `hermes auth` to authenticate.")
assert requested == "openrouter"
+1 -1
View File
@@ -763,7 +763,7 @@ def _install_fake_aiohttp(monkeypatch, session):
"""Replace ``aiohttp`` in ``sys.modules`` so ``import aiohttp as _aiohttp``
inside ``_standalone_send`` picks up our fake."""
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda timeout=None: session,
ClientSession=lambda timeout=None, **kwargs: session,
ClientTimeout=lambda total=None: None,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
@@ -407,6 +407,7 @@ async def test_gateway_runner_busy_ack_replies_to_triggering_message_for_telegra
assert adapter.calls[0]["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20197",
"telegram_reply_to_message_id": "463",
}
+1 -1
View File
@@ -31,7 +31,7 @@ def _filter_history(history: list) -> list:
role = msg.get("role")
if not role:
continue
if role in ("session_meta",):
if role in {"session_meta",}:
continue
if role == "system":
continue
+2
View File
@@ -237,6 +237,8 @@ class TestUpdateCommandGatewayFlag:
cmd_string = call_args[-1] if isinstance(call_args, list) else str(call_args)
assert "--gateway" in cmd_string
assert "PYTHONUNBUFFERED" in cmd_string
assert "rc=$?" in cmd_string
assert "status=$?" not in cmd_string
assert "stream progress" in result
+1
View File
@@ -461,6 +461,7 @@ class TestSendVoiceReply:
assert call_kwargs["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20197",
"telegram_reply_to_message_id": "462",
}
+2 -2
View File
@@ -107,7 +107,7 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"scope": "inference:mint_agent_key",
"scope": "inference:invoke inference:mint_agent_key",
"token_type": "Bearer",
"access_token": token,
"refresh_token": "refresh-token",
@@ -228,7 +228,7 @@ def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"scope": "inference:mint_agent_key",
"scope": "inference:invoke inference:mint_agent_key",
"token_type": "Bearer",
"access_token": token,
"refresh_token": "refresh-token",
+649 -10
View File
@@ -1,6 +1,9 @@
"""Regression tests for Nous OAuth refresh + agent-key mint interactions."""
import base64
import json
import logging
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -125,6 +128,11 @@ def _setup_nous_auth(
*,
access_token: str = "access-old",
refresh_token: str = "refresh-old",
scope: str = "inference:mint_agent_key",
expires_at: str = "2026-02-01T00:00:00+00:00",
expires_in: int = 0,
agent_key: str | None = None,
agent_key_expires_at: str | None = None,
) -> None:
hermes_home.mkdir(parents=True, exist_ok=True)
auth_store = {
@@ -136,15 +144,15 @@ def _setup_nous_auth(
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"token_type": "Bearer",
"scope": "inference:mint_agent_key",
"scope": scope,
"access_token": access_token,
"refresh_token": refresh_token,
"obtained_at": "2026-02-01T00:00:00+00:00",
"expires_in": 0,
"expires_at": "2026-02-01T00:00:00+00:00",
"agent_key": None,
"expires_in": expires_in,
"expires_at": expires_at,
"agent_key": agent_key,
"agent_key_id": None,
"agent_key_expires_at": None,
"agent_key_expires_at": agent_key_expires_at,
"agent_key_expires_in": None,
"agent_key_reused": None,
"agent_key_obtained_at": None,
@@ -164,6 +172,463 @@ def _mint_payload(api_key: str = "agent-key") -> dict:
}
def _jwt_with_claims(claims: dict) -> str:
def _part(payload: dict) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig"
def _future_iso(seconds: int = 3600) -> str:
return datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat()
def _invoke_jwt(*, seconds: int = 3600, scope: object = "inference:invoke inference:mint_agent_key") -> str:
return _jwt_with_claims({
"sub": "test-user",
"scope": scope,
"exp": int(time.time() + seconds),
})
def test_resolve_nous_runtime_credentials_prefers_invoke_jwt_and_mirrors(
tmp_path,
monkeypatch,
):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
token = _invoke_jwt(seconds=3600)
_setup_nous_auth(
hermes_home,
access_token=token,
scope=auth_mod.DEFAULT_NOUS_SCOPE,
expires_at=_future_iso(3600),
expires_in=3600,
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
def _unexpected_mint(*args, **kwargs):
raise AssertionError("legacy agent-key mint should not run for invoke JWT")
monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint)
creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
assert creds["api_key"] == token
assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT
assert creds["auth_path"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT
payload = json.loads((hermes_home / "auth.json").read_text())
singleton = payload["providers"]["nous"]
assert singleton["agent_key"] == token
assert datetime.fromisoformat(singleton["agent_key_expires_at"]).timestamp() > time.time() + 300
pool_entries = payload["credential_pool"]["nous"]
assert len(pool_entries) == 1
assert pool_entries[0]["agent_key"] == token
assert pool_entries[0]["source"] == auth_mod.NOUS_DEVICE_CODE_SOURCE
def test_resolve_nous_runtime_credentials_invoke_jwt_is_idempotent(
tmp_path,
monkeypatch,
):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
exp = int(time.time() + 3600)
expires_at = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
token = _jwt_with_claims({
"sub": "test-user",
"scope": auth_mod.DEFAULT_NOUS_SCOPE,
"exp": exp,
})
original_obtained_at = "2026-04-17T22:00:10+00:00"
auth_store = {
"version": 1,
"active_provider": "nous",
"providers": {
"nous": {
"portal_base_url": "https://portal.example.com",
"inference_base_url": "https://inference.example.com/v1",
"client_id": "hermes-cli",
"token_type": "Bearer",
"scope": auth_mod.DEFAULT_NOUS_SCOPE,
"access_token": token,
"refresh_token": "refresh-token",
"obtained_at": "2026-02-01T00:00:00+00:00",
"expires_in": 123,
"expires_at": expires_at,
"agent_key": token,
"agent_key_id": None,
"agent_key_expires_at": expires_at,
"agent_key_expires_in": 123,
"agent_key_reused": False,
"agent_key_obtained_at": original_obtained_at,
"tls": {"insecure": False, "ca_bundle": None},
},
},
}
auth_path = hermes_home / "auth.json"
auth_path.write_text(json.dumps(auth_store, indent=2))
before_content = auth_path.read_text()
before_mtime = auth_path.stat().st_mtime_ns
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
def _unexpected_mint(*args, **kwargs):
raise AssertionError("stable invoke JWT should not mint a legacy key")
def _unexpected_shared_write(*args, **kwargs):
raise AssertionError("unchanged invoke JWT resolution should not sync shared store")
sync_calls = []
monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint)
monkeypatch.setattr(auth_mod, "_write_shared_nous_state", _unexpected_shared_write)
monkeypatch.setattr(
auth_mod,
"_sync_nous_pool_from_auth_store",
lambda: sync_calls.append(True),
)
creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
assert creds["api_key"] == token
assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT
assert auth_path.read_text() == before_content
assert auth_path.stat().st_mtime_ns == before_mtime
assert sync_calls == []
payload = json.loads(auth_path.read_text())
assert (
payload["providers"]["nous"]["agent_key_obtained_at"]
== original_obtained_at
)
def test_resolve_nous_runtime_credentials_trusts_invoke_jwt_exp_over_stale_metadata(
tmp_path,
monkeypatch,
):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
token = _invoke_jwt(seconds=3600)
_setup_nous_auth(
hermes_home,
access_token=token,
scope=auth_mod.DEFAULT_NOUS_SCOPE,
expires_at="2000-01-01T00:00:00+00:00",
expires_in=0,
agent_key=token,
agent_key_expires_at="2000-01-01T00:00:00+00:00",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
def _unexpected_refresh(*args, **kwargs):
raise AssertionError("valid invoke JWT should not be refreshed because metadata is stale")
def _unexpected_mint(*args, **kwargs):
raise AssertionError("valid invoke JWT should not fall back to legacy mint")
monkeypatch.setattr(auth_mod, "_refresh_access_token", _unexpected_refresh)
monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint)
creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
assert creds["api_key"] == token
assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT
payload = json.loads((hermes_home / "auth.json").read_text())
singleton = payload["providers"]["nous"]
assert singleton["agent_key"] == token
assert datetime.fromisoformat(singleton["expires_at"]).timestamp() > time.time() + 300
assert datetime.fromisoformat(singleton["agent_key_expires_at"]).timestamp() > time.time() + 300
def test_resolve_nous_runtime_credentials_does_not_apply_legacy_ttl_to_invoke_jwt(
tmp_path,
monkeypatch,
):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
token = _invoke_jwt(seconds=900)
_setup_nous_auth(
hermes_home,
access_token=token,
scope=auth_mod.DEFAULT_NOUS_SCOPE,
expires_at=_future_iso(900),
expires_in=900,
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
def _unexpected_mint(*args, **kwargs):
raise AssertionError("1800s legacy min TTL should not force opaque mint for invoke JWT")
monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint)
creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=1800)
assert creds["api_key"] == token
assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT
payload = json.loads((hermes_home / "auth.json").read_text())
assert payload["providers"]["nous"]["agent_key"] == token
assert payload["credential_pool"]["nous"][0]["agent_key"] == token
def test_legacy_auth_mode_bypasses_usable_invoke_jwt(tmp_path, monkeypatch):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
token = _invoke_jwt(seconds=3600)
_setup_nous_auth(
hermes_home,
access_token=token,
scope=auth_mod.DEFAULT_NOUS_SCOPE,
expires_at=_future_iso(3600),
expires_in=3600,
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
mint_calls = []
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
del client, portal_base_url, min_ttl_seconds
mint_calls.append(access_token)
return _mint_payload(api_key="legacy-after-jwt-401")
monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key)
creds = auth_mod.resolve_nous_runtime_credentials(
min_key_ttl_seconds=300,
inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY,
)
assert mint_calls == [token]
assert creds["api_key"] == "legacy-after-jwt-401"
assert creds["auth_path"] == auth_mod.NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT
payload = json.loads((hermes_home / "auth.json").read_text())
assert payload["providers"]["nous"]["agent_key"] == "legacy-after-jwt-401"
def test_resolve_nous_runtime_credentials_falls_back_when_invoke_scope_missing(
tmp_path,
monkeypatch,
):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
token = _jwt_with_claims({
"sub": "test-user",
"scope": "inference:mint_agent_key",
"exp": int(time.time() + 3600),
})
_setup_nous_auth(
hermes_home,
access_token=token,
scope=auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE,
expires_at=_future_iso(3600),
expires_in=3600,
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
calls = []
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
del client, portal_base_url, min_ttl_seconds
calls.append(access_token)
return _mint_payload(api_key="opaque-agent-key")
monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key)
creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
assert calls == [token]
assert creds["api_key"] == "opaque-agent-key"
assert creds["source"] == "portal"
payload = json.loads((hermes_home / "auth.json").read_text())
assert payload["providers"]["nous"]["agent_key"] == "opaque-agent-key"
assert payload["credential_pool"]["nous"][0]["agent_key"] == "opaque-agent-key"
def test_nous_device_code_login_retries_legacy_scope_when_invoke_refused(monkeypatch):
import hermes_cli.auth as auth_mod
scopes = []
def _fake_request_device_code(*, client, portal_base_url, client_id, scope):
del client, portal_base_url, client_id
scopes.append(scope)
if len(scopes) == 1:
request = httpx.Request("POST", "https://portal.example.com/api/oauth/device/code")
response = httpx.Response(
400,
json={
"error": "invalid_scope",
"error_description": "unsupported inference:invoke",
},
request=request,
)
raise httpx.HTTPStatusError("invalid_scope", request=request, response=response)
return {
"device_code": "device",
"user_code": "user",
"verification_uri": "https://portal.example.com/device",
"verification_uri_complete": "https://portal.example.com/device?code=user",
"expires_in": 600,
"interval": 1,
}
def _fake_poll_for_token(**kwargs):
del kwargs
return {
"access_token": "access-legacy",
"refresh_token": "refresh-legacy",
"expires_in": 900,
"scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE,
}
def _fake_refresh(state, **kwargs):
del kwargs
refreshed = dict(state)
refreshed["agent_key"] = "opaque-agent-key"
refreshed["agent_key_expires_at"] = _future_iso(1800)
return refreshed
monkeypatch.setattr(auth_mod, "_request_device_code", _fake_request_device_code)
monkeypatch.setattr(auth_mod, "_poll_for_token", _fake_poll_for_token)
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh)
result = auth_mod._nous_device_code_login(
portal_base_url="https://portal.example.com",
inference_base_url="https://inference.example.com/v1",
open_browser=False,
timeout_seconds=1,
)
assert scopes == [auth_mod.DEFAULT_NOUS_SCOPE, auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE]
assert result["scope"] == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE
assert result["agent_key"] == "opaque-agent-key"
def test_forced_legacy_env_skips_invoke_scope_and_jwt_storage(tmp_path, monkeypatch):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
token = _invoke_jwt(seconds=3600)
_setup_nous_auth(
hermes_home,
access_token=token,
scope=auth_mod.DEFAULT_NOUS_SCOPE,
expires_at=_future_iso(3600),
expires_in=3600,
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, "true")
mint_calls = []
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
del client, portal_base_url, min_ttl_seconds
mint_calls.append(access_token)
return _mint_payload(api_key="forced-legacy-key")
monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key)
creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
assert mint_calls == [token]
assert creds["api_key"] == "forced-legacy-key"
payload = json.loads((hermes_home / "auth.json").read_text())
assert payload["providers"]["nous"]["agent_key"] == "forced-legacy-key"
requested_scopes = []
def _fake_request_device_code(*, client, portal_base_url, client_id, scope):
del client, portal_base_url, client_id
requested_scopes.append(scope)
return {
"device_code": "device",
"user_code": "user",
"verification_uri": "https://portal.example.com/device",
"verification_uri_complete": "https://portal.example.com/device?code=user",
"expires_in": 600,
"interval": 1,
}
def _fake_poll_for_token(**kwargs):
del kwargs
return {
"access_token": "access-legacy",
"refresh_token": "refresh-legacy",
"expires_in": 900,
"scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE,
}
def _fake_refresh(state, **kwargs):
del kwargs
refreshed = dict(state)
refreshed["agent_key"] = "forced-legacy-login-key"
refreshed["agent_key_expires_at"] = _future_iso(1800)
return refreshed
monkeypatch.setattr(auth_mod, "_request_device_code", _fake_request_device_code)
monkeypatch.setattr(auth_mod, "_poll_for_token", _fake_poll_for_token)
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh)
auth_mod._nous_device_code_login(
portal_base_url="https://portal.example.com",
inference_base_url="https://inference.example.com/v1",
open_browser=False,
timeout_seconds=1,
)
assert requested_scopes == [auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE]
def test_nous_inference_auth_logs_do_not_include_secret_values(
tmp_path,
monkeypatch,
caplog,
):
import hermes_cli.auth as auth_mod
hermes_home = tmp_path / "hermes"
token = _jwt_with_claims({
"sub": "secret-user",
"scope": "inference:mint_agent_key",
"exp": int(time.time() + 3600),
})
refresh_token = "refresh-secret-token"
opaque_key = "opaque-secret-agent-key"
_setup_nous_auth(
hermes_home,
access_token=token,
refresh_token=refresh_token,
scope=auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE,
expires_at=_future_iso(3600),
expires_in=3600,
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
del client, portal_base_url, access_token, min_ttl_seconds
return _mint_payload(api_key=opaque_key)
monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key)
caplog.set_level(logging.INFO, logger="hermes_cli.auth")
auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
logged = caplog.text
assert "legacy session key path" in logged
assert token not in logged
assert refresh_token not in logged
assert opaque_key not in logged
def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch):
"""get_nous_auth_status() should find Nous credentials in the pool
even when the auth store has no Nous provider entry this is the
@@ -373,6 +838,99 @@ def test_refresh_token_persisted_when_mint_times_out(tmp_path, monkeypatch):
assert state_after_failure["access_token"] == "access-1"
def test_terminal_refresh_failure_quarantines_tokens(
tmp_path, monkeypatch, shared_store_env,
):
"""A revoked/invalid Nous refresh token must not be replayed forever."""
from hermes_cli import auth as auth_mod
hermes_home = tmp_path / "hermes"
_setup_nous_auth(hermes_home, refresh_token="refresh-old")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from agent.credential_pool import load_pool
assert load_pool("nous").select() is not None
shared_state = _full_state_fixture()
shared_state["access_token"] = "access-old"
shared_state["refresh_token"] = "refresh-old"
shared_state["expires_at"] = "2026-02-01T00:00:00+00:00"
auth_mod._write_shared_nous_state(shared_state)
refresh_calls: list[str] = []
def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_token):
refresh_calls.append(refresh_token)
raise AuthError(
"Refresh session has been revoked",
provider="nous",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth_mod, "_refresh_access_token", _terminal_refresh_failure)
with pytest.raises(AuthError, match="Refresh session has been revoked"):
auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
state_after_failure = auth_mod.get_provider_auth_state("nous")
assert state_after_failure is not None
assert not state_after_failure.get("refresh_token")
assert not state_after_failure.get("access_token")
assert not state_after_failure.get("agent_key")
assert state_after_failure["last_auth_error"]["code"] == "invalid_grant"
assert auth_mod._read_shared_nous_state() is None
payload = json.loads((hermes_home / "auth.json").read_text())
assert payload.get("credential_pool", {}).get("nous") == []
with pytest.raises(AuthError, match="No access token found"):
auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300)
assert refresh_calls == ["refresh-old"]
def test_managed_access_token_refresh_failure_quarantines_tokens(
tmp_path, monkeypatch, shared_store_env,
):
from hermes_cli import auth as auth_mod
hermes_home = tmp_path / "hermes"
_setup_nous_auth(hermes_home, refresh_token="refresh-old")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from agent.credential_pool import load_pool
assert load_pool("nous").select() is not None
refresh_calls: list[str] = []
def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_token):
refresh_calls.append(refresh_token)
raise AuthError(
"Invalid refresh token",
provider="nous",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth_mod, "_refresh_access_token", _terminal_refresh_failure)
with pytest.raises(AuthError, match="Invalid refresh token"):
auth_mod.resolve_nous_access_token()
state_after_failure = auth_mod.get_provider_auth_state("nous")
assert state_after_failure is not None
assert not state_after_failure.get("refresh_token")
assert not state_after_failure.get("access_token")
assert state_after_failure["last_auth_error"]["message"] == "Invalid refresh token"
payload = json.loads((hermes_home / "auth.json").read_text())
assert payload.get("credential_pool", {}).get("nous") == []
with pytest.raises(AuthError, match="No access token found"):
auth_mod.resolve_nous_access_token()
assert refresh_calls == ["refresh-old"]
def test_mint_retry_uses_latest_rotated_refresh_token(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
_setup_nous_auth(hermes_home, refresh_token="refresh-old")
@@ -555,7 +1113,7 @@ class TestLoginNousSkipKeepsCurrent:
auth_path = hermes_home / "auth.json"
auth_after = json.loads(auth_path.read_text())
# active_provider should NOT be set to "nous" after Skip
assert auth_after.get("active_provider") in (None, "")
assert auth_after.get("active_provider") in {None, ""}
# But Nous creds are still saved
assert "nous" in auth_after.get("providers", {})
@@ -640,7 +1198,11 @@ def test_persist_nous_credentials_allows_recovery_from_401(tmp_path, monkeypatch
calls after a Nous 401 before the fix it would raise AuthError because
providers.nous was empty.
"""
from hermes_cli.auth import persist_nous_credentials, resolve_nous_runtime_credentials
from hermes_cli.auth import (
NOUS_INFERENCE_AUTH_MODE_FRESH,
persist_nous_credentials,
resolve_nous_runtime_credentials,
)
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
@@ -668,7 +1230,10 @@ def test_persist_nous_credentials_allows_recovery_from_401(tmp_path, monkeypatch
monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token)
monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key)
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300, force_mint=True)
creds = resolve_nous_runtime_credentials(
min_key_ttl_seconds=300,
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH,
)
assert creds["api_key"] == "new-agent-key"
@@ -861,6 +1426,36 @@ def test_refresh_token_reuse_detection_surfaces_actionable_message():
assert exc_info.value.relogin_required is True
def test_refresh_token_reuse_error_code_is_terminal():
"""Nous may return refresh_token_reused as the OAuth error code itself."""
from hermes_cli import auth as auth_mod
class _FakeResponse:
status_code = 400
def json(self):
return {
"error": "refresh_token_reused",
"error_description": "Refresh token reuse detected",
}
class _FakeClient:
def post(self, *args, **kwargs):
return _FakeResponse()
with pytest.raises(AuthError) as exc_info:
auth_mod._refresh_access_token(
client=_FakeClient(),
portal_base_url="https://portal.nousresearch.com",
client_id="hermes-cli",
refresh_token="rt_consumed_elsewhere",
)
assert exc_info.value.code == "refresh_token_reused"
assert exc_info.value.relogin_required is True
assert auth_mod._is_terminal_nous_refresh_error(exc_info.value) is True
def test_refresh_token_exchange_sends_refresh_token_header():
"""Nous refresh tokens must be sent in a header so sandbox proxies can
substitute placeholder credentials without parsing form bodies.
@@ -1118,6 +1713,47 @@ def test_try_import_shared_returns_none_on_refresh_failure(
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _boom)
assert auth_mod._try_import_shared_nous_state() is None
assert auth_mod._read_shared_nous_state() is None
def test_try_import_shared_persists_rotated_token_when_mint_fails(
shared_store_env, monkeypatch,
):
"""A forced shared import refresh rotates the single-use token before minting.
If the later agent-key mint fails, the shared store must still keep the
rotated refresh token; otherwise the next import attempt replays the
consumed token and trips refresh-token reuse.
"""
from hermes_cli import auth as auth_mod
shared_state = _full_state_fixture()
shared_state["refresh_token"] = "refresh-old"
shared_state["access_token"] = "access-old"
auth_mod._write_shared_nous_state(shared_state)
def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token):
assert refresh_token == "refresh-old"
return {
"access_token": "access-new",
"refresh_token": "refresh-new",
"expires_in": 900,
"token_type": "Bearer",
}
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
assert access_token == "access-new"
raise AuthError("credits exhausted", provider="nous", code="insufficient_credits")
monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token)
monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key)
assert auth_mod._try_import_shared_nous_state() is None
shared_after = auth_mod._read_shared_nous_state()
assert shared_after is not None
assert shared_after["refresh_token"] == "refresh-new"
assert shared_after["access_token"] == "access-new"
def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch):
@@ -1132,7 +1768,10 @@ def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch):
def _fake_refresh(state, **kwargs):
# Simulate portal returning fresh tokens + a new agent_key
assert kwargs.get("force_refresh") is True
assert kwargs.get("force_mint") is True
assert (
kwargs.get("inference_auth_mode")
== auth_mod.NOUS_INFERENCE_AUTH_MODE_FRESH
)
return {
**state,
"access_token": "fresh-access-tok",
@@ -1260,7 +1899,7 @@ def test_runtime_refresh_uses_newer_shared_token_before_local_stale_token(
creds = auth_mod.resolve_nous_runtime_credentials(
min_key_ttl_seconds=300,
force_mint=True,
inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_FRESH,
)
assert creds["api_key"] == "agent-key-from-shared-token"
+18
View File
@@ -157,6 +157,24 @@ class TestCmdUpdateBranchFallback:
(["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "apps" / "dashboard"),
]
# Regression for #18840: repo root + ui-tui installs must stream
# output (capture_output=False) so postinstall progress is visible
# to the user.
repo_and_tui_calls = [
call
for call in mock_run.call_args_list
if call.args
and call.args[0][0] == "/usr/bin/npm"
and call.args[0][1] == "ci"
and call.kwargs.get("cwd") in {PROJECT_ROOT, PROJECT_ROOT / "ui-tui"}
]
assert len(repo_and_tui_calls) == 2
for call in repo_and_tui_calls:
assert call.kwargs.get("capture_output") is False, (
"repo-root / ui-tui npm install must stream output "
"(no capture_output) so postinstall progress is visible"
)
def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys):
"""Dashboard/web updates apply non-interactive migrations before restart."""
with patch("shutil.which", return_value=None), patch(
@@ -105,7 +105,7 @@ class TestApply:
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, "")
assert cfg.get("model", {}).get("openai_runtime") in {None, ""}
def test_enable_succeeds_when_codex_present(self):
cfg = {}
+7
View File
@@ -107,6 +107,7 @@ class TestResolveCommand:
assert resolve_command("gateway").name == "platforms"
assert resolve_command("set-home").name == "sethome"
assert resolve_command("reload_mcp").name == "reload-mcp"
assert resolve_command("codex_runtime").name == "codex-runtime"
assert resolve_command("tasks").name == "agents"
def test_topic_is_gateway_command(self):
@@ -251,6 +252,12 @@ class TestTelegramBotCommands:
assert "queue" in names
assert "steer" in names
def test_hyphenated_codex_runtime_is_exposed_as_underscore_command(self):
"""Telegram autocomplete exposes /codex-runtime as /codex_runtime."""
names = {name for name, _ in telegram_bot_commands()}
assert "codex_runtime" in names
assert "codex-runtime" not in names
class TestSlackSubcommandMap:
def test_returns_dict(self):
+280 -1
View File
@@ -320,6 +320,7 @@ class TestDoctorMemoryProviderSection:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
@@ -426,6 +427,7 @@ def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, t
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
@@ -463,6 +465,7 @@ def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
@@ -474,6 +477,48 @@ def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
assert "model.provider 'custom' is not a recognised provider" not in out
def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: openrouter\n"
" default: openai/gpt-4.1-mini\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'openrouter' is set but no API key is configured" in out
assert "No credentials found for provider 'openrouter'." in out
@pytest.mark.parametrize(
("provider", "default_model"),
[
@@ -510,6 +555,7 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
@@ -556,6 +602,7 @@ def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
@@ -594,6 +641,7 @@ def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
@@ -633,6 +681,7 @@ def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch,
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
@@ -681,6 +730,7 @@ def test_run_doctor_dashscope_retries_china_endpoint_after_intl_unauthorized(mon
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except ImportError:
pass
@@ -739,6 +789,7 @@ def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except ImportError:
pass
@@ -850,6 +901,7 @@ def _run_doctor_with_healthy_oauth_fallback(
failing_host: str,
gemini_oauth_status: dict,
minimax_oauth_status: dict,
xai_oauth_status: dict | None = None,
) -> str:
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
@@ -886,6 +938,8 @@ def _run_doctor_with_healthy_oauth_fallback(
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: gemini_oauth_status)
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status)
_xai_status = xai_oauth_status if xai_oauth_status is not None else {}
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: _xai_status)
def fake_get(url, headers=None, timeout=None):
status = 401 if failing_host in url else 200
@@ -902,7 +956,7 @@ def _run_doctor_with_healthy_oauth_fallback(
@pytest.mark.parametrize(
("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "unexpected_issue"),
("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"),
[
(
"GOOGLE_API_KEY",
@@ -910,6 +964,7 @@ def _run_doctor_with_healthy_oauth_fallback(
"googleapis.com",
{"logged_in": True, "email": "user@example.com"},
{},
None,
"Check GOOGLE_API_KEY in .env",
),
(
@@ -918,8 +973,18 @@ def _run_doctor_with_healthy_oauth_fallback(
"minimax.io",
{},
{"logged_in": True, "region": "global"},
None,
"Check MINIMAX_API_KEY in .env",
),
(
"XAI_API_KEY",
"bad-xai-key",
"api.x.ai",
{},
{},
{"logged_in": True, "auth_mode": "oauth_pkce"},
"Check XAI_API_KEY in .env",
),
],
)
def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
@@ -930,6 +995,7 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
failing_host,
gemini_oauth_status,
minimax_oauth_status,
xai_oauth_status,
unexpected_issue,
):
out = _run_doctor_with_healthy_oauth_fallback(
@@ -940,7 +1006,220 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy(
failing_host=failing_host,
gemini_oauth_status=gemini_oauth_status,
minimax_oauth_status=minimax_oauth_status,
xai_oauth_status=xai_oauth_status,
)
assert "invalid API key" in out
assert unexpected_issue not in out
def test_has_healthy_oauth_fallback_returns_false_for_unknown_provider():
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
assert _has_healthy_oauth_fallback_for_apikey_provider("unknown-provider") is False
class TestHasHealthyOauthFallbackForXai:
def test_returns_true_when_xai_oauth_healthy(self, monkeypatch):
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": True})
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is True
def test_returns_false_when_xai_oauth_not_logged_in(self, monkeypatch):
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": False})
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False
def test_returns_false_when_xai_oauth_returns_none(self, monkeypatch):
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: None)
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False
def test_returns_false_when_xai_import_unavailable(self, monkeypatch):
import sys
# Simulate get_xai_oauth_auth_status missing from auth module
monkeypatch.delattr("hermes_cli.auth.get_xai_oauth_auth_status", raising=False)
# Force doctor module to re-import the function
monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False)
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False
def test_xai_import_failure_does_not_affect_gemini(self, monkeypatch):
import sys
from hermes_cli import auth as _auth_mod
# xAI function missing, but Gemini is healthy
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": True})
monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False)
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
assert _has_healthy_oauth_fallback_for_apikey_provider("gemini") is True
# ---------------------------------------------------------------------------
# ◆ Auth Providers — xAI OAuth display in run_doctor()
# ---------------------------------------------------------------------------
class TestDoctorXaiOAuthStatus:
"""The ◆ Auth Providers section must show xAI OAuth login state.
xAI OAuth is checked in a *separate* try/except block so that an import
failure (or runtime exception) cannot silence the Nous / Codex / Gemini /
MiniMax rows that were already printed above it.
"""
def _run(self, monkeypatch, tmp_path, *, xai_auth_fn) -> str:
"""Run doctor with a controlled xAI auth callable; return stdout."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
return buf.getvalue()
def test_logged_in_shows_ok(self, monkeypatch, tmp_path):
out = self._run(
monkeypatch, tmp_path,
xai_auth_fn=lambda: {"logged_in": True},
)
assert "xAI OAuth" in out
assert "(logged in)" in out
def test_not_logged_in_shows_warn(self, monkeypatch, tmp_path):
out = self._run(
monkeypatch, tmp_path,
xai_auth_fn=lambda: {"logged_in": False},
)
assert "xAI OAuth" in out
assert "(not logged in)" in out
def test_error_shown_when_not_logged_in_and_error_present(self, monkeypatch, tmp_path):
out = self._run(
monkeypatch, tmp_path,
xai_auth_fn=lambda: {"logged_in": False, "error": "refresh token expired"},
)
assert "xAI OAuth" in out
assert "refresh token expired" in out
def test_no_error_line_when_error_key_absent(self, monkeypatch, tmp_path):
out = self._run(
monkeypatch, tmp_path,
xai_auth_fn=lambda: {"logged_in": False},
)
assert "xAI OAuth" in out
# The check_info line is only emitted when the "error" key is present.
# Pick a token that would appear in no ordinary doctor output.
assert "refresh token expired" not in out
def test_logged_in_does_not_emit_not_logged_in_on_xai_line(self, monkeypatch, tmp_path):
out = self._run(
monkeypatch, tmp_path,
xai_auth_fn=lambda: {"logged_in": True},
)
assert "xAI OAuth" in out
# The xAI OAuth line itself must say "(logged in)", not "(not logged in)".
xai_line = next(l for l in out.splitlines() if "xAI OAuth" in l)
assert "(logged in)" in xai_line
assert "(not logged in)" not in xai_line
def test_import_failure_does_not_crash_doctor(self, monkeypatch, tmp_path):
"""Doctor must not crash when get_xai_oauth_auth_status cannot be imported."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
# The ◆ Auth Providers header must still appear — other providers unaffected.
assert "Auth Providers" in out
def test_import_failure_does_not_affect_other_providers(self, monkeypatch, tmp_path):
"""Nous / Codex / Gemini / MiniMax rows must survive an xAI import failure."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "Nous Portal auth" in out
assert "logged in" in out
def test_function_raises_does_not_crash_doctor(self, monkeypatch, tmp_path):
"""A runtime exception from get_xai_oauth_auth_status must be swallowed."""
def _raise():
raise RuntimeError("simulated xAI status failure")
out = self._run(monkeypatch, tmp_path, xai_auth_fn=_raise)
assert "Auth Providers" in out
def test_function_returns_none_does_not_crash_doctor(self, monkeypatch, tmp_path):
"""None return is normalised to {} via `or {}` — must not AttributeError."""
out = self._run(monkeypatch, tmp_path, xai_auth_fn=lambda: None)
# None → {} → logged_in falsy → shows not-logged-in warn
assert "xAI OAuth" in out
assert "(not logged in)" in out
+2 -2
View File
@@ -48,7 +48,7 @@ class TestInstallCuaDriverUpgrade:
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n in ("cua-driver", "curl") else None), \
if n in {"cua-driver", "curl"} else None), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner, \
patch("subprocess.run"):
@@ -82,7 +82,7 @@ class TestInstallCuaDriverUpgrade:
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n in ("cua-driver", "curl") else None), \
if n in {"cua-driver", "curl"} else None), \
patch.object(tools_config, "_run_cua_driver_installer") as runner, \
patch("subprocess.run"):
assert tools_config.install_cua_driver(upgrade=False) is True
@@ -1046,7 +1046,7 @@ def test_enforce_max_runtime_integrates_with_dispatch(kanban_home, monkeypatch):
task = kb.get_task(conn, tid)
# After timeout, task is back in 'ready' and will be re-spawned
# by the same pass. That's the intended behaviour.
assert task.status in ("ready", "running")
assert task.status in {"ready", "running"}
finally:
conn.close()
+242
View File
@@ -0,0 +1,242 @@
"""Tests for the decomposer module + `hermes kanban decompose` CLI surface.
The auxiliary LLM client is mocked no network calls. Tests exercise the
prompt plumbing, response parsing, DB writes (via the real DB helper),
and the assignee-fallback logic.
"""
from __future__ import annotations
import argparse
import json as jsonlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import kanban as kanban_cli
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_decompose as decomp
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
def _fake_aux_response(content: str):
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = content
return resp
def _mock_client_returning(content: str):
client = MagicMock()
client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content))
return client
def _patch_aux_client(content: str, *, model: str = "test-model"):
client = _mock_client_returning(content)
return patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, model),
)
def _patch_extra_body():
return patch(
"agent.auxiliary_client.get_auxiliary_extra_body",
return_value={},
)
def _patch_list_profiles(names: list[str]):
"""Pretend the named profiles exist. The decomposer uses
profiles_mod.list_profiles() to build the roster + valid-set, and
profiles_mod.profile_exists() to resolve orchestrator/default."""
from types import SimpleNamespace
fake_profiles = [
SimpleNamespace(
name=n, is_default=(i == 0), description=f"desc for {n}",
description_auto=False, model="m", provider="p", skill_count=1,
)
for i, n in enumerate(names)
]
return [
patch("hermes_cli.profiles.list_profiles", return_value=fake_profiles),
patch("hermes_cli.profiles.profile_exists", side_effect=lambda x: x in names),
patch("hermes_cli.profiles.get_active_profile_name", return_value=names[0] if names else "default"),
]
def test_decompose_with_fanout_creates_children(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="ship a feature", triage=True)
llm_payload = jsonlib.dumps({
"fanout": True,
"rationale": "test split",
"tasks": [
{"title": "research", "body": "look it up", "assignee": "researcher", "parents": []},
{"title": "build", "body": "code it", "assignee": "engineer", "parents": [0]},
],
})
patches = _patch_list_profiles(["orchestrator", "researcher", "engineer"])
for p in patches:
p.start()
try:
with _patch_aux_client(llm_payload), _patch_extra_body():
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
assert outcome.fanout is True
assert outcome.child_ids and len(outcome.child_ids) == 2
with kb.connect() as conn:
root = kb.get_task(conn, tid)
c0 = kb.get_task(conn, outcome.child_ids[0])
c1 = kb.get_task(conn, outcome.child_ids[1])
assert root.status == "todo"
assert c0.status == "ready"
assert c1.status == "todo"
assert c0.assignee == "researcher"
assert c1.assignee == "engineer"
def test_decompose_fanout_false_falls_back_to_specify(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="just one thing", triage=True)
llm_payload = jsonlib.dumps({
"fanout": False,
"rationale": "single unit",
"title": "Tightened title",
"body": "**Goal**\nDo the thing.",
})
patches = _patch_list_profiles(["orchestrator"])
for p in patches:
p.start()
try:
with _patch_aux_client(llm_payload), _patch_extra_body():
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
assert outcome.fanout is False
assert outcome.new_title == "Tightened title"
with kb.connect() as conn:
task = kb.get_task(conn, tid)
# specify path with no parents -> recompute_ready flips to 'ready'
assert task.status == "ready"
assert task.title == "Tightened title"
def test_decompose_unknown_assignee_falls_back_to_default(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", triage=True)
# Roster only has 'orchestrator' and 'fallback'; LLM picks 'made_up'.
llm_payload = jsonlib.dumps({
"fanout": True,
"rationale": "test",
"tasks": [
{"title": "do X", "body": "", "assignee": "made_up", "parents": []},
],
})
patches = _patch_list_profiles(["orchestrator", "fallback"])
for p in patches:
p.start()
try:
with patch.dict(
"os.environ", {}, clear=False,
), _patch_aux_client(llm_payload), _patch_extra_body(), \
patch(
"hermes_cli.kanban_decompose._load_config",
return_value={
"kanban": {
"orchestrator_profile": "orchestrator",
"default_assignee": "fallback",
}
},
):
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok, outcome.reason
assert outcome.child_ids and len(outcome.child_ids) == 1
with kb.connect() as conn:
child = kb.get_task(conn, outcome.child_ids[0])
# 'made_up' wasn't in roster, so assignee rewritten to 'fallback'
assert child.assignee == "fallback"
def test_decompose_handles_malformed_llm_json(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", triage=True)
patches = _patch_list_profiles(["orchestrator"])
for p in patches:
p.start()
try:
with _patch_aux_client("not json at all, sorry"), _patch_extra_body():
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok is False
assert "malformed JSON" in outcome.reason
def test_decompose_returns_false_when_task_not_triage(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x") # ready, not triage
patches = _patch_list_profiles(["orchestrator"])
for p in patches:
p.start()
try:
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok is False
assert "not in triage" in outcome.reason
def test_decompose_no_aux_client_configured(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", triage=True)
patches = _patch_list_profiles(["orchestrator"])
for p in patches:
p.start()
try:
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(None, ""),
):
outcome = decomp.decompose_task(tid, author="me")
finally:
for p in patches:
p.stop()
assert outcome.ok is False
assert "no auxiliary client" in outcome.reason
@@ -0,0 +1,152 @@
"""Tests for kb.decompose_triage_task — the DB-layer atomic fan-out
from the triage column. LLM-free by design.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from hermes_cli import kanban_db as kb
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
def _create_triage(conn, title="rough idea", body=None, assignee=None, tenant=None):
return kb.create_task(
conn,
title=title,
body=body,
assignee=assignee,
tenant=tenant,
triage=True,
)
def test_decompose_creates_children_and_promotes_root(kanban_home):
with kb.connect() as conn:
tid = _create_triage(conn, title="ship a feature")
assert kb.get_task(conn, tid).status == "triage"
children = [
{"title": "research", "body": "look at prior art", "assignee": "researcher", "parents": []},
{"title": "build it", "body": "write code", "assignee": "engineer", "parents": [0]},
]
with kb.connect() as conn:
child_ids = kb.decompose_triage_task(
conn,
tid,
root_assignee="orchestrator",
children=children,
author="decomposer",
)
assert child_ids is not None
assert len(child_ids) == 2
with kb.connect() as conn:
root = kb.get_task(conn, tid)
c0 = kb.get_task(conn, child_ids[0])
c1 = kb.get_task(conn, child_ids[1])
# Root flipped to todo with orchestrator assignee, gated by children.
assert root.status == "todo"
assert root.assignee == "orchestrator"
# First child has no internal parents → ready on recompute_ready.
assert c0.status == "ready"
assert c0.assignee == "researcher"
# Second child has parents=[0] → stays in todo until c0 completes.
assert c1.status == "todo"
assert c1.assignee == "engineer"
def test_decompose_returns_none_when_task_missing(kanban_home):
with kb.connect() as conn:
result = kb.decompose_triage_task(
conn,
"nonexistent",
root_assignee="orch",
children=[{"title": "x"}],
author="me",
)
assert result is None
def test_decompose_returns_none_when_task_not_in_triage(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="already a real task") # not triage
result = kb.decompose_triage_task(
conn,
tid,
root_assignee="orch",
children=[{"title": "x"}],
author="me",
)
assert result is None
def test_decompose_empty_children_returns_none(kanban_home):
with kb.connect() as conn:
tid = _create_triage(conn)
result = kb.decompose_triage_task(
conn,
tid,
root_assignee="orch",
children=[],
author="me",
)
assert result is None
def test_decompose_rejects_self_parent(kanban_home):
with kb.connect() as conn:
tid = _create_triage(conn)
with pytest.raises(ValueError, match="cannot list itself"):
kb.decompose_triage_task(
conn,
tid,
root_assignee="orch",
children=[{"title": "x", "parents": [0]}],
author="me",
)
def test_decompose_rejects_out_of_range_parent(kanban_home):
with kb.connect() as conn:
tid = _create_triage(conn)
with pytest.raises(ValueError, match="not a valid index"):
kb.decompose_triage_task(
conn,
tid,
root_assignee="orch",
children=[{"title": "x", "parents": [5]}],
author="me",
)
def test_decompose_records_audit_comment_and_event(kanban_home):
with kb.connect() as conn:
tid = _create_triage(conn)
child_ids = kb.decompose_triage_task(
conn,
tid,
root_assignee="orch",
children=[{"title": "task A", "assignee": "researcher"}],
author="alice",
)
assert child_ids is not None
with kb.connect() as conn:
comments = kb.list_comments(conn, tid)
events = kb.list_events(conn, tid)
assert any("Decomposed into" in (c.body or "") for c in comments)
assert any(ev.kind == "decomposed" for ev in events)
+2 -2
View File
@@ -43,9 +43,9 @@ def _run_memory_reset(target="all", yes=False, monkeypatch=None, confirm_input="
mem_dir = get_hermes_home() / "memories"
files_to_reset = []
if target in ("all", "memory"):
if target in {"all", "memory"}:
files_to_reset.append(("MEMORY.md", "agent notes"))
if target in ("all", "user"):
if target in {"all", "user"}:
files_to_reset.append(("USER.md", "user profile"))
existing = [(f, desc) for f, desc in files_to_reset if (mem_dir / f).exists()]
+2 -2
View File
@@ -252,7 +252,7 @@ class TestDetectProviderForModel:
result = detect_provider_for_model("deepseek-chat", "openai-codex")
assert result is not None
# Provider is deepseek (direct) or openrouter (fallback) depending on creds
assert result[0] in ("deepseek", "openrouter")
assert result[0] in {"deepseek", "openrouter"}
def test_current_provider_model_returns_none(self):
"""Models belonging to the current provider should not trigger a switch."""
@@ -302,7 +302,7 @@ class TestDetectProviderForModel:
with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS):
result = detect_provider_for_model("claude-opus-4-6", "openai-codex")
assert result is not None
assert result[0] not in ("nous",) # nous has claude models but shouldn't be suggested
assert result[0] not in {"nous",} # nous has claude models but shouldn't be suggested
class TestIsNousFreeTier:
@@ -44,7 +44,7 @@ def test_opencode_go_appears_when_api_key_set():
# opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when
# models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when
# the API is unavailable, e.g. in CI).
assert opencode_go["source"] in ("built-in", "hermes")
assert opencode_go["source"] in {"built-in", "hermes"}
def test_opencode_go_not_appears_when_no_creds():
+168
View File
@@ -0,0 +1,168 @@
"""Tests for the profile.yaml metadata layer (description + description_auto)
and the profile_describer LLM module.
"""
from __future__ import annotations
import json as jsonlib
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import profiles as profiles_mod
from hermes_cli import profile_describer as describer
@pytest.fixture
def profile_env(tmp_path, monkeypatch):
"""Set up an isolated HERMES_HOME with a default profile dir."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
return home
def test_read_profile_meta_empty_when_missing(profile_env):
meta = profiles_mod.read_profile_meta(profile_env)
assert meta == {"description": "", "description_auto": False}
def test_write_and_read_profile_meta(profile_env):
profiles_mod.write_profile_meta(
profile_env,
description="a useful researcher",
description_auto=False,
)
meta = profiles_mod.read_profile_meta(profile_env)
assert meta["description"] == "a useful researcher"
assert meta["description_auto"] is False
def test_write_profile_meta_preserves_other_fields(profile_env):
# First write sets description_auto=True; second write only updates
# description and leaves description_auto unchanged.
profiles_mod.write_profile_meta(
profile_env,
description="auto-gen",
description_auto=True,
)
profiles_mod.write_profile_meta(profile_env, description="edited by hand")
meta = profiles_mod.read_profile_meta(profile_env)
assert meta["description"] == "edited by hand"
assert meta["description_auto"] is True
def test_write_profile_meta_rejects_missing_dir(tmp_path):
bogus = tmp_path / "does_not_exist"
with pytest.raises(FileNotFoundError):
profiles_mod.write_profile_meta(bogus, description="x")
def test_read_profile_meta_tolerates_corrupt_yaml(profile_env):
(profile_env / "profile.yaml").write_text("not: valid: yaml: [unclosed")
meta = profiles_mod.read_profile_meta(profile_env)
assert meta == {"description": "", "description_auto": False}
# ---------------------------------------------------------------------------
# profile_describer module
# ---------------------------------------------------------------------------
def _fake_aux_response(content: str):
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = content
return resp
def _patch_aux_client(content: str):
client = MagicMock()
client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content))
return patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, "test-model"),
)
def test_describer_writes_description_with_auto_true(profile_env, monkeypatch):
# Pretend "myprof" is a registered profile pointing at profile_env.
monkeypatch.setattr(
profiles_mod, "profile_exists", lambda n: n == "myprof",
)
monkeypatch.setattr(
profiles_mod, "normalize_profile_name", lambda n: n,
)
monkeypatch.setattr(
profiles_mod, "get_profile_dir", lambda n: profile_env,
)
payload = jsonlib.dumps({"description": "writes Python codebases"})
with _patch_aux_client(payload), patch(
"agent.auxiliary_client.get_auxiliary_extra_body", return_value={}
):
outcome = describer.describe_profile("myprof")
assert outcome.ok, outcome.reason
assert outcome.description == "writes Python codebases"
meta = profiles_mod.read_profile_meta(profile_env)
assert meta["description"] == "writes Python codebases"
assert meta["description_auto"] is True
def test_describer_refuses_to_overwrite_user_authored(profile_env, monkeypatch):
profiles_mod.write_profile_meta(
profile_env, description="curated", description_auto=False,
)
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof")
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env)
outcome = describer.describe_profile("myprof")
assert outcome.ok is False
assert "already has a user-authored description" in outcome.reason
# Description unchanged
assert profiles_mod.read_profile_meta(profile_env)["description"] == "curated"
def test_describer_overwrite_flag_replaces_user_authored(profile_env, monkeypatch):
profiles_mod.write_profile_meta(
profile_env, description="curated", description_auto=False,
)
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof")
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env)
payload = jsonlib.dumps({"description": "new auto-gen"})
with _patch_aux_client(payload), patch(
"agent.auxiliary_client.get_auxiliary_extra_body", return_value={}
):
outcome = describer.describe_profile("myprof", overwrite=True)
assert outcome.ok, outcome.reason
meta = profiles_mod.read_profile_meta(profile_env)
assert meta["description"] == "new auto-gen"
assert meta["description_auto"] is True
def test_describer_handles_malformed_llm_response(profile_env, monkeypatch):
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof")
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env)
# Non-JSON: describer falls back to taking the first paragraph as the description.
with _patch_aux_client("Plain text description that sneaks in"), patch(
"agent.auxiliary_client.get_auxiliary_extra_body", return_value={}
):
outcome = describer.describe_profile("myprof")
assert outcome.ok
assert "Plain text description" in (outcome.description or "")
def test_describer_returns_false_when_profile_missing(profile_env, monkeypatch):
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: False)
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
outcome = describer.describe_profile("ghost")
assert outcome.ok is False
assert "not found" in outcome.reason
+175 -23
View File
@@ -103,7 +103,7 @@ def test_nous_adapter_authenticated_with_refresh_token_only(tmp_path, monkeypatc
assert NousPortalAdapter().is_authenticated()
def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatch):
def test_nous_adapter_get_credential_uses_runtime_resolver(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_write_auth_store(tmp_path, {
"access_token": "access-tok",
@@ -114,31 +114,82 @@ def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatc
})
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",
"api_key": "minted-bearer",
"base_url": "https://inference-api.nousresearch.com/v1",
"expires_at": "2099-01-01T00:00:00Z",
}
with patch(
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
return_value=refreshed_state,
) as mock_refresh:
) as mock_resolve:
adapter = NousPortalAdapter()
cred = adapter.get_credential()
mock_refresh.assert_called_once()
mock_resolve.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_retry_credential_forces_legacy_mint(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_write_auth_store(tmp_path, {
"access_token": "jwt-access",
"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": "jwt-access",
})
refreshed_state = {
"api_key": "legacy-bearer",
"base_url": "https://inference-api.nousresearch.com/v1",
"expires_at": "2099-01-01T00:00:00Z",
}
with patch(
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
return_value=refreshed_state,
) as mock_resolve:
adapter = NousPortalAdapter()
cred = adapter.get_retry_credential(
failed_credential=UpstreamCredential(
bearer="header.jwt.signature",
base_url="https://inference-api.nousresearch.com/v1",
),
status_code=401,
)
assert cred is not None
assert cred.bearer == "legacy-bearer"
assert mock_resolve.call_args.kwargs["inference_auth_mode"] == "legacy"
def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_write_auth_store(tmp_path, {
"access_token": "jwt-access",
"refresh_token": "refresh-tok",
"agent_key": "opaque-bearer",
})
with patch(
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
) as mock_resolve:
adapter = NousPortalAdapter()
cred = adapter.get_retry_credential(
failed_credential=UpstreamCredential(
bearer="opaque-bearer",
base_url="https://inference-api.nousresearch.com/v1",
),
status_code=401,
)
assert cred is None
mock_resolve.assert_not_called()
def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch):
@@ -156,7 +207,7 @@ def test_nous_adapter_get_credential_raises_on_refresh_failure(tmp_path, monkeyp
})
with patch(
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
side_effect=RuntimeError("Refresh session has been revoked"),
):
adapter = NousPortalAdapter()
@@ -164,6 +215,40 @@ def test_nous_adapter_get_credential_raises_on_refresh_failure(tmp_path, monkeyp
adapter.get_credential()
def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch):
from hermes_cli.auth import AuthError
from agent.credential_pool import load_pool
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_write_auth_store(tmp_path, {
"access_token": "access-tok",
"refresh_token": "refresh-tok",
"agent_key": "stale-agent-key",
})
assert load_pool("nous").select() is not None
with patch(
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
side_effect=AuthError(
"Refresh session has been revoked",
provider="nous",
code="invalid_grant",
relogin_required=True,
),
):
adapter = NousPortalAdapter()
with pytest.raises(RuntimeError, match="Refresh session has been revoked"):
adapter.get_credential()
stored = json.loads((tmp_path / "auth.json").read_text())
nous_state = stored["providers"]["nous"]
assert not nous_state.get("refresh_token")
assert not nous_state.get("access_token")
assert not nous_state.get("agent_key")
assert nous_state["last_auth_error"]["code"] == "invalid_grant"
assert stored.get("credential_pool", {}).get("nous") == []
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))
@@ -173,7 +258,7 @@ def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path,
})
with patch(
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
return_value={"access_token": "a", "refresh_token": "r"},
):
adapter = NousPortalAdapter()
@@ -194,7 +279,7 @@ def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch):
counter = [0]
counter_lock = threading.Lock()
def serializing_refresh(state, **kwargs):
def serializing_refresh(**kwargs):
# If another thread is already inside refresh, the lock is broken.
if in_flight.is_set():
overlap_detected.set()
@@ -208,10 +293,9 @@ def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch):
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",
"api_key": f"key-{idx}",
"expires_at": "2099-01-01T00:00:00Z",
"base_url": "https://inference-api.nousresearch.com/v1",
}
finally:
in_flight.clear()
@@ -227,7 +311,7 @@ def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch):
errors.append(exc)
with patch(
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
side_effect=serializing_refresh,
):
threads = [threading.Thread(target=worker) for _ in range(3)]
@@ -260,12 +344,15 @@ 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):
allowed=None, raise_on_credential=False,
retry_bearer: str | None = None):
self._base_url = base_url
self._bearer = bearer
self._allowed = frozenset(allowed or ["/chat/completions"])
self._raise = raise_on_credential
self._retry_bearer = retry_bearer
self.calls = 0
self.retry_calls = 0
@property
def name(self): return "fake"
@@ -287,6 +374,17 @@ class FakeAdapter(UpstreamAdapter):
expires_at="2099-01-01T00:00:00Z",
)
def get_retry_credential(self, *, failed_credential, status_code):
_ = failed_credential
self.retry_calls += 1
if status_code != 401 or not self._retry_bearer:
return None
return UpstreamCredential(
bearer=self._retry_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)."""
@@ -327,6 +425,25 @@ def _build_fake_upstream(captured: Dict[str, Any]) -> "web.Application":
return app
def _build_retrying_fake_upstream(captured: Dict[str, Any]) -> "web.Application":
async def maybe_unauthorized(request):
body = await request.read()
auth = request.headers.get("Authorization")
captured["requests"].append({
"method": request.method,
"path": request.path,
"auth": auth,
"body": body.decode("utf-8") if body else "",
})
if auth == "Bearer jwt-bearer":
return web.json_response({"error": "bad token"}, status=401)
return web.json_response({"ok": True})
app = web.Application()
app.router.add_route("*", "/v1/chat/completions", maybe_unauthorized)
return app
def test_server_forwards_chat_completions():
async def run():
captured: Dict[str, Any] = {"requests": []}
@@ -357,6 +474,41 @@ def test_server_forwards_chat_completions():
asyncio.run(run())
def test_server_retries_once_with_adapter_retry_credential_on_401():
async def run():
captured: Dict[str, Any] = {"requests": []}
upstream_runner, upstream_base = await _start_runner(
_build_retrying_fake_upstream(captured)
)
adapter = FakeAdapter(
f"{upstream_base}/v1",
bearer="jwt-bearer",
retry_bearer="legacy-bearer",
)
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"},
) as resp:
assert resp.status == 200
data = await resp.json()
assert data["ok"] is True
assert adapter.retry_calls == 1
assert [req["auth"] for req in captured["requests"]] == [
"Bearer jwt-bearer",
"Bearer legacy-bearer",
]
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"])
+223
View File
@@ -29,6 +29,7 @@ def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys,
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False)
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
def _unexpected_systemctl(*args, **kwargs):
@@ -70,6 +71,7 @@ def test_show_status_reports_nous_auth_error(monkeypatch, capsys, tmp_path):
)
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
@@ -96,6 +98,7 @@ def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_pa
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
@@ -109,3 +112,223 @@ def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_pa
assert "oidc-token" not in output
assert "snapshot filesystem" in output
assert "live processes do not survive" in output
# ---------------------------------------------------------------------------
# Helpers shared by xAI OAuth status tests
# ---------------------------------------------------------------------------
def _base_xai_mocks(monkeypatch, tmp_path):
"""Set up the minimal environment for show_status, returning status_mod."""
from hermes_cli import status as status_mod
import hermes_cli.auth as auth_mod
import hermes_cli.gateway as gateway_mod
monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False)
monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False)
monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False)
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_minimax_oauth_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
return status_mod
class TestShowStatusXaiOAuth:
"""xAI OAuth row in hermes status."""
# ------------------------------------------------------------------
# Logged-in branch
# ------------------------------------------------------------------
def test_logged_in_shows_check_mark_and_label(self, monkeypatch, capsys, tmp_path):
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {"logged_in": True, "auth_store": "/a/auth.json"},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "xAI OAuth" in out
# The logged-in label must appear; the "not logged in" label must not
assert "" in out or "logged in" in out
assert "not logged in" not in out.split("xAI OAuth", 1)[1].split("\n")[0]
def test_logged_in_shows_auth_store(self, monkeypatch, capsys, tmp_path):
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {"logged_in": True, "auth_store": "/home/u/.hermes/auth.json"},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Auth file: /home/u/.hermes/auth.json" in out
def test_logged_in_shows_last_refresh(self, monkeypatch, capsys, tmp_path):
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {
"logged_in": True,
"auth_store": "/a/auth.json",
"last_refresh": "2026-05-17T10:00:00+00:00",
},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Refreshed:" in out
def test_logged_in_does_not_show_error_line(self, monkeypatch, capsys, tmp_path):
"""Error field must be suppressed when logged_in is True."""
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {
"logged_in": True,
"auth_store": "/a/auth.json",
"error": "stale-error-must-not-appear",
},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
xai_section = out.split("xAI OAuth", 1)[1]
assert "stale-error-must-not-appear" not in xai_section
def test_no_auth_store_line_when_field_absent(self, monkeypatch, capsys, tmp_path):
"""Auth file line must not appear when auth_store is missing."""
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {"logged_in": True},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
xai_section = out.split("xAI OAuth", 1)[1].split("", 1)[0]
assert "Auth file:" not in xai_section
def test_no_refreshed_line_when_last_refresh_absent(self, monkeypatch, capsys, tmp_path):
"""Refreshed line must not appear when last_refresh is not present."""
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {"logged_in": True, "auth_store": "/a/auth.json"},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
xai_section = out.split("xAI OAuth", 1)[1].split("", 1)[0]
assert "Refreshed:" not in xai_section
# ------------------------------------------------------------------
# Not-logged-in branch
# ------------------------------------------------------------------
def test_not_logged_in_shows_login_command(self, monkeypatch, capsys, tmp_path):
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {"logged_in": False, "error": "no credentials"},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "not logged in (run: hermes auth add xai-oauth)" in out
def test_not_logged_in_shows_error(self, monkeypatch, capsys, tmp_path):
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {"logged_in": False, "error": "Token has expired"},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Error: Token has expired" in out
def test_not_logged_in_omits_error_line_when_error_absent(self, monkeypatch, capsys, tmp_path):
"""No Error: line when not logged in but error key is missing."""
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: {"logged_in": False},
raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
xai_section = out.split("xAI OAuth", 1)[1].split("", 1)[0]
assert "Error:" not in xai_section
# ------------------------------------------------------------------
# Resilience: import failure and runtime exception
# ------------------------------------------------------------------
def test_import_failure_does_not_crash_show_status(self, monkeypatch, capsys, tmp_path):
"""show_status must complete even when get_xai_oauth_auth_status cannot be imported."""
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.delattr(auth_mod, "get_xai_oauth_auth_status", raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "◆ Auth Providers" in out
def test_import_failure_does_not_break_other_oauth_providers(self, monkeypatch, capsys, tmp_path):
"""Nous/Codex/MiniMax rows must still appear when xAI import fails."""
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_nous_auth_status",
lambda: {"logged_in": True}, raising=False)
monkeypatch.delattr(auth_mod, "get_xai_oauth_auth_status", raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Nous Portal" in out
assert "MiniMax OAuth" in out
def test_status_function_exception_does_not_crash(self, monkeypatch, capsys, tmp_path):
"""show_status must not propagate an exception raised by get_xai_oauth_auth_status."""
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
def _raises():
raise RuntimeError("backend unreachable")
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", _raises, raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "◆ Auth Providers" in out
def test_status_function_returns_none_does_not_crash(self, monkeypatch, capsys, tmp_path):
"""get_xai_oauth_auth_status returning None must be handled gracefully."""
import hermes_cli.auth as auth_mod
status_mod = _base_xai_mocks(monkeypatch, tmp_path)
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status",
lambda: None, raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "xAI OAuth" in out
assert "not logged in (run: hermes auth add xai-oauth)" in out
+80
View File
@@ -125,6 +125,62 @@ def test_get_platform_tools_homeassistant_toolset_off_for_cron_when_hass_token_m
assert "homeassistant" not in cron_enabled
def test_get_platform_tools_x_search_auto_enabled_when_xai_oauth_present(monkeypatch):
"""x_search toolset auto-enables across platforms when xAI Grok OAuth
tokens are present, mirroring the HASS_TOKEN homeassistant rule.
The user already authenticated via SuperGrok OAuth; they shouldn't have
to also click through `hermes tools` X (Twitter) Search to flip the
toolset on. Tool's check_fn still gates schema registration if creds
later go missing.
"""
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setattr(
"hermes_cli.tools_config._xai_credentials_present", lambda: True
)
for plat in ("cli", "cron", "telegram"):
enabled = _get_platform_tools({}, plat)
assert "x_search" in enabled, f"x_search missing for {plat}"
def test_get_platform_tools_x_search_auto_enabled_when_xai_api_key_present(monkeypatch):
"""x_search toolset auto-enables when XAI_API_KEY is set, even without
OAuth tokens the API-key path is a supported credential source."""
monkeypatch.setenv("XAI_API_KEY", "fake-xai-key")
cli_enabled = _get_platform_tools({}, "cli")
assert "x_search" in cli_enabled
def test_get_platform_tools_x_search_off_when_no_xai_credentials(monkeypatch):
"""Without any xAI credentials, x_search stays off — preserves the
"don't ship the schema to users who can't use it" default."""
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setattr(
"hermes_cli.tools_config._xai_credentials_present", lambda: False
)
cli_enabled = _get_platform_tools({}, "cli")
assert "x_search" not in cli_enabled
def test_get_platform_tools_x_search_respects_explicit_config(monkeypatch):
"""Once the user has saved an explicit toolset list via `hermes tools`,
that list is authoritative x_search auto-enable does NOT fire even
when xAI creds exist. The saved list represents deliberate choices."""
monkeypatch.delenv("XAI_API_KEY", raising=False)
monkeypatch.setattr(
"hermes_cli.tools_config._xai_credentials_present", lambda: True
)
# User explicitly opted into spotify but not x_search via `hermes tools`.
config = {"platform_toolsets": {"cli": ["hermes-cli", "spotify"]}}
enabled = _get_platform_tools(config, "cli")
assert "x_search" not in enabled
assert "spotify" in enabled
def test_get_platform_tools_expands_composite_when_mixed_with_configurable():
"""``[hermes-cli, spotify]`` (composite + configurable) must keep the full
``hermes-cli`` toolset alongside the explicit Spotify opt-in. The
@@ -989,3 +1045,27 @@ def test_reconfigure_browser_provider_overwrites_stale_use_gateway():
provider = {"name": "Browserbase", "browser_provider": "browserbase", "env_vars": []}
_reconfigure_provider(provider, config)
assert config["browser"]["use_gateway"] is False
@pytest.mark.parametrize("provider_name,post_setup_key", [
("Camofox", "camofox"),
])
def test_reconfigure_provider_runs_post_setup_for_env_var_providers(
monkeypatch, provider_name, post_setup_key
):
"""_reconfigure_provider() must call _run_post_setup() for providers that have
both env_vars and post_setup parity with _configure_provider() line 2286."""
called = []
monkeypatch.setattr("hermes_cli.tools_config._run_post_setup", lambda key: called.append(key))
monkeypatch.setattr("hermes_cli.tools_config.get_env_value", lambda k: None)
monkeypatch.setattr("hermes_cli.tools_config._prompt", lambda *a, **kw: "")
monkeypatch.setattr("hermes_cli.tools_config.save_env_value", lambda k, v: None)
provider = next(
p
for p in TOOL_CATEGORIES["browser"]["providers"]
if p["name"] == provider_name
)
_reconfigure_provider(provider, {})
assert called == [post_setup_key]
@@ -237,7 +237,7 @@ class TestKillStaleDashboardPosix:
sent.append((pid, sig))
# Simulate stubborn process: probe (sig 0) always succeeds,
# SIGTERM does nothing, SIGKILL is where it "dies".
if sig in (_signal.SIGTERM, 0, _signal.SIGKILL):
if sig in {_signal.SIGTERM, 0, _signal.SIGKILL}:
return
# Any other signal — also fine.
+138 -1
View File
@@ -19,11 +19,12 @@ The fix:
These tests pin the corrected behavior.
"""
import asyncio
import time
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
import httpx
from fastapi.testclient import TestClient
from hermes_cli.web_server import _SESSION_TOKEN, app
@@ -32,6 +33,32 @@ client = TestClient(app)
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
def _fake_nous_device_data():
return {
"device_code": "device-code",
"user_code": "NOUS-1234",
"verification_uri": "https://portal.nousresearch.com/device",
"verification_uri_complete": (
"https://portal.nousresearch.com/device?user_code=NOUS-1234"
),
"expires_in": 600,
"interval": 5,
}
def _invoke_scope_refusal():
request = httpx.Request("POST", "https://portal.nousresearch.com/oauth/device/code")
response = httpx.Response(
400,
json={
"error": "invalid_scope",
"error_description": "unsupported scope inference:invoke",
},
request=request,
)
return httpx.HTTPStatusError("invalid scope", request=request, response=response)
def test_minimax_login_does_not_launch_anthropic_flow():
"""Click 'Login' on MiniMax → MUST NOT return claude.ai auth_url."""
fake_user_code_resp = {
@@ -48,6 +75,9 @@ def test_minimax_login_does_not_launch_anthropic_flow():
), patch(
"hermes_cli.auth._minimax_pkce_pair",
return_value=("verifier-stub", "challenge-stub", "stub-state"),
), patch(
"hermes_cli.web_server._minimax_poller",
return_value=None,
):
resp = client.post(
"/api/providers/oauth/minimax-oauth/start",
@@ -69,6 +99,113 @@ def test_minimax_login_does_not_launch_anthropic_flow():
assert body["expires_in"] == 600
def test_nous_dashboard_device_flow_honors_legacy_scope_override(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
requested_scopes = []
def fake_request_device_code(**kwargs):
requested_scopes.append(kwargs["scope"])
return _fake_nous_device_data()
monkeypatch.setenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, "true")
monkeypatch.setattr(auth_mod, "_request_device_code", fake_request_device_code)
monkeypatch.setattr(ws, "_nous_poller", lambda sid: None)
result = asyncio.run(ws._start_device_code_flow("nous"))
try:
assert requested_scopes == [auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE]
assert result["flow"] == "device_code"
assert result["user_code"] == "NOUS-1234"
assert (
ws._oauth_sessions[result["session_id"]]["scope"]
== auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE
)
finally:
ws._oauth_sessions.pop(result["session_id"], None)
def test_nous_dashboard_device_flow_retries_legacy_scope_on_invoke_refusal(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
requested_scopes = []
def fake_request_device_code(**kwargs):
requested_scopes.append(kwargs["scope"])
if len(requested_scopes) == 1:
raise _invoke_scope_refusal()
return _fake_nous_device_data()
monkeypatch.delenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, raising=False)
monkeypatch.setattr(auth_mod, "_request_device_code", fake_request_device_code)
monkeypatch.setattr(ws, "_nous_poller", lambda sid: None)
result = asyncio.run(ws._start_device_code_flow("nous"))
try:
assert requested_scopes == [
auth_mod.DEFAULT_NOUS_SCOPE,
auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE,
]
assert (
ws._oauth_sessions[result["session_id"]]["scope"]
== auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE
)
finally:
ws._oauth_sessions.pop(result["session_id"], None)
def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
session_id = "nous-effective-scope-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "nous",
"flow": "device_code",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"portal_base_url": "https://portal.nousresearch.com",
"client_id": "hermes-cli",
"device_code": "device-code",
"interval": 5,
"expires_at": time.time() + 600,
"scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE,
}
captured_state = {}
def fake_refresh_nous_oauth_from_state(state, **kwargs):
captured_state.update(state)
return {**state, "agent_key": "legacy-agent-key"}
monkeypatch.setattr(
auth_mod,
"_poll_for_token",
lambda **kwargs: {
"access_token": "access-token",
"refresh_token": "refresh-token",
"expires_in": 3600,
"token_type": "Bearer",
},
)
monkeypatch.setattr(
auth_mod,
"refresh_nous_oauth_from_state",
fake_refresh_nous_oauth_from_state,
)
monkeypatch.setattr(auth_mod, "persist_nous_credentials", lambda state: None)
try:
ws._nous_poller(session_id)
assert captured_state["scope"] == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE
assert ws._oauth_sessions[session_id]["status"] == "approved"
finally:
ws._oauth_sessions.pop(session_id, None)
def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in():
"""Dashboard MiniMax completion must accept unix-ms token expiry values."""
from hermes_cli import web_server as ws
+5 -5
View File
@@ -449,7 +449,7 @@ class TestWebServerEndpoints:
resp = self.client.get("/api/auth/session-token")
# The endpoint is gone — the catch-all SPA route serves index.html
# or the middleware returns 401 for unauthenticated /api/ paths.
assert resp.status_code in (200, 404)
assert resp.status_code in {200, 404}
# Either way, it must NOT return the token as JSON
try:
data = resp.json()
@@ -476,7 +476,7 @@ class TestWebServerEndpoints:
# %2e%2e = ..
resp = self.client.get("/%2e%2e/%2e%2e/etc/passwd")
# Should return 200 with index.html (SPA fallback), not the actual file
assert resp.status_code in (200, 404)
assert resp.status_code in {200, 404}
if resp.status_code == 200:
# Should be the SPA fallback, not the system file
assert "root:" not in resp.text
@@ -484,7 +484,7 @@ class TestWebServerEndpoints:
def test_path_traversal_dotdot_blocked(self):
"""Direct .. path traversal via encoded sequences."""
resp = self.client.get("/%2e%2e/hermes_cli/web_server.py")
assert resp.status_code in (200, 404)
assert resp.status_code in {200, 404}
if resp.status_code == 200:
assert "FastAPI" not in resp.text # Should not serve the actual source
@@ -678,7 +678,7 @@ class TestConfigRoundTrip:
if val is None:
continue # not set in user config — fine
expected = entry["type"]
if expected in ("string", "select") and not isinstance(val, str):
if expected in {"string", "select"} and not isinstance(val, str):
mismatches.append(f"{key}: expected str, got {type(val).__name__}")
elif expected == "number" and not isinstance(val, (int, float)):
mismatches.append(f"{key}: expected number, got {type(val).__name__}")
@@ -1175,7 +1175,7 @@ class TestNewEndpoints:
"""GET /api/auth/session-token no longer exists."""
resp = self.client.get("/api/auth/session-token")
# Should not return a JSON token object
assert resp.status_code in (200, 404)
assert resp.status_code in {200, 404}
try:
data = resp.json()
assert "token" not in data
@@ -0,0 +1,359 @@
"""Regression coverage for xAI OAuth PKCE token exchange (issue #26990).
Issue [#26990] reported that ``hermes auth add xai-oauth`` succeeds at the
browser-side authorize step but fails at the token endpoint with
``code_challenge is required`` the symptom of an OAuth server that
re-validates PKCE at the token step instead of relying purely on
state captured during the authorize redirect.
The fix in ``hermes_cli/auth.py`` extracts the token POST into
:func:`_xai_oauth_exchange_code_for_tokens` and:
* Sends ``code_verifier`` (RFC 7636 §4.5 requirement).
* **Also** echoes ``code_challenge`` and ``code_challenge_method``
in the request body as defense-in-depth strictly compliant
servers ignore extras at the token endpoint, but xAI's server
needs them.
* Refuses to fire the POST locally when ``code_verifier`` is empty
(avoids leaking the auth code to a server that can't redeem it).
* Surfaces the HTTP status code prominently in the error message so
users / maintainers can tell a 400 (bad request) from a 403
(entitlement denied) at a glance.
These tests pin all three behaviors so the fix can't silently regress.
"""
from __future__ import annotations
from typing import Any, Dict, List
from urllib.parse import parse_qs
import httpx
import pytest
from hermes_cli.auth import (
AuthError,
XAI_OAUTH_CLIENT_ID,
_xai_oauth_exchange_code_for_tokens,
)
# ---------------------------------------------------------------------------
# httpx.post recorder
# ---------------------------------------------------------------------------
class _PostRecorder:
"""Capture every ``httpx.post`` call without touching the network."""
def __init__(self, response: httpx.Response) -> None:
self.response = response
self.calls: List[Dict[str, Any]] = []
def __call__(self, url, *, headers=None, data=None, timeout=None, **kw):
self.calls.append(
{"url": url, "headers": headers or {}, "data": data or {},
"timeout": timeout, "extra": kw}
)
return self.response
def _ok_response(payload: dict) -> httpx.Response:
return httpx.Response(200, json=payload)
def _err_response(status: int, body: str) -> httpx.Response:
return httpx.Response(status, text=body)
@pytest.fixture
def post_recorder(monkeypatch):
"""Default: 200 response with a full xAI token payload."""
recorder = _PostRecorder(
_ok_response(
{
"access_token": "AT-fresh",
"refresh_token": "RT-fresh",
"id_token": "ID",
"expires_in": 3600,
"token_type": "Bearer",
}
)
)
monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder)
return recorder
# ---------------------------------------------------------------------------
# Core contract: which fields go on the wire?
# ---------------------------------------------------------------------------
def test_token_exchange_includes_code_verifier(post_recorder):
"""RFC 7636 §4.5 — ``code_verifier`` MUST be sent."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="theVerifier_43_to_128_chars_____________________",
code_challenge="aBcDeF",
)
sent = post_recorder.calls[-1]["data"]
assert sent["code_verifier"] == "theVerifier_43_to_128_chars_____________________"
def test_token_exchange_also_echoes_code_challenge_for_xai(post_recorder):
"""Defense-in-depth for #26990 — xAI re-validates the challenge
at the token endpoint, not just at authorize. Without this echo
we get ``code_challenge is required`` even though we send a valid
``code_verifier``."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="aBcDeF",
)
sent = post_recorder.calls[-1]["data"]
assert sent["code_challenge"] == "aBcDeF"
assert sent["code_challenge_method"] == "S256"
def test_token_exchange_uses_correct_grant_and_client(post_recorder):
"""Lock the static fields too — a future refactor must not flip
these to ``client_credentials`` or drop ``client_id``."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
sent = post_recorder.calls[-1]["data"]
assert sent["grant_type"] == "authorization_code"
assert sent["code"] == "AUTHCODE"
assert sent["redirect_uri"] == "http://127.0.0.1:56121/callback"
assert sent["client_id"] == XAI_OAUTH_CLIENT_ID
def test_token_exchange_uses_form_urlencoded_content_type(post_recorder):
"""xAI's token endpoint expects ``application/x-www-form-urlencoded``."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
headers = post_recorder.calls[-1]["headers"]
assert headers["Content-Type"] == "application/x-www-form-urlencoded"
assert headers["Accept"] == "application/json"
def test_token_exchange_targets_the_supplied_endpoint(post_recorder):
"""Some test fixtures sniff the discovered token endpoint dynamically.
We must POST to the URL the caller passed, not a hard-coded constant."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/some/other/token/path",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
assert post_recorder.calls[-1]["url"] == "https://auth.x.ai/some/other/token/path"
def test_token_exchange_passes_timeout_through(post_recorder):
"""Operators on slow networks pass a higher ``timeout_seconds``;
the helper must forward it (and bump the floor to 20s)."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
timeout_seconds=45.0,
)
assert post_recorder.calls[-1]["timeout"] == 45.0
def test_token_exchange_floor_timeout_is_20s(post_recorder):
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
timeout_seconds=2.0,
)
assert post_recorder.calls[-1]["timeout"] == 20.0
# ---------------------------------------------------------------------------
# Sanity guard: refuse to POST with an empty code_verifier
# ---------------------------------------------------------------------------
def test_empty_code_verifier_raises_without_posting(post_recorder):
"""If ``code_verifier`` is somehow lost upstream, we must refuse to
send the request leaking an authorization code to xAI without a
verifier is worse than failing locally with an actionable error."""
with pytest.raises(AuthError) as exc_info:
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="",
code_challenge="c" * 43,
)
assert exc_info.value.code == "xai_pkce_verifier_missing"
assert "26990" in str(exc_info.value)
# And critically: nothing was sent.
assert post_recorder.calls == []
def test_missing_code_challenge_omits_echo_but_still_sends_verifier(post_recorder):
"""``code_challenge`` is defensive — if a caller doesn't have it
handy, we must still send the standards-compliant request rather
than refusing. This keeps RFC-compliant servers happy."""
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="",
)
sent = post_recorder.calls[-1]["data"]
assert sent["code_verifier"] == "v" * 64
assert "code_challenge" not in sent
assert "code_challenge_method" not in sent
# ---------------------------------------------------------------------------
# Error surfacing
# ---------------------------------------------------------------------------
def test_non_200_response_surfaces_status_and_body(monkeypatch):
"""When xAI returns a 4xx, the operator needs both the HTTP status
code (to tell 400 from 401 from 403 at a glance) and the response
body (the actual server-side reason)."""
recorder = _PostRecorder(
_err_response(400, '{"error":"invalid_grant","error_description":"code_challenge is required"}')
)
monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder)
with pytest.raises(AuthError) as exc_info:
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
msg = str(exc_info.value)
assert "HTTP 400" in msg, (
"Status code must be in the error so callers can disambiguate "
"tier-denied (403) from bad-request (400) without inspecting "
"exc.code."
)
assert "code_challenge is required" in msg
assert exc_info.value.code == "xai_token_exchange_failed"
def test_transport_error_wraps_as_auth_error(monkeypatch):
"""A connection failure must come back as ``AuthError`` so the
surrounding ``format_auth_error`` UI mapping fires correctly."""
def _boom(*args, **kwargs):
raise httpx.ConnectError("dns failure")
monkeypatch.setattr("hermes_cli.auth.httpx.post", _boom)
with pytest.raises(AuthError) as exc_info:
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
assert exc_info.value.code == "xai_token_exchange_failed"
assert "dns failure" in str(exc_info.value)
def test_non_dict_payload_raises_invalid_json(monkeypatch):
"""xAI returning ``[]`` or a string at 200 is a server bug — fail
with a precise error rather than crashing later in token storage."""
recorder = _PostRecorder(_ok_response([1, 2, 3])) # type: ignore[arg-type]
monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder)
with pytest.raises(AuthError) as exc_info:
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
assert exc_info.value.code == "xai_token_exchange_invalid"
def test_success_returns_full_payload_dict(post_recorder):
"""200 happy path: the parsed JSON dict comes back verbatim so the
caller can pluck ``access_token`` / ``refresh_token`` etc."""
out = _xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="v" * 64,
code_challenge="c" * 43,
)
assert out["access_token"] == "AT-fresh"
assert out["refresh_token"] == "RT-fresh"
# ---------------------------------------------------------------------------
# Wire-format guard: httpx must serialise ``data`` as form-urlencoded
# ---------------------------------------------------------------------------
def test_wire_format_is_form_urlencoded_with_all_pkce_fields(monkeypatch):
"""End-to-end check on the actual bytes httpx puts on the wire.
If anyone ever swaps ``data=`` for ``json=`` or refactors the dict,
xAI will start rejecting again this catches it locally."""
captured: Dict[str, Any] = {}
class _Transport(httpx.BaseTransport):
def handle_request(self, request):
captured["body"] = bytes(request.read())
captured["content_type"] = request.headers.get("content-type", "")
return httpx.Response(
200,
json={"access_token": "AT", "refresh_token": "RT",
"id_token": "", "expires_in": 60, "token_type": "Bearer"},
)
real_post = httpx.post
def _post(*args, **kwargs):
with httpx.Client(transport=_Transport()) as c:
return c.post(*args, **kwargs)
monkeypatch.setattr("hermes_cli.auth.httpx.post", _post)
_xai_oauth_exchange_code_for_tokens(
token_endpoint="https://auth.x.ai/oauth2/token",
code="AUTHCODE",
redirect_uri="http://127.0.0.1:56121/callback",
code_verifier="theVerifier_43+",
code_challenge="theChallenge_43+",
)
assert "application/x-www-form-urlencoded" in captured["content_type"]
parsed = parse_qs(captured["body"].decode())
assert parsed["grant_type"] == ["authorization_code"]
assert parsed["code"] == ["AUTHCODE"]
assert parsed["redirect_uri"] == ["http://127.0.0.1:56121/callback"]
assert parsed["client_id"] == [XAI_OAUTH_CLIENT_ID]
assert parsed["code_verifier"] == ["theVerifier_43+"]
assert parsed["code_challenge"] == ["theChallenge_43+"]
assert parsed["code_challenge_method"] == ["S256"]
+1 -1
View File
@@ -1570,7 +1570,7 @@ class TestDialecticLifecycleSmoke:
self._await_thread(provider)
assert mgr.dialectic_query.call_count == 2, "turn 4 cadence fire"
_, kwargs = mgr.dialectic_query.call_args
assert kwargs.get("reasoning_level") in ("medium", "high"), \
assert kwargs.get("reasoning_level") in {"medium", "high"}, \
f"long query must bump reasoning level above 'low'; got {kwargs.get('reasoning_level')}"
assert provider._last_dialectic_turn == 4, "cadence tracker advances on success"
View File
@@ -0,0 +1,273 @@
"""Behavior-parity check for the browser-provider plugin migration (#25214).
Spawns one subprocess per (version, scenario) cell pinned to either
origin/main (legacy in-tree providers + class-instantiation lookup) or
this PR's worktree (plugin-based registry) via `sys.path[0]`. Each
subprocess clears all browser-related env vars + writes a config.yaml,
loads `tools.browser_tool._get_cloud_provider()`, and emits a reduced
"shape tuple" {is_local, provider_name, is_available} as JSON.
The parent process diffs the shapes per scenario. A diff means the
migration introduced an observable behaviour change vs origin/main
which would be a real regression for users on the existing config keys.
Run from the PR worktree:
cd ~/.hermes/hermes-agent/.worktrees/browser-providers-plugin
python tests/plugins/browser/check_parity_vs_main.py
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
# Pin one path to current main, one to the PR worktree.
# ``REPO_ROOT`` is ``.../.worktrees/browser-providers-plugin``; the main
# checkout lives two levels up at ``~/.hermes/hermes-agent``.
MAIN_DIR = REPO_ROOT.parent.parent # ~/.hermes/hermes-agent
PR_DIR = REPO_ROOT # the worktree we're in
assert (MAIN_DIR / "tools" / "browser_tool.py").exists(), (
f"MAIN_DIR={MAIN_DIR} doesn't look like a hermes-agent checkout"
)
assert (PR_DIR / "tools" / "browser_tool.py").exists(), (
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
)
# Reduced shape comparison — exact instance addresses obviously differ
# between subprocesses, so we compare the parts that matter for users.
SUBPROCESS_SCRIPT = r"""
import json, os, sys, tempfile
sys.path.insert(0, sys.argv[1])
# Isolated HERMES_HOME for the config write.
home = tempfile.mkdtemp()
os.environ["HERMES_HOME"] = home
# Clear every browser-related env var so is_available() is deterministic.
for k in (
"BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "BROWSERBASE_BASE_URL",
"BROWSER_USE_API_KEY", "BROWSER_USE_GATEWAY_URL",
"FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_BROWSER_TTL",
"TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN",
):
os.environ.pop(k, None)
# Apply per-scenario env (passed as JSON via argv[2]).
scenario_env = json.loads(sys.argv[2])
os.environ.update(scenario_env)
# Apply per-scenario config (passed as YAML body via argv[3]).
config_yaml = sys.argv[3]
config_path = os.path.join(home, "config.yaml")
with open(config_path, "w") as f:
f.write(config_yaml)
# Fresh import — must not have any browser modules cached.
for name in list(sys.modules):
if name.startswith("tools.") or name.startswith("agent.") or name.startswith("plugins."):
sys.modules.pop(name, None)
from tools.browser_tool import _get_cloud_provider, _is_local_mode
provider = _get_cloud_provider()
# Pull the human-readable backend name via the API that exists on BOTH
# legacy (origin/main: CloudBrowserProvider.provider_name()) and the new
# ABC (BrowserProvider exposes provider_name() as a backward-compat alias
# returning display_name). Both shapes resolve to the same string —
# 'Browserbase' / 'Browser Use' / 'Firecrawl' — so we can compare safely.
provider_name = None
is_available = None
if provider is not None:
pn = getattr(provider, "provider_name", None)
if callable(pn):
provider_name = pn()
elif isinstance(pn, str):
provider_name = pn
is_conf = getattr(provider, "is_configured", None)
if callable(is_conf):
is_available = bool(is_conf())
shape = {
"is_local": _is_local_mode(),
"provider_name": provider_name,
"is_available": is_available,
}
print(json.dumps(shape))
"""
SCENARIOS: list[tuple[str, str, dict[str, str]]] = [
# (label, config.yaml body, extra env vars)
("no-config-no-env", "", {}),
("explicit-local-no-env", "browser:\n cloud_provider: local\n", {}),
(
"explicit-browserbase-no-creds",
"browser:\n cloud_provider: browserbase\n",
{},
),
(
"explicit-browserbase-with-creds",
"browser:\n cloud_provider: browserbase\n",
{"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"},
),
(
"explicit-browser-use-no-creds",
"browser:\n cloud_provider: browser-use\n",
{},
),
(
"explicit-browser-use-with-creds",
"browser:\n cloud_provider: browser-use\n",
{"BROWSER_USE_API_KEY": "k"},
),
(
"explicit-firecrawl-no-creds",
"browser:\n cloud_provider: firecrawl\n",
{},
),
(
"explicit-firecrawl-with-creds",
"browser:\n cloud_provider: firecrawl\n",
{"FIRECRAWL_API_KEY": "k"},
),
(
"no-config-bu-creds",
"",
{"BROWSER_USE_API_KEY": "k"},
),
(
"no-config-bb-creds",
"",
{"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"},
),
(
"no-config-both-creds",
"",
{
"BROWSER_USE_API_KEY": "k",
"BROWSERBASE_API_KEY": "x",
"BROWSERBASE_PROJECT_ID": "y",
},
),
(
"no-config-firecrawl-only",
"",
{"FIRECRAWL_API_KEY": "k"},
),
(
"no-config-firecrawl-and-bb",
"",
{
"FIRECRAWL_API_KEY": "k",
"BROWSERBASE_API_KEY": "x",
"BROWSERBASE_PROJECT_ID": "y",
},
),
]
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict:
"""Run one (version, scenario) cell. Returns the shape dict."""
venv_python = repo_path / ".venv" / "bin" / "python"
if not venv_python.exists():
# Worktrees share the main repo's venv.
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = Path("python3")
out = subprocess.run(
[
str(venv_python),
"-c",
SUBPROCESS_SCRIPT,
str(repo_path),
json.dumps(env),
config_yaml,
],
capture_output=True,
text=True,
timeout=30,
)
if out.returncode != 0:
return {
"error": "subprocess failed",
"stdout": out.stdout,
"stderr": out.stderr[-500:],
}
try:
return json.loads(out.stdout.strip().splitlines()[-1])
except Exception as exc:
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
def _reduce_for_comparison(shape: dict) -> dict:
"""Reduce a shape dict to the parts that matter for user-visible parity.
We compare ``(is_local, provider_name, is_available)`` the trio that
decides what the dispatcher does with each tool call. ``provider_name``
is the legacy ``provider_name()`` return value ('Browserbase' / 'Browser
Use' / 'Firecrawl'), which is identical between legacy and plugin
classes (the plugin's ``display_name`` matches the legacy
``provider_name()`` return).
"""
return {
"is_local": shape.get("is_local"),
"provider_name": shape.get("provider_name"),
"is_available": shape.get("is_available"),
}
def main() -> int:
print(f"main: {MAIN_DIR}")
print(f"pr: {PR_DIR}")
print()
failures: list[str] = []
errors: list[str] = []
for label, config_yaml, env in SCENARIOS:
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env)
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env)
if "error" in main_shape or "error" in pr_shape:
print(f" [ERR ] {label}: subprocess failed")
print(f" main: {main_shape}")
print(f" pr: {pr_shape}")
errors.append(label)
continue
main_reduced = _reduce_for_comparison(main_shape)
pr_reduced = _reduce_for_comparison(pr_shape)
if main_reduced == pr_reduced:
print(f" [OK] {label}: {main_reduced}")
else:
print(f" [FAIL] {label}")
print(f" main: {main_reduced}")
print(f" pr: {pr_reduced}")
failures.append(label)
print()
if errors:
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
for e in errors:
print(f" - {e}")
if failures:
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
for f in failures:
print(f" - {f}")
if failures or errors:
return 1
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,379 @@
"""Plugin-side tests for the browser provider migration (PR #25214).
Covers:
- All three bundled plugins (browserbase, browser-use, firecrawl)
instantiate and self-report the expected ABC defaults.
- Each plugin's ``is_available()`` correctly reflects env-var presence.
- The browser_registry resolves an active provider in the documented
scenarios:
* explicit config wins ignoring availability (so dispatcher surfaces
a typed credentials error)
* legacy preference walk: browser-use browserbase (filtered by
availability)
* firecrawl is NOT in the legacy walk explicit-only
* unknown name falls through to auto-detect
* ``local`` short-circuits to None
These tests use *real* imports from the plugin modules no mocking of
provider classes themselves so the test catches drift in the ABC
interface, the registry, and the plugin glue layer simultaneously.
Mirrors ``tests/plugins/web/test_web_search_provider_plugins.py`` from
PR #25182.
"""
from __future__ import annotations
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clear_browser_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Strip every browser-provider env var so is_available() returns False."""
for k in (
"BROWSERBASE_API_KEY",
"BROWSERBASE_PROJECT_ID",
"BROWSERBASE_BASE_URL",
"BROWSER_USE_API_KEY",
"BROWSER_USE_GATEWAY_URL",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_BROWSER_TTL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_USER_TOKEN",
):
monkeypatch.delenv(k, raising=False)
def _ensure_plugins_loaded() -> None:
"""Idempotently load plugins so the registry is populated."""
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
# ---------------------------------------------------------------------------
# Per-test isolation
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Each test starts with a clean browser-provider env."""
_clear_browser_env(monkeypatch)
# ---------------------------------------------------------------------------
# Bundled plugins register
# ---------------------------------------------------------------------------
class TestBundledPluginsRegister:
"""All three bundled browser plugins discover and register correctly."""
def test_all_three_plugins_present_in_registry(self) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import list_providers
names = sorted(p.name for p in list_providers())
assert names == ["browser-use", "browserbase", "firecrawl"]
@pytest.mark.parametrize(
"plugin_name,expected_display",
[
("browserbase", "Browserbase"),
("browser-use", "Browser Use"),
("firecrawl", "Firecrawl"),
],
)
def test_each_plugin_has_name_and_display_name(
self, plugin_name: str, expected_display: str
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None, f"plugin {plugin_name!r} not registered"
assert provider.name == plugin_name
assert provider.display_name == expected_display
@pytest.mark.parametrize(
"plugin_name",
["browserbase", "browser-use", "firecrawl"],
)
def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None:
"""``get_setup_schema()`` returns a dict the picker can consume."""
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None
schema = provider.get_setup_schema()
assert isinstance(schema, dict)
assert "name" in schema
assert "env_vars" in schema
# Every cloud-browser plugin needs the agent-browser post-setup hook
# so the picker auto-installs the CLI on selection.
assert schema.get("post_setup") == "agent_browser"
@pytest.mark.parametrize(
"plugin_name",
["browserbase", "browser-use", "firecrawl"],
)
def test_each_plugin_implements_full_lifecycle(self, plugin_name: str) -> None:
"""The ABC's three lifecycle methods are all overridden."""
_ensure_plugins_loaded()
from agent.browser_provider import BrowserProvider
from agent.browser_registry import get_provider
provider = get_provider(plugin_name)
assert provider is not None
# Each method must be a real override, not the ABC's NotImplementedError
# default — we check by comparing the function reference.
assert type(provider).create_session is not BrowserProvider.create_session
assert type(provider).close_session is not BrowserProvider.close_session
assert (
type(provider).emergency_cleanup is not BrowserProvider.emergency_cleanup
)
# ---------------------------------------------------------------------------
# is_available() behavior
# ---------------------------------------------------------------------------
class TestIsAvailable:
"""Each plugin's ``is_available()`` reflects env-var presence accurately."""
def test_browserbase_requires_both_api_key_and_project_id(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider("browserbase")
assert p is not None
assert p.is_available() is False
# API key alone is insufficient.
monkeypatch.setenv("BROWSERBASE_API_KEY", "key")
assert p.is_available() is False
# Both env vars set → available.
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj")
assert p.is_available() is True
def test_browserbase_project_id_alone_insufficient(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider("browserbase")
assert p is not None
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj")
assert p.is_available() is False
def test_browser_use_satisfied_by_api_key(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider("browser-use")
assert p is not None
assert p.is_available() is False
monkeypatch.setenv("BROWSER_USE_API_KEY", "key")
assert p.is_available() is True
def test_firecrawl_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider("firecrawl")
assert p is not None
assert p.is_available() is False
monkeypatch.setenv("FIRECRAWL_API_KEY", "key")
assert p.is_available() is True
# ---------------------------------------------------------------------------
# Registry resolution semantics
# ---------------------------------------------------------------------------
class TestRegistryResolution:
"""``_resolve()`` implements the documented three-rule precedence."""
def test_resolve_none_with_no_creds_returns_none(self) -> None:
"""No config, no env → local mode (None)."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
assert _resolve(None) is None
def test_explicit_local_returns_none(self) -> None:
"""``cloud_provider: local`` is a positive choice; short-circuits to None."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
assert _resolve("local") is None
def test_explicit_browserbase_returns_provider_even_when_unavailable(self) -> None:
"""Rule 1: explicit-config wins even when credentials are missing.
This is critical the dispatcher needs to surface a typed
credentials error rather than silently switching backends.
"""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
provider = _resolve("browserbase")
assert provider is not None
assert provider.name == "browserbase"
assert provider.is_available() is False # confirms "ignoring availability"
def test_explicit_firecrawl_returns_provider_even_when_unavailable(self) -> None:
"""Firecrawl behaves the same as browserbase under explicit config."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
provider = _resolve("firecrawl")
assert provider is not None
assert provider.name == "firecrawl"
def test_explicit_unknown_falls_back_to_auto_detect(self) -> None:
"""Rule 1 miss: unknown name → fall through to legacy walk."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
# With no credentials anywhere, auto-detect should also fail.
assert _resolve("not-a-real-provider") is None
def test_legacy_walk_prefers_browser_use_over_browserbase(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rule 3: walk order is browser-use → browserbase."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
# Both available — browser-use should win.
monkeypatch.setenv("BROWSER_USE_API_KEY", "k1")
monkeypatch.setenv("BROWSERBASE_API_KEY", "k2")
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p")
provider = _resolve(None)
assert provider is not None
assert provider.name == "browser-use"
def test_legacy_walk_falls_through_to_browserbase(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rule 3: browser-use unavailable → browserbase picked."""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
monkeypatch.setenv("BROWSERBASE_API_KEY", "k")
monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p")
provider = _resolve(None)
assert provider is not None
assert provider.name == "browserbase"
def test_firecrawl_not_in_legacy_walk_even_when_only_one_available(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression: firecrawl is NEVER auto-selected even when single-eligible.
Pre-PR-#25214, the dispatcher only auto-detected between Browser Use
and Browserbase; firecrawl was reachable solely via explicit
config. We preserve that gate because FIRECRAWL_API_KEY is shared
with the *web* firecrawl plugin auto-routing a web-extract user
to a paid cloud browser would be a real behaviour regression.
"""
_ensure_plugins_loaded()
from agent.browser_registry import _resolve
monkeypatch.setenv("FIRECRAWL_API_KEY", "k")
# Only firecrawl is_available() — but it's not in the legacy walk.
assert _resolve(None) is None
# ---------------------------------------------------------------------------
# Legacy ABC backward-compat aliases (is_configured / provider_name)
# ---------------------------------------------------------------------------
class TestLegacyAbcAliases:
"""is_configured() and provider_name() delegate to the new API."""
@pytest.mark.parametrize(
"plugin_name",
["browserbase", "browser-use", "firecrawl"],
)
def test_is_configured_delegates_to_is_available(self, plugin_name: str) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider(plugin_name)
assert p is not None
assert p.is_configured() is p.is_available()
@pytest.mark.parametrize(
"plugin_name,expected_label",
[
("browserbase", "Browserbase"),
("browser-use", "Browser Use"),
("firecrawl", "Firecrawl"),
],
)
def test_provider_name_returns_display_name(
self, plugin_name: str, expected_label: str
) -> None:
_ensure_plugins_loaded()
from agent.browser_registry import get_provider
p = get_provider(plugin_name)
assert p is not None
assert p.provider_name() == expected_label
# ---------------------------------------------------------------------------
# Picker integration
# ---------------------------------------------------------------------------
class TestPickerIntegration:
"""`_plugin_browser_providers()` exposes all three plugins as picker rows."""
def test_picker_rows_match_registered_plugins(self) -> None:
_ensure_plugins_loaded()
from hermes_cli.tools_config import _plugin_browser_providers
rows = _plugin_browser_providers()
names = sorted(r.get("browser_provider") for r in rows)
assert names == ["browser-use", "browserbase", "firecrawl"]
def test_picker_rows_carry_post_setup_hook(self) -> None:
"""Every browser plugin row has post_setup='agent_browser' so
selecting it triggers the agent-browser CLI install."""
_ensure_plugins_loaded()
from hermes_cli.tools_config import _plugin_browser_providers
for row in _plugin_browser_providers():
assert row.get("post_setup") == "agent_browser", (
f"plugin row {row['browser_provider']!r} missing post_setup hook"
)
def test_picker_rows_carry_browser_plugin_name_marker(self) -> None:
"""`browser_plugin_name` matches `browser_provider` so downstream
code can route through the registry when it wants to."""
_ensure_plugins_loaded()
from hermes_cli.tools_config import _plugin_browser_providers
for row in _plugin_browser_providers():
assert row.get("browser_plugin_name") == row.get("browser_provider")
+1 -1
View File
@@ -271,7 +271,7 @@ def test_evaluate_all_force_runs_synchronously(plugin_api):
# Synchronous — snapshot is fresh on return.
assert result["scan_meta"].get("sessions_total") == 25
assert result["scan_meta"]["mode"] in ("full", "incremental")
assert result["scan_meta"]["mode"] in {"full", "incremental"}
def test_start_background_scan_is_idempotent_while_running(plugin_api):
+1 -1
View File
@@ -110,4 +110,4 @@ def test_xai_no_operation_kwarg():
result = XAIVideoGenProvider().generate("x", operation="generate")
assert result["success"] is False
# auth_required, NOT some signature error
assert result["error_type"] in ("auth_required", "api_error")
assert result["error_type"] in {"auth_required", "api_error"}
@@ -106,9 +106,9 @@ class TestContinuationLogicBranching:
def test_all_three_api_modes_hit_continuation_branch(self, api_mode):
# The guard in run_agent.py is:
# if self.api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages"):
assert api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages")
assert api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}
def test_codex_responses_still_excluded(self):
# codex_responses has its own truncation path (not continuation-based)
# and should NOT be routed through the shared block.
assert "codex_responses" not in ("chat_completions", "bedrock_converse", "anthropic_messages")
assert "codex_responses" not in {"chat_completions", "bedrock_converse", "anthropic_messages"}
@@ -73,15 +73,20 @@ class TestAgentLoopSourceStillHasCarveOut:
revert that happens to leave the test file intact."""
def test_run_agent_excludes_jsondecodeerror_from_local_validation(self):
import run_agent
import inspect
src = inspect.getsource(run_agent)
from agent import conversation_loop
# The agent loop body lives in agent/conversation_loop.py after
# the run_agent.py refactor. Assert the carve-out is present in
# the extracted module specifically — if it ever moves back or
# disappears, this fails loudly rather than silently passing
# against a non-existent inline replica.
src = inspect.getsource(conversation_loop)
# The predicate we care about must reference json.JSONDecodeError
# in its exclusion tuple. We check for the specific co-occurrence
# rather than the literal string so harmless reformatting doesn't
# break us.
assert "is_local_validation_error" in src
assert "JSONDecodeError" in src, (
"run_agent.py must carve out json.JSONDecodeError from the "
"is_local_validation_error classification — see #14782."
"agent/conversation_loop.py must carve out json.JSONDecodeError "
"from the is_local_validation_error classification — see #14782."
)
@@ -120,10 +120,22 @@ def test_production_code_contains_hydration_block():
"""Smoke test: confirm the hydration code is actually wired into
run_conversation(). If someone deletes it, tests above still pass
against the inline replica this fails them awake.
After the run_agent.py refactor the agent-loop body lives in
``agent/conversation_loop.py`` and uses ``agent.X`` rather than
``self.X``. Assert the block is present in the extracted module
specifically if it ever drifts back into run_agent.py or
disappears entirely, this guard fails loudly.
"""
from pathlib import Path
src = Path(__file__).resolve().parents[2] / "run_agent.py"
content = src.read_text(encoding="utf-8")
repo = Path(__file__).resolve().parents[2]
cl_path = repo / "agent" / "conversation_loop.py"
src_cl = cl_path.read_text(encoding="utf-8")
# Anchor on the unique comment + the modulo line.
assert "Hydrate per-session nudge counters from persisted history" in content
assert "self._turns_since_memory = prior_user_turns % self._memory_nudge_interval" in content
assert "Hydrate per-session nudge counters from persisted history" in src_cl, (
f"Hydration comment missing from {cl_path}"
)
assert (
"agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval"
in src_cl
), f"Hydration modulo assignment missing from {cl_path}"
+18 -4
View File
@@ -254,8 +254,12 @@ class TestDeveloperRoleSwap:
assert messages[0]["role"] == "system"
def test_developer_role_via_nous_portal(self, monkeypatch):
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
agent.model = "gpt-5"
agent = _make_agent(
monkeypatch,
"nous",
base_url="https://inference-api.nousresearch.com/v1",
model="gpt-5",
)
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "hi"},
@@ -346,14 +350,24 @@ class TestBuildApiKwargsAIGateway:
class TestBuildApiKwargsNousPortal:
def test_includes_nous_product_tags(self, monkeypatch):
from agent.portal_tags import nous_portal_tags
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
agent = _make_agent(
monkeypatch,
"nous",
base_url="https://inference-api.nousresearch.com/v1",
model="gpt-5",
)
messages = [{"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
extra = kwargs.get("extra_body", {})
assert extra.get("tags") == nous_portal_tags()
def test_uses_chat_completions_format(self, monkeypatch):
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
agent = _make_agent(
monkeypatch,
"nous",
base_url="https://inference-api.nousresearch.com/v1",
model="gpt-5",
)
messages = [{"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
assert "messages" in kwargs
+45 -20
View File
@@ -2282,9 +2282,11 @@ class TestMcpParallelToolBatch:
def test_mcp_tools_parallel_when_server_opted_in(self):
"""MCP tools from a parallel-safe server can run concurrently."""
from run_agent import _should_parallelize_tool_batch
from tools.mcp_tool import _parallel_safe_servers, _lock
from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock
with _lock:
_parallel_safe_servers.add("github")
_mcp_tool_server_names["mcp_github_list_repos"] = "github"
_mcp_tool_server_names["mcp_github_search_code"] = "github"
try:
tc1 = _mock_tool_call(name="mcp_github_list_repos", arguments='{"org":"openai"}', call_id="c1")
tc2 = _mock_tool_call(name="mcp_github_search_code", arguments='{"q":"test"}', call_id="c2")
@@ -2292,13 +2294,16 @@ class TestMcpParallelToolBatch:
finally:
with _lock:
_parallel_safe_servers.discard("github")
_mcp_tool_server_names.pop("mcp_github_list_repos", None)
_mcp_tool_server_names.pop("mcp_github_search_code", None)
def test_mixed_mcp_and_builtin_parallel(self):
"""MCP parallel tools mixed with built-in parallel-safe tools."""
from run_agent import _should_parallelize_tool_batch
from tools.mcp_tool import _parallel_safe_servers, _lock
from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock
with _lock:
_parallel_safe_servers.add("docs")
_mcp_tool_server_names["mcp_docs_search"] = "docs"
try:
tc1 = _mock_tool_call(name="mcp_docs_search", arguments='{"query":"api"}', call_id="c1")
tc2 = _mock_tool_call(name="web_search", arguments='{"query":"test"}', call_id="c2")
@@ -2306,14 +2311,17 @@ class TestMcpParallelToolBatch:
finally:
with _lock:
_parallel_safe_servers.discard("docs")
_mcp_tool_server_names.pop("mcp_docs_search", None)
def test_mixed_parallel_and_serial_mcp_servers(self):
"""One parallel MCP server + one non-parallel MCP server = sequential."""
from run_agent import _should_parallelize_tool_batch
from tools.mcp_tool import _parallel_safe_servers, _lock
from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock
with _lock:
_parallel_safe_servers.add("docs")
# "github" is NOT in _parallel_safe_servers
_mcp_tool_server_names["mcp_docs_search"] = "docs"
_mcp_tool_server_names["mcp_github_list_repos"] = "github"
try:
tc1 = _mock_tool_call(name="mcp_docs_search", arguments='{"query":"api"}', call_id="c1")
tc2 = _mock_tool_call(name="mcp_github_list_repos", arguments='{"org":"openai"}', call_id="c2")
@@ -2321,6 +2329,8 @@ class TestMcpParallelToolBatch:
finally:
with _lock:
_parallel_safe_servers.discard("docs")
_mcp_tool_server_names.pop("mcp_docs_search", None)
_mcp_tool_server_names.pop("mcp_github_list_repos", None)
class TestHandleMaxIterations:
@@ -3657,7 +3667,7 @@ class TestNousCredentialRefresh:
assert ok is True
assert closed["value"] is True
assert captured["force_mint"] is True
assert captured["inference_auth_mode"] == "legacy"
assert rebuilt["kwargs"]["api_key"] == "new-nous-key"
assert (
rebuilt["kwargs"]["base_url"] == "https://inference-api.nousresearch.com/v1"
@@ -4832,23 +4842,26 @@ class TestAnthropicInterruptHandler:
def test_interruptible_has_anthropic_branch(self):
"""The interrupt handler must check api_mode == 'anthropic_messages'."""
import inspect
source = inspect.getsource(AIAgent._interruptible_api_call)
from agent.chat_completion_helpers import interruptible_api_call
source = inspect.getsource(interruptible_api_call)
assert "anthropic_messages" in source, \
"_interruptible_api_call must handle Anthropic interrupt (api_mode check)"
"interruptible_api_call must handle Anthropic interrupt (api_mode check)"
def test_interruptible_rebuilds_anthropic_client(self):
"""After interrupting, the Anthropic client should be rebuilt."""
import inspect
source = inspect.getsource(AIAgent._interruptible_api_call)
from agent.chat_completion_helpers import interruptible_api_call
source = inspect.getsource(interruptible_api_call)
assert "build_anthropic_client" in source, \
"_interruptible_api_call must rebuild Anthropic client after interrupt"
"interruptible_api_call must rebuild Anthropic client after interrupt"
def test_streaming_has_anthropic_branch(self):
"""_streaming_api_call must also handle Anthropic interrupt."""
import inspect
source = inspect.getsource(AIAgent._interruptible_streaming_api_call)
from agent.chat_completion_helpers import interruptible_streaming_api_call
source = inspect.getsource(interruptible_streaming_api_call)
assert "anthropic_messages" in source, \
"_streaming_api_call must handle Anthropic interrupt"
"interruptible_streaming_api_call must handle Anthropic interrupt"
# ---------------------------------------------------------------------------
@@ -5257,14 +5270,20 @@ class TestMemoryNudgeCounterPersistence:
def test_counters_not_reset_in_preamble(self):
"""The run_conversation preamble must not zero the nudge counters."""
import inspect
src = inspect.getsource(AIAgent.run_conversation)
from agent.conversation_loop import run_conversation as _rc
src = inspect.getsource(_rc)
# The preamble resets many fields (retry counts, budget, etc.)
# before the main loop. Find that reset block and verify our
# counters aren't in it. The reset block ends at iteration_budget.
preamble_end = src.index("self.iteration_budget = IterationBudget")
# The extracted body uses ``agent.X`` (not ``self.X``). Anchor
# exactly on ``agent.iteration_budget = IterationBudget`` so an
# unrelated identifier ending in ``iteration_budget`` (e.g.
# ``_iteration_budget`` or ``shared_iteration_budget``) can't
# match the boundary.
preamble_end = src.index("agent.iteration_budget = IterationBudget")
preamble = src[:preamble_end]
assert "self._turns_since_memory = 0" not in preamble
assert "self._iters_since_skill = 0" not in preamble
assert "agent._turns_since_memory = 0" not in preamble
assert "agent._iters_since_skill = 0" not in preamble
class TestDeadRetryCode:
@@ -5272,7 +5291,8 @@ class TestDeadRetryCode:
def test_no_unreachable_max_retries_after_backoff(self):
import inspect
source = inspect.getsource(AIAgent.run_conversation)
from agent.conversation_loop import run_conversation as _rc
source = inspect.getsource(_rc)
occurrences = source.count("if retry_count >= max_retries:")
assert occurrences == 2, (
f"Expected 2 occurrences of 'if retry_count >= max_retries:' "
@@ -5310,7 +5330,8 @@ class TestMemoryContextSanitization:
a literal <memory-context> tag we don't silently delete their text.
The streaming scrubber + plugin-side scrub cover real leak paths."""
import inspect
src = inspect.getsource(AIAgent.run_conversation)
from agent.conversation_loop import run_conversation as _rc
src = inspect.getsource(_rc)
assert "sanitize_context(user_message)" not in src
assert "sanitize_context(persist_user_message)" not in src
@@ -5346,7 +5367,8 @@ class TestMemoryProviderTurnStart:
def test_on_turn_start_called_before_prefetch(self):
"""Source-level check: on_turn_start appears before prefetch_all in run_conversation."""
import inspect
src = inspect.getsource(AIAgent.run_conversation)
from agent.conversation_loop import run_conversation as _rc
src = inspect.getsource(_rc)
# Find the actual method calls, not comments
idx_turn_start = src.index(".on_turn_start(")
idx_prefetch = src.index(".prefetch_all(")
@@ -5356,7 +5378,10 @@ class TestMemoryProviderTurnStart:
)
def test_on_turn_start_uses_user_turn_count(self):
"""Source-level check: on_turn_start receives self._user_turn_count."""
"""Source-level check: on_turn_start receives the user_turn_count."""
import inspect
src = inspect.getsource(AIAgent.run_conversation)
assert "on_turn_start(self._user_turn_count" in src
from agent.conversation_loop import run_conversation as _rc
src = inspect.getsource(_rc)
# The extracted body uses ``agent.X`` rather than ``self.X``;
# assert the extracted-form spelling directly.
assert "on_turn_start(agent._user_turn_count" in src
@@ -152,19 +152,28 @@ def test_run_agent_concurrent_executor_wraps_submit_with_copy_context():
import inspect
import run_agent
from agent import tool_executor as tool_executor_module
src_path = inspect.getsourcefile(run_agent)
assert src_path is not None
tree = ast.parse(open(src_path, encoding="utf-8").read())
# Source for both modules — the concurrent-executor body lives in
# ``agent/tool_executor.py`` after the run_agent.py refactor (PR
# following #16660). Search both so this guard keeps firing
# regardless of where the call site lives.
sources = []
for mod in (run_agent, tool_executor_module):
src_path = inspect.getsourcefile(mod)
assert src_path is not None
sources.append((src_path, open(src_path, encoding="utf-8").read()))
submit_calls_in_agent: list[ast.Call] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
# Match executor.submit(...) style calls.
if isinstance(func, ast.Attribute) and func.attr == "submit":
submit_calls_in_agent.append(node)
for _src_path, src_text in sources:
tree = ast.parse(src_text)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
# Match executor.submit(...) style calls.
if isinstance(func, ast.Attribute) and func.attr == "submit":
submit_calls_in_agent.append(node)
# Filter to the submit call inside the concurrent tool executor —
# identifiable by passing `_run_tool` as its target. Other submit()
+1 -1
View File
@@ -846,7 +846,7 @@ def test_skill_installs_cleanly_under_skills_guard():
# the script never writes to that file
#
# Accept "caution" or "safe" — just not "dangerous" from a *real* threat.
assert result.verdict in ("safe", "caution", "dangerous"), f"Unexpected verdict: {result.verdict}"
assert result.verdict in {"safe", "caution", "dangerous"}, f"Unexpected verdict: {result.verdict}"
KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"}
for f in result.findings:
assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}"
+4 -4
View File
@@ -902,7 +902,7 @@ def _(home, kb):
pass
# Empty body → accept (legitimate: just title says it all)
tid = kb.create_task(conn, title="empty body ok", body="", assignee="w")
assert kb.get_task(conn, tid).body in ("", None)
assert kb.get_task(conn, tid).body in {"", None}
# Empty summary on complete → accept
kb.claim_task(conn, tid)
kb.complete_task(conn, tid, summary="")
@@ -994,7 +994,7 @@ def _(home, kb):
# Empty title
r = client.post("/api/plugins/kanban/tasks", json={"title": ""})
assert r.status_code in (400, 422), f"empty title should 4xx, got {r.status_code}"
assert r.status_code in {400, 422}, f"empty title should 4xx, got {r.status_code}"
# Title only
r = client.post("/api/plugins/kanban/tasks", json={"title": "x"})
@@ -1019,7 +1019,7 @@ def _(home, kb):
r = client.post("/api/plugins/kanban/tasks", json={
"title": "fine", "nonexistent_field": "whatever",
})
assert r.status_code in (200, 422)
assert r.status_code in {200, 422}
# Priority as non-int
r = client.post("/api/plugins/kanban/tasks", json={"title": "prio", "priority": "high"})
@@ -1028,7 +1028,7 @@ def _(home, kb):
# PATCH with empty body (no changes requested)
r = client.patch(f"/api/plugins/kanban/tasks/{tid}", json={})
# Accept either success-no-op or 400
assert r.status_code in (200, 400)
assert r.status_code in {200, 400}
print(" dashboard REST handles weird inputs correctly")
# =============================================================================
+1 -1
View File
@@ -259,7 +259,7 @@ def test_kill_own_subtree_passes_through():
finally:
p.wait(timeout=2)
# SIGTERM = 15; subprocess returncode is -15 on POSIX.
assert p.returncode in (-signal.SIGTERM, 128 + int(signal.SIGTERM))
assert p.returncode in {-signal.SIGTERM, 128 + int(signal.SIGTERM)}
def test_subprocess_pkill_with_unrelated_pattern_passes_through():
+1 -1
View File
@@ -63,7 +63,7 @@ class TestHermesTimeNow:
assert result.tzinfo is not None
# Offset is -5h or -4h depending on DST
offset_hours = result.utcoffset().total_seconds() / 3600
assert offset_hours in (-5, -4)
assert offset_hours in {-5, -4}
def test_invalid_timezone_falls_back(self, caplog):
"""Invalid timezone logs warning and falls back to server-local."""
+1 -1
View File
@@ -3799,7 +3799,7 @@ def test_prompt_submit_preserves_empty_response_without_error(monkeypatch):
assert payload.get("status") == "complete"
# Text stays empty — we did NOT fabricate an "Error:" string
text = payload.get("text", "")
assert text in ("", None), f"expected empty text, got {text!r}"
assert text in {"", None}, f"expected empty text, got {text!r}"
# ── session.most_recent ──────────────────────────────────────────────
+6 -6
View File
@@ -68,10 +68,10 @@ class TestDiscoverHomebrewNodeDirs:
if p == "/opt/homebrew/opt":
return True
# node@20/bin and node@24/bin exist
if p in (
if p in {
"/opt/homebrew/opt/node@20/bin",
"/opt/homebrew/opt/node@24/bin",
):
}:
return True
return False
@@ -171,10 +171,10 @@ class TestFindAgentBrowser:
real_isdir = os.path.isdir
def selective_isdir(path):
if path in (
if path in {
"/data/data/com.termux/files/usr/bin",
"/data/data/com.termux/files/usr/sbin",
):
}:
return True
return real_isdir(path)
@@ -486,10 +486,10 @@ class TestRunBrowserCommandPathConstruction:
real_isdir = os.path.isdir
def selective_isdir(path):
if path in (
if path in {
"/data/data/com.termux/files/usr/bin",
"/data/data/com.termux/files/usr/sbin",
):
}:
return True
if path.startswith(str(tmp_path)):
return True
+1 -1
View File
@@ -125,7 +125,7 @@ class TestResolveChildPython(unittest.TestCase):
def test_project_with_no_venv_falls_back(self):
"""Project mode without VIRTUAL_ENV or CONDA_PREFIX → sys.executable."""
env = {k: v for k, v in os.environ.items()
if k not in ("VIRTUAL_ENV", "CONDA_PREFIX")}
if k not in {"VIRTUAL_ENV", "CONDA_PREFIX"}}
with patch.dict(os.environ, env, clear=True):
self.assertEqual(_resolve_child_python("project"), sys.executable)
+83
View File
@@ -1014,6 +1014,89 @@ class TestDelegationCredentialResolution(unittest.TestCase):
self.assertIsNone(creds["model"])
self.assertIsNone(creds["provider"])
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
def test_named_custom_provider_preserves_provider_name(self, mock_resolve):
"""Named custom provider (e.g. crof.ai) resolves to 'custom' at runtime level
but the subagent must retain the original provider identity so that
resolve_provider_client routes to the correct endpoint on retry/fallback.
Regression test for #26954.
"""
mock_resolve.return_value = {
"provider": "custom", # runtime marks it as "custom" type
"model": "deepseek-v4-pro-CEER",
"base_url": "https://api.crof.ai/v1",
"api_key": "crof-key-abc",
"api_mode": "chat_completions",
}
parent = _make_mock_parent(depth=0)
cfg = {"model": "deepseek-v4-pro-CEER", "provider": "crof.ai"}
creds = _resolve_delegation_credentials(cfg, parent)
# The key assertion: subagent must keep "crof.ai", NOT "custom"
self.assertEqual(creds["provider"], "crof.ai")
self.assertEqual(creds["model"], "deepseek-v4-pro-CEER")
self.assertEqual(creds["base_url"], "https://api.crof.ai/v1")
self.assertEqual(creds["api_key"], "crof-key-abc")
# Verify resolve_runtime_provider was called with the configured name
mock_resolve.assert_called_once_with(
requested="crof.ai", target_model="deepseek-v4-pro-CEER"
)
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
def test_standard_provider_not_overwritten_by_configured_name(self, mock_resolve):
"""Standard (non-custom) providers must still return runtime identity,
not the configured name, to preserve existing behaviour for openrouter,
nous, etc.
"""
mock_resolve.return_value = {
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "or-key-xyz",
"api_mode": "chat_completions",
}
parent = _make_mock_parent(depth=0)
cfg = {"model": "anthropic/claude-sonnet-4", "provider": "openrouter"}
creds = _resolve_delegation_credentials(cfg, parent)
# Standard provider returns its own name, not "custom"
self.assertEqual(creds["provider"], "openrouter")
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
def test_custom_provider_with_empty_configured_provider_falls_back_to_runtime(self, mock_resolve):
"""When configured_provider is empty/None, the early return kicks in and
we return provider=None regardless of what runtime resolved. The runtime
path is only reached when configured_provider is a non-empty string.
"""
mock_resolve.return_value = {
"provider": "custom",
"model": "some-model",
"base_url": "https://fallback.example.com/v1",
"api_key": "key-fallback",
"api_mode": "chat_completions",
}
parent = _make_mock_parent(depth=0)
cfg = {"model": "some-model", "provider": ""}
creds = _resolve_delegation_credentials(cfg, parent)
# Empty provider → early return with None (child inherits parent)
self.assertIsNone(creds["provider"])
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
def test_runtime_missing_provider_key_returns_none(self, mock_resolve):
"""When resolve_runtime_provider returns a dict without 'provider' key,
the result must be None regardless of configured_provider.
This protects against malformed runtime responses.
"""
mock_resolve.return_value = {
# deliberately missing "provider"
"model": "some-model",
"base_url": "https://example.com/v1",
"api_key": "key-123",
"api_mode": "chat_completions",
}
parent = _make_mock_parent(depth=0)
cfg = {"model": "some-model", "provider": "crof.ai"}
creds = _resolve_delegation_credentials(cfg, parent)
self.assertIsNone(creds["provider"])
class TestDelegationProviderIntegration(unittest.TestCase):
"""Integration tests: delegation config → _run_single_child → AIAgent construction."""
+1 -1
View File
@@ -633,7 +633,7 @@ class TestToolsetInclusion:
def test_discord_tools_not_in_other_toolsets(self):
from toolsets import TOOLSETS
for name, ts in TOOLSETS.items():
if name in ("hermes-discord", "hermes-gateway", "discord", "discord_admin"):
if name in {"hermes-discord", "hermes-gateway", "discord", "discord_admin"}:
continue
tools = ts.get("tools", [])
assert "discord" not in tools or name == "discord", (
@@ -121,6 +121,20 @@ def test_dockerfile_installs_tui_dependencies(dockerfile_text):
)
def test_dockerfile_preinstalls_gateway_messaging_dependencies(dockerfile_text):
sync_steps = [
step for step in _run_steps(dockerfile_text)
if "uv sync" in step and "--no-install-project" in step
]
assert sync_steps, "Dockerfile must install Python dependencies with uv sync"
assert any("--extra messaging" in step for step in sync_steps), (
"Published Docker images must preload the [messaging] extra so "
"Telegram/Discord gateway adapters do not depend on first-boot "
"lazy installation (#24698)."
)
def test_dockerfile_builds_tui_assets(dockerfile_text):
assert any(
"ui-tui" in step and "npm" in step and "run build" in step
+1 -1
View File
@@ -24,7 +24,7 @@ def _new_filter_matches(path: Path) -> bool:
Returns True when the path SHOULD be filtered out.
"""
return any(part in ('.git', '.github', '.hub') for part in path.parts)
return any(part in {'.git', '.github', '.hub'} for part in path.parts)
class TestOldFilterBrokenOnWindows:
@@ -10,7 +10,9 @@ from unittest.mock import patch
import pytest
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
REPO_ROOT = Path(__file__).resolve().parents[2]
TOOLS_DIR = REPO_ROOT / "tools"
PLUGINS_DIR = REPO_ROOT / "plugins"
def _load_tool_module(module_name: str, filename: str):
@@ -22,6 +24,21 @@ def _load_tool_module(module_name: str, filename: str):
return module
def _load_plugin_module(module_name: str, relpath: str):
"""Load a plugin module by file path from ``plugins/``.
Mirror of :func:`_load_tool_module` for the plugin tree. Used by tests
that exercise the per-vendor browser plugins' session-lifecycle
behaviour after the PR #25214 migration.
"""
spec = spec_from_file_location(module_name, PLUGINS_DIR / relpath)
assert spec and spec.loader
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if name.startswith(prefixes):
@@ -76,6 +93,48 @@ def _install_fake_tools_package():
call_llm=lambda *args, **kwargs: "",
)
# Stubs for the browser-provider plugin layer introduced in PR #25214.
# The fake `agent` package has an empty __path__ so real submodules
# aren't reachable; we install just enough stand-ins to satisfy
# ``tools.browser_tool``'s top-level imports. The actual lifecycle
# tests instantiate the real plugin classes via _load_tool_module
# below, so the stubs only need to satisfy import + isinstance.
class _StubBrowserProvider:
"""Minimal BrowserProvider stub for ``from agent.browser_provider import BrowserProvider``."""
sys.modules["agent.browser_provider"] = types.SimpleNamespace(
BrowserProvider=_StubBrowserProvider,
)
sys.modules["agent.browser_registry"] = types.SimpleNamespace(
get_provider=lambda name: None,
list_providers=lambda: [],
register_provider=lambda provider: None,
_resolve=lambda configured: None,
)
# Plugin module stubs — the real plugin classes are loaded from disk by
# the lifecycle tests below via _load_tool_module(). For the import
# phase, we just need the class names to exist on the right module path.
plugins_package = types.ModuleType("plugins")
plugins_package.__path__ = [] # type: ignore[attr-defined]
sys.modules["plugins"] = plugins_package
plugins_browser_package = types.ModuleType("plugins.browser")
plugins_browser_package.__path__ = [] # type: ignore[attr-defined]
sys.modules["plugins.browser"] = plugins_browser_package
for _name, _classname in (
("browserbase", "BrowserbaseBrowserProvider"),
("browser_use", "BrowserUseBrowserProvider"),
("firecrawl", "FirecrawlBrowserProvider"),
):
_vendor_pkg = types.ModuleType(f"plugins.browser.{_name}")
_vendor_pkg.__path__ = [] # type: ignore[attr-defined]
sys.modules[f"plugins.browser.{_name}"] = _vendor_pkg
_provider_stub_cls = type(_classname, (_StubBrowserProvider,), {})
sys.modules[f"plugins.browser.{_name}.provider"] = types.SimpleNamespace(
**{_classname: _provider_stub_cls},
)
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
"tools.managed_tool_gateway",
"managed_tool_gateway.py",
@@ -157,13 +216,13 @@ def test_browserbase_does_not_use_gateway_only_configuration():
})
with patch.dict(os.environ, env, clear=True):
browserbase_module = _load_tool_module(
"tools.browser_providers.browserbase",
"browser_providers/browserbase.py",
browserbase_module = _load_plugin_module(
"plugins.browser.browserbase.provider",
"browser/browserbase/provider.py",
)
provider = browserbase_module.BrowserbaseProvider()
provider = browserbase_module.BrowserbaseBrowserProvider()
assert provider.is_configured() is False
assert provider.is_available() is False
def test_browser_use_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
@@ -188,13 +247,13 @@ def test_browser_use_managed_gateway_adds_idempotency_key_and_persists_external_
}
with patch.dict(os.environ, env, clear=True):
browser_use_module = _load_tool_module(
"tools.browser_providers.browser_use",
"browser_providers/browser_use.py",
browser_use_module = _load_plugin_module(
"plugins.browser.browser_use.provider",
"browser/browser_use/provider.py",
)
with patch.object(browser_use_module.requests, "post", return_value=_Response()) as post:
provider = browser_use_module.BrowserUseProvider()
provider = browser_use_module.BrowserUseBrowserProvider()
session = provider.create_session("task-browser-use-managed")
sent_headers = post.call_args.kwargs["headers"]
@@ -228,11 +287,11 @@ def test_browser_use_managed_gateway_reuses_pending_idempotency_key_after_timeou
}
with patch.dict(os.environ, env, clear=True):
browser_use_module = _load_tool_module(
"tools.browser_providers.browser_use",
"browser_providers/browser_use.py",
browser_use_module = _load_plugin_module(
"plugins.browser.browser_use.provider",
"browser/browser_use/provider.py",
)
provider = browser_use_module.BrowserUseProvider()
provider = browser_use_module.BrowserUseBrowserProvider()
timeout = browser_use_module.requests.Timeout("timed out")
with patch.object(
@@ -290,11 +349,11 @@ def test_browser_use_managed_gateway_preserves_pending_idempotency_key_for_in_pr
}
with patch.dict(os.environ, env, clear=True):
browser_use_module = _load_tool_module(
"tools.browser_providers.browser_use",
"browser_providers/browser_use.py",
browser_use_module = _load_plugin_module(
"plugins.browser.browser_use.provider",
"browser/browser_use/provider.py",
)
provider = browser_use_module.BrowserUseProvider()
provider = browser_use_module.BrowserUseBrowserProvider()
with patch.object(
browser_use_module.requests,
@@ -337,11 +396,11 @@ def test_browser_use_managed_gateway_uses_new_idempotency_key_for_a_new_session_
}
with patch.dict(os.environ, env, clear=True):
browser_use_module = _load_tool_module(
"tools.browser_providers.browser_use",
"browser_providers/browser_use.py",
browser_use_module = _load_plugin_module(
"plugins.browser.browser_use.provider",
"browser/browser_use/provider.py",
)
provider = browser_use_module.BrowserUseProvider()
provider = browser_use_module.BrowserUseBrowserProvider()
with patch.object(browser_use_module.requests, "post", side_effect=[_Response(), _Response()]) as post:
provider.create_session("task-browser-use-new")
@@ -33,7 +33,7 @@ def _restore_tool_and_agent_modules():
original_modules = {
name: module
for name, module in sys.modules.items()
if name in ("tools", "agent", "hermes_cli")
if name in {"tools", "agent", "hermes_cli"}
or name.startswith("tools.")
or name.startswith("agent.")
or name.startswith("hermes_cli.")
@@ -62,7 +62,7 @@ class TestCancelledErrorPropagation:
return "clean_return"
outcome = asyncio.run(drive())
assert outcome in ("cancelled_cleanly", "clean_return"), (
assert outcome in {"cancelled_cleanly", "clean_return"}, (
f"MCPServerTask.run wedged on cancel (outcome={outcome}) — "
f"#9930 regression"
)
+61
View File
@@ -10,6 +10,8 @@ from unittest.mock import patch, MagicMock, AsyncMock
import pytest
import asyncio
from tools.mcp_oauth import (
HermesTokenStorage,
OAuthNonInteractiveError,
@@ -20,6 +22,7 @@ from tools.mcp_oauth import (
_is_interactive,
_wait_for_callback,
_make_callback_handler,
_redirect_handler,
)
@@ -241,6 +244,64 @@ class TestUtilities:
assert _can_open_browser() is True
class TestRedirectHandlerSshHint:
"""_redirect_handler must print an SSH tunnel hint on remote sessions."""
def _run(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
def test_ssh_hint_shown_on_ssh_session(self, monkeypatch, capsys):
import tools.mcp_oauth as mco
monkeypatch.setattr(mco, "_oauth_port", 49200)
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 1234 22")
monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.setattr(mco, "_can_open_browser", lambda: False)
self._run(_redirect_handler("https://example.com/auth?foo=bar"))
err = capsys.readouterr().err
assert "49200" in err
assert "ssh -N -L" in err
assert "Remote session detected" in err
def test_ssh_hint_shown_via_ssh_tty(self, monkeypatch, capsys):
import tools.mcp_oauth as mco
monkeypatch.setattr(mco, "_oauth_port", 49201)
monkeypatch.delenv("SSH_CLIENT", raising=False)
monkeypatch.setenv("SSH_TTY", "/dev/pts/1")
monkeypatch.setattr(mco, "_can_open_browser", lambda: False)
self._run(_redirect_handler("https://example.com/auth"))
err = capsys.readouterr().err
assert "49201" in err
assert "ssh -N -L" in err
def test_no_ssh_hint_on_local_session(self, monkeypatch, capsys):
import tools.mcp_oauth as mco
monkeypatch.setattr(mco, "_oauth_port", 49202)
monkeypatch.delenv("SSH_CLIENT", raising=False)
monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.setattr(mco, "_can_open_browser", lambda: True)
monkeypatch.setattr("webbrowser.open", lambda url, **kw: True)
self._run(_redirect_handler("https://example.com/auth"))
err = capsys.readouterr().err
assert "ssh -N -L" not in err
def test_no_ssh_hint_when_port_not_set(self, monkeypatch, capsys):
import tools.mcp_oauth as mco
monkeypatch.setattr(mco, "_oauth_port", None)
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 1234 22")
monkeypatch.setattr(mco, "_can_open_browser", lambda: False)
self._run(_redirect_handler("https://example.com/auth"))
err = capsys.readouterr().err
assert "ssh -N -L" not in err
# ---------------------------------------------------------------------------
# Path traversal protection
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -135,7 +135,7 @@ class TestStdioPidTracking:
# bpo-14484). Return True so the SIGKILL escalation fires.
with patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("gateway.status._pid_exists", return_value=True), \
patch("time.sleep") as mock_sleep:
patch("tools.mcp_tool.time.sleep") as mock_sleep:
_kill_orphaned_mcp_children()
# SIGTERM then SIGKILL; the alive check no longer touches os.kill.
@@ -163,7 +163,7 @@ class TestStdioPidTracking:
monkeypatch.delattr(signal, "SIGKILL", raising=False)
with patch("tools.mcp_tool.os.kill") as mock_kill, \
patch("time.sleep") as mock_sleep:
patch("tools.mcp_tool.time.sleep") as mock_sleep:
_kill_orphaned_mcp_children()
# SIGTERM phase, alive check raises (process gone), no escalation
+77 -4
View File
@@ -3781,16 +3781,26 @@ class TestMcpParallelToolCalls:
def test_is_mcp_tool_parallel_safe_no_servers(self):
"""MCP tool from unknown server returns False."""
from tools.mcp_tool import is_mcp_tool_parallel_safe, _parallel_safe_servers, _lock
from tools.mcp_tool import (
is_mcp_tool_parallel_safe, _mcp_tool_server_names,
_parallel_safe_servers, _lock,
)
with _lock:
_parallel_safe_servers.clear()
_mcp_tool_server_names.clear()
assert is_mcp_tool_parallel_safe("mcp_docs_search") is False
def test_is_mcp_tool_parallel_safe_with_flag(self):
"""MCP tool from a parallel-safe server returns True."""
from tools.mcp_tool import is_mcp_tool_parallel_safe, _parallel_safe_servers, _lock
from tools.mcp_tool import (
is_mcp_tool_parallel_safe, _mcp_tool_server_names,
_parallel_safe_servers, _lock,
)
with _lock:
_parallel_safe_servers.add("docs")
_mcp_tool_server_names["mcp_docs_search"] = "docs"
_mcp_tool_server_names["mcp_docs_read_file"] = "docs"
_mcp_tool_server_names["mcp_github_list_repos"] = "github"
try:
assert is_mcp_tool_parallel_safe("mcp_docs_search") is True
assert is_mcp_tool_parallel_safe("mcp_docs_read_file") is True
@@ -3799,23 +3809,86 @@ class TestMcpParallelToolCalls:
finally:
with _lock:
_parallel_safe_servers.discard("docs")
_mcp_tool_server_names.pop("mcp_docs_search", None)
_mcp_tool_server_names.pop("mcp_docs_read_file", None)
_mcp_tool_server_names.pop("mcp_github_list_repos", None)
def test_is_mcp_tool_parallel_safe_server_with_underscores(self):
"""Server names containing underscores are correctly matched."""
from tools.mcp_tool import is_mcp_tool_parallel_safe, _parallel_safe_servers, _lock
from tools.mcp_tool import (
is_mcp_tool_parallel_safe, _mcp_tool_server_names,
_parallel_safe_servers, _lock,
)
with _lock:
_parallel_safe_servers.add("my_server")
_mcp_tool_server_names["mcp_my_server_query"] = "my_server"
try:
assert is_mcp_tool_parallel_safe("mcp_my_server_query") is True
finally:
with _lock:
_parallel_safe_servers.discard("my_server")
_mcp_tool_server_names.pop("mcp_my_server_query", None)
def test_is_mcp_tool_parallel_safe_uses_exact_registered_server(self):
"""Ambiguous MCP names must not match a shorter parallel-safe prefix."""
from tools.mcp_tool import (
is_mcp_tool_parallel_safe, _mcp_tool_server_names,
_parallel_safe_servers, _lock,
)
with _lock:
_parallel_safe_servers.add("a")
_mcp_tool_server_names["mcp_a_search"] = "a"
_mcp_tool_server_names["mcp_a_b_tool"] = "a_b"
try:
assert is_mcp_tool_parallel_safe("mcp_a_search") is True
assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is False
finally:
with _lock:
_parallel_safe_servers.discard("a")
_mcp_tool_server_names.pop("mcp_a_search", None)
_mcp_tool_server_names.pop("mcp_a_b_tool", None)
def test_registered_tool_provenance_prevents_prefix_collision(self):
"""Registration records exact server ownership for ambiguous names."""
from tools.registry import registry
from tools.mcp_tool import (
_mcp_tool_server_names, _parallel_safe_servers,
_register_server_tools, is_mcp_tool_parallel_safe, _lock,
)
server = _make_mock_server(
"a_b",
tools=[_make_mcp_tool("tool", "Ambiguous tool name")],
)
registered = _register_server_tools("a_b", server, {})
try:
assert registered == ["mcp_a_b_tool"]
with _lock:
assert _mcp_tool_server_names["mcp_a_b_tool"] == "a_b"
_parallel_safe_servers.add("a")
assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is False
with _lock:
_parallel_safe_servers.add("a_b")
assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is True
finally:
for tool_name in registered:
registry.deregister(tool_name)
with _lock:
_parallel_safe_servers.discard("a")
_parallel_safe_servers.discard("a_b")
_mcp_tool_server_names.pop("mcp_a_b_tool", None)
def test_is_mcp_tool_parallel_safe_no_tool_suffix(self):
"""Tool name that is just 'mcp_{server}' without a tool part returns False."""
from tools.mcp_tool import is_mcp_tool_parallel_safe, _parallel_safe_servers, _lock
from tools.mcp_tool import (
is_mcp_tool_parallel_safe, _mcp_tool_server_names,
_parallel_safe_servers, _lock,
)
with _lock:
_parallel_safe_servers.add("docs")
_mcp_tool_server_names.pop("mcp_docs", None)
_mcp_tool_server_names.pop("mcp_docs_", None)
try:
# "mcp_docs" has no tool part after the server name
assert is_mcp_tool_parallel_safe("mcp_docs") is False
+131
View File
@@ -304,6 +304,30 @@ def test_strip_none_returns_zero():
assert stripped == 0
def test_strip_responses_format_strips_format_keyword():
"""Responses-format: keyword should be stripped."""
from tools.schema_sanitizer import strip_pattern_and_format
tools = [
{
"name": "get_event",
"parameters": {
"type": "object",
"properties": {
"ts": {"type": "string", "format": "date-time"},
}
},
"type": "function"
}
]
result, stripped = strip_pattern_and_format(tools)
assert stripped == 1, f"Expected 1 format stripped, got {stripped}"
assert "format" not in result[0]["parameters"]["properties"]["ts"], "format should be stripped"
assert result[0]["parameters"]["properties"]["ts"]["type"] == "string", "type should be preserved"
def test_top_level_allof_stripped_for_codex_backend_compat():
"""OpenAI Codex backend rejects top-level allOf/oneOf/anyOf/enum/not."""
tools = [_tool("memory", {
@@ -360,3 +384,110 @@ def test_nested_allof_preserved():
nested = out[0]["function"]["parameters"]["properties"]["config"]
assert "allOf" in nested
assert nested["allOf"] == [{"required": ["mode"]}]
def test_strip_responses_format_tools():
"""strip_pattern_and_format should handle Responses-format tools (no function wrapper)."""
from tools.schema_sanitizer import strip_pattern_and_format
# Responses-format: {"name": "...", "parameters": {...}, "type": "function"}
tools = [
{
"name": "mcp_firecrawl_search",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"includeDomains": {
"type": "array",
"items": {
"type": "string",
"pattern": "^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$"
}
}
}
},
"type": "function"
}
]
result, stripped = strip_pattern_and_format(tools)
assert stripped == 1, f"Expected 1 pattern stripped, got {stripped}"
# Verify pattern keyword was removed from includeDomains
domains = result[0]["parameters"]["properties"]["includeDomains"]["items"]
assert "pattern" not in domains, f"pattern should be stripped: {domains}"
assert domains["type"] == "string", "type should be preserved"
def test_strip_responses_idempotent():
"""Second call on already-stripped Responses-format tools should return 0."""
from tools.schema_sanitizer import strip_pattern_and_format
tools = [
{
"name": "search_files",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string"} # This is a property named pattern, NOT schema keyword
}
}
}
]
# Pass 1 - property named 'pattern' should NOT be stripped
result, first = strip_pattern_and_format(tools)
assert first == 0, f"Expected 0 stripped (property pattern preserved), got {first}"
assert "pattern" in result[0]["parameters"]["properties"], "property named pattern should survive"
# Pass 2 - idempotent
_, second = strip_pattern_and_format(tools)
assert second == 0, f"Expected 0 on second pass, got {second}"
def test_strip_responses_mixed_formats():
"""Mixed list of OpenAI-format and Responses-format tools should both be sanitized."""
from tools.schema_sanitizer import strip_pattern_and_format
tools = [
# OpenAI-format: {"function": {"parameters": {...}}}
{
"type": "function",
"function": {
"name": "search",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "pattern": "^[a-z]+$"}
}
}
}
},
# Responses-format: {"name": "...", "parameters": {...}}
{
"name": "get_time",
"parameters": {
"type": "object",
"properties": {
"tz": {"type": "string", "format": "date-time"}
}
},
"type": "function"
}
]
result, stripped = strip_pattern_and_format(tools)
assert stripped == 2, f"Expected 2 stripped (1 pattern + 1 format), got {stripped}"
# OpenAI-format tool: pattern stripped from parameters
openai_params = result[0]["function"]["parameters"]["properties"]["query"]
assert "pattern" not in openai_params, f"pattern should be stripped: {openai_params}"
# Responses-format tool: format stripped
resp_params = result[1]["parameters"]["properties"]["tz"]
assert "format" not in resp_params, f"format should be stripped: {resp_params}"
# Verify structure preserved
assert result[0]["function"]["parameters"]["type"] == "object"
assert result[1]["parameters"]["type"] == "object"
+93 -3
View File
@@ -182,6 +182,81 @@ class TestSendMessageTool:
force_document=False,
)
def test_resolved_slack_thread_name_preserves_thread_id(self):
slack_cfg = SimpleNamespace(enabled=True, token="xoxb-test", extra={})
config = SimpleNamespace(
platforms={Platform.SLACK: slack_cfg},
get_home_channel=lambda _platform: None,
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch("gateway.channel_directory.resolve_channel_name", return_value="C123ABCDEF:171.000001"), \
patch("model_tools._run_async", side_effect=_run_async_immediately), \
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
patch("gateway.mirror.mirror_to_session", return_value=True):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "slack:ops / topic 171.000001",
"message": "hello",
}
)
)
assert result["success"] is True
send_mock.assert_awaited_once_with(
Platform.SLACK,
slack_cfg,
"C123ABCDEF",
"hello",
thread_id="171.000001",
media_files=[],
force_document=False,
)
def test_resolved_matrix_thread_name_preserves_thread_id(self):
matrix_cfg = SimpleNamespace(
enabled=True,
token="tok",
extra={"homeserver": "https://matrix.example.com"},
)
config = SimpleNamespace(
platforms={Platform.MATRIX: matrix_cfg},
get_home_channel=lambda _platform: None,
)
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch(
"gateway.channel_directory.resolve_channel_name",
return_value="!roomid:matrix.example.org:$thread123:matrix.example.org",
), \
patch("model_tools._run_async", side_effect=_run_async_immediately), \
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
patch("gateway.mirror.mirror_to_session", return_value=True):
result = json.loads(
send_message_tool(
{
"action": "send",
"target": "matrix:Ops / topic $thread123",
"message": "hello",
}
)
)
assert result["success"] is True
send_mock.assert_awaited_once_with(
Platform.MATRIX,
matrix_cfg,
"!roomid:matrix.example.org",
"hello",
thread_id="$thread123:matrix.example.org",
media_files=[],
force_document=False,
)
def test_mirror_receives_current_session_user_id(self):
config, _telegram_cfg = _make_config()
@@ -503,9 +578,8 @@ class TestSendToPlatformChunking:
assert all(call == [] for call in sent_calls[:-1])
assert sent_calls[-1] == media
def test_matrix_media_uses_native_adapter_helper(self):
doc_path = Path("/tmp/test-send-message-matrix.pdf")
def test_matrix_media_uses_native_adapter_helper(self, tmp_path):
doc_path = tmp_path / "test-send-message-matrix.pdf"
doc_path.write_bytes(b"%PDF-1.4 test")
try:
@@ -847,6 +921,16 @@ class TestParseTargetRefDiscord:
class TestParseTargetRefMatrix:
"""_parse_target_ref correctly handles Matrix room IDs and user MXIDs."""
def test_matrix_thread_target_is_explicit(self):
"""Session-derived Matrix thread targets round-trip as room + event id."""
chat_id, thread_id, is_explicit = _parse_target_ref(
"matrix",
"!HLOQwxYGgFPMPJUSNR:matrix.org:$thread123:matrix.org",
)
assert chat_id == "!HLOQwxYGgFPMPJUSNR:matrix.org"
assert thread_id == "$thread123:matrix.org"
assert is_explicit is True
def test_matrix_room_id_is_explicit(self):
"""Matrix room IDs (!) are recognized as explicit targets."""
chat_id, thread_id, is_explicit = _parse_target_ref("matrix", "!HLOQwxYGgFPMPJUSNR:matrix.org")
@@ -919,6 +1003,12 @@ class TestParseTargetRefE164:
class TestParseTargetRefSlack:
"""_parse_target_ref recognizes Slack channel/user IDs as explicit."""
def test_thread_target_is_explicit(self):
chat_id, thread_id, is_explicit = _parse_target_ref("slack", "C0B0QV5434G:171.000001")
assert chat_id == "C0B0QV5434G"
assert thread_id == "171.000001"
assert is_explicit is True
def test_public_channel_id_is_explicit(self):
chat_id, thread_id, is_explicit = _parse_target_ref("slack", "C0B0QV5434G")
assert chat_id == "C0B0QV5434G"
+1 -1
View File
@@ -23,7 +23,7 @@ class TestFindSingularityExecutable:
def test_prefers_apptainer(self):
"""When both are available, apptainer should be preferred."""
def which_both(name):
return f"/usr/bin/{name}" if name in ("apptainer", "singularity") else None
return f"/usr/bin/{name}" if name in {"apptainer", "singularity"} else None
with patch("shutil.which", side_effect=which_both):
assert _find_singularity_executable() == "apptainer"
+1 -1
View File
@@ -547,7 +547,7 @@ class TestSkillManageDispatcher:
# No provenance marker on a foreground create — record either missing
# entirely (telemetry best-effort) or present with created_by unset.
rec = usage.get("test-skill") or {}
assert rec.get("created_by") in (None, "", False)
assert rec.get("created_by") in {None, "", False}
def test_create_from_background_review_marks_agent_created(self, tmp_path):
"""Background-review fork creates ARE marked as agent-created."""
+1 -1
View File
@@ -101,7 +101,7 @@ class TestTrustLevelFor:
src = self._source()
result = src.trust_level_for("owner/repo")
# No path part — still resolves repo correctly
assert result in ("trusted", "community")
assert result in {"trusted", "community"}
# ---------------------------------------------------------------------------
@@ -60,6 +60,33 @@ class TestProviderSelectionGate:
finally:
importlib.reload(tt)
def test_xai_resolver_import_after_config_env_patch_uses_restored_dotenv_loader(self):
"""xAI HTTP auth must not cache a temporarily patched env helper."""
import importlib
import hermes_cli.config as config_mod
from tools import xai_http
with pytest.MonkeyPatch.context() as mp:
mp.setattr(config_mod, "get_env_value", lambda name, default=None: "")
xai_http = importlib.reload(xai_http)
try:
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=RuntimeError("no oauth"),
), patch(
"hermes_cli.auth.resolve_xai_oauth_runtime_credentials",
return_value={},
), patch(
"hermes_cli.config.load_env",
return_value={"XAI_API_KEY": "dotenv-secret"},
):
creds = xai_http.resolve_xai_http_credentials()
finally:
importlib.reload(xai_http)
assert creds["api_key"] == "dotenv-secret"
def test_explicit_groq_sees_dotenv(self):
from tools import transcription_tools as tt
+5 -2
View File
@@ -482,8 +482,11 @@ class TestVprintForceParameter:
else:
unforced_error_count += 1
assert forced_error_count > 0, \
"Expected at least one _vprint with force=True for error messages"
# Invariant: no critical-error _vprint call may silently drop under
# streaming suppression — every ❌-prefixed _vprint must pass force=True.
# The codebase may legitimately have zero such calls if errors are
# routed through print() or higher-level Rich panels; what matters is
# that none are quietly suppressed.
assert unforced_error_count == 0, \
f"Found {unforced_error_count} critical error _vprint calls without force=True"
+5 -5
View File
@@ -25,7 +25,7 @@ def _reload_entry_with_env(env_overrides: dict) -> None:
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
sys.path = [p for p in sys.path if p not in ("", ".")]
sys.path = [p for p in sys.path if p not in {"", "."}]
return sys.path[:]
finally:
sys.path = original_path
@@ -45,7 +45,7 @@ def test_empty_string_and_dot_removed_from_sys_path():
assert "." in sys.path
# Run the entry.py fixup logic directly
sys.path = [p for p in sys.path if p not in ("", ".")]
sys.path = [p for p in sys.path if p not in {"", "."}]
assert "" not in sys.path
assert "." not in sys.path
@@ -61,7 +61,7 @@ def test_hermes_src_root_inserted_at_front():
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
sys.path = [p for p in sys.path if p not in ("", ".")]
sys.path = [p for p in sys.path if p not in {"", "."}]
assert sys.path[0] == fake_root
finally:
@@ -79,7 +79,7 @@ def test_src_root_not_duplicated_if_already_present():
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
sys.path = [p for p in sys.path if p not in ("", ".")]
sys.path = [p for p in sys.path if p not in {"", "."}]
assert sys.path.count(fake_root) == count_before
finally:
@@ -95,7 +95,7 @@ def test_no_src_root_env_does_not_crash():
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
sys.path = [p for p in sys.path if p not in ("", ".")]
sys.path = [p for p in sys.path if p not in {"", "."}]
# No exception raised
finally:
sys.path = original