mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""Tests for SSH bulk upload via tar pipe."""
|
|
|
|
import os
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tools.environments import ssh as ssh_env
|
|
from tools.environments.file_sync import quoted_mkdir_command, unique_parent_dirs
|
|
from tools.environments.ssh import SSHEnvironment
|
|
|
|
|
|
def _mock_proc(*, returncode=0, poll_return=0, communicate_return=(b"", b""),
|
|
stderr_read=b""):
|
|
"""Create a MagicMock mimicking subprocess.Popen for tar/ssh pipes."""
|
|
m = MagicMock()
|
|
m.stdout = MagicMock()
|
|
m.returncode = returncode
|
|
m.poll.return_value = poll_return
|
|
m.communicate.return_value = communicate_return
|
|
m.stderr = MagicMock()
|
|
m.stderr.read.return_value = stderr_read
|
|
return m
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_env(monkeypatch):
|
|
"""Create an SSHEnvironment with mocked connection/sync."""
|
|
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: "/usr/bin/ssh")
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_establish_connection", lambda self: None)
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_detect_remote_home", lambda self: "/home/testuser")
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_ensure_remote_dirs", lambda self: None)
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "init_session", lambda self: None)
|
|
monkeypatch.setattr(
|
|
ssh_env, "FileSyncManager",
|
|
lambda **kw: type("M", (), {"sync": lambda self, **k: None})(),
|
|
)
|
|
return SSHEnvironment(host="example.com", user="testuser")
|
|
|
|
|
|
class TestSSHBulkUpload:
|
|
"""Unit tests for _ssh_bulk_upload — tar pipe mechanics."""
|
|
|
|
def test_empty_files_is_noop(self, mock_env):
|
|
"""Empty file list should not spawn any subprocesses."""
|
|
with patch.object(subprocess, "run") as mock_run, \
|
|
patch.object(subprocess, "Popen") as mock_popen:
|
|
mock_env._ssh_bulk_upload([])
|
|
mock_run.assert_not_called()
|
|
mock_popen.assert_not_called()
|
|
|
|
def test_mkdir_batched_into_single_call(self, mock_env, tmp_path):
|
|
"""All parent directories should be created in one SSH call."""
|
|
# Create test files
|
|
f1 = tmp_path / "a.txt"
|
|
f1.write_text("aaa")
|
|
f2 = tmp_path / "b.txt"
|
|
f2.write_text("bbb")
|
|
|
|
files = [
|
|
(str(f1), "/home/testuser/.hermes/skills/a.txt"),
|
|
(str(f2), "/home/testuser/.hermes/credentials/b.txt"),
|
|
]
|
|
|
|
# Mock subprocess.run for mkdir and Popen for tar pipe
|
|
mock_run = MagicMock(return_value=subprocess.CompletedProcess([], 0))
|
|
|
|
def make_proc(cmd, **kwargs):
|
|
m = MagicMock()
|
|
m.stdout = MagicMock()
|
|
m.returncode = 0
|
|
m.poll.return_value = 0
|
|
m.communicate.return_value = (b"", b"")
|
|
m.stderr = MagicMock()
|
|
m.stderr.read.return_value = b""
|
|
return m
|
|
|
|
with patch.object(subprocess, "run", mock_run), \
|
|
patch.object(subprocess, "Popen", side_effect=make_proc):
|
|
mock_env._ssh_bulk_upload(files)
|
|
|
|
# Exactly one subprocess.run call for mkdir
|
|
assert mock_run.call_count == 1
|
|
mkdir_cmd = mock_run.call_args[0][0]
|
|
# Should contain mkdir -p with both parent dirs
|
|
mkdir_str = " ".join(mkdir_cmd)
|
|
assert "mkdir -p" in mkdir_str
|
|
assert "/home/testuser/.hermes/skills" in mkdir_str
|
|
assert "/home/testuser/.hermes/credentials" in mkdir_str
|
|
|
|
def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path):
|
|
"""Staged file in staging dir should mirror the remote path structure.
|
|
|
|
On platforms where symlinks are available (Linux/macOS) the staged
|
|
entry is a symlink; on Windows it may be a regular copy. Either way
|
|
the file must exist at the expected path and contain the right data.
|
|
"""
|
|
f1 = tmp_path / "local_a.txt"
|
|
f1.write_text("content a")
|
|
|
|
files = [
|
|
(str(f1), "/home/testuser/.hermes/skills/my_skill.md"),
|
|
]
|
|
|
|
staging_paths = []
|
|
|
|
def capture_tar_cmd(cmd, **kwargs):
|
|
if cmd[0] == "tar":
|
|
# Capture the staging dir from -C argument
|
|
c_idx = cmd.index("-C")
|
|
staging_dir = cmd[c_idx + 1]
|
|
# Check the staged entry exists at the base-relative path
|
|
expected = os.path.join(staging_dir, "skills/my_skill.md")
|
|
staging_paths.append(expected)
|
|
# File must exist (either as symlink or copy)
|
|
assert os.path.exists(expected), f"Expected staged file at {expected}"
|
|
# Content must match the source
|
|
with open(expected, "r") as fh:
|
|
assert fh.read() == "content a"
|
|
|
|
mock = MagicMock()
|
|
mock.stdout = MagicMock()
|
|
mock.returncode = 0
|
|
mock.poll.return_value = 0
|
|
mock.communicate.return_value = (b"", b"")
|
|
mock.stderr = MagicMock()
|
|
mock.stderr.read.return_value = b""
|
|
return mock
|
|
|
|
with patch.object(subprocess, "run",
|
|
return_value=subprocess.CompletedProcess([], 0)), \
|
|
patch.object(subprocess, "Popen", side_effect=capture_tar_cmd):
|
|
mock_env._ssh_bulk_upload(files)
|
|
|
|
assert len(staging_paths) == 1, "tar command should have been called"
|
|
|
|
|
|
def test_bulk_upload_never_stages_remote_home_prefix(self, mock_env, tmp_path):
|
|
"""Regression: do not archive /home/<user> path components."""
|
|
f1 = tmp_path / "nested.txt"
|
|
f1.write_text("nested")
|
|
files = [(str(f1), "/home/testuser/.hermes/cache/nested.txt")]
|
|
|
|
def capture_tar_cmd(cmd, **kwargs):
|
|
if cmd[0] == "tar":
|
|
c_idx = cmd.index("-C")
|
|
staging_dir = cmd[c_idx + 1]
|
|
assert not os.path.exists(os.path.join(staging_dir, "home"))
|
|
expected = os.path.join(staging_dir, "cache/nested.txt")
|
|
assert os.path.islink(expected)
|
|
|
|
mock = MagicMock()
|
|
mock.stdout = MagicMock()
|
|
mock.returncode = 0
|
|
mock.poll.return_value = 0
|
|
mock.communicate.return_value = (b"", b"")
|
|
mock.stderr = MagicMock()
|
|
mock.stderr.read.return_value = b""
|
|
return mock
|
|
|
|
with patch.object(subprocess, "run",
|
|
return_value=subprocess.CompletedProcess([], 0)), \
|
|
patch.object(subprocess, "Popen", side_effect=capture_tar_cmd):
|
|
mock_env._ssh_bulk_upload(files)
|
|
|
|
|
|
def test_timeout_kills_both_processes(self, mock_env, tmp_path):
|
|
"""TimeoutExpired during communicate should kill both processes."""
|
|
f1 = tmp_path / "t.txt"
|
|
f1.write_text("t")
|
|
files = [(str(f1), "/home/testuser/.hermes/skills/t.txt")]
|
|
|
|
mock_tar = MagicMock()
|
|
mock_tar.stdout = MagicMock()
|
|
mock_tar.returncode = None
|
|
mock_tar.poll.return_value = None
|
|
|
|
mock_ssh = MagicMock()
|
|
mock_ssh.communicate.side_effect = subprocess.TimeoutExpired("ssh", 120)
|
|
mock_ssh.returncode = None
|
|
|
|
def make_proc(cmd, **kwargs):
|
|
if cmd[0] == "tar":
|
|
return mock_tar
|
|
return mock_ssh
|
|
|
|
with patch.object(subprocess, "run",
|
|
return_value=subprocess.CompletedProcess([], 0)), \
|
|
patch.object(subprocess, "Popen", side_effect=make_proc):
|
|
with pytest.raises(RuntimeError, match="SSH bulk upload timed out"):
|
|
mock_env._ssh_bulk_upload(files)
|
|
|
|
mock_tar.kill.assert_called_once()
|
|
mock_ssh.kill.assert_called_once()
|
|
|
|
|
|
class TestSSHBulkUploadWiring:
|
|
"""Verify bulk_upload_fn is wired into FileSyncManager."""
|
|
|
|
def test_filesyncmanager_receives_bulk_upload_fn(self, monkeypatch):
|
|
"""SSHEnvironment should pass _ssh_bulk_upload to FileSyncManager."""
|
|
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: "/usr/bin/ssh")
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_establish_connection", lambda self: None)
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_detect_remote_home", lambda self: "/root")
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_ensure_remote_dirs", lambda self: None)
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "init_session", lambda self: None)
|
|
|
|
captured_kwargs = {}
|
|
|
|
class FakeSyncManager:
|
|
def __init__(self, **kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
|
|
def sync(self, **kw):
|
|
pass
|
|
|
|
monkeypatch.setattr(ssh_env, "FileSyncManager", FakeSyncManager)
|
|
|
|
env = SSHEnvironment(host="h", user="u")
|
|
|
|
assert "bulk_upload_fn" in captured_kwargs
|
|
assert captured_kwargs["bulk_upload_fn"] is not None
|
|
# Should be the bound method
|
|
assert callable(captured_kwargs["bulk_upload_fn"])
|
|
|
|
|
|
class TestSharedHelpers:
|
|
"""Direct unit tests for file_sync.py helpers."""
|
|
|
|
def test_quoted_mkdir_command_basic(self):
|
|
result = quoted_mkdir_command(["/a", "/b/c"])
|
|
assert result == "mkdir -p /a /b/c"
|
|
|
|
|
|
def test_unique_parent_dirs_empty(self):
|
|
assert unique_parent_dirs([]) == []
|
|
|
|
|
|
class TestSSHBulkUploadEdgeCases:
|
|
"""Edge cases for _ssh_bulk_upload."""
|
|
|
|
def test_ssh_popen_failure_kills_tar(self, mock_env, tmp_path):
|
|
"""If SSH Popen raises, tar process must be killed and cleaned up."""
|
|
f1 = tmp_path / "e.txt"
|
|
f1.write_text("e")
|
|
files = [(str(f1), "/home/testuser/.hermes/skills/e.txt")]
|
|
|
|
mock_tar = _mock_proc()
|
|
|
|
call_count = 0
|
|
|
|
def failing_ssh_popen(cmd, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return mock_tar # tar Popen succeeds
|
|
raise OSError("SSH binary not found")
|
|
|
|
with patch.object(subprocess, "run",
|
|
return_value=subprocess.CompletedProcess([], 0)), \
|
|
patch.object(subprocess, "Popen", side_effect=failing_ssh_popen):
|
|
with pytest.raises(OSError, match="SSH binary not found"):
|
|
mock_env._ssh_bulk_upload(files)
|
|
|
|
mock_tar.kill.assert_called_once()
|
|
mock_tar.wait.assert_called_once()
|