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:
@@ -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"}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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")
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user