Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -1102,3 +1102,206 @@ class TestDetectSudoStdin:
|
||||
"make 2>&1 | tee build.log"
|
||||
)
|
||||
assert is_dangerous is False
|
||||
|
||||
|
||||
class TestMacOSPrivateSystemPaths:
|
||||
"""Inspired by Claude Code 2.1.113 "dangerous path protection".
|
||||
|
||||
On macOS, /etc, /var, /tmp, /home are symlinks to
|
||||
/private/{etc,var,tmp,home}. A command that writes to
|
||||
/private/etc/sudoers works identically to /etc/sudoers but bypasses
|
||||
a plain "/etc/" pattern check. These tests guard the shared
|
||||
_SYSTEM_CONFIG_PATH fragment used across redirect / tee / cp / mv /
|
||||
install / sed -i patterns.
|
||||
"""
|
||||
|
||||
def test_private_etc_redirect(self):
|
||||
dangerous, _, desc = detect_dangerous_command(
|
||||
"echo 'root ALL=NOPASSWD: ALL' > /private/etc/sudoers"
|
||||
)
|
||||
assert dangerous is True
|
||||
assert "system config" in desc.lower()
|
||||
|
||||
def test_private_var_redirect(self):
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"echo payload > /private/var/db/dslocal/nodes/x"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_private_etc_via_tee(self):
|
||||
dangerous, _, desc = detect_dangerous_command(
|
||||
"echo malicious | tee /private/etc/hosts"
|
||||
)
|
||||
assert dangerous is True
|
||||
assert "tee" in desc.lower() or "system" in desc.lower()
|
||||
|
||||
def test_private_etc_cp(self):
|
||||
dangerous, _, desc = detect_dangerous_command(
|
||||
"cp malicious.conf /private/etc/hosts"
|
||||
)
|
||||
assert dangerous is True
|
||||
assert "copy" in desc.lower() or "system config" in desc.lower()
|
||||
|
||||
def test_private_etc_mv(self):
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"mv evil /private/etc/ssh/sshd_config"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_private_etc_install(self):
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"install -m 600 key /private/etc/ssh/keys"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_private_etc_sed_in_place(self):
|
||||
dangerous, _, desc = detect_dangerous_command(
|
||||
"sed -i 's/root/pwned/' /private/etc/passwd"
|
||||
)
|
||||
assert dangerous is True
|
||||
assert "in-place" in desc.lower() or "system config" in desc.lower()
|
||||
|
||||
def test_private_var_sed_long_flag(self):
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"sed --in-place 's/x/y/' /private/var/log/wtmp"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_private_tmp_cp(self):
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"cp rootkit /private/tmp/payload"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_ls_private_is_safe(self):
|
||||
"""Reading under /private/ must not trigger approval."""
|
||||
dangerous, _, _ = detect_dangerous_command("ls /private")
|
||||
assert dangerous is False
|
||||
|
||||
def test_echo_mentioning_private_path_is_safe(self):
|
||||
"""Literal mention of /private/etc in an echo string must not fire."""
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"echo 'the macOS path is /private/etc on disk'"
|
||||
)
|
||||
assert dangerous is False
|
||||
|
||||
|
||||
class TestKillallKillSignals:
|
||||
"""Inspired by Claude Code 2.1.113 expanded deny rules.
|
||||
|
||||
The existing pattern caught `pkill -9` but not the equivalent
|
||||
`killall -9` / `-KILL` / `-s KILL` / `-r <regex>` broad sweeps that
|
||||
can wipe out unrelated processes.
|
||||
"""
|
||||
|
||||
def test_killall_dash_9(self):
|
||||
dangerous, _, desc = detect_dangerous_command("killall -9 firefox")
|
||||
assert dangerous is True
|
||||
assert "kill" in desc.lower()
|
||||
|
||||
def test_killall_dash_kill(self):
|
||||
dangerous, _, _ = detect_dangerous_command("killall -KILL firefox")
|
||||
assert dangerous is True
|
||||
|
||||
def test_killall_dash_sigkill(self):
|
||||
dangerous, _, _ = detect_dangerous_command("killall -SIGKILL firefox")
|
||||
assert dangerous is True
|
||||
|
||||
def test_killall_dash_s_kill(self):
|
||||
dangerous, _, _ = detect_dangerous_command("killall -s KILL firefox")
|
||||
assert dangerous is True
|
||||
|
||||
def test_killall_dash_s_signum(self):
|
||||
dangerous, _, _ = detect_dangerous_command("killall -s 9 firefox")
|
||||
assert dangerous is True
|
||||
|
||||
def test_killall_regex(self):
|
||||
"""killall -r <regex> is a broad sweep; require approval."""
|
||||
dangerous, _, desc = detect_dangerous_command("killall -r 'fire.*'")
|
||||
assert dangerous is True
|
||||
assert "regex" in desc.lower() or "kill" in desc.lower()
|
||||
|
||||
def test_killall_combined_flags(self):
|
||||
dangerous, _, _ = detect_dangerous_command("killall -9 -r 'herm.*'")
|
||||
assert dangerous is True
|
||||
|
||||
def test_killall_list_signals_is_safe(self):
|
||||
"""`killall -l` lists signals and is harmless — must not fire."""
|
||||
dangerous, _, _ = detect_dangerous_command("killall -l")
|
||||
assert dangerous is False
|
||||
|
||||
def test_killall_version_is_safe(self):
|
||||
dangerous, _, _ = detect_dangerous_command("killall -V")
|
||||
assert dangerous is False
|
||||
|
||||
|
||||
class TestFindExecdir:
|
||||
"""Inspired by Claude Code 2.1.113 tightening of find rules.
|
||||
|
||||
`find -execdir rm` has the same destructive effect as `find -exec rm`
|
||||
but ran in each match's directory. Previously missed because the
|
||||
pattern required a literal `-exec ` followed by a space.
|
||||
"""
|
||||
|
||||
def test_find_execdir_rm(self):
|
||||
dangerous, _, desc = detect_dangerous_command(
|
||||
"find . -execdir rm {} \\;"
|
||||
)
|
||||
assert dangerous is True
|
||||
assert "find" in desc.lower() or "rm" in desc.lower()
|
||||
|
||||
def test_find_execdir_with_absolute_rm(self):
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"find /var -execdir /bin/rm -rf {} \\;"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_find_exec_rm_still_caught(self):
|
||||
"""Original -exec pattern must still fire (regression guard)."""
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"find . -exec rm {} \\;"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_find_execdir_ls_is_safe(self):
|
||||
"""-execdir with a read-only command is not dangerous."""
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"find . -execdir ls {} \\;"
|
||||
)
|
||||
assert dangerous is False
|
||||
|
||||
|
||||
class TestEtcPatternsUnaffectedByRefactor:
|
||||
"""Regression guard: the /etc/ patterns were refactored to share the
|
||||
_SYSTEM_CONFIG_PATH fragment with the /private/ mirror. Make sure the
|
||||
existing /etc/ coverage remains identical.
|
||||
"""
|
||||
|
||||
def test_etc_redirect(self):
|
||||
dangerous, _, _ = detect_dangerous_command("echo x > /etc/hosts")
|
||||
assert dangerous is True
|
||||
|
||||
def test_etc_cp(self):
|
||||
dangerous, _, _ = detect_dangerous_command("cp evil /etc/hosts")
|
||||
assert dangerous is True
|
||||
|
||||
def test_etc_sed_inline(self):
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"sed -i 's/a/b/' /etc/hosts"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_etc_tee(self):
|
||||
dangerous, _, _ = detect_dangerous_command(
|
||||
"echo x | tee /etc/hosts"
|
||||
)
|
||||
assert dangerous is True
|
||||
|
||||
def test_cat_etc_hostname_is_safe(self):
|
||||
"""Reading /etc/ files is safe — only writes require approval."""
|
||||
dangerous, _, _ = detect_dangerous_command("cat /etc/hostname")
|
||||
assert dangerous is False
|
||||
|
||||
def test_grep_etc_passwd_is_safe(self):
|
||||
dangerous, _, _ = detect_dangerous_command("grep root /etc/passwd")
|
||||
assert dangerous is False
|
||||
|
||||
@@ -890,6 +890,63 @@ class TestDelegationCredentialResolution(unittest.TestCase):
|
||||
self.assertEqual(creds["api_key"], "local-key")
|
||||
self.assertEqual(creds["api_mode"], "chat_completions")
|
||||
|
||||
def test_direct_endpoint_auto_detects_anthropic_messages_suffix(self):
|
||||
# Issue #10213: Azure AI Foundry exposes Anthropic-compatible models at
|
||||
# a /anthropic URL suffix. Subagents must pick anthropic_messages
|
||||
# automatically, matching the main agent's runtime resolver.
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "claude-opus-4-6",
|
||||
"provider": "custom",
|
||||
"base_url": "https://myfoundry.services.ai.azure.com/anthropic",
|
||||
"api_key": "foundry-key",
|
||||
}
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
self.assertEqual(creds["provider"], "custom")
|
||||
self.assertEqual(creds["base_url"], "https://myfoundry.services.ai.azure.com/anthropic")
|
||||
self.assertEqual(creds["api_key"], "foundry-key")
|
||||
self.assertEqual(creds["api_mode"], "anthropic_messages")
|
||||
|
||||
def test_direct_endpoint_honors_explicit_api_mode(self):
|
||||
# When delegation.api_mode is set explicitly, it overrides URL-based
|
||||
# detection so users can force a transport on non-standard endpoints.
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "claude-opus-4-6",
|
||||
"provider": "custom",
|
||||
"base_url": "https://proxy.example.com/v1",
|
||||
"api_key": "proxy-key",
|
||||
"api_mode": "anthropic_messages",
|
||||
}
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
self.assertEqual(creds["api_mode"], "anthropic_messages")
|
||||
|
||||
def test_direct_endpoint_explicit_api_mode_overrides_url_detection(self):
|
||||
# Explicit api_mode in config always wins over auto-detection.
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "claude-opus-4-6",
|
||||
"provider": "custom",
|
||||
"base_url": "https://myfoundry.services.ai.azure.com/anthropic",
|
||||
"api_key": "foundry-key",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
self.assertEqual(creds["api_mode"], "chat_completions")
|
||||
|
||||
def test_direct_endpoint_invalid_api_mode_falls_back_to_detection(self):
|
||||
# An invalid api_mode string must not break detection; fall back to URL heuristic.
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
"model": "claude-opus-4-6",
|
||||
"provider": "custom",
|
||||
"base_url": "https://myfoundry.services.ai.azure.com/anthropic",
|
||||
"api_key": "foundry-key",
|
||||
"api_mode": "garbage",
|
||||
}
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
self.assertEqual(creds["api_mode"], "anthropic_messages")
|
||||
|
||||
def test_direct_endpoint_returns_none_api_key_when_not_configured(self):
|
||||
# When base_url is set without api_key, api_key should be None so
|
||||
# _build_child_agent inherits the parent's key (effective_api_key = override or parent).
|
||||
|
||||
@@ -3762,3 +3762,135 @@ class TestRegisterMcpServers:
|
||||
)
|
||||
|
||||
_servers.pop("srv", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for parallel tool call support (port from openai/codex#17667)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpParallelToolCalls:
|
||||
"""Tests for the supports_parallel_tool_calls config option."""
|
||||
|
||||
def test_is_mcp_tool_parallel_safe_non_mcp_tool(self):
|
||||
"""Non-MCP tool names always return False."""
|
||||
from tools.mcp_tool import is_mcp_tool_parallel_safe
|
||||
assert is_mcp_tool_parallel_safe("web_search") is False
|
||||
assert is_mcp_tool_parallel_safe("read_file") is False
|
||||
assert is_mcp_tool_parallel_safe("terminal") is False
|
||||
assert is_mcp_tool_parallel_safe("") is False
|
||||
|
||||
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
|
||||
with _lock:
|
||||
_parallel_safe_servers.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
|
||||
with _lock:
|
||||
_parallel_safe_servers.add("docs")
|
||||
try:
|
||||
assert is_mcp_tool_parallel_safe("mcp_docs_search") is True
|
||||
assert is_mcp_tool_parallel_safe("mcp_docs_read_file") is True
|
||||
# Different server should be False
|
||||
assert is_mcp_tool_parallel_safe("mcp_github_list_repos") is False
|
||||
finally:
|
||||
with _lock:
|
||||
_parallel_safe_servers.discard("docs")
|
||||
|
||||
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
|
||||
with _lock:
|
||||
_parallel_safe_servers.add("my_server")
|
||||
try:
|
||||
assert is_mcp_tool_parallel_safe("mcp_my_server_query") is True
|
||||
finally:
|
||||
with _lock:
|
||||
_parallel_safe_servers.discard("my_server")
|
||||
|
||||
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
|
||||
with _lock:
|
||||
_parallel_safe_servers.add("docs")
|
||||
try:
|
||||
# "mcp_docs" has no tool part after the server name
|
||||
assert is_mcp_tool_parallel_safe("mcp_docs") is False
|
||||
# "mcp_docs_" has empty tool part
|
||||
assert is_mcp_tool_parallel_safe("mcp_docs_") is False
|
||||
finally:
|
||||
with _lock:
|
||||
_parallel_safe_servers.discard("docs")
|
||||
|
||||
def test_register_mcp_servers_tracks_parallel_flag(self):
|
||||
"""register_mcp_servers populates _parallel_safe_servers from config."""
|
||||
from tools.mcp_tool import (
|
||||
register_mcp_servers, _parallel_safe_servers, _lock,
|
||||
sanitize_mcp_name_component,
|
||||
)
|
||||
fake_config = {
|
||||
"parallel_srv": {
|
||||
"command": "echo",
|
||||
"supports_parallel_tool_calls": True,
|
||||
},
|
||||
"serial_srv": {
|
||||
"command": "echo",
|
||||
"supports_parallel_tool_calls": False,
|
||||
},
|
||||
"default_srv": {
|
||||
"command": "echo",
|
||||
# no supports_parallel_tool_calls key
|
||||
},
|
||||
}
|
||||
with patch("tools.mcp_tool._MCP_AVAILABLE", True), \
|
||||
patch("tools.mcp_tool._ensure_mcp_loop"), \
|
||||
patch("tools.mcp_tool._run_on_mcp_loop"), \
|
||||
patch("tools.mcp_tool._existing_tool_names", return_value=[]):
|
||||
register_mcp_servers(fake_config)
|
||||
|
||||
with _lock:
|
||||
assert sanitize_mcp_name_component("parallel_srv") in _parallel_safe_servers
|
||||
assert sanitize_mcp_name_component("serial_srv") not in _parallel_safe_servers
|
||||
assert sanitize_mcp_name_component("default_srv") not in _parallel_safe_servers
|
||||
# Cleanup
|
||||
_parallel_safe_servers.discard(sanitize_mcp_name_component("parallel_srv"))
|
||||
|
||||
def test_register_mcp_servers_removes_parallel_flag_on_toggle(self):
|
||||
"""Toggling supports_parallel_tool_calls to false removes server from the set."""
|
||||
from tools.mcp_tool import (
|
||||
register_mcp_servers, _parallel_safe_servers, _lock,
|
||||
sanitize_mcp_name_component,
|
||||
)
|
||||
|
||||
# First registration: parallel enabled
|
||||
config_on = {
|
||||
"toggle_srv": {
|
||||
"command": "echo",
|
||||
"supports_parallel_tool_calls": True,
|
||||
},
|
||||
}
|
||||
with patch("tools.mcp_tool._MCP_AVAILABLE", True), \
|
||||
patch("tools.mcp_tool._ensure_mcp_loop"), \
|
||||
patch("tools.mcp_tool._run_on_mcp_loop"), \
|
||||
patch("tools.mcp_tool._existing_tool_names", return_value=[]):
|
||||
register_mcp_servers(config_on)
|
||||
with _lock:
|
||||
assert sanitize_mcp_name_component("toggle_srv") in _parallel_safe_servers
|
||||
|
||||
# Second registration: parallel disabled
|
||||
config_off = {
|
||||
"toggle_srv": {
|
||||
"command": "echo",
|
||||
"supports_parallel_tool_calls": False,
|
||||
},
|
||||
}
|
||||
with patch("tools.mcp_tool._MCP_AVAILABLE", True), \
|
||||
patch("tools.mcp_tool._ensure_mcp_loop"), \
|
||||
patch("tools.mcp_tool._run_on_mcp_loop"), \
|
||||
patch("tools.mcp_tool._existing_tool_names", return_value=[]):
|
||||
register_mcp_servers(config_off)
|
||||
with _lock:
|
||||
assert sanitize_mcp_name_component("toggle_srv") not in _parallel_safe_servers
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Tests for the X (Twitter) Search tool backed by xAI Responses API.
|
||||
|
||||
Covers:
|
||||
- HTTP request shape (URL, headers, payload, model from config)
|
||||
- Handle filter validation (allowed vs excluded mutual exclusion)
|
||||
- Inline url_citation extraction from message annotations
|
||||
- Structured error handling (4xx with code, 5xx retry, ReadTimeout retry)
|
||||
- Credential resolution: API key path, OAuth path, both-set preference, none-set
|
||||
- check_x_search_requirements gating in registry
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, *, status_code=200, text=None):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.text = text if text is not None else json.dumps(payload)
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
err = requests.HTTPError(f"{self.status_code} Client Error")
|
||||
err.response = self
|
||||
raise err
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Original PR #10786 test coverage (HTTP shape, handle validation, citations,
|
||||
# retry behavior) — preserved verbatim. Uses XAI_API_KEY env var via the
|
||||
# default resolver path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_x_search_posts_responses_request(monkeypatch):
|
||||
from tools.x_search_tool import x_search_tool
|
||||
from hermes_cli import __version__
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
captured["json"] = json
|
||||
captured["timeout"] = timeout
|
||||
return _FakeResponse(
|
||||
{
|
||||
"output_text": "People on X are discussing xAI's latest launch.",
|
||||
"citations": [{"url": "https://x.com/example/status/1", "title": "Example post"}],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
|
||||
monkeypatch.setattr("requests.post", _fake_post)
|
||||
|
||||
result = json.loads(
|
||||
x_search_tool(
|
||||
query="What are people saying about xAI on X?",
|
||||
allowed_x_handles=["xai", "@grok"],
|
||||
from_date="2026-04-01",
|
||||
to_date="2026-04-10",
|
||||
enable_image_understanding=True,
|
||||
)
|
||||
)
|
||||
|
||||
tool_def = captured["json"]["tools"][0]
|
||||
assert captured["url"] == "https://api.x.ai/v1/responses"
|
||||
assert captured["headers"]["User-Agent"] == f"Hermes-Agent/{__version__}"
|
||||
assert captured["json"]["model"] == "grok-4.20-reasoning"
|
||||
assert captured["json"]["store"] is False
|
||||
assert tool_def["type"] == "x_search"
|
||||
assert tool_def["allowed_x_handles"] == ["xai", "grok"]
|
||||
assert tool_def["from_date"] == "2026-04-01"
|
||||
assert tool_def["to_date"] == "2026-04-10"
|
||||
assert tool_def["enable_image_understanding"] is True
|
||||
assert result["success"] is True
|
||||
assert result["answer"] == "People on X are discussing xAI's latest launch."
|
||||
|
||||
|
||||
def test_x_search_rejects_conflicting_handle_filters(monkeypatch):
|
||||
from tools.x_search_tool import x_search_tool
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
|
||||
|
||||
result = json.loads(
|
||||
x_search_tool(
|
||||
query="latest xAI discussion",
|
||||
allowed_x_handles=["xai"],
|
||||
excluded_x_handles=["grok"],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["error"] == "allowed_x_handles and excluded_x_handles cannot be used together"
|
||||
|
||||
|
||||
def test_x_search_extracts_inline_url_citations(monkeypatch):
|
||||
from tools.x_search_tool import x_search_tool
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
return _FakeResponse(
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "xAI posted an update on X.",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://x.com/xai/status/123",
|
||||
"title": "xAI update",
|
||||
"start_index": 0,
|
||||
"end_index": 3,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
|
||||
monkeypatch.setattr("requests.post", _fake_post)
|
||||
|
||||
result = json.loads(x_search_tool(query="latest post from xai"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["answer"] == "xAI posted an update on X."
|
||||
assert result["inline_citations"] == [
|
||||
{
|
||||
"url": "https://x.com/xai/status/123",
|
||||
"title": "xAI update",
|
||||
"start_index": 0,
|
||||
"end_index": 3,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_x_search_returns_structured_http_error(monkeypatch):
|
||||
from tools.x_search_tool import x_search_tool
|
||||
|
||||
class _FailingResponse:
|
||||
status_code = 403
|
||||
text = '{"code":"forbidden","error":"x_search is not enabled for this model"}'
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"code": "forbidden",
|
||||
"error": "x_search is not enabled for this model",
|
||||
}
|
||||
|
||||
def raise_for_status(self):
|
||||
err = requests.HTTPError("403 Client Error: Forbidden")
|
||||
err.response = self
|
||||
raise err
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
|
||||
monkeypatch.setattr("requests.post", lambda *a, **k: _FailingResponse())
|
||||
|
||||
result = json.loads(x_search_tool(query="latest xai discussion"))
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["provider"] == "xai"
|
||||
assert result["tool"] == "x_search"
|
||||
assert result["error_type"] == "HTTPError"
|
||||
assert result["error"] == "forbidden: x_search is not enabled for this model"
|
||||
|
||||
|
||||
def test_x_search_retries_read_timeout_then_succeeds(monkeypatch):
|
||||
from tools.x_search_tool import x_search_tool
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
raise requests.ReadTimeout("timed out")
|
||||
return _FakeResponse(
|
||||
{
|
||||
"output_text": "Recovered after retry.",
|
||||
"citations": [],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
|
||||
monkeypatch.setattr("requests.post", _fake_post)
|
||||
monkeypatch.setattr("tools.x_search_tool.time.sleep", lambda *_: None)
|
||||
|
||||
result = json.loads(x_search_tool(query="grok xai"))
|
||||
|
||||
assert calls["count"] == 2
|
||||
assert result["success"] is True
|
||||
assert result["answer"] == "Recovered after retry."
|
||||
|
||||
|
||||
def test_x_search_retries_5xx_then_succeeds(monkeypatch):
|
||||
from tools.x_search_tool import x_search_tool
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
return _FakeResponse(
|
||||
{"code": "Internal error", "error": "Service temporarily unavailable."},
|
||||
status_code=500,
|
||||
)
|
||||
return _FakeResponse({"output_text": "Recovered after 5xx retry."})
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
|
||||
monkeypatch.setattr("requests.post", _fake_post)
|
||||
monkeypatch.setattr("tools.x_search_tool.time.sleep", lambda *_: None)
|
||||
|
||||
result = json.loads(x_search_tool(query="grok xai"))
|
||||
|
||||
assert calls["count"] == 2
|
||||
assert result["success"] is True
|
||||
assert result["answer"] == "Recovered after 5xx retry."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential-resolution coverage — the OAuth-or-API-key gating contract.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _no_xai_env(monkeypatch):
|
||||
"""Strip any XAI_* env vars so the resolver doesn't see a leaked dev key."""
|
||||
for var in ("XAI_API_KEY", "XAI_BASE_URL", "HERMES_XAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
def test_x_search_uses_xai_oauth_when_only_oauth_available(monkeypatch):
|
||||
"""OAuth-only user: credential_source should be ``xai-oauth``."""
|
||||
from tools.registry import invalidate_check_fn_cache
|
||||
from tools.x_search_tool import check_x_search_requirements, x_search_tool
|
||||
|
||||
_no_xai_env(monkeypatch)
|
||||
|
||||
def _fake_resolve():
|
||||
return {
|
||||
"provider": "xai-oauth",
|
||||
"api_key": "oauth-bearer-token",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve
|
||||
)
|
||||
invalidate_check_fn_cache()
|
||||
|
||||
assert check_x_search_requirements() is True
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["headers"] = headers
|
||||
return _FakeResponse({"output_text": "Found posts via OAuth."})
|
||||
|
||||
monkeypatch.setattr("requests.post", _fake_post)
|
||||
|
||||
result = json.loads(x_search_tool(query="anything about xai"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["credential_source"] == "xai-oauth"
|
||||
assert captured["headers"]["Authorization"] == "Bearer oauth-bearer-token"
|
||||
|
||||
|
||||
def test_x_search_uses_api_key_when_only_xai_api_key_set(monkeypatch):
|
||||
"""API-key-only user: credential_source should be ``xai``."""
|
||||
from tools.registry import invalidate_check_fn_cache
|
||||
from tools.x_search_tool import check_x_search_requirements, x_search_tool
|
||||
|
||||
_no_xai_env(monkeypatch)
|
||||
|
||||
def _fake_resolve():
|
||||
# Real ``resolve_xai_http_credentials`` returns ``"xai"`` when it
|
||||
# falls through to the XAI_API_KEY env var path.
|
||||
return {
|
||||
"provider": "xai",
|
||||
"api_key": "raw-api-key",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve
|
||||
)
|
||||
invalidate_check_fn_cache()
|
||||
|
||||
assert check_x_search_requirements() is True
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["headers"] = headers
|
||||
return _FakeResponse({"output_text": "Found posts via API key."})
|
||||
|
||||
monkeypatch.setattr("requests.post", _fake_post)
|
||||
|
||||
result = json.loads(x_search_tool(query="anything"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["credential_source"] == "xai"
|
||||
assert captured["headers"]["Authorization"] == "Bearer raw-api-key"
|
||||
|
||||
|
||||
def test_x_search_prefers_oauth_when_both_available(monkeypatch):
|
||||
"""Both credentials present: OAuth wins (matches Teknium's billing preference).
|
||||
|
||||
The real ordering is implemented in ``tools.xai_http.resolve_xai_http_credentials``
|
||||
— OAuth runtime first, fallback OAuth resolver second, ``XAI_API_KEY`` third.
|
||||
This test exercises the contract by having the resolver return the OAuth
|
||||
bearer (the ``xai-oauth`` ``provider`` tag is the marker).
|
||||
"""
|
||||
from tools.registry import invalidate_check_fn_cache
|
||||
from tools.x_search_tool import x_search_tool
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "raw-api-key")
|
||||
|
||||
# Mimic xai_http's preference: OAuth wins, so we return the OAuth tuple
|
||||
# even though XAI_API_KEY is also set.
|
||||
def _fake_resolve():
|
||||
return {
|
||||
"provider": "xai-oauth",
|
||||
"api_key": "oauth-bearer-token",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve
|
||||
)
|
||||
invalidate_check_fn_cache()
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["headers"] = headers
|
||||
return _FakeResponse({"output_text": "OAuth preferred."})
|
||||
|
||||
monkeypatch.setattr("requests.post", _fake_post)
|
||||
|
||||
result = json.loads(x_search_tool(query="anything"))
|
||||
|
||||
assert result["credential_source"] == "xai-oauth"
|
||||
assert captured["headers"]["Authorization"] == "Bearer oauth-bearer-token"
|
||||
|
||||
|
||||
def test_x_search_returns_tool_error_when_no_credentials(monkeypatch):
|
||||
"""No credentials anywhere: tool returns a clear error, not a 401 from xAI."""
|
||||
from tools.registry import invalidate_check_fn_cache
|
||||
from tools.x_search_tool import check_x_search_requirements, x_search_tool
|
||||
|
||||
_no_xai_env(monkeypatch)
|
||||
|
||||
def _fake_resolve():
|
||||
return {
|
||||
"provider": "xai",
|
||||
"api_key": "",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve
|
||||
)
|
||||
invalidate_check_fn_cache()
|
||||
|
||||
assert check_x_search_requirements() is False
|
||||
|
||||
# If a model somehow invokes the tool despite a False check_fn, the call
|
||||
# surfaces a friendly error rather than an HTTP exception.
|
||||
result = x_search_tool(query="anything")
|
||||
assert "No xAI credentials available" in result
|
||||
assert "hermes auth add xai-oauth" in result
|
||||
|
||||
|
||||
def test_x_search_check_fn_false_when_resolver_raises(monkeypatch):
|
||||
"""Resolver exceptions (e.g. expired token + failed refresh) gate the tool out."""
|
||||
from tools.registry import invalidate_check_fn_cache
|
||||
from tools.x_search_tool import check_x_search_requirements
|
||||
|
||||
_no_xai_env(monkeypatch)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("token revoked and refresh failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.x_search_tool.resolve_xai_http_credentials", _boom
|
||||
)
|
||||
invalidate_check_fn_cache()
|
||||
|
||||
assert check_x_search_requirements() is False
|
||||
|
||||
|
||||
def test_x_search_honors_config_model_and_timeout(monkeypatch, tmp_path):
|
||||
"""``x_search.model`` and ``x_search.timeout_seconds`` override the defaults."""
|
||||
from tools.x_search_tool import x_search_tool
|
||||
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test-key")
|
||||
|
||||
# Patch the in-module config loader so tests don't touch ~/.hermes/config.yaml.
|
||||
monkeypatch.setattr(
|
||||
"tools.x_search_tool._load_x_search_config",
|
||||
lambda: {"model": "grok-custom-test", "timeout_seconds": 45, "retries": 0},
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["model"] = json["model"]
|
||||
captured["timeout"] = timeout
|
||||
return _FakeResponse({"output_text": "Custom model OK."})
|
||||
|
||||
monkeypatch.setattr("requests.post", _fake_post)
|
||||
|
||||
result = json.loads(x_search_tool(query="anything"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["model"] == "grok-custom-test"
|
||||
assert captured["timeout"] == 45
|
||||
|
||||
|
||||
def test_x_search_registered_in_registry_with_check_fn():
|
||||
"""The tool is registered under the x_search toolset with the gating check_fn."""
|
||||
import tools.x_search_tool # noqa: F401 — ensures registration runs
|
||||
from tools.registry import registry
|
||||
|
||||
entry = registry.get_entry("x_search")
|
||||
assert entry is not None
|
||||
assert entry.toolset == "x_search"
|
||||
assert entry.check_fn is not None
|
||||
assert entry.check_fn.__name__ == "check_x_search_requirements"
|
||||
assert "XAI_API_KEY" in entry.requires_env
|
||||
assert entry.emoji == "🐦"
|
||||
Reference in New Issue
Block a user