mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 01:31:41 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
commit
7e4dd6ea02
220 changed files with 23482 additions and 1959 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
145
tests/tools/test_tts_speed.py
Normal file
145
tests/tools/test_tts_speed.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"), \
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue