Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson 2026-04-13 18:32:13 -05:00
commit 7e4dd6ea02
220 changed files with 23482 additions and 1959 deletions

View file

@ -64,4 +64,4 @@ class TestCamofoxConfigDefaults:
# The current schema version is tracked globally; unrelated default
# options may bump it after browser defaults are added.
assert DEFAULT_CONFIG["_config_version"] == 15
assert DEFAULT_CONFIG["_config_version"] == 17

View file

@ -79,5 +79,33 @@ class TestSafeWriteRoot:
assert _is_write_denied(os.path.expanduser("~/.ssh/id_rsa")) is True
class TestCheckSensitivePathMacOSBypass:
"""Verify _check_sensitive_path blocks /private/etc paths (issue #8734)."""
def test_etc_hosts_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/etc/hosts") is not None
def test_private_etc_hosts_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/private/etc/hosts") is not None
def test_private_etc_ssh_config_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/private/etc/ssh/sshd_config") is not None
def test_private_var_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/private/var/db/something") is not None
def test_boot_still_blocked(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/boot/grub/grub.cfg") is not None
def test_safe_path_allowed(self):
from tools.file_tools import _check_sensitive_path
assert _check_sensitive_path("/tmp/safe_file.txt") is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View file

@ -5,6 +5,7 @@ handler validation, and availability gating.
"""
import json
from unittest.mock import patch
import pytest
@ -18,6 +19,7 @@ from tools.homeassistant_tool import (
_handle_call_service,
_BLOCKED_DOMAINS,
_ENTITY_ID_RE,
_SERVICE_NAME_RE,
)
@ -303,6 +305,147 @@ class TestEntityIdValidation:
assert "Invalid entity_id" not in result["error"]
# ---------------------------------------------------------------------------
# String-data deserialization (XML tool calling workaround)
# ---------------------------------------------------------------------------
class TestCallServiceStringData:
"""data param may arrive as a JSON string (XML tool calling mode)."""
@patch("tools.homeassistant_tool._run_async", return_value={"success": True})
def test_string_data_deserialized(self, mock_run):
"""JSON string data is parsed into a dict before dispatch."""
_handle_call_service({
"domain": "climate",
"service": "set_hvac_mode",
"entity_id": "climate.living_room",
"data": '{"hvac_mode": "heat"}',
})
call_args = mock_run.call_args[0][0] # the coroutine arg
# _run_async was called, meaning we got past validation
@patch("tools.homeassistant_tool._run_async", return_value={"success": True})
def test_dict_data_passthrough(self, mock_run):
"""Dict data (JSON tool calling mode) still works unchanged."""
_handle_call_service({
"domain": "light",
"service": "turn_on",
"entity_id": "light.bedroom",
"data": {"brightness": 255},
})
mock_run.assert_called_once()
def test_invalid_json_string_returns_error(self):
"""Malformed JSON string in data returns a clear error."""
result = json.loads(_handle_call_service({
"domain": "light",
"service": "turn_on",
"entity_id": "light.bedroom",
"data": "{not valid json}",
}))
assert "error" in result
assert "Invalid JSON" in result["error"]
@patch("tools.homeassistant_tool._run_async", return_value={"success": True})
def test_empty_string_data_becomes_none(self, mock_run):
"""Empty/whitespace string data is treated as None."""
_handle_call_service({
"domain": "light",
"service": "turn_on",
"entity_id": "light.bedroom",
"data": " ",
})
mock_run.assert_called_once()
# ---------------------------------------------------------------------------
# Security: domain/service name format validation
# ---------------------------------------------------------------------------
class TestServiceNameValidation:
"""Verify domain/service format validation prevents path traversal in URL.
The domain and service parameters are interpolated into
/api/services/{domain}/{service}, so allowing arbitrary strings would
enable SSRF via path traversal or blocked-domain bypass.
"""
def test_valid_domain_names(self):
assert _SERVICE_NAME_RE.match("light")
assert _SERVICE_NAME_RE.match("switch")
assert _SERVICE_NAME_RE.match("climate")
assert _SERVICE_NAME_RE.match("shell_command")
assert _SERVICE_NAME_RE.match("media_player")
def test_valid_service_names(self):
assert _SERVICE_NAME_RE.match("turn_on")
assert _SERVICE_NAME_RE.match("turn_off")
assert _SERVICE_NAME_RE.match("set_temperature")
assert _SERVICE_NAME_RE.match("toggle")
def test_path_traversal_in_domain_rejected(self):
assert _SERVICE_NAME_RE.match("../../api/config") is None
assert _SERVICE_NAME_RE.match("light/../../../etc") is None
assert _SERVICE_NAME_RE.match("../config") is None
def test_path_traversal_in_service_rejected(self):
assert _SERVICE_NAME_RE.match("../../api/config") is None
assert _SERVICE_NAME_RE.match("turn_on/../../config") is None
def test_blocked_domain_bypass_via_traversal_rejected(self):
"""Ensure shell_command/../light is rejected, not just checked against blocklist."""
assert _SERVICE_NAME_RE.match("shell_command/../light") is None
assert _SERVICE_NAME_RE.match("python_script/../scene") is None
assert _SERVICE_NAME_RE.match("hassio/../automation") is None
def test_slashes_rejected(self):
assert _SERVICE_NAME_RE.match("light/turn_on") is None
assert _SERVICE_NAME_RE.match("a/b/c") is None
def test_dots_rejected(self):
assert _SERVICE_NAME_RE.match("light.turn_on") is None
assert _SERVICE_NAME_RE.match("..") is None
def test_uppercase_rejected(self):
assert _SERVICE_NAME_RE.match("LIGHT") is None
assert _SERVICE_NAME_RE.match("Turn_On") is None
def test_special_chars_rejected(self):
assert _SERVICE_NAME_RE.match("light;rm") is None
assert _SERVICE_NAME_RE.match("light&cmd") is None
assert _SERVICE_NAME_RE.match("light cmd") is None
def test_handler_rejects_traversal_domain(self):
"""_handle_call_service must reject domain with path traversal."""
result = json.loads(_handle_call_service({
"domain": "../../api/config",
"service": "turn_on",
}))
assert "error" in result
assert "Invalid domain" in result["error"]
def test_handler_rejects_traversal_service(self):
"""_handle_call_service must reject service with path traversal."""
result = json.loads(_handle_call_service({
"domain": "light",
"service": "../../api/config",
}))
assert "error" in result
assert "Invalid service" in result["error"]
def test_handler_rejects_blocklist_bypass_traversal(self):
"""Blocklist bypass via shell_command/../light must be caught by format validation."""
result = json.loads(_handle_call_service({
"domain": "shell_command/../light",
"service": "turn_on",
}))
assert "error" in result
# Must be rejected as "Invalid domain", not slip through the blocklist
assert "Invalid domain" in result["error"]
# ---------------------------------------------------------------------------
# Availability check
# ---------------------------------------------------------------------------

View file

@ -28,7 +28,7 @@ class TestInterruptModule:
assert not is_interrupted()
def test_thread_safety(self):
"""Set from one thread, check from another."""
"""Set from one thread targeting another thread's ident."""
from tools.interrupt import set_interrupt, is_interrupted
set_interrupt(False)
@ -45,11 +45,12 @@ class TestInterruptModule:
time.sleep(0.05)
assert not seen["value"]
set_interrupt(True)
# Target the checker thread's ident so it sees the interrupt
set_interrupt(True, thread_id=t.ident)
t.join(timeout=1)
assert seen["value"]
set_interrupt(False)
set_interrupt(False, thread_id=t.ident)
# ---------------------------------------------------------------------------
@ -189,10 +190,10 @@ class TestSIGKILLEscalation:
t.start()
time.sleep(0.5)
set_interrupt(True)
set_interrupt(True, thread_id=t.ident)
t.join(timeout=5)
set_interrupt(False)
set_interrupt(False, thread_id=t.ident)
assert result_holder["value"] is not None
assert result_holder["value"]["returncode"] == 130

View file

@ -146,6 +146,40 @@ class TestTruncateAroundMatches:
result = _truncate_around_matches(text, "KEYWORD")
assert "KEYWORD" in result
def test_multiword_phrase_match_beats_individual_term(self):
"""Full phrase deep in text should be found even when a single term
appears much earlier in boilerplate."""
boilerplate = "The project setup is complex. " * 500 # ~15K, has 'project' early
filler = "x" * (MAX_SESSION_CHARS + 20000)
target = "We reviewed the keystone project roadmap in detail."
text = boilerplate + filler + target + filler
result = _truncate_around_matches(text, "keystone project")
assert "keystone project" in result.lower()
def test_multiword_proximity_cooccurrence(self):
"""When exact phrase is absent, terms co-occurring within proximity
should be preferred over a lone early term."""
early = "project " + "a" * (MAX_SESSION_CHARS + 20000)
# Place 'keystone' and 'project' near each other (but not as exact phrase)
cooccur = "this keystone initiative for the project was pivotal"
tail = "b" * (MAX_SESSION_CHARS + 20000)
text = early + cooccur + tail
result = _truncate_around_matches(text, "keystone project")
assert "keystone" in result.lower()
assert "project" in result.lower()
def test_multiword_window_maximises_coverage(self):
"""Sliding window should capture as many match clusters as possible."""
# Place two phrase matches: one at ~50K, one at ~60K, both should fit
pre = "z" * 50000
match1 = " alpha beta "
gap = "z" * 10000
match2 = " alpha beta "
post = "z" * (MAX_SESSION_CHARS + 40000)
text = pre + match1 + gap + match2 + post
result = _truncate_around_matches(text, "alpha beta")
assert result.lower().count("alpha beta") == 2
# =========================================================================
# session_search (dispatcher)

View file

@ -0,0 +1,145 @@
"""Tests for TTS speed configuration across providers."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in ("OPENAI_API_KEY", "MINIMAX_API_KEY", "HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(key, raising=False)
# ---------------------------------------------------------------------------
# Edge TTS speed
# ---------------------------------------------------------------------------
class TestEdgeTtsSpeed:
def _run(self, tts_config, tmp_path):
mock_comm = MagicMock()
mock_comm.save = AsyncMock()
mock_edge = MagicMock()
mock_edge.Communicate = MagicMock(return_value=mock_comm)
with patch("tools.tts_tool._import_edge_tts", return_value=mock_edge):
from tools.tts_tool import _generate_edge_tts
asyncio.run(_generate_edge_tts("Hello", str(tmp_path / "out.mp3"), tts_config))
return mock_edge.Communicate
def test_default_no_rate_kwarg(self, tmp_path):
"""No speed config => no rate kwarg passed to Communicate."""
comm_cls = self._run({}, tmp_path)
kwargs = comm_cls.call_args[1]
assert "rate" not in kwargs
def test_global_speed_applied(self, tmp_path):
"""Global tts.speed used as fallback."""
comm_cls = self._run({"speed": 1.5}, tmp_path)
kwargs = comm_cls.call_args[1]
assert kwargs["rate"] == "+50%"
def test_provider_speed_overrides_global(self, tmp_path):
"""tts.edge.speed takes precedence over tts.speed."""
comm_cls = self._run({"speed": 1.5, "edge": {"speed": 2.0}}, tmp_path)
kwargs = comm_cls.call_args[1]
assert kwargs["rate"] == "+100%"
def test_speed_below_one(self, tmp_path):
"""Speed < 1.0 produces a negative rate string."""
comm_cls = self._run({"speed": 0.5}, tmp_path)
kwargs = comm_cls.call_args[1]
assert kwargs["rate"] == "-50%"
def test_speed_exactly_one_no_rate(self, tmp_path):
"""Explicit speed=1.0 should not pass rate kwarg."""
comm_cls = self._run({"speed": 1.0}, tmp_path)
kwargs = comm_cls.call_args[1]
assert "rate" not in kwargs
# ---------------------------------------------------------------------------
# OpenAI TTS speed
# ---------------------------------------------------------------------------
class TestOpenaiTtsSpeed:
def _run(self, tts_config, tmp_path, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
mock_response = MagicMock()
mock_client = MagicMock()
mock_client.audio.speech.create.return_value = mock_response
mock_cls = MagicMock(return_value=mock_client)
with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \
patch("tools.tts_tool._resolve_openai_audio_client_config",
return_value=("test-key", None)):
from tools.tts_tool import _generate_openai_tts
_generate_openai_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
return mock_client.audio.speech.create
def test_default_no_speed_kwarg(self, tmp_path, monkeypatch):
"""No speed config => no speed kwarg in create call."""
create = self._run({}, tmp_path, monkeypatch)
kwargs = create.call_args[1]
assert "speed" not in kwargs
def test_global_speed_applied(self, tmp_path, monkeypatch):
"""Global tts.speed used as fallback."""
create = self._run({"speed": 1.5}, tmp_path, monkeypatch)
kwargs = create.call_args[1]
assert kwargs["speed"] == 1.5
def test_provider_speed_overrides_global(self, tmp_path, monkeypatch):
"""tts.openai.speed takes precedence over tts.speed."""
create = self._run({"speed": 1.5, "openai": {"speed": 2.0}}, tmp_path, monkeypatch)
kwargs = create.call_args[1]
assert kwargs["speed"] == 2.0
def test_speed_clamped_low(self, tmp_path, monkeypatch):
"""Speed below 0.25 is clamped to 0.25."""
create = self._run({"speed": 0.1}, tmp_path, monkeypatch)
kwargs = create.call_args[1]
assert kwargs["speed"] == 0.25
def test_speed_clamped_high(self, tmp_path, monkeypatch):
"""Speed above 4.0 is clamped to 4.0."""
create = self._run({"speed": 10.0}, tmp_path, monkeypatch)
kwargs = create.call_args[1]
assert kwargs["speed"] == 4.0
# ---------------------------------------------------------------------------
# MiniMax TTS speed (global fallback wired)
# ---------------------------------------------------------------------------
class TestMinimaxTtsSpeed:
def _run(self, tts_config, tmp_path, monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {"audio": "deadbeef"},
"base_resp": {"status_code": 0, "status_msg": "success"},
"extra_info": {"audio_size": 8},
}
# requests is imported locally inside _generate_minimax_tts
with patch("requests.post", return_value=mock_response) as mock_post:
from tools.tts_tool import _generate_minimax_tts
_generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
return mock_post
def test_global_speed_fallback(self, tmp_path, monkeypatch):
"""Global tts.speed used when minimax.speed not set."""
mock_post = self._run({"speed": 1.5}, tmp_path, monkeypatch)
payload = mock_post.call_args[1]["json"]
assert payload["voice_setting"]["speed"] == 1.5
def test_provider_speed_overrides_global(self, tmp_path, monkeypatch):
"""tts.minimax.speed takes precedence over tts.speed."""
mock_post = self._run(
{"speed": 1.5, "minimax": {"speed": 2.0}}, tmp_path, monkeypatch
)
payload = mock_post.call_args[1]["json"]
assert payload["voice_setting"]["speed"] == 2.0

View file

@ -463,8 +463,6 @@ class TestVisionRequirements:
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("AUXILIARY_VISION_PROVIDER", raising=False)
monkeypatch.delenv("CONTEXT_VISION_PROVIDER", raising=False)
assert check_vision_requirements() is True

View file

@ -32,6 +32,7 @@ def _make_voice_cli(**overrides):
cli._voice_tts_done.set()
cli._pending_input = queue.Queue()
cli._app = None
cli._attached_images = []
cli.console = SimpleNamespace(width=80)
for k, v in overrides.items():
setattr(cli, k, v)

View file

@ -190,17 +190,38 @@ class TestGatewayCleanupWiring:
def test_gateway_stop_calls_close(self):
"""gateway stop() should call close() on all running agents."""
import asyncio
from unittest.mock import MagicMock, patch
import threading
from unittest.mock import AsyncMock, MagicMock, patch
runner = MagicMock()
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._running = True
runner._running_agents = {}
runner._running_agents_ts = {}
runner.adapters = {}
runner._background_tasks = set()
runner._pending_messages = {}
runner._pending_approvals = {}
runner._pending_model_notes = {}
runner._shutdown_event = asyncio.Event()
runner._exit_reason = None
runner._exit_code = None
runner._stop_task = None
runner._draining = False
runner._restart_requested = False
runner._restart_task_started = False
runner._restart_detached = False
runner._restart_via_service = False
runner._restart_drain_timeout = 5.0
runner._voice_mode = {}
runner._session_model_overrides = {}
runner._update_prompt_pending = {}
runner._busy_input_mode = "interrupt"
runner._agent_cache = {}
runner._agent_cache_lock = threading.Lock()
runner._shutdown_all_gateway_honcho = lambda: None
runner._update_runtime_status = MagicMock()
mock_agent_1 = MagicMock()
mock_agent_2 = MagicMock()
@ -209,8 +230,6 @@ class TestGatewayCleanupWiring:
"session-2": mock_agent_2,
}
from gateway.run import GatewayRunner
loop = asyncio.new_event_loop()
try:
with patch("gateway.status.remove_pid_file"), \