Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

213 lines
9.3 KiB
Python

"""Tests for the agent-facing ``get_hermes_logs`` tool."""
from __future__ import annotations
from pathlib import Path
import pytest
from core.tool_framework.registered_tool import REGISTERED_TOOL_ATTR, RegisteredTool
from integrations.hermes.poller import HermesLogCursor
from integrations.hermes.tools.hermes_logs_tool import get_hermes_logs
_LINES = [
"2026-05-12 00:00:00,000 WARNING gateway.platforms.telegram: polling conflict (1/3)",
"2026-05-12 00:00:10,000 WARNING gateway.platforms.telegram: polling conflict (2/3)",
"2026-05-12 00:00:20,000 ERROR gateway.auth: auth bypass: user 9876543210 not in allowlist",
]
def _write_log(tmp_path: Path, lines: list[str]) -> Path:
log = tmp_path / "errors.log"
log.write_text("\n".join(lines) + "\n", encoding="utf-8")
return log
@pytest.fixture(autouse=True)
def _allow_tmp_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Allow the test's tmp_path directory by setting HERMES_LOG_PATH.
The tool restricts log_path to HERMES_LOG_PATH's parent (or ~/.hermes
by default). Tests use tmp_path which is outside ~/.hermes, so we point
the env var at a file inside tmp_path to add the directory to the
allow-list without changing test logic.
"""
monkeypatch.setenv("HERMES_LOG_PATH", str(tmp_path / "errors.log"))
class TestScanMode:
def test_scan_returns_recent_records(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES)
result = get_hermes_logs(op="scan", log_path=str(log), tail_lines=10)
assert "error" not in result
assert len(result["records"]) == 3
assert result["records"][-1]["level"] == "ERROR"
# Incidents should include the auth-bypass error_severity.
rules = {i["rule"] for i in result["incidents"]}
assert "error_severity" in rules
def test_scan_flushes_open_traceback_at_end_of_window(self, tmp_path: Path) -> None:
"""A traceback with no following log line must still emit via classifier.flush()."""
lines = [
"2026-05-12 00:00:00,000 ERROR tools.x: Traceback (most recent call last):",
' File "/x", line 1, in foo',
]
log = _write_log(tmp_path, lines)
result = get_hermes_logs(op="scan", log_path=str(log), tail_lines=10)
assert "error" not in result
assert any(i["rule"] == "traceback" for i in result["incidents"])
def test_scan_respects_tail_lines(self, tmp_path: Path) -> None:
# Generate enough lines that the tail cap matters.
lines = [
f"2026-05-12 00:{i:02d}:00,000 INFO gateway.run: heartbeat #{i}" for i in range(50)
]
log = _write_log(tmp_path, lines)
result = get_hermes_logs(op="scan", log_path=str(log), tail_lines=5)
# tail_lines is a soft floor (we seek back ~5 lines of bytes
# then read forward), but the response is capped by max_records
# which defaults to tail_lines when smaller — so we expect ≤5.
assert len(result["records"]) <= 5
# Last record should be the latest one.
assert result["records"][-1]["message"].endswith("#49")
class TestTailMode:
def test_tail_first_call_anchors_at_end(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES)
first = get_hermes_logs(op="tail", log_path=str(log))
# First tail call with no cursor must not replay history.
assert first["records"] == []
# The returned cursor must be a non-empty token.
assert first["cursor"]
assert ":" in first["cursor"]
def test_tail_cursor_resumes_correctly(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES[:2])
first = get_hermes_logs(op="tail", log_path=str(log))
cursor = first["cursor"]
# Append the third line and re-poll with the cursor.
with log.open("a", encoding="utf-8") as handle:
handle.write(_LINES[2] + "\n")
second = get_hermes_logs(op="tail", log_path=str(log), cursor=cursor)
assert len(second["records"]) == 1
assert second["records"][0]["level"] == "ERROR"
# And the incident pipeline must surface the auth-bypass.
assert any(i["rule"] == "error_severity" for i in second["incidents"])
def test_tail_malformed_cursor_returns_error(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES)
result = get_hermes_logs(op="tail", log_path=str(log), cursor="garbage-not-a-real-cursor")
assert "error" in result
assert "cursor" in result["error"].lower()
def test_tail_rejects_cursor_for_foreign_path(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES)
other = tmp_path / "other.log"
other.write_text("secret\n", encoding="utf-8")
bad_token = HermesLogCursor(path=str(other), device=0, inode=0, offset=0).to_token()
result = get_hermes_logs(op="tail", log_path=str(log), cursor=bad_token)
assert "error" in result
assert "does not refer" in result["error"]
class TestLevelFilter:
def test_levels_filter_drops_lower_severity(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES)
result = get_hermes_logs(op="scan", log_path=str(log), tail_lines=10, levels=["ERROR"])
assert all(r["level"] == "ERROR" for r in result["records"])
# The error_severity incident still fires — classifier
# observed the WARNING records even though they were filtered
# from the response. The auth-bypass ERROR is the actionable
# one and it should be present.
assert any(i["rule"] == "error_severity" for i in result["incidents"])
def test_unknown_level_returns_error(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES)
result = get_hermes_logs(op="scan", log_path=str(log), levels=["BOGUS_LEVEL"])
assert "error" in result
class TestErrorPaths:
def test_unknown_op_returns_error(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES)
result = get_hermes_logs(op="ponder", log_path=str(log))
assert "error" in result
def test_missing_file_returns_empty_response_with_at_start_cursor(self, tmp_path: Path) -> None:
ghost = tmp_path / "no-such.log"
result = get_hermes_logs(op="tail", log_path=str(ghost))
# Missing file is NOT an error — the watcher pattern allows a
# late-appearing file. Just empty records + a fresh cursor.
assert result.get("records") == []
assert result["cursor"].endswith(f"@{ghost}")
class TestLogPathValidation:
def test_rejects_path_outside_allowed_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Clear the env override so only ~/.hermes is allowed, then try
# to read a file in tmp_path (which is outside ~/.hermes).
monkeypatch.delenv("HERMES_LOG_PATH", raising=False)
log = _write_log(tmp_path, _LINES)
result = get_hermes_logs(op="scan", log_path=str(log))
assert "error" in result
assert "permitted" in result["error"]
def test_accepts_path_within_env_override_dir(self, tmp_path: Path) -> None:
log = _write_log(tmp_path, _LINES)
# autouse fixture already sets HERMES_LOG_PATH to tmp_path/errors.log
# so tmp_path is in the allow-list.
result = get_hermes_logs(op="scan", log_path=str(log), tail_lines=10)
assert "error" not in result
assert len(result["records"]) == 3
def test_rejects_traversal_outside_allowed_dir(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Even with a symlink pointing outside tmp_path, resolve() sees
# the real path and the validator rejects it.
monkeypatch.delenv("HERMES_LOG_PATH", raising=False)
result = get_hermes_logs(op="scan", log_path="/etc/passwd")
assert "error" in result
assert "permitted" in result["error"]
class TestDefaultPathResolution:
def test_env_override_resolves(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
log = _write_log(tmp_path, _LINES)
monkeypatch.setenv("HERMES_LOG_PATH", str(log))
# Don't pass log_path — env should win.
result = get_hermes_logs(op="scan", tail_lines=10)
assert len(result["records"]) == 3
class TestAvailabilityGate:
"""The tool must only be offered when Hermes is connected or a backend is injected.
Regression for the bug where ``get_hermes_logs`` had no ``is_available`` and
defaulted to always-available, so Hermes log polling ran on every alert.
"""
def _registered(self) -> RegisteredTool:
registered = getattr(get_hermes_logs, REGISTERED_TOOL_ATTR)
assert isinstance(registered, RegisteredTool)
return registered
def test_unavailable_when_hermes_not_connected(self) -> None:
assert self._registered().is_available({}) is False
def test_unavailable_for_other_integrations(self) -> None:
sources = {"datadog": {"connection_verified": True}}
assert self._registered().is_available(sources) is False
def test_available_when_hermes_connected(self) -> None:
sources = {"hermes": {"connection_verified": True}}
assert self._registered().is_available(sources) is True
def test_available_with_injected_backend(self) -> None:
sources = {"hermes": {"_backend": object()}}
assert self._registered().is_available(sources) is True