chore: uptick
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""Unit tests for _SupervisorRegistry cache-hit healthcheck.
|
||||
|
||||
Verifies that get_or_start() does NOT return a cached supervisor whose
|
||||
thread has exited or whose event loop has stopped. Avoids a real Chrome —
|
||||
the only thing under test is the registry's cache decision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import browser_supervisor as bs
|
||||
|
||||
|
||||
class _FakeLoop:
|
||||
def __init__(self, running: bool) -> None:
|
||||
self._running = running
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
|
||||
def _make_fake_supervisor(cdp_url: str, *, thread_alive: bool, loop_running: bool):
|
||||
"""Build a minimal stand-in for a CDPSupervisor entry in the registry.
|
||||
|
||||
Only the attributes touched by the healthcheck (_thread, _loop, cdp_url)
|
||||
and by the teardown path (stop()) need to exist.
|
||||
"""
|
||||
|
||||
if thread_alive:
|
||||
# A thread that is actually running — parks on an Event we never set.
|
||||
hold = threading.Event()
|
||||
t = threading.Thread(target=hold.wait, daemon=True)
|
||||
t.start()
|
||||
# Attach the release hook so the test can let the thread exit.
|
||||
setattr(t, "_release", hold.set)
|
||||
else:
|
||||
# An un-started thread — is_alive() returns False.
|
||||
t = threading.Thread(target=lambda: None)
|
||||
|
||||
stop_calls: list[bool] = []
|
||||
|
||||
fake = SimpleNamespace(
|
||||
cdp_url=cdp_url,
|
||||
_thread=t,
|
||||
_loop=_FakeLoop(loop_running),
|
||||
stop=lambda: stop_calls.append(True),
|
||||
)
|
||||
fake._stop_calls = stop_calls # type: ignore[attr-defined]
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_registry():
|
||||
"""A fresh registry instance, independent of the global SUPERVISOR_REGISTRY."""
|
||||
return bs._SupervisorRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_cdp_supervisor(monkeypatch):
|
||||
"""Replace CDPSupervisor in the module so recreate paths don't touch Chrome.
|
||||
|
||||
Returns a callable that reads the last-constructed fake out.
|
||||
"""
|
||||
created: list[SimpleNamespace] = []
|
||||
|
||||
class _StubSupervisor:
|
||||
def __init__(self, *, task_id, cdp_url, dialog_policy, dialog_timeout_s):
|
||||
self.task_id = task_id
|
||||
self.cdp_url = cdp_url
|
||||
self.dialog_policy = dialog_policy
|
||||
self.dialog_timeout_s = dialog_timeout_s
|
||||
# Healthy by default — real thread, running "loop".
|
||||
hold = threading.Event()
|
||||
self._thread = threading.Thread(target=hold.wait, daemon=True)
|
||||
self._thread.start()
|
||||
self._thread_release = hold.set # type: ignore[attr-defined]
|
||||
self._loop = _FakeLoop(True)
|
||||
self.start_called = False
|
||||
self.stop_called = False
|
||||
created.append(self)
|
||||
|
||||
def start(self, timeout: float = 15.0) -> None:
|
||||
self.start_called = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_called = True
|
||||
# Release the parked thread so the process exits cleanly.
|
||||
release = getattr(self, "_thread_release", None)
|
||||
if release is not None:
|
||||
release()
|
||||
|
||||
monkeypatch.setattr(bs, "CDPSupervisor", _StubSupervisor)
|
||||
yield created
|
||||
# Teardown: release any parked threads in stubs the test left behind.
|
||||
for s in created:
|
||||
release = getattr(s, "_thread_release", None)
|
||||
if release is not None:
|
||||
release()
|
||||
|
||||
|
||||
def test_cache_hit_returns_same_instance_when_healthy(
|
||||
isolated_registry, stub_cdp_supervisor
|
||||
):
|
||||
"""Sanity: healthy cached supervisor is returned without recreate."""
|
||||
first = isolated_registry.get_or_start(task_id="t1", cdp_url="http://h/1")
|
||||
second = isolated_registry.get_or_start(task_id="t1", cdp_url="http://h/1")
|
||||
assert first is second
|
||||
# Only one CDPSupervisor was ever constructed.
|
||||
assert len(stub_cdp_supervisor) == 1
|
||||
first.stop()
|
||||
|
||||
|
||||
def test_dead_thread_triggers_recreate(isolated_registry, stub_cdp_supervisor):
|
||||
"""Cached supervisor with a non-live thread must not be reused."""
|
||||
cdp_url = "http://h/2"
|
||||
dead = _make_fake_supervisor(cdp_url, thread_alive=False, loop_running=True)
|
||||
isolated_registry._by_task["t2"] = dead # pre-seed cache with a dead entry
|
||||
|
||||
fresh = isolated_registry.get_or_start(task_id="t2", cdp_url=cdp_url)
|
||||
|
||||
assert fresh is not dead, "dead-thread supervisor must be replaced"
|
||||
assert dead._stop_calls == [True], "dead supervisor must be torn down"
|
||||
assert isolated_registry._by_task["t2"] is fresh
|
||||
assert len(stub_cdp_supervisor) == 1
|
||||
assert stub_cdp_supervisor[0].start_called
|
||||
fresh.stop()
|
||||
|
||||
|
||||
def test_stopped_loop_triggers_recreate(isolated_registry, stub_cdp_supervisor):
|
||||
"""Cached supervisor whose event loop is no longer running is recreated."""
|
||||
cdp_url = "http://h/3"
|
||||
broken = _make_fake_supervisor(cdp_url, thread_alive=True, loop_running=False)
|
||||
isolated_registry._by_task["t3"] = broken
|
||||
|
||||
fresh = isolated_registry.get_or_start(task_id="t3", cdp_url=cdp_url)
|
||||
|
||||
assert fresh is not broken
|
||||
assert broken._stop_calls == [True]
|
||||
# Release the still-live thread from the pre-seeded fake so we don't leak.
|
||||
release = getattr(broken._thread, "_release", None)
|
||||
if release is not None:
|
||||
release()
|
||||
assert isolated_registry._by_task["t3"] is fresh
|
||||
fresh.stop()
|
||||
|
||||
|
||||
def test_missing_thread_and_loop_attrs_trigger_recreate(
|
||||
isolated_registry, stub_cdp_supervisor
|
||||
):
|
||||
"""Defensive: None _thread or None _loop counts as unhealthy."""
|
||||
cdp_url = "http://h/4"
|
||||
broken = SimpleNamespace(
|
||||
cdp_url=cdp_url,
|
||||
_thread=None,
|
||||
_loop=None,
|
||||
stop=lambda: None,
|
||||
)
|
||||
isolated_registry._by_task["t4"] = broken
|
||||
|
||||
fresh = isolated_registry.get_or_start(task_id="t4", cdp_url=cdp_url)
|
||||
assert fresh is not broken
|
||||
assert isolated_registry._by_task["t4"] is fresh
|
||||
fresh.stop()
|
||||
@@ -786,6 +786,26 @@ class TestDelegationCredentialResolution(unittest.TestCase):
|
||||
self.assertEqual(creds["api_mode"], "chat_completions")
|
||||
mock_resolve.assert_called_once_with(requested="openrouter")
|
||||
|
||||
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
|
||||
def test_provider_resolution_uses_runtime_model_when_config_model_missing(self, mock_resolve):
|
||||
"""Named providers should propagate their runtime default model to children."""
|
||||
mock_resolve.return_value = {
|
||||
"provider": "custom",
|
||||
"base_url": "https://my-server.example/v1",
|
||||
"api_key": "sk-test-key",
|
||||
"api_mode": "chat_completions",
|
||||
"model": "server-default-model",
|
||||
}
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {"provider": "custom:my-server", "model": ""}
|
||||
|
||||
creds = _resolve_delegation_credentials(cfg, parent)
|
||||
|
||||
self.assertEqual(creds["model"], "server-default-model")
|
||||
self.assertEqual(creds["provider"], "custom")
|
||||
self.assertEqual(creds["base_url"], "https://my-server.example/v1")
|
||||
mock_resolve.assert_called_once_with(requested="custom:my-server")
|
||||
|
||||
def test_direct_endpoint_uses_configured_base_url_and_api_key(self):
|
||||
parent = _make_mock_parent(depth=0)
|
||||
cfg = {
|
||||
|
||||
@@ -696,6 +696,38 @@ class TestCapabilityDetection:
|
||||
_detect_capabilities("tok", force=True)
|
||||
assert mock_req.call_count == 2
|
||||
|
||||
@patch("tools.discord_tool._discord_request")
|
||||
def test_cache_is_keyed_by_token(self, mock_req):
|
||||
"""Regression: token A's capabilities must not leak to token B.
|
||||
|
||||
Before the fix, the cache was a single module-global dict. The first
|
||||
call populated it and every subsequent call — regardless of token —
|
||||
returned the same cached value, producing wrong schema gating for
|
||||
rotated or multi-token deployments.
|
||||
"""
|
||||
def _per_token_flags(method, path, token, **_kwargs):
|
||||
# token A: both intents; token B: neither.
|
||||
if token == "tok_a":
|
||||
return {"flags": (1 << 14) | (1 << 18)}
|
||||
return {"flags": 0}
|
||||
|
||||
mock_req.side_effect = _per_token_flags
|
||||
|
||||
caps_a = _detect_capabilities("tok_a")
|
||||
caps_b = _detect_capabilities("tok_b")
|
||||
|
||||
assert caps_a["has_members_intent"] is True
|
||||
assert caps_a["has_message_content"] is True
|
||||
assert caps_b["has_members_intent"] is False
|
||||
assert caps_b["has_message_content"] is False
|
||||
# Each token should hit the endpoint exactly once.
|
||||
assert mock_req.call_count == 2
|
||||
|
||||
# Re-requesting either token serves from its own cache entry.
|
||||
_detect_capabilities("tok_a")
|
||||
_detect_capabilities("tok_b")
|
||||
assert mock_req.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config allowlist
|
||||
|
||||
@@ -304,6 +304,7 @@ class TestBuiltinDiscovery:
|
||||
"tools.file_tools",
|
||||
"tools.homeassistant_tool",
|
||||
"tools.image_generation_tool",
|
||||
"tools.kanban_tools",
|
||||
"tools.memory_tool",
|
||||
"tools.mixture_of_agents_tool",
|
||||
"tools.process_registry",
|
||||
|
||||
@@ -242,6 +242,21 @@ class TestSessionSearchConcurrency:
|
||||
|
||||
|
||||
class TestRecentSessionListing:
|
||||
def test_recent_mode_requests_last_active_ordering(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.list_sessions_rich.return_value = []
|
||||
|
||||
result = json.loads(_list_recent_sessions(mock_db, limit=5))
|
||||
|
||||
assert result["success"] is True
|
||||
mock_db.list_sessions_rich.assert_called_once_with(
|
||||
limit=10,
|
||||
exclude_sources=["tool"],
|
||||
order_by_last_active=True,
|
||||
)
|
||||
|
||||
def test_current_child_session_excludes_root_lineage_even_when_child_id_is_longer(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
@@ -567,6 +567,26 @@ class TestSecurityScanGate:
|
||||
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
|
||||
assert _guard_agent_created_enabled() is False
|
||||
|
||||
def test_guard_flag_quoted_false_stays_disabled(self):
|
||||
"""Quoted 'false' from YAML edits must not enable the guard."""
|
||||
from tools.skill_manager_tool import _guard_agent_created_enabled
|
||||
|
||||
for quoted in ("false", "False", "0", "no", "off"):
|
||||
with patch("hermes_cli.config.load_config",
|
||||
return_value={"skills": {"guard_agent_created": quoted}}):
|
||||
assert _guard_agent_created_enabled() is False, \
|
||||
f"guard_agent_created={quoted!r} must coerce to False"
|
||||
|
||||
def test_guard_flag_quoted_true_enables(self):
|
||||
"""Quoted truthy strings must enable the guard."""
|
||||
from tools.skill_manager_tool import _guard_agent_created_enabled
|
||||
|
||||
for quoted in ("true", "True", "1", "yes", "on"):
|
||||
with patch("hermes_cli.config.load_config",
|
||||
return_value={"skills": {"guard_agent_created": quoted}}):
|
||||
assert _guard_agent_created_enabled() is True, \
|
||||
f"guard_agent_created={quoted!r} must coerce to True"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# External skills directories (skills.external_dirs) — mutations in place
|
||||
|
||||
@@ -104,6 +104,57 @@ def test_cached_sudo_password_isolated_by_session_key(monkeypatch):
|
||||
assert terminal_tool._get_cached_sudo_password() == "alpha-pass"
|
||||
|
||||
|
||||
def test_passwordless_sudo_skips_interactive_prompt_and_rewrite(monkeypatch):
|
||||
monkeypatch.delenv("SUDO_PASSWORD", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
|
||||
def _fail_prompt(*_args, **_kwargs):
|
||||
raise AssertionError(
|
||||
"interactive sudo prompt should not run when sudo -n already works"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(terminal_tool, "_prompt_for_sudo_password", _fail_prompt)
|
||||
monkeypatch.setattr(terminal_tool, "_sudo_nopasswd_works", lambda: True, raising=False)
|
||||
|
||||
transformed, sudo_stdin = terminal_tool._transform_sudo_command("sudo whoami")
|
||||
|
||||
assert transformed == "sudo whoami"
|
||||
assert sudo_stdin is None
|
||||
|
||||
|
||||
def test_passwordless_sudo_probe_rechecks_local_terminal(monkeypatch):
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
calls = []
|
||||
|
||||
class Result:
|
||||
def __init__(self, returncode):
|
||||
self.returncode = returncode
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return Result(0 if len(calls) == 1 else 1)
|
||||
|
||||
monkeypatch.setattr(terminal_tool.subprocess, "run", fake_run)
|
||||
|
||||
assert terminal_tool._sudo_nopasswd_works() is True
|
||||
assert terminal_tool._sudo_nopasswd_works() is False
|
||||
assert len(calls) == 2
|
||||
assert calls[0][0] == ["sudo", "-n", "true"]
|
||||
assert calls[1][0] == ["sudo", "-n", "true"]
|
||||
|
||||
|
||||
def test_passwordless_sudo_probe_is_disabled_for_nonlocal_terminal_env(monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
||||
|
||||
def _fail_run(*_args, **_kwargs):
|
||||
raise AssertionError("host sudo probe must not run for non-local terminal envs")
|
||||
|
||||
monkeypatch.setattr(terminal_tool.subprocess, "run", _fail_run)
|
||||
|
||||
assert terminal_tool._sudo_nopasswd_works() is False
|
||||
|
||||
|
||||
def test_validate_workdir_allows_windows_drive_paths():
|
||||
assert terminal_tool._validate_workdir(r"C:\Users\Alice\project") is None
|
||||
assert terminal_tool._validate_workdir("C:/Users/Alice/project") is None
|
||||
|
||||
@@ -125,6 +125,33 @@ class TestYoloMode:
|
||||
approval_callback=lambda *a: "deny")
|
||||
assert not result["approved"]
|
||||
|
||||
@pytest.mark.parametrize("value", ["false", "False", "0", "off", "no"])
|
||||
def test_false_like_yolo_values_do_not_bypass_dangerous_command(self, monkeypatch, value):
|
||||
"""False-like env strings must not silently enable YOLO bypass."""
|
||||
monkeypatch.setenv("HERMES_YOLO_MODE", value)
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
monkeypatch.setenv("HERMES_SESSION_KEY", "test-session")
|
||||
|
||||
result = check_dangerous_command(
|
||||
"rm -rf /tmp/stuff",
|
||||
"local",
|
||||
approval_callback=lambda *a: "deny",
|
||||
)
|
||||
assert not result["approved"]
|
||||
|
||||
@pytest.mark.parametrize("value", ["false", "False", "0", "off", "no"])
|
||||
def test_false_like_yolo_values_do_not_bypass_combined_guard(self, monkeypatch, value):
|
||||
"""Combined guard must treat false-like YOLO env strings as disabled."""
|
||||
monkeypatch.setenv("HERMES_YOLO_MODE", value)
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
|
||||
result = check_all_command_guards(
|
||||
"rm -rf /tmp/stuff",
|
||||
"local",
|
||||
approval_callback=lambda *a: "deny",
|
||||
)
|
||||
assert not result["approved"]
|
||||
|
||||
def test_session_scoped_yolo_only_bypasses_current_session(self, monkeypatch):
|
||||
"""Gateway /yolo should only bypass approvals for the active session."""
|
||||
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
||||
|
||||
Reference in New Issue
Block a user