mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
Merge remote-tracking branch 'origin/main' into bb/skills-renovate
This commit is contained in:
commit
65415b1a12
119 changed files with 13920 additions and 336 deletions
|
|
@ -543,6 +543,124 @@ class TestResolveVisionMainFirst:
|
|||
mock_strict.assert_called_once_with("nous", None)
|
||||
|
||||
|
||||
# ── Vision — custom provider endpoint credential passthrough ────────────────
|
||||
|
||||
|
||||
class TestResolveVisionCustomProvider:
|
||||
"""Custom-endpoint mains must forward base_url/api_key to Step 1.
|
||||
|
||||
Regression: a ``custom:<name>`` main provider resolves to the bare
|
||||
runtime provider id ``"custom"``. ``resolve_provider_client("custom")``
|
||||
has no built-in endpoint, so without forwarding the live base_url/api_key
|
||||
it returns ``(None, None)`` and vision falls through to OpenRouter / Nous,
|
||||
which an offline / aggregator-less user has never configured — breaking
|
||||
vision entirely with ``No LLM provider configured for task=vision
|
||||
provider=auto``. The fix recovers the live endpoint that
|
||||
``set_runtime_main()`` recorded for the turn.
|
||||
"""
|
||||
|
||||
def test_custom_main_forwards_runtime_endpoint(self, monkeypatch):
|
||||
"""custom main with recorded runtime endpoint → Step 1 builds a client."""
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_BASE_URL", "https://my.endpoint.example/v1")
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_KEY", "sk-runtime-key")
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_MODE", "anthropic_messages")
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="custom",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="claude-opus-4-8",
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_client = MagicMock()
|
||||
mock_resolve.return_value = (mock_client, "claude-opus-4-8")
|
||||
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "custom"
|
||||
assert client is mock_client
|
||||
assert model == "claude-opus-4-8"
|
||||
# The endpoint credentials recorded for the turn MUST be forwarded,
|
||||
# otherwise resolve_provider_client("custom") returns (None, None).
|
||||
kwargs = mock_resolve.call_args.kwargs
|
||||
assert kwargs.get("explicit_base_url") == "https://my.endpoint.example/v1"
|
||||
assert kwargs.get("explicit_api_key") == "sk-runtime-key"
|
||||
assert kwargs.get("is_vision") is True
|
||||
|
||||
def test_custom_prefixed_main_forwards_runtime_endpoint(self, monkeypatch):
|
||||
"""A ``custom:<name>`` provider id also forwards the runtime endpoint."""
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_BASE_URL", "https://named.example/v1")
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_KEY", "sk-named")
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_MODE", "")
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider",
|
||||
return_value="custom:copilot-gateway",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="claude-opus-4-8",
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_client = MagicMock()
|
||||
mock_resolve.return_value = (mock_client, "claude-opus-4-8")
|
||||
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "custom:copilot-gateway"
|
||||
assert client is mock_client
|
||||
kwargs = mock_resolve.call_args.kwargs
|
||||
assert kwargs.get("explicit_base_url") == "https://named.example/v1"
|
||||
assert kwargs.get("explicit_api_key") == "sk-named"
|
||||
assert kwargs.get("is_vision") is True
|
||||
|
||||
def test_custom_main_no_runtime_falls_back_to_configured_endpoint(self, monkeypatch):
|
||||
"""No recorded runtime endpoint → resolve the configured custom endpoint."""
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_BASE_URL", "")
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_KEY", "")
|
||||
monkeypatch.setattr(aux, "_RUNTIME_MAIN_API_MODE", "")
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="custom",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="claude-opus-4-8",
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_custom_runtime",
|
||||
return_value=("https://configured.example/v1", "sk-configured", "chat_completions"),
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_client = MagicMock()
|
||||
mock_resolve.return_value = (mock_client, "claude-opus-4-8")
|
||||
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert client is mock_client
|
||||
kwargs = mock_resolve.call_args.kwargs
|
||||
assert kwargs.get("explicit_base_url") == "https://configured.example/v1"
|
||||
assert kwargs.get("explicit_api_key") == "sk-configured"
|
||||
|
||||
|
||||
# ── Constant cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -97,11 +97,21 @@ class TestDecideImageInputMode:
|
|||
with patch("agent.image_routing._lookup_supports_vision", return_value=None):
|
||||
assert decide_image_input_mode("openrouter", "brand-new-slug", {}) == "text"
|
||||
|
||||
def test_auto_respects_aux_vision_override_even_for_vision_model(self):
|
||||
"""If the user configured a dedicated vision backend, don't bypass it."""
|
||||
def test_auto_prefers_native_for_vision_capable_main_model_even_with_aux_configured(self):
|
||||
"""Regression #29135: vision-capable main model wins over aux fallback.
|
||||
|
||||
Auxiliary.vision is a fallback for text-only main models; it must
|
||||
not preempt native vision on a vision-capable main model.
|
||||
"""
|
||||
cfg = {"auxiliary": {"vision": {"provider": "openrouter", "model": "google/gemini-2.5-flash"}}}
|
||||
with patch("agent.image_routing._lookup_supports_vision", return_value=True):
|
||||
assert decide_image_input_mode("anthropic", "claude-sonnet-4", cfg) == "text"
|
||||
assert decide_image_input_mode("anthropic", "claude-sonnet-4", cfg) == "native"
|
||||
|
||||
def test_auto_uses_aux_vision_fallback_for_text_only_main_model(self):
|
||||
"""#29135: aux vision still acts as fallback for non-vision main models."""
|
||||
cfg = {"auxiliary": {"vision": {"provider": "openrouter", "model": "google/gemini-2.5-flash"}}}
|
||||
with patch("agent.image_routing._lookup_supports_vision", return_value=False):
|
||||
assert decide_image_input_mode("deepseek", "deepseek-v4-pro", cfg) == "text"
|
||||
|
||||
def test_none_config_is_auto(self):
|
||||
with patch("agent.image_routing._lookup_supports_vision", return_value=True):
|
||||
|
|
@ -224,6 +234,37 @@ class TestSupportsVisionOverride:
|
|||
cfg = {"model": "some-string", "providers": ["not-a-dict"]}
|
||||
assert _supports_vision_override(cfg, "custom", "my-llava") is None
|
||||
|
||||
def test_custom_colon_name_stripped_suffix_lookup(self):
|
||||
# model.provider: custom:my-proxy → should resolve stripped key "my-proxy"
|
||||
cfg = {
|
||||
"model": {"provider": "custom:my-proxy"},
|
||||
"providers": {
|
||||
"my-proxy": {"models": {"gpt-5.5": {"supports_vision": True}}},
|
||||
},
|
||||
}
|
||||
assert _supports_vision_override(cfg, "custom", "gpt-5.5") is True
|
||||
|
||||
def test_custom_colon_name_stripped_suffix_false(self):
|
||||
# Explicitly disabled vision on the stripped key.
|
||||
cfg = {
|
||||
"model": {"provider": "custom:my-proxy"},
|
||||
"providers": {
|
||||
"my-proxy": {"models": {"gpt-5.5": {"supports_vision": False}}},
|
||||
},
|
||||
}
|
||||
assert _supports_vision_override(cfg, "custom", "gpt-5.5") is False
|
||||
|
||||
def test_custom_colon_name_no_stripped_key_falls_through(self):
|
||||
# custom:my-proxy but providers only has "custom" — stripped key
|
||||
# doesn't match, but "custom" does via runtime provider.
|
||||
cfg = {
|
||||
"model": {"provider": "custom:my-proxy"},
|
||||
"providers": {
|
||||
"custom": {"models": {"gpt-5.5": {"supports_vision": True}}},
|
||||
},
|
||||
}
|
||||
assert _supports_vision_override(cfg, "custom", "gpt-5.5") is True
|
||||
|
||||
|
||||
# ─── _lookup_supports_vision (override-aware) ────────────────────────────────
|
||||
|
||||
|
|
@ -294,15 +335,25 @@ class TestAutoModeRespectsOverride:
|
|||
with patch("agent.models_dev.get_model_capabilities", return_value=None):
|
||||
assert decide_image_input_mode("custom", "unknown", {}) == "text"
|
||||
|
||||
def test_explicit_aux_vision_override_still_wins(self):
|
||||
# If the user has configured a dedicated vision aux backend, respect
|
||||
# it even when supports_vision: true is also set.
|
||||
def test_explicit_aux_vision_no_longer_overrides_native_capable_main(self):
|
||||
# #29135: aux.vision is a fallback for text-only main models; it
|
||||
# must NOT preempt native routing when the main model can take
|
||||
# images directly (supports_vision: true).
|
||||
cfg = {
|
||||
"model": {"supports_vision": True},
|
||||
"auxiliary": {"vision": {"provider": "openrouter", "model": "gemini-2.5-pro"}},
|
||||
}
|
||||
with patch("agent.models_dev.get_model_capabilities", return_value=None):
|
||||
assert decide_image_input_mode("custom", "qwen3.6-35b", cfg) == "text"
|
||||
assert decide_image_input_mode("custom", "qwen3.6-35b", cfg) == "native"
|
||||
|
||||
def test_explicit_aux_vision_used_when_main_model_supports_vision_false(self):
|
||||
# #29135 counterpart: text-only main model + aux fallback → text.
|
||||
cfg = {
|
||||
"model": {"supports_vision": False},
|
||||
"auxiliary": {"vision": {"provider": "openrouter", "model": "gemini-2.5-pro"}},
|
||||
}
|
||||
with patch("agent.models_dev.get_model_capabilities", return_value=None):
|
||||
assert decide_image_input_mode("custom", "deepseek-v4", cfg) == "text"
|
||||
|
||||
|
||||
# ─── build_native_content_parts ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -117,3 +117,46 @@ def test_run_one_job_exception_marks_failure(monkeypatch):
|
|||
|
||||
assert ok is False
|
||||
assert marks == [("j6", False)]
|
||||
|
||||
|
||||
def test_run_one_job_installs_secret_scope_under_multiplex(monkeypatch, tmp_path):
|
||||
"""Regression: under profile isolation (multiplex active), run_one_job must
|
||||
execute run_job inside a profile secret scope so credential reads
|
||||
(resolve_runtime_provider -> get_secret) don't fail-close with
|
||||
UnscopedSecretError, and must tear the scope down afterward.
|
||||
|
||||
Behavior contract: a scope is present during run_job and absent after,
|
||||
regardless of the concrete secret values.
|
||||
"""
|
||||
from agent import secret_scope as ss
|
||||
|
||||
# Point cron's home resolution at a profile whose .env carries a secret.
|
||||
(tmp_path / ".env").write_text("OPENROUTER_BASE_URL=https://openrouter.ai/api/v1\n")
|
||||
monkeypatch.setattr(s, "_get_hermes_home", lambda: tmp_path)
|
||||
|
||||
scope_during_run = {}
|
||||
|
||||
def fake_run_job(job):
|
||||
# This is where resolve_runtime_provider() would read a secret. Prove a
|
||||
# scope is installed and the profile's secret resolves without raising.
|
||||
scope_during_run["scope"] = ss.current_secret_scope()
|
||||
scope_during_run["base_url"] = ss.get_secret("OPENROUTER_BASE_URL")
|
||||
return (True, "out", "final", None)
|
||||
|
||||
monkeypatch.setattr(s, "run_job", fake_run_job)
|
||||
monkeypatch.setattr(s, "save_job_output", lambda jid, out: f"/tmp/{jid}.txt")
|
||||
monkeypatch.setattr(s, "_deliver_result", lambda *a, **k: None)
|
||||
monkeypatch.setattr(s, "mark_job_run", lambda *a, **k: None)
|
||||
|
||||
ss.set_multiplex_active(True)
|
||||
try:
|
||||
ok = s.run_one_job({"id": "j7", "name": "t"})
|
||||
finally:
|
||||
ss.set_multiplex_active(False)
|
||||
|
||||
assert ok is True
|
||||
# Scope was installed during run_job and the profile secret resolved.
|
||||
assert scope_during_run["scope"] is not None
|
||||
assert scope_during_run["base_url"] == "https://openrouter.ai/api/v1"
|
||||
# And it was torn down after run_one_job returned (no leak).
|
||||
assert ss.current_secret_scope() is None
|
||||
|
|
|
|||
|
|
@ -72,6 +72,40 @@ class TestResolveMediaToDataUrls(unittest.TestCase):
|
|||
out = _resolve_media_to_data_urls(f"MEDIA:{p1}\nand MEDIA:{p2}")
|
||||
self.assertEqual(out.count("data:image/png;base64,"), 2)
|
||||
|
||||
def test_relative_traversal_path_not_inlined(self):
|
||||
"""A relative/traversal path must never be inlined — the anchored
|
||||
MEDIA_TAG_CLEANUP_RE matcher requires an absolute-path prefix
|
||||
(~/, /, or a Windows drive letter), so a bare relative token after
|
||||
MEDIA: is left as literal text rather than resolved against cwd."""
|
||||
text = "MEDIA:../../../../etc/passwd.png"
|
||||
self.assertEqual(_resolve_media_to_data_urls(text), text)
|
||||
|
||||
def test_credential_path_not_inlined_even_with_image_extension(self):
|
||||
"""An absolute path under the credential/system-path denylist
|
||||
(validate_media_delivery_path) must not be inlined even though it
|
||||
has an allowed image extension and the tag matcher's shape."""
|
||||
text = "MEDIA:~/.ssh/id_rsa.png"
|
||||
self.assertEqual(_resolve_media_to_data_urls(text), text)
|
||||
|
||||
def test_symlink_escaping_to_denylisted_target_not_inlined(self):
|
||||
"""A symlink whose resolved target lands under a denylisted system
|
||||
prefix (/etc) must not be inlined — validate_media_delivery_path
|
||||
resolves symlinks before the containment/denylist check runs, so
|
||||
the traversal can't be laundered through an innocuous-looking
|
||||
image-suffixed symlink name."""
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
d = Path(tempfile.mkdtemp(prefix="hermes_media_test_symlink"))
|
||||
link = d / "shot.png"
|
||||
try:
|
||||
os.symlink("/etc/hosts", link)
|
||||
except OSError:
|
||||
self.skipTest("symlink creation not supported in this environment")
|
||||
text = f"MEDIA:{link}"
|
||||
self.assertEqual(_resolve_media_to_data_urls(text), text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
140
tests/gateway/test_image_input_routing_runtime.py
Normal file
140
tests/gateway/test_image_input_routing_runtime.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_runner() -> GatewayRunner:
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake")}
|
||||
)
|
||||
runner.adapters = {}
|
||||
runner._pending_native_image_paths_by_session = {}
|
||||
runner._session_model_overrides = {}
|
||||
runner._session_reasoning_overrides = {}
|
||||
return runner
|
||||
|
||||
|
||||
def _source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="273403055",
|
||||
chat_type="dm",
|
||||
user_id="42",
|
||||
user_name="Maxim",
|
||||
)
|
||||
|
||||
|
||||
def _image_event(text: str = "look") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.PHOTO,
|
||||
source=_source(),
|
||||
media_urls=["/tmp/cashback.png"],
|
||||
media_types=["image/png"],
|
||||
)
|
||||
|
||||
|
||||
def _auto_config() -> dict:
|
||||
return {
|
||||
"agent": {"image_input_mode": "auto"},
|
||||
"auxiliary": {"vision": {"provider": "auto", "model": "", "base_url": ""}},
|
||||
"model": {"provider": "xiaomi", "default": "mimo-v2.5-pro"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_image_routing_uses_session_vision_model_override(monkeypatch):
|
||||
"""Telegram /model overrides must affect native-vs-text image routing.
|
||||
|
||||
Regression: _prepare_inbound_message_text used config.yaml's default model
|
||||
before the per-session model override was installed on auxiliary_client's
|
||||
runtime globals. A Telegram session switched to a vision model still had
|
||||
screenshots pre-analyzed as text when config.default was text-only.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
source = _source()
|
||||
event = _image_event()
|
||||
cfg = _auto_config()
|
||||
|
||||
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: cfg)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg)
|
||||
monkeypatch.setattr("agent.auxiliary_client._read_main_provider", lambda: "xiaomi")
|
||||
monkeypatch.setattr("agent.auxiliary_client._read_main_model", lambda: "mimo-v2.5-pro")
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_resolve_session_agent_runtime",
|
||||
lambda **_: ("gpt-5.5", {"provider": "openai-codex"}),
|
||||
)
|
||||
|
||||
def fake_supports(provider, model, config):
|
||||
return provider == "openai-codex" and model == "gpt-5.5"
|
||||
|
||||
monkeypatch.setattr("agent.image_routing._lookup_supports_vision", fake_supports)
|
||||
|
||||
async def fail_enrich(*_args, **_kwargs):
|
||||
pytest.fail("vision-capable session override should use native image routing")
|
||||
|
||||
monkeypatch.setattr(runner, "_enrich_message_with_vision", fail_enrich)
|
||||
|
||||
result = await runner._prepare_inbound_message_text(
|
||||
event=event,
|
||||
source=source,
|
||||
history=[],
|
||||
)
|
||||
|
||||
session_key = runner._session_key_for_source(source)
|
||||
assert result == "look"
|
||||
assert runner._pending_native_image_paths_by_session[session_key] == [
|
||||
"/tmp/cashback.png"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_image_routing_falls_back_to_text_for_text_only_session_override(monkeypatch):
|
||||
"""A text-only session override should get vision_analyze text fallback.
|
||||
|
||||
Regression mirror case: if config.default is a vision model but the current
|
||||
Telegram session is switched to a text-only provider (for example Mimo),
|
||||
auto routing must not attach pixels natively to the text-only model.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
source = _source()
|
||||
event = _image_event()
|
||||
cfg = _auto_config()
|
||||
cfg["model"] = {"provider": "openai-codex", "default": "gpt-5.5"}
|
||||
|
||||
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: cfg)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg)
|
||||
monkeypatch.setattr("agent.auxiliary_client._read_main_provider", lambda: "openai-codex")
|
||||
monkeypatch.setattr("agent.auxiliary_client._read_main_model", lambda: "gpt-5.5")
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_resolve_session_agent_runtime",
|
||||
lambda **_: ("mimo-v2.5-pro", {"provider": "xiaomi"}),
|
||||
)
|
||||
|
||||
def fake_supports(provider, model, config):
|
||||
return provider == "openai-codex" and model == "gpt-5.5"
|
||||
|
||||
monkeypatch.setattr("agent.image_routing._lookup_supports_vision", fake_supports)
|
||||
|
||||
async def fake_enrich(user_text, image_paths):
|
||||
assert user_text == "look"
|
||||
assert image_paths == ["/tmp/cashback.png"]
|
||||
return "[vision summary]\n\nlook"
|
||||
|
||||
monkeypatch.setattr(runner, "_enrich_message_with_vision", fake_enrich)
|
||||
|
||||
result = await runner._prepare_inbound_message_text(
|
||||
event=event,
|
||||
source=source,
|
||||
history=[],
|
||||
)
|
||||
|
||||
session_key = runner._session_key_for_source(source)
|
||||
assert result == "[vision summary]\n\nlook"
|
||||
assert runner._pending_native_image_paths_by_session.get(session_key) is None
|
||||
|
|
@ -5276,3 +5276,36 @@ class TestDeviceIdRecoveryOnReconnect:
|
|||
assert None not in _verify_call.args[0]["@bot:example.org"]
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
|
||||
class TestMatrixDispatchSyncIsolation:
|
||||
"""A failing mautrix event handler must not abort the whole sync batch.
|
||||
|
||||
``_dispatch_sync`` gathers the per-event handler tasks. Without
|
||||
``return_exceptions=True`` the first exception aborts the gather and the
|
||||
sibling events in the same sync response are silently dropped.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_sync_isolates_failing_handler(self, caplog):
|
||||
import logging
|
||||
|
||||
adapter = _make_adapter()
|
||||
ran = {"ok": False}
|
||||
|
||||
async def _boom():
|
||||
raise RuntimeError("handler boom")
|
||||
|
||||
async def _ok():
|
||||
ran["ok"] = True
|
||||
|
||||
client = MagicMock()
|
||||
client.handle_sync = MagicMock(return_value=[_boom(), _ok()])
|
||||
adapter._client = client
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
# Must not raise despite the failing handler.
|
||||
await adapter._dispatch_sync({"next_batch": "s1"})
|
||||
|
||||
assert ran["ok"] is True # the sibling handler still ran
|
||||
assert "event handler failed" in caplog.text # failure surfaced, not swallowed
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ def _make_runner() -> GatewayRunner:
|
|||
runner.adapters = {}
|
||||
runner._model = "openai/gpt-4.1-mini"
|
||||
runner._base_url = None
|
||||
runner._decide_image_input_mode = lambda: "native"
|
||||
runner._decide_image_input_mode = lambda **_: "native"
|
||||
return runner
|
||||
|
||||
|
||||
|
|
@ -77,3 +77,20 @@ async def test_native_image_buffer_not_cleared_by_other_sessions_without_images(
|
|||
|
||||
assert runner._consume_pending_native_image_paths(build_session_key(source_a)) == ["/tmp/a.png"]
|
||||
assert runner._consume_pending_native_image_paths(build_session_key(source_b)) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_image_buffer_uses_resolved_session_key_when_provided():
|
||||
runner = _make_runner()
|
||||
source = _source("chat-a")
|
||||
runner._session_key_for_source = lambda _source: "source-derived-key"
|
||||
|
||||
await runner._prepare_inbound_message_text(
|
||||
event=_image_event(source, "/tmp/a.png"),
|
||||
source=source,
|
||||
history=[],
|
||||
session_key="canonical-session-key",
|
||||
)
|
||||
|
||||
assert runner._consume_pending_native_image_paths("source-derived-key") == []
|
||||
assert runner._consume_pending_native_image_paths("canonical-session-key") == ["/tmp/a.png"]
|
||||
|
|
|
|||
151
tests/gateway/test_queued_native_image_session_key.py
Normal file
151
tests/gateway/test_queued_native_image_session_key.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import base64
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
_ONE_BY_ONE_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6L2ioAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class CaptureAdapter(BasePlatformAdapter):
|
||||
def __init__(self):
|
||||
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM)
|
||||
self.sent = []
|
||||
self.typing = []
|
||||
|
||||
async def connect(self) -> bool:
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
return None
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
||||
self.sent.append(
|
||||
{
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"reply_to": reply_to,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
return SendResult(success=True, message_id="sent-1")
|
||||
|
||||
async def send_typing(self, chat_id, metadata=None) -> None:
|
||||
self.typing.append({"chat_id": chat_id, "metadata": metadata})
|
||||
|
||||
async def stop_typing(self, chat_id) -> None:
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id: str):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
class CaptureQueuedNativeImageAgent:
|
||||
calls = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tools = []
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
type(self).calls.append(message)
|
||||
return {
|
||||
"final_response": f"done-{len(type(self).calls)}",
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
}
|
||||
|
||||
|
||||
def _make_runner(adapter):
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {adapter.platform: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner._prefill_messages = []
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._session_db = None
|
||||
runner._running_agents = {}
|
||||
runner._session_run_generation = {}
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
||||
runner.config = SimpleNamespace(
|
||||
thread_sessions_per_user=False,
|
||||
group_sessions_per_user=False,
|
||||
stt_enabled=False,
|
||||
)
|
||||
runner._model = "openai/gpt-4.1-mini"
|
||||
runner._base_url = None
|
||||
runner._decide_image_input_mode = lambda **_kw: "native"
|
||||
return runner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_followup_uses_pending_event_session_key_for_native_images(monkeypatch, tmp_path):
|
||||
CaptureQueuedNativeImageAgent.calls = []
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = CaptureQueuedNativeImageAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
adapter = CaptureAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
||||
image_path = tmp_path / "queued-image.png"
|
||||
image_path.write_bytes(_ONE_BY_ONE_PNG)
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_type="group",
|
||||
)
|
||||
pending_source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
)
|
||||
|
||||
adapter._pending_messages["agent:main:telegram:group:-1001"] = MessageEvent(
|
||||
text="describe this",
|
||||
message_type=MessageType.PHOTO,
|
||||
source=pending_source,
|
||||
media_urls=[str(image_path)],
|
||||
media_types=["image/png"],
|
||||
message_id="queued-1",
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-native-image-followup",
|
||||
session_key="agent:main:telegram:group:-1001",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done-2"
|
||||
assert len(CaptureQueuedNativeImageAgent.calls) == 2
|
||||
queued_message = CaptureQueuedNativeImageAgent.calls[1]
|
||||
assert isinstance(queued_message, list)
|
||||
assert queued_message[0]["type"] == "text"
|
||||
assert queued_message[0]["text"].startswith("describe this")
|
||||
assert any(part.get("type") == "image_url" for part in queued_message)
|
||||
681
tests/hermes_cli/test_console_engine.py
Normal file
681
tests/hermes_cli/test_console_engine.py
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.console_engine import HermesConsoleEngine, run_console_repl
|
||||
|
||||
|
||||
EXPECTED_CONSOLE_COMMANDS = {
|
||||
("status",),
|
||||
("doctor",),
|
||||
("logs",),
|
||||
("version",),
|
||||
("dump",),
|
||||
("debug", "share"),
|
||||
("debug", "delete"),
|
||||
("prompt-size",),
|
||||
("insights",),
|
||||
("security", "audit"),
|
||||
("portal", "info"),
|
||||
("portal", "tools"),
|
||||
("backup",),
|
||||
("import",),
|
||||
("send",),
|
||||
("config", "show"),
|
||||
("config", "path"),
|
||||
("config", "env-path"),
|
||||
("config", "check"),
|
||||
("config", "migrate"),
|
||||
("config", "set"),
|
||||
("sessions", "list"),
|
||||
("sessions", "stats"),
|
||||
("sessions", "export"),
|
||||
("sessions", "rename"),
|
||||
("sessions", "optimize"),
|
||||
("sessions", "repair"),
|
||||
("cron", "list"),
|
||||
("cron", "status"),
|
||||
("cron", "create"),
|
||||
("cron", "edit"),
|
||||
("cron", "pause"),
|
||||
("cron", "resume"),
|
||||
("cron", "run"),
|
||||
("cron", "remove"),
|
||||
("cron", "tick"),
|
||||
("profile",),
|
||||
("profile", "list"),
|
||||
("profile", "show"),
|
||||
("profile", "info"),
|
||||
("profile", "create"),
|
||||
("profile", "use"),
|
||||
("profile", "describe"),
|
||||
("profile", "rename"),
|
||||
("profile", "delete"),
|
||||
("profile", "export"),
|
||||
("profile", "import"),
|
||||
("profile", "install"),
|
||||
("profile", "update"),
|
||||
("tools", "list"),
|
||||
("tools", "enable"),
|
||||
("tools", "disable"),
|
||||
("tools", "post-setup"),
|
||||
("plugins", "list"),
|
||||
("plugins", "enable"),
|
||||
("plugins", "disable"),
|
||||
("plugins", "install"),
|
||||
("plugins", "update"),
|
||||
("plugins", "remove"),
|
||||
("skills", "browse"),
|
||||
("skills", "search"),
|
||||
("skills", "inspect"),
|
||||
("skills", "list"),
|
||||
("skills", "check"),
|
||||
("skills", "list-modified"),
|
||||
("skills", "diff"),
|
||||
("skills", "install"),
|
||||
("skills", "update"),
|
||||
("skills", "audit"),
|
||||
("skills", "uninstall"),
|
||||
("skills", "reset"),
|
||||
("skills", "opt-in"),
|
||||
("skills", "opt-out"),
|
||||
("skills", "repair-official"),
|
||||
("skills", "snapshot", "export"),
|
||||
("skills", "snapshot", "import"),
|
||||
("skills", "tap", "list"),
|
||||
("skills", "tap", "add"),
|
||||
("skills", "tap", "remove"),
|
||||
("mcp", "list"),
|
||||
("mcp", "catalog"),
|
||||
("mcp", "test"),
|
||||
("mcp", "add"),
|
||||
("mcp", "remove"),
|
||||
("mcp", "install"),
|
||||
("mcp", "login"),
|
||||
("mcp", "reauth"),
|
||||
("mcp", "configure"),
|
||||
("mcp", "picker"),
|
||||
("memory", "status"),
|
||||
("memory", "off"),
|
||||
("memory", "reset"),
|
||||
("auth", "list"),
|
||||
("auth", "status"),
|
||||
("auth", "reset"),
|
||||
("auth", "add"),
|
||||
("auth", "remove"),
|
||||
("auth", "logout"),
|
||||
("auth", "spotify", "status"),
|
||||
("auth", "spotify", "login"),
|
||||
("auth", "spotify", "logout"),
|
||||
("pairing", "list"),
|
||||
("pairing", "approve"),
|
||||
("pairing", "revoke"),
|
||||
("pairing", "clear-pending"),
|
||||
("webhook", "list"),
|
||||
("webhook", "subscribe"),
|
||||
("webhook", "remove"),
|
||||
("webhook", "test"),
|
||||
("hooks", "list"),
|
||||
("hooks", "test"),
|
||||
("hooks", "doctor"),
|
||||
("hooks", "revoke"),
|
||||
("slack", "manifest"),
|
||||
("project", "list"),
|
||||
("project", "show"),
|
||||
("project", "create"),
|
||||
("project", "add-folder"),
|
||||
("project", "remove-folder"),
|
||||
("project", "rename"),
|
||||
("project", "set-primary"),
|
||||
("project", "use"),
|
||||
("project", "archive"),
|
||||
("project", "restore"),
|
||||
("project", "bind-board"),
|
||||
("kanban", "init"),
|
||||
("kanban", "boards", "list"),
|
||||
("kanban", "boards", "create"),
|
||||
("kanban", "boards", "rm"),
|
||||
("kanban", "boards", "switch"),
|
||||
("kanban", "boards", "current"),
|
||||
("kanban", "boards", "rename"),
|
||||
("kanban", "boards", "set-workdir"),
|
||||
("kanban", "create"),
|
||||
("kanban", "list"),
|
||||
("kanban", "show"),
|
||||
("kanban", "assign"),
|
||||
("kanban", "reclaim"),
|
||||
("kanban", "reassign"),
|
||||
("kanban", "diagnose"),
|
||||
("kanban", "link"),
|
||||
("kanban", "unlink"),
|
||||
("kanban", "claim"),
|
||||
("kanban", "comment"),
|
||||
("kanban", "complete"),
|
||||
("kanban", "edit"),
|
||||
("kanban", "block"),
|
||||
("kanban", "schedule"),
|
||||
("kanban", "unblock"),
|
||||
("kanban", "promote"),
|
||||
("kanban", "archive"),
|
||||
("kanban", "stats"),
|
||||
("kanban", "runs"),
|
||||
("kanban", "heartbeat"),
|
||||
("kanban", "assignments"),
|
||||
("kanban", "context"),
|
||||
("bundles", "list"),
|
||||
("bundles", "show"),
|
||||
("bundles", "create"),
|
||||
("bundles", "delete"),
|
||||
("bundles", "reload"),
|
||||
("checkpoints", "status"),
|
||||
("checkpoints", "list"),
|
||||
("checkpoints", "prune"),
|
||||
("checkpoints", "clear"),
|
||||
("checkpoints", "clear-legacy"),
|
||||
("curator", "status"),
|
||||
("curator", "run"),
|
||||
("curator", "pause"),
|
||||
("curator", "resume"),
|
||||
("curator", "pin"),
|
||||
("curator", "unpin"),
|
||||
("curator", "restore"),
|
||||
("curator", "list-archived"),
|
||||
("curator", "archive"),
|
||||
("curator", "prune"),
|
||||
("curator", "backup"),
|
||||
("curator", "rollback"),
|
||||
("pets", "list"),
|
||||
("pets", "install"),
|
||||
("pets", "select"),
|
||||
("pets", "show"),
|
||||
("pets", "off"),
|
||||
("pets", "scale"),
|
||||
("pets", "remove"),
|
||||
("pets", "doctor"),
|
||||
}
|
||||
|
||||
|
||||
MUTATING_CONFIRMATION_SMOKE_COMMANDS = [
|
||||
"config set console.test true",
|
||||
"config migrate",
|
||||
"sessions rename abc123 new title",
|
||||
"sessions optimize",
|
||||
"cron create 'every 1h' 'say hello'",
|
||||
"cron remove abc123",
|
||||
"profile create tester --no-alias --no-skills",
|
||||
"profile delete tester",
|
||||
"tools disable web",
|
||||
"plugins install owner/repo --no-enable",
|
||||
"skills install openai/skills/example",
|
||||
"mcp add demo --url https://example.com/sse",
|
||||
"mcp configure github",
|
||||
"mcp picker",
|
||||
"backup --quick -o /tmp/hermes-console-test.zip",
|
||||
"import /tmp/hermes-console-test.zip",
|
||||
"send --to telegram hello",
|
||||
"memory reset --target memory",
|
||||
"auth remove openrouter 1",
|
||||
"pairing approve abc123",
|
||||
"webhook subscribe test --prompt hello",
|
||||
"hooks test pre_tool_call",
|
||||
"project create demo",
|
||||
"kanban create 'demo task'",
|
||||
"bundles create demo --skill skill-a",
|
||||
"checkpoints prune",
|
||||
"curator pause",
|
||||
"pets install cat",
|
||||
]
|
||||
|
||||
|
||||
def test_console_parses_bare_and_hermes_prefixed_commands(_isolate_hermes_home):
|
||||
engine = HermesConsoleEngine()
|
||||
|
||||
bare = engine.execute("config path")
|
||||
prefixed = engine.execute("hermes config path")
|
||||
|
||||
assert bare.status == "ok"
|
||||
assert prefixed.status == "ok"
|
||||
assert bare.output == prefixed.output
|
||||
assert bare.output.endswith("config.yaml")
|
||||
|
||||
|
||||
def test_console_status_hides_cli_next_step_footer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolate_hermes_home,
|
||||
):
|
||||
import hermes_cli.status as status_mod
|
||||
|
||||
def fake_show_status(_args):
|
||||
print("◆ Sessions")
|
||||
print("Active: 3 session(s)")
|
||||
print()
|
||||
rule = "\u2500" * 60
|
||||
print(f"\x1b[2m{rule}\x1b[0m")
|
||||
print("\x1b[2m Run 'hermes doctor' for detailed diagnostics\x1b[0m")
|
||||
print("\x1b[2m Run 'hermes setup' to configure\x1b[0m")
|
||||
print()
|
||||
|
||||
monkeypatch.setattr(status_mod, "show_status", fake_show_status)
|
||||
|
||||
result = HermesConsoleEngine().execute("status")
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "Sessions" in result.output
|
||||
assert "Active: 3 session(s)" in result.output
|
||||
assert "hermes doctor" not in result.output
|
||||
assert "hermes setup" not in result.output
|
||||
assert "\u2500" not in result.output
|
||||
|
||||
|
||||
def test_console_status_hides_osc_linked_cli_next_step_footer(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolate_hermes_home,
|
||||
):
|
||||
import hermes_cli.status as status_mod
|
||||
|
||||
def osc_link(text: str) -> str:
|
||||
return f"\x1b]8;;https://example.test\x1b\\{text}\x1b]8;;\x1b\\"
|
||||
|
||||
def fake_show_status(_args):
|
||||
print("◆ Sessions")
|
||||
print("Active: 3 session(s)")
|
||||
print()
|
||||
print(osc_link("\u2500" * 60))
|
||||
print(osc_link(" Run 'hermes doctor' for detailed diagnostics"))
|
||||
print(osc_link(" Run 'hermes setup' to configure"))
|
||||
print()
|
||||
|
||||
monkeypatch.setattr(status_mod, "show_status", fake_show_status)
|
||||
|
||||
result = HermesConsoleEngine().execute("status")
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "Sessions" in result.output
|
||||
assert "Active: 3 session(s)" in result.output
|
||||
assert "hermes doctor" not in result.output
|
||||
assert "hermes setup" not in result.output
|
||||
assert "https://example.test" not in result.output
|
||||
assert "\u2500" not in result.output
|
||||
|
||||
|
||||
def test_console_help_uses_cli_subcommand_summaries():
|
||||
help_text = HermesConsoleEngine().help_text()
|
||||
|
||||
assert "skills list" in help_text
|
||||
assert "List installed skills" in help_text
|
||||
assert "Show all tools and their enabled/disabled status" in help_text
|
||||
assert "Remove an MCP server" in help_text
|
||||
assert "Check pet setup + terminal graphics support" in help_text
|
||||
assert "Run `hermes skills list`" not in help_text
|
||||
assert "Run `hermes tools list`" not in help_text
|
||||
|
||||
|
||||
def test_console_help_table_keeps_long_summaries_compact():
|
||||
help_text = HermesConsoleEngine().help_text()
|
||||
|
||||
slack_line = next(
|
||||
line for line in help_text.splitlines() if line.strip().startswith("slack manifest")
|
||||
)
|
||||
|
||||
assert len(slack_line) <= 112
|
||||
assert slack_line.endswith("...")
|
||||
|
||||
|
||||
def test_console_help_for_command_uses_cli_summary():
|
||||
help_text = HermesConsoleEngine().help_text("skills list")
|
||||
|
||||
assert help_text == "skills list\nList installed skills"
|
||||
|
||||
|
||||
def test_console_registry_covers_non_admin_cli_surface():
|
||||
registered = set(HermesConsoleEngine().commands)
|
||||
|
||||
missing = EXPECTED_CONSOLE_COMMANDS - registered
|
||||
|
||||
assert missing == set()
|
||||
|
||||
|
||||
EXPECTED_HOSTED_CONSOLE_COMMANDS = {
|
||||
("status",),
|
||||
("doctor",),
|
||||
("logs",),
|
||||
("version",),
|
||||
("prompt-size",),
|
||||
("insights",),
|
||||
("security", "audit"),
|
||||
("portal", "info"),
|
||||
("portal", "tools"),
|
||||
("send",),
|
||||
("config", "show"),
|
||||
("config", "path"),
|
||||
("config", "env-path"),
|
||||
("config", "check"),
|
||||
("config", "migrate"),
|
||||
("config", "set"),
|
||||
("sessions", "list"),
|
||||
("sessions", "stats"),
|
||||
("sessions", "export"),
|
||||
("sessions", "rename"),
|
||||
("sessions", "optimize"),
|
||||
("sessions", "repair"),
|
||||
("cron", "list"),
|
||||
("cron", "status"),
|
||||
("cron", "create"),
|
||||
("cron", "edit"),
|
||||
("cron", "pause"),
|
||||
("cron", "resume"),
|
||||
("cron", "run"),
|
||||
("cron", "remove"),
|
||||
("cron", "tick"),
|
||||
("profile",),
|
||||
("profile", "list"),
|
||||
("profile", "show"),
|
||||
("profile", "info"),
|
||||
("tools", "list"),
|
||||
("tools", "enable"),
|
||||
("tools", "disable"),
|
||||
("tools", "post-setup"),
|
||||
("skills", "browse"),
|
||||
("skills", "search"),
|
||||
("skills", "inspect"),
|
||||
("skills", "list"),
|
||||
("skills", "check"),
|
||||
("skills", "list-modified"),
|
||||
("skills", "diff"),
|
||||
("skills", "install"),
|
||||
("skills", "update"),
|
||||
("skills", "audit"),
|
||||
("skills", "uninstall"),
|
||||
("skills", "reset"),
|
||||
("skills", "opt-in"),
|
||||
("skills", "opt-out"),
|
||||
("skills", "repair-official"),
|
||||
("skills", "snapshot", "export"),
|
||||
("skills", "tap", "list"),
|
||||
("mcp", "list"),
|
||||
("mcp", "catalog"),
|
||||
("mcp", "test"),
|
||||
("mcp", "add"),
|
||||
("mcp", "remove"),
|
||||
("mcp", "install"),
|
||||
("mcp", "login"),
|
||||
("mcp", "reauth"),
|
||||
("mcp", "configure"),
|
||||
("mcp", "picker"),
|
||||
("memory", "status"),
|
||||
("auth", "list"),
|
||||
("auth", "status"),
|
||||
("auth", "reset"),
|
||||
("auth", "spotify", "status"),
|
||||
("pairing", "list"),
|
||||
("pairing", "approve"),
|
||||
("pairing", "revoke"),
|
||||
("pairing", "clear-pending"),
|
||||
("webhook", "list"),
|
||||
("webhook", "subscribe"),
|
||||
("webhook", "remove"),
|
||||
("webhook", "test"),
|
||||
}
|
||||
|
||||
|
||||
def test_hosted_console_registry_exposes_only_hosted_safe_surface():
|
||||
engine = HermesConsoleEngine(context="hosted")
|
||||
hosted = {
|
||||
path for path, command in engine.commands.items() if "hosted" in command.contexts
|
||||
}
|
||||
|
||||
assert hosted == EXPECTED_HOSTED_CONSOLE_COMMANDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"portal login",
|
||||
"auth add nous --type oauth",
|
||||
"auth logout nous",
|
||||
"profile create tester",
|
||||
"profile use default",
|
||||
"plugins list",
|
||||
"plugins install owner/repo",
|
||||
"kanban list",
|
||||
"hooks list",
|
||||
"checkpoints clear",
|
||||
"curator pause",
|
||||
"pets install cat",
|
||||
"backup --quick",
|
||||
"import /tmp/hermes-console-test.zip",
|
||||
"mcp serve",
|
||||
"model",
|
||||
"setup",
|
||||
"dashboard",
|
||||
"gateway restart",
|
||||
"update",
|
||||
"uninstall",
|
||||
],
|
||||
)
|
||||
def test_hosted_console_rejects_local_only_or_dangerous_commands(line):
|
||||
result = HermesConsoleEngine(context="hosted").execute(line)
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"mcp add demo --url https://example.com/sse",
|
||||
"mcp install n8n",
|
||||
"mcp configure github",
|
||||
"mcp picker",
|
||||
"config set display.interface cli",
|
||||
"cron create 'every 1h' 'say hello'",
|
||||
],
|
||||
)
|
||||
def test_hosted_console_allows_guarded_useful_commands_before_confirmation(line):
|
||||
result = HermesConsoleEngine(context="hosted").execute(line)
|
||||
|
||||
assert result.status == "confirm_required"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"mcp add local --command npx --args foo",
|
||||
"mcp add local --preset unsafe",
|
||||
"mcp add local --url file:///tmp/server",
|
||||
"config set model.provider openrouter",
|
||||
"config set portal.url https://evil.example",
|
||||
"cron create 'every 1h' 'say hello' --script scripts/ping.py",
|
||||
"cron create 'every 1h' 'say hello' --no-agent",
|
||||
"cron edit abc123 --workdir /tmp/project",
|
||||
],
|
||||
)
|
||||
def test_hosted_console_blocks_known_footgun_arguments_before_confirmation(line):
|
||||
result = HermesConsoleEngine(context="hosted").execute(line)
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
"sessions delete abc123",
|
||||
"sessions prune --older-than 1",
|
||||
"chat",
|
||||
"--cli",
|
||||
"--tui",
|
||||
"oneshot hello",
|
||||
"model",
|
||||
"setup",
|
||||
"postinstall",
|
||||
"fallback add",
|
||||
"moa configure",
|
||||
"claw migrate",
|
||||
"gateway restart",
|
||||
"gateway start",
|
||||
"gateway stop",
|
||||
"dashboard",
|
||||
"serve",
|
||||
"proxy start",
|
||||
"mcp serve",
|
||||
"skills config",
|
||||
"skills publish ./skill",
|
||||
"completion bash",
|
||||
"acp",
|
||||
"update",
|
||||
"uninstall",
|
||||
"gui",
|
||||
"desktop",
|
||||
"login",
|
||||
"logout",
|
||||
"--tui",
|
||||
"logs | cat",
|
||||
"config show > out.txt",
|
||||
],
|
||||
)
|
||||
def test_console_rejects_destructive_and_shell_like_commands(line):
|
||||
result = HermesConsoleEngine().execute(line)
|
||||
|
||||
assert result.status == "error"
|
||||
assert result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line", MUTATING_CONFIRMATION_SMOKE_COMMANDS)
|
||||
def test_mutating_console_commands_require_confirmation(line):
|
||||
result = HermesConsoleEngine().execute(line)
|
||||
|
||||
assert result.status == "confirm_required"
|
||||
assert result.confirmation_message
|
||||
|
||||
|
||||
def test_help_lists_supported_commands_and_not_full_cli():
|
||||
result = HermesConsoleEngine().execute("help")
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "sessions list" in result.output
|
||||
assert "config set" in result.output
|
||||
assert "dashboard" not in result.output
|
||||
assert "gateway restart" not in result.output
|
||||
|
||||
|
||||
def test_config_set_requires_confirmation_then_writes(_isolate_hermes_home):
|
||||
engine = HermesConsoleEngine()
|
||||
|
||||
pending = engine.execute("config set console.test true")
|
||||
assert pending.status == "confirm_required"
|
||||
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
assert read_raw_config() == {}
|
||||
|
||||
result = engine.execute("config set console.test true", confirmed=True)
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "console.test" in result.output
|
||||
assert read_raw_config()["console"]["test"] is True
|
||||
|
||||
|
||||
def test_sessions_list_and_stats_use_isolated_session_store(_isolate_hermes_home):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session("chat-session", source="cli", model="test/model")
|
||||
db.create_session("tool-session", source="tool", model="test/model")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
engine = HermesConsoleEngine()
|
||||
listed = engine.execute("sessions list --limit 10")
|
||||
stats = engine.execute("sessions stats")
|
||||
|
||||
assert listed.status == "ok"
|
||||
assert "chat-session" in listed.output
|
||||
assert "tool-session" not in listed.output
|
||||
assert "Total sessions: 2" in stats.output
|
||||
assert "Listable sessions: 1" in stats.output
|
||||
|
||||
|
||||
def test_cron_pause_resume_and_run_require_confirmation(_isolate_hermes_home):
|
||||
from cron.jobs import create_job, get_job
|
||||
|
||||
job = create_job(prompt="say hello", schedule="every 1h", name="alpha")
|
||||
engine = HermesConsoleEngine()
|
||||
|
||||
pending = engine.execute(f"cron pause {job['id']}")
|
||||
assert pending.status == "confirm_required"
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["state"] == "scheduled"
|
||||
|
||||
paused = engine.execute(f"cron pause {job['id']}", confirmed=True)
|
||||
assert paused.status == "ok"
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["state"] == "paused"
|
||||
|
||||
resumed = engine.execute("cron resume alpha", confirmed=True)
|
||||
assert resumed.status == "ok"
|
||||
stored = get_job(job["id"])
|
||||
assert stored is not None
|
||||
assert stored["state"] == "scheduled"
|
||||
|
||||
triggered = engine.execute("cron run alpha", confirmed=True)
|
||||
assert triggered.status == "ok"
|
||||
assert "Triggered job" in triggered.output
|
||||
|
||||
|
||||
def test_repl_runs_non_interactive_lines_without_prompts(_isolate_hermes_home):
|
||||
stdin = io.StringIO("help\nexit\n")
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
code = run_console_repl(
|
||||
stdin=stdin,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert code == 0
|
||||
assert "Hermes Console" in stdout.getvalue()
|
||||
assert "hermes>" not in stdout.getvalue()
|
||||
assert stderr.getvalue() == ""
|
||||
|
||||
|
||||
def test_repl_refuses_non_interactive_confirmation(_isolate_hermes_home):
|
||||
stdin = io.StringIO("config set console.test true\n")
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
code = run_console_repl(
|
||||
stdin=stdin,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert code == 1
|
||||
assert "Confirmation required" in stderr.getvalue()
|
||||
|
||||
|
||||
def test_main_console_subcommand_smoke(_isolate_hermes_home):
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "console"],
|
||||
cwd=Path(__file__).resolve().parents[2],
|
||||
input="help\nexit\n",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "Hermes Console" in result.stdout
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
"""Tests for the WS-upgrade auth helper (Phase 5 task 5.2).
|
||||
|
||||
The dashboard's four WS endpoints (``/api/pty``, ``/api/ws``, ``/api/pub``,
|
||||
``/api/events``) share an auth gate: ``_ws_auth_ok``. In loopback mode it
|
||||
accepts ``?token=<_SESSION_TOKEN>``; in gated mode it accepts a single-use
|
||||
``?ticket=`` minted by ``POST /api/auth/ws-ticket``.
|
||||
The dashboard's WS endpoints (``/api/pty``, ``/api/console``, ``/api/ws``,
|
||||
``/api/pub``, ``/api/events``) share an auth gate: ``_ws_auth_ok``. In
|
||||
loopback mode it accepts ``?token=<_SESSION_TOKEN>``; in gated mode it accepts
|
||||
a single-use ``?ticket=`` minted by ``POST /api/auth/ws-ticket``.
|
||||
|
||||
These tests exercise the helper at the unit level (no actual WS upgrade)
|
||||
plus the ticket-mint endpoint under realistic gated-mode setup. We don't
|
||||
|
|
@ -315,9 +315,10 @@ class TestWsRequestIsAllowedGated:
|
|||
(intended only for unauthenticated loopback dev) must not also reject
|
||||
those upgrades: the OAuth gate + single-use ticket is the auth.
|
||||
|
||||
Regression coverage: every WS endpoint (``/api/pty``, ``/api/ws``,
|
||||
``/api/pub``, ``/api/events``) calls ``_ws_request_is_allowed`` after
|
||||
``_ws_auth_ok``. If the peer-IP check rejects gated mode, the chat
|
||||
Regression coverage: every WS endpoint (``/api/pty``, ``/api/console``,
|
||||
``/api/ws``, ``/api/pub``, ``/api/events``) calls
|
||||
``_ws_request_is_allowed`` after ``_ws_auth_ok``. If the peer-IP check
|
||||
rejects gated mode, the chat
|
||||
tab + sidebar tool feed silently fail to connect even after a
|
||||
successful OAuth login.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -207,6 +207,51 @@ class TestProviderPersistsAfterModelSave:
|
|||
assert model.get("base_url") == "https://packy.example.com/v1"
|
||||
assert model.get("api_mode") == "codex_responses"
|
||||
|
||||
def test_named_custom_provider_with_builtin_slug_persists_custom_prefix(
|
||||
self, config_home, monkeypatch
|
||||
):
|
||||
"""providers.<builtin-slug> must persist as a named custom provider."""
|
||||
import yaml
|
||||
|
||||
from hermes_cli.main import _model_flow_named_custom
|
||||
|
||||
config_path = config_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"providers:\n"
|
||||
" minimax-cn:\n"
|
||||
" name: MiniMax CN Proxy\n"
|
||||
" api: https://mimimax.cn/v1\n"
|
||||
" key_env: MINIMAX_CN_PROXY_KEY\n"
|
||||
" transport: chat_completions\n"
|
||||
" model: MiniMax-M3\n"
|
||||
" default_model: MiniMax-M3\n"
|
||||
)
|
||||
monkeypatch.setenv("MINIMAX_CN_PROXY_KEY", "proxy-secret")
|
||||
|
||||
provider_info = {
|
||||
"name": "MiniMax CN Proxy",
|
||||
"base_url": "https://mimimax.cn/v1",
|
||||
"api_key": "",
|
||||
"key_env": "MINIMAX_CN_PROXY_KEY",
|
||||
"model": "MiniMax-M3",
|
||||
"api_mode": "chat_completions",
|
||||
"provider_key": "minimax-cn",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.auth._save_model_choice"), \
|
||||
patch("hermes_cli.auth.deactivate_provider"), \
|
||||
patch("hermes_cli.models.fetch_api_models", return_value=["MiniMax-M3"]), \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=OSError("no tty in test")), \
|
||||
patch("builtins.input", return_value="1"):
|
||||
_model_flow_named_custom({}, provider_info)
|
||||
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert isinstance(model, dict)
|
||||
assert model.get("provider") == "custom:minimax-cn"
|
||||
assert "base_url" not in model
|
||||
assert "api_key" not in model
|
||||
|
||||
def test_copilot_acp_provider_saved_when_selected(self, config_home):
|
||||
"""_model_flow_copilot_acp should persist provider/base_url/model together."""
|
||||
from hermes_cli.main import _model_flow_copilot_acp
|
||||
|
|
@ -555,4 +600,3 @@ class TestZaiEndpointPicker:
|
|||
_select_zai_endpoint(custom_url)
|
||||
|
||||
assert captured["default"] == expected_default
|
||||
|
||||
|
|
|
|||
|
|
@ -1272,6 +1272,33 @@ def test_resolve_requested_provider_precedence(monkeypatch):
|
|||
assert rp.resolve_requested_provider() == "auto"
|
||||
|
||||
|
||||
def test_resolve_runtime_provider_named_custom_with_builtin_slug(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_CN_PROXY_KEY", "proxy-secret")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"model": {"provider": "custom:minimax-cn"},
|
||||
"providers": {
|
||||
"minimax-cn": {
|
||||
"name": "MiniMax CN Proxy",
|
||||
"api": "https://mimimax.cn/v1",
|
||||
"key_env": "MINIMAX_CN_PROXY_KEY",
|
||||
"transport": "chat_completions",
|
||||
"default_model": "MiniMax-M3",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider()
|
||||
|
||||
assert resolved["provider"] == "custom"
|
||||
assert resolved["base_url"] == "https://mimimax.cn/v1"
|
||||
assert resolved["api_key"] == "proxy-secret"
|
||||
assert resolved["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
# ── api_mode config override tests ──────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
346
tests/hermes_cli/test_update_venv_health.py
Normal file
346
tests/hermes_cli/test_update_venv_health.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""Tests for the Windows half-updated-venv hardening (July 2026 incident).
|
||||
|
||||
Covers three additions to ``hermes update``:
|
||||
|
||||
1. ``_venv_core_imports_healthy`` — the venv health probe that lets an
|
||||
"Already up to date" checkout still repair a broken dependency install.
|
||||
2. ``_detect_venv_python_processes`` — the venv-interpreter process guard
|
||||
that refuses to mutate the venv while a desktop backend / stray python
|
||||
holds .pyd files mapped.
|
||||
3. The commit_count == 0 repair branch wiring in ``_cmd_update_impl``.
|
||||
|
||||
All Windows-specific paths are exercised via ``_is_windows`` patching so
|
||||
they run on any host (same approach as test_update_concurrent_quarantine).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import main as cli_main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _venv_core_imports_healthy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_venv_health_reports_healthy_when_no_venv(tmp_path):
|
||||
"""No venv python in a DEV checkout → nothing to probe → healthy."""
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is True
|
||||
assert detail == ""
|
||||
|
||||
|
||||
def test_venv_health_missing_venv_unhealthy_on_managed_install(tmp_path):
|
||||
"""On a managed install (bootstrap marker) the venv IS the install —
|
||||
its absence must be reported unhealthy so the repair lane runs instead
|
||||
of 'Already up to date!'."""
|
||||
(tmp_path / ".hermes-bootstrap-complete").write_text("done")
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is False
|
||||
assert "venv python missing" in detail
|
||||
|
||||
|
||||
def test_venv_health_missing_venv_unhealthy_with_interrupted_marker(tmp_path):
|
||||
"""An interrupted-update breadcrumb also flips missing-venv to unhealthy."""
|
||||
(tmp_path / ".update-incomplete").write_text("started=1\npid=1\n")
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is False
|
||||
assert "venv python missing" in detail
|
||||
|
||||
|
||||
def _fake_venv_python(tmp_path, *, windows: bool = False):
|
||||
bin_dir = tmp_path / "venv" / ("Scripts" if windows else "bin")
|
||||
bin_dir.mkdir(parents=True)
|
||||
py = bin_dir / ("python.exe" if windows else "python")
|
||||
py.write_bytes(b"")
|
||||
return py
|
||||
|
||||
|
||||
def test_venv_health_reports_missing_imports(tmp_path):
|
||||
"""Probe output lines are surfaced as the unhealthy detail."""
|
||||
_fake_venv_python(tmp_path)
|
||||
|
||||
fake = SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="fastapi: No module named 'annotated_doc'\n",
|
||||
stderr="",
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object(
|
||||
cli_main.subprocess, "run", return_value=fake
|
||||
):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
|
||||
assert healthy is False
|
||||
assert "annotated_doc" in detail
|
||||
|
||||
|
||||
def test_venv_health_healthy_when_probe_clean(tmp_path):
|
||||
_fake_venv_python(tmp_path)
|
||||
fake = SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object(
|
||||
cli_main.subprocess, "run", return_value=fake
|
||||
):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is True
|
||||
|
||||
|
||||
def test_venv_health_broken_interpreter_is_unhealthy(tmp_path):
|
||||
"""Nonzero exit with no module list = interpreter itself is broken."""
|
||||
_fake_venv_python(tmp_path)
|
||||
fake = SimpleNamespace(returncode=1, stdout="", stderr="Fatal Python error: init failed\n")
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object(
|
||||
cli_main.subprocess, "run", return_value=fake
|
||||
):
|
||||
healthy, detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is False
|
||||
assert "Fatal Python error" in detail
|
||||
|
||||
|
||||
def test_venv_health_probe_failure_reports_healthy(tmp_path):
|
||||
"""A probe that can't run must NOT force needless reinstalls."""
|
||||
_fake_venv_python(tmp_path)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.object(
|
||||
cli_main.subprocess,
|
||||
"run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="python", timeout=60),
|
||||
):
|
||||
healthy, _detail = cli_main._venv_core_imports_healthy()
|
||||
assert healthy is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_venv_python_processes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _proc(pid: int, exe: str, name: str, cmdline: list[str] | None = None, cwd: str = ""):
|
||||
proc = MagicMock()
|
||||
proc.info = {
|
||||
"pid": pid,
|
||||
"exe": exe,
|
||||
"name": name,
|
||||
"cmdline": cmdline or [],
|
||||
"cwd": cwd,
|
||||
}
|
||||
return proc
|
||||
|
||||
|
||||
def test_detect_venv_python_off_windows_is_empty():
|
||||
with patch.object(cli_main, "_is_windows", return_value=False):
|
||||
assert cli_main._detect_venv_python_processes() == []
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_python_finds_backend(_winp, tmp_path):
|
||||
venv_py = str(tmp_path / "venv" / "Scripts" / "python.exe")
|
||||
other_py = "C:\\Python311\\python.exe"
|
||||
|
||||
me = MagicMock()
|
||||
me.parents.return_value = []
|
||||
fake_psutil = types.SimpleNamespace(
|
||||
process_iter=lambda attrs: iter(
|
||||
[
|
||||
_proc(101, venv_py, "python.exe", ["python.exe", "-m", "hermes_cli.main", "serve"]),
|
||||
_proc(102, other_py, "python.exe", ["python.exe", "somescript.py"]),
|
||||
]
|
||||
),
|
||||
Process=lambda *a, **k: me,
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": fake_psutil}
|
||||
):
|
||||
matches = cli_main._detect_venv_python_processes()
|
||||
|
||||
assert [m[0] for m in matches] == [101]
|
||||
assert "serve" in matches[0][2]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_python_excludes_self_and_ancestors(_winp, tmp_path):
|
||||
import os as _os
|
||||
|
||||
venv_py = str(tmp_path / "venv" / "Scripts" / "python.exe")
|
||||
parent = MagicMock()
|
||||
parent.pid = 555
|
||||
me = MagicMock()
|
||||
me.parents.return_value = [parent]
|
||||
fake_psutil = types.SimpleNamespace(
|
||||
process_iter=lambda attrs: iter(
|
||||
[
|
||||
_proc(_os.getpid(), venv_py, "python.exe"),
|
||||
_proc(555, venv_py, "hermes.exe"),
|
||||
]
|
||||
),
|
||||
Process=lambda *a, **k: me,
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": fake_psutil}
|
||||
):
|
||||
assert cli_main._detect_venv_python_processes() == []
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_python_no_psutil_is_empty(_winp, tmp_path):
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": None}
|
||||
):
|
||||
assert cli_main._detect_venv_python_processes() == []
|
||||
|
||||
|
||||
def test_format_venv_holders_message_flags_desktop_backend(tmp_path):
|
||||
matches = [
|
||||
(101, "python.exe", "python.exe -m hermes_cli.main serve --host 127.0.0.1"),
|
||||
(102, "pythonw.exe", "pythonw.exe -m hermes_cli.main gateway run"),
|
||||
]
|
||||
msg = cli_main._format_venv_python_holders_message(matches)
|
||||
assert "101" in msg
|
||||
assert "desktop app" in msg.lower()
|
||||
assert "gateway" in msg
|
||||
assert "hermes update" in msg
|
||||
assert "--force-venv" in msg
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_python_catches_outside_venv_trampoline(_winp, tmp_path):
|
||||
"""uv/base-interpreter trampoline: exe OUTSIDE the venv, but the cmdline
|
||||
clearly runs Hermes from this install → must still be flagged as a holder
|
||||
(it imports from the venv and holds its .pyd files)."""
|
||||
base_py = "C:\\Python311\\python.exe"
|
||||
venv_path = str(tmp_path / "venv" / "Scripts" / "python.exe")
|
||||
|
||||
me = MagicMock()
|
||||
me.parents.return_value = []
|
||||
fake_psutil = types.SimpleNamespace(
|
||||
process_iter=lambda attrs: iter(
|
||||
[
|
||||
# cmdline references the venv path directly
|
||||
_proc(201, base_py, "python.exe", [base_py, venv_path, "-m", "x"]),
|
||||
# `-m hermes_cli.main serve` with the install root as cwd
|
||||
_proc(
|
||||
202,
|
||||
base_py,
|
||||
"python.exe",
|
||||
[base_py, "-m", "hermes_cli.main", "serve"],
|
||||
cwd=str(tmp_path),
|
||||
),
|
||||
# unrelated base-interpreter python → NOT a holder
|
||||
_proc(203, base_py, "python.exe", [base_py, "somescript.py"], cwd="C:\\other"),
|
||||
]
|
||||
),
|
||||
Process=lambda *a, **k: me,
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": fake_psutil}
|
||||
):
|
||||
matches = cli_main._detect_venv_python_processes()
|
||||
|
||||
assert sorted(m[0] for m in matches) == [201, 202]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_venv_hermes_cli_cmdline_outside_install_not_matched(_winp, tmp_path):
|
||||
"""A hermes_cli.main process belonging to a DIFFERENT install (neither
|
||||
install root in cmdline nor cwd under it) must not be flagged."""
|
||||
base_py = "C:\\Python311\\python.exe"
|
||||
me = MagicMock()
|
||||
me.parents.return_value = []
|
||||
fake_psutil = types.SimpleNamespace(
|
||||
process_iter=lambda attrs: iter(
|
||||
[
|
||||
_proc(
|
||||
301,
|
||||
base_py,
|
||||
"python.exe",
|
||||
[base_py, "-m", "hermes_cli.main", "serve"],
|
||||
cwd="C:\\other-install",
|
||||
),
|
||||
]
|
||||
),
|
||||
Process=lambda *a, **k: me,
|
||||
)
|
||||
with patch.object(cli_main, "PROJECT_ROOT", tmp_path), patch.dict(
|
||||
sys.modules, {"psutil": fake_psutil}
|
||||
):
|
||||
assert cli_main._detect_venv_python_processes() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --force vs --force-venv gating of the venv-holder guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _update_args(**overrides):
|
||||
defaults = dict(
|
||||
gateway=False,
|
||||
check=False,
|
||||
no_backup=True,
|
||||
backup=False,
|
||||
yes=True,
|
||||
branch=None,
|
||||
force=False,
|
||||
force_venv=False,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def _run_update_until_guard(args):
|
||||
"""Drive _cmd_update_impl just far enough to hit the venv-holder guard.
|
||||
|
||||
Everything before the guard is stubbed; the guard firing is observed via
|
||||
SystemExit(2). The first statement AFTER the guard is
|
||||
``git_dir = PROJECT_ROOT / ".git"`` — a PROJECT_ROOT sentinel whose
|
||||
``__truediv__`` raises marks 'guard passed'."""
|
||||
|
||||
class _PastGuard(Exception):
|
||||
pass
|
||||
|
||||
class _RootSentinel:
|
||||
def __truediv__(self, _other):
|
||||
raise _PastGuard
|
||||
|
||||
with patch.object(cli_main, "_is_windows", return_value=True), patch.object(
|
||||
cli_main, "_venv_scripts_dir", return_value=None
|
||||
), patch.object(cli_main, "_run_pre_update_backup"), patch.object(
|
||||
cli_main, "_pause_windows_gateways_for_update", return_value=None
|
||||
), patch.object(
|
||||
cli_main, "_resume_windows_gateways_after_update"
|
||||
), patch.object(
|
||||
cli_main,
|
||||
"_detect_venv_python_processes",
|
||||
return_value=[(101, "python.exe", "python.exe -m hermes_cli.main serve")],
|
||||
), patch.object(
|
||||
cli_main, "PROJECT_ROOT", _RootSentinel()
|
||||
):
|
||||
try:
|
||||
cli_main._cmd_update_impl(args, gateway_mode=False)
|
||||
except _PastGuard:
|
||||
return "past_guard"
|
||||
except SystemExit as exc:
|
||||
return f"exit_{exc.code}"
|
||||
return "returned"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"force,force_venv,expected",
|
||||
[
|
||||
(False, False, "exit_2"), # guard fires
|
||||
(True, False, "exit_2"), # plain --force does NOT bypass the venv guard
|
||||
(False, True, "past_guard"), # --force-venv is the explicit escape hatch
|
||||
(True, True, "past_guard"),
|
||||
],
|
||||
)
|
||||
def test_venv_holder_guard_force_semantics(force, force_venv, expected, capsys):
|
||||
result = _run_update_until_guard(_update_args(force=force, force_venv=force_venv))
|
||||
assert result == expected, capsys.readouterr().out
|
||||
134
tests/hermes_cli/test_web_server_console_ws.py
Normal file
134
tests/hermes_cli/test_web_server_console_ws.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Dashboard Hermes Console websocket tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_client(monkeypatch, _isolate_hermes_home):
|
||||
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
previous_bound_host = getattr(web_server.app.state, "bound_host", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = None
|
||||
monkeypatch.setattr(web_server, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
if previous_auth_required is None:
|
||||
if hasattr(web_server.app.state, "auth_required"):
|
||||
delattr(web_server.app.state, "auth_required")
|
||||
else:
|
||||
web_server.app.state.auth_required = previous_auth_required
|
||||
if previous_bound_host is None:
|
||||
if hasattr(web_server.app.state, "bound_host"):
|
||||
delattr(web_server.app.state, "bound_host")
|
||||
else:
|
||||
web_server.app.state.bound_host = previous_bound_host
|
||||
|
||||
|
||||
def _url(token: str | None = None, **params: str) -> str:
|
||||
query = {"token": web_server._SESSION_TOKEN, **params}
|
||||
if token is not None:
|
||||
query["token"] = token
|
||||
return f"/api/console?{urlencode(query)}"
|
||||
|
||||
|
||||
def _recv_until(conn, frame_type: str, *, status: str | None = None) -> dict:
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
frame = conn.receive_json()
|
||||
if frame.get("type") != frame_type:
|
||||
continue
|
||||
if status is not None and frame.get("status") != status:
|
||||
continue
|
||||
return frame
|
||||
raise AssertionError(f"Timed out waiting for {frame_type} frame")
|
||||
|
||||
|
||||
def test_console_ws_rejects_missing_or_bad_token(console_client):
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with console_client.websocket_connect("/api/console"):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with console_client.websocket_connect(_url(token="wrong")):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
|
||||
|
||||
def test_console_ws_runs_read_only_command(console_client):
|
||||
with console_client.websocket_connect(_url()) as conn:
|
||||
ready = conn.receive_json()
|
||||
assert ready["type"] == "ready"
|
||||
assert ready["context"] == "local"
|
||||
assert ready["prompt"] == "hermes> "
|
||||
|
||||
conn.send_json({"type": "input", "line": "help"})
|
||||
|
||||
output = _recv_until(conn, "output")
|
||||
assert "Hermes Console" in output["data"]
|
||||
complete = _recv_until(conn, "complete", status="ok")
|
||||
assert complete["prompt"] == "hermes> "
|
||||
|
||||
|
||||
def test_console_ws_confirmed_command_executes_after_confirmation(console_client):
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
with console_client.websocket_connect(_url()) as conn:
|
||||
assert conn.receive_json()["type"] == "ready"
|
||||
conn.send_json({"type": "input", "line": "config set display.interface cli"})
|
||||
|
||||
confirmation = _recv_until(conn, "confirm_required")
|
||||
assert confirmation["command"] == "config set display.interface cli"
|
||||
assert confirmation["message"]
|
||||
|
||||
conn.send_json({"type": "confirm", "command": confirmation["command"]})
|
||||
_recv_until(conn, "complete", status="ok")
|
||||
|
||||
assert load_config()["display"]["interface"] == "cli"
|
||||
|
||||
|
||||
def test_console_ws_uses_hosted_context_for_opt_data_policy(console_client, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_default_hermes_root_is_opt_data", lambda: True)
|
||||
|
||||
with console_client.websocket_connect(_url()) as conn:
|
||||
ready = conn.receive_json()
|
||||
assert ready["type"] == "ready"
|
||||
assert ready["context"] == "hosted"
|
||||
|
||||
conn.send_json({"type": "input", "line": "profile create nope"})
|
||||
|
||||
error = _recv_until(conn, "error")
|
||||
assert "hosted Hermes Console" in error["message"]
|
||||
|
||||
|
||||
def test_console_ws_cancel_returns_to_prompt(console_client, monkeypatch):
|
||||
from hermes_cli.console_engine import ConsoleResult, HermesConsoleEngine
|
||||
|
||||
def slow_execute(self, line: str, *, confirmed: bool = False):
|
||||
time.sleep(0.5)
|
||||
return ConsoleResult("ok", output="late", command=line)
|
||||
|
||||
monkeypatch.setattr(HermesConsoleEngine, "execute", slow_execute)
|
||||
|
||||
with console_client.websocket_connect(_url()) as conn:
|
||||
assert conn.receive_json()["type"] == "ready"
|
||||
conn.send_json({"type": "input", "line": "status"})
|
||||
conn.send_json({"type": "cancel"})
|
||||
|
||||
complete = _recv_until(conn, "complete", status="cancelled")
|
||||
assert complete["prompt"] == "hermes> "
|
||||
|
|
@ -488,3 +488,75 @@ def test_stream_upload_cleans_temp_on_cancellation(forced_files_client):
|
|||
# ... and no .upload temp file was left behind.
|
||||
leftovers = [p.name for p in target.parent.iterdir() if ".upload" in p.name]
|
||||
assert leftovers == [], f"temp upload files leaked on cancellation: {leftovers}"
|
||||
|
||||
|
||||
def test_sensitive_env_files_hidden_from_listing(forced_files_client):
|
||||
"""Regression test for #57505: .env files must not appear in directory listings."""
|
||||
client, root = forced_files_client
|
||||
|
||||
# Create a regular file and .env variants including shorthand suffixes.
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
regular = root / "config.txt"
|
||||
regular.write_text("safe content")
|
||||
env_file = root / ".env"
|
||||
env_file.write_text("SECRET_KEY=abc123")
|
||||
env_local = root / ".env.local"
|
||||
env_local.write_text("LOCAL_SECRET=def456")
|
||||
env_prod = root / ".env.prod"
|
||||
env_prod.write_text("PROD_SECRET=ghi789")
|
||||
|
||||
listing = client.get("/api/files", params={"path": str(root)})
|
||||
assert listing.status_code == 200
|
||||
names = [e["name"] for e in listing.json()["entries"]]
|
||||
assert "config.txt" in names
|
||||
assert ".env" not in names
|
||||
assert ".env.local" not in names
|
||||
assert ".env.prod" not in names
|
||||
|
||||
|
||||
def test_sensitive_env_files_blocked_read(forced_files_client):
|
||||
"""Regression test for #57505: .env files must not be readable."""
|
||||
client, root = forced_files_client
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
env_file = root / ".env"
|
||||
env_file.write_text("SECRET_KEY=abc123")
|
||||
|
||||
resp = client.get("/api/files/read", params={"path": str(env_file)})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_sensitive_env_files_blocked_download(forced_files_client):
|
||||
"""Regression test for #57505: .env files must not be downloadable."""
|
||||
client, root = forced_files_client
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
env_file = root / ".env"
|
||||
env_file.write_text("SECRET_KEY=abc123")
|
||||
|
||||
resp = client.get("/api/files/download", params={"path": str(env_file)})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_sensitive_env_suffix_variants_blocked(forced_files_client):
|
||||
"""Regression: .env.<suffix> shorthand variants (e.g. .env.prod) must also be blocked."""
|
||||
client, root = forced_files_client
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for suffix in ("prod", "dev", "staging.local", "ci"):
|
||||
p = root / f".env.{suffix}"
|
||||
p.write_text(f"SECRET_{suffix}=abc123")
|
||||
assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403
|
||||
assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403
|
||||
|
||||
|
||||
def test_sensitive_env_case_insensitive_blocked(forced_files_client):
|
||||
"""Regression: .ENV / .Env.local casings must be blocked too (case-insensitive FS mounts)."""
|
||||
client, root = forced_files_client
|
||||
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for name in (".ENV", ".Env.local", ".eNv.PROD"):
|
||||
p = root / name
|
||||
p.write_text("SECRET=abc123")
|
||||
assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403
|
||||
assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403
|
||||
|
|
|
|||
|
|
@ -124,6 +124,68 @@ class TestModelResolution:
|
|||
# ── Generate ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSourceImageLoading:
|
||||
def test_load_image_bytes_blocks_credential_store(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
openai_plugin._load_image_bytes(str(auth_json))
|
||||
|
||||
def test_load_image_bytes_never_opens_blocked_credential(self, tmp_path, monkeypatch):
|
||||
"""The guard must fire BEFORE the file is opened — a credential store
|
||||
must never be read into memory (#57698). Spy builtins.open and assert
|
||||
it is never called for the blocked path."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
import builtins
|
||||
|
||||
real_open = builtins.open
|
||||
opened: list = []
|
||||
|
||||
def _spy_open(file, *a, **k):
|
||||
opened.append(str(file))
|
||||
return real_open(file, *a, **k)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", _spy_open)
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
openai_plugin._load_image_bytes(str(auth_json))
|
||||
assert str(auth_json) not in opened, "blocked credential must never be opened"
|
||||
|
||||
def test_load_image_bytes_allows_legit_local_image(self, tmp_path, monkeypatch):
|
||||
"""Negative control: a legitimate local image path is NOT blocked and
|
||||
loads normally — proves the guard doesn't over-fire on everything."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
img = tmp_path / "pic.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
|
||||
|
||||
data, name = openai_plugin._load_image_bytes(str(img))
|
||||
assert data == b"\x89PNG\r\n\x1a\nfake-image-bytes"
|
||||
assert name == "pic.png"
|
||||
|
||||
def test_load_image_bytes_passthrough_data_uri_not_blocked(self, tmp_path, monkeypatch):
|
||||
"""Negative control: data: URIs are decoded, never routed through the
|
||||
local-path guard (the guard only applies to local file reads)."""
|
||||
import base64
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
b64 = base64.b64encode(b"xyz").decode("ascii")
|
||||
data, name = openai_plugin._load_image_bytes(f"data:image/png;base64,{b64}")
|
||||
assert data == b"xyz"
|
||||
assert name.endswith(".png")
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_empty_prompt_rejected(self, provider):
|
||||
result = provider.generate("", aspect_ratio="square")
|
||||
|
|
|
|||
|
|
@ -169,6 +169,43 @@ class TestHelpers:
|
|||
|
||||
assert _to_image_url_part("/no/such/file.png") is None
|
||||
|
||||
def test_to_image_url_part_blocks_credential_store(self, tmp_path, monkeypatch):
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_to_image_url_part(str(auth_json))
|
||||
|
||||
def test_to_image_url_part_never_reads_blocked_credential(self, tmp_path, monkeypatch):
|
||||
"""The guard must fire BEFORE path.read_bytes() — the credential store
|
||||
must never be inlined into a provider request (#57698)."""
|
||||
from pathlib import Path as _P
|
||||
|
||||
from plugins.image_gen.openrouter import _to_image_url_part
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
real_read_bytes = _P.read_bytes
|
||||
read: list = []
|
||||
|
||||
def _spy_read_bytes(self, *a, **k):
|
||||
read.append(str(self))
|
||||
return real_read_bytes(self, *a, **k)
|
||||
|
||||
monkeypatch.setattr(_P, "read_bytes", _spy_read_bytes)
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_to_image_url_part(str(auth_json))
|
||||
assert str(auth_json) not in read, "blocked credential must never be read"
|
||||
|
||||
def test_extract_images(self):
|
||||
from plugins.image_gen.openrouter import _extract_images
|
||||
|
||||
|
|
|
|||
|
|
@ -492,3 +492,50 @@ def test_xai_image_field_expands_user_home(tmp_path, monkeypatch):
|
|||
field = _xai_image_field("~/pic.png")
|
||||
assert field["type"] == "image_url"
|
||||
assert field["url"].startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
class TestXAIImageFieldReadGuard:
|
||||
"""#57698: local image inputs must not read Hermes credential stores."""
|
||||
|
||||
def test_xai_image_field_blocks_credential_store(self, tmp_path, monkeypatch):
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_xai_image_field(str(auth_json))
|
||||
|
||||
def test_xai_image_field_never_opens_blocked_credential(self, tmp_path, monkeypatch):
|
||||
"""Guard fires before open() — credential store never read into memory."""
|
||||
import builtins
|
||||
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
real_open = builtins.open
|
||||
opened: list = []
|
||||
|
||||
def _spy_open(file, *a, **k):
|
||||
opened.append(str(file))
|
||||
return real_open(file, *a, **k)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", _spy_open)
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_xai_image_field(str(auth_json))
|
||||
assert str(auth_json) not in opened, "blocked credential must never be opened"
|
||||
|
||||
def test_xai_image_field_passthrough_url_not_blocked(self, monkeypatch):
|
||||
"""Negative control: remote URLs and data: URIs pass through unguarded."""
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
assert _xai_image_field("https://example.com/pic.png")["url"] == "https://example.com/pic.png"
|
||||
assert _xai_image_field("data:image/png;base64,eHl6")["url"].startswith("data:image/png")
|
||||
|
|
|
|||
|
|
@ -186,3 +186,41 @@ def test_video_input_from_public_url_rejects_bare_file_id():
|
|||
)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_xai_video_image_input_blocks_credential_store_symlink(tmp_path, monkeypatch):
|
||||
from plugins.video_gen.xai import _image_ref_to_xai_input
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
image_link = hermes_home / "leak.png"
|
||||
try:
|
||||
image_link.symlink_to(auth_json)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink unavailable on this platform: {exc}")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_image_ref_to_xai_input(str(image_link))
|
||||
|
||||
|
||||
def test_xai_video_file_input_blocks_credential_store_symlink(tmp_path, monkeypatch):
|
||||
from plugins.video_gen.xai import _video_ref_to_xai_url
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
|
||||
video_link = hermes_home / "leak.mp4"
|
||||
try:
|
||||
video_link.symlink_to(auth_json)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink unavailable on this platform: {exc}")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(ValueError, match="credential store"):
|
||||
_video_ref_to_xai_url(str(video_link))
|
||||
|
|
|
|||
1447
tests/skills/test_unbroker_skill.py
Normal file
1447
tests/skills/test_unbroker_skill.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -83,6 +83,34 @@ def test_private_page_blocks_camofox_reads(monkeypatch, _session, tool_call, act
|
|||
assert action_phrase in out["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_call", "action_phrase"),
|
||||
[
|
||||
(lambda: browser_camofox.camofox_click("@e1", task_id="t1"), "click"),
|
||||
(
|
||||
lambda: browser_camofox.camofox_type("@e1", "do-not-send-this", task_id="t1"),
|
||||
"type",
|
||||
),
|
||||
(lambda: browser_camofox.camofox_press("Enter", task_id="t1"), "press"),
|
||||
],
|
||||
)
|
||||
def test_private_page_blocks_camofox_input_actions(monkeypatch, _session, tool_call, action_phrase):
|
||||
_block_active(monkeypatch)
|
||||
|
||||
def fail_post(*_args, **_kwargs):
|
||||
raise AssertionError("Camofox action HTTP call should not run on a private page")
|
||||
|
||||
monkeypatch.setattr(browser_camofox, "_post", fail_post)
|
||||
|
||||
out = json.loads(tool_call())
|
||||
|
||||
assert out["success"] is False
|
||||
assert PRIVATE_URL in out["error"]
|
||||
assert "private or internal address" in out["error"]
|
||||
assert action_phrase in out["error"]
|
||||
assert "do-not-send-this" not in json.dumps(out)
|
||||
|
||||
|
||||
def test_snapshot_still_runs_when_page_is_public(monkeypatch, _session):
|
||||
_public_page(monkeypatch)
|
||||
|
||||
|
|
@ -98,6 +126,29 @@ def test_snapshot_still_runs_when_page_is_public(monkeypatch, _session):
|
|||
assert out["element_count"] == 1
|
||||
|
||||
|
||||
def test_camofox_click_still_runs_when_page_is_public(monkeypatch, _session):
|
||||
_public_page(monkeypatch)
|
||||
calls = []
|
||||
|
||||
def fake_post(path, body=None, timeout=None):
|
||||
calls.append((path, body, timeout))
|
||||
return {"url": "https://example.test/"}
|
||||
|
||||
monkeypatch.setattr(browser_camofox, "_post", fake_post)
|
||||
|
||||
out = json.loads(browser_camofox.camofox_click("@e1", task_id="t1"))
|
||||
|
||||
assert out["success"] is True
|
||||
assert out["clicked"] == "e1"
|
||||
assert calls == [
|
||||
(
|
||||
"/tabs/tab-1/click",
|
||||
{"userId": "user-1", "ref": "e1"},
|
||||
None,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_guard_inactive_does_not_probe(monkeypatch, _session):
|
||||
"""When the SSRF guard is inactive the read proceeds WITHOUT probing the URL.
|
||||
|
||||
|
|
|
|||
|
|
@ -430,6 +430,70 @@ def test_runtime_evaluate_blocked_when_current_page_is_private(monkeypatch):
|
|||
assert calls == []
|
||||
|
||||
|
||||
def test_frame_id_route_blocked_when_current_page_is_private(monkeypatch):
|
||||
"""frame_id routing (OOPIF via supervisor) must not bypass the guard
|
||||
applied to the stateless path — same private-page boundary either way."""
|
||||
supervisor_calls = []
|
||||
|
||||
import tools.browser_tool as bt
|
||||
|
||||
monkeypatch.setattr(bt, "_eval_ssrf_guard_active", lambda task_id: True)
|
||||
monkeypatch.setattr(bt, "_current_page_private_url", lambda task_id: PRIVATE_URL)
|
||||
|
||||
def fake_supervisor_route(**kwargs):
|
||||
supervisor_calls.append(kwargs)
|
||||
return json.dumps({"success": True, "result": {"value": "private data"}})
|
||||
|
||||
monkeypatch.setattr(
|
||||
browser_cdp_tool, "_browser_cdp_via_supervisor", fake_supervisor_route
|
||||
)
|
||||
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(
|
||||
method="Runtime.evaluate",
|
||||
params={"expression": "document.body.innerText"},
|
||||
frame_id="frame-1",
|
||||
task_id="task-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert PRIVATE_URL in result["error"]
|
||||
assert "private or internal address" in result["error"]
|
||||
assert supervisor_calls == []
|
||||
|
||||
|
||||
def test_frame_id_route_allowed_when_page_is_not_private(monkeypatch):
|
||||
"""Sanity check: the new guard call must not block ordinary frame_id
|
||||
routing when the current page isn't private."""
|
||||
supervisor_calls = []
|
||||
|
||||
import tools.browser_tool as bt
|
||||
|
||||
monkeypatch.setattr(bt, "_eval_ssrf_guard_active", lambda task_id: True)
|
||||
monkeypatch.setattr(bt, "_current_page_private_url", lambda task_id: None)
|
||||
|
||||
def fake_supervisor_route(**kwargs):
|
||||
supervisor_calls.append(kwargs)
|
||||
return json.dumps({"success": True, "result": {"value": "ok"}})
|
||||
|
||||
monkeypatch.setattr(
|
||||
browser_cdp_tool, "_browser_cdp_via_supervisor", fake_supervisor_route
|
||||
)
|
||||
|
||||
result = json.loads(
|
||||
browser_cdp_tool.browser_cdp(
|
||||
method="Runtime.evaluate",
|
||||
params={"expression": "document.title"},
|
||||
frame_id="frame-1",
|
||||
task_id="task-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.get("success") is True
|
||||
assert len(supervisor_calls) == 1
|
||||
|
||||
|
||||
def test_page_navigate_to_private_url_blocked_before_cdp(monkeypatch):
|
||||
calls = []
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,12 @@ class TestDelegateRequirements(unittest.TestCase):
|
|||
# config-authoritative via delegation.max_iterations so users get
|
||||
# predictable budgets.
|
||||
self.assertNotIn("max_iterations", props)
|
||||
# ACP subprocess transport is operator-controlled via config.yaml, not
|
||||
# model-controlled via delegate_task arguments.
|
||||
self.assertNotIn("acp_command", props)
|
||||
self.assertNotIn("acp_args", props)
|
||||
self.assertNotIn("acp_command", props["tasks"]["items"]["properties"])
|
||||
self.assertNotIn("acp_args", props["tasks"]["items"]["properties"])
|
||||
self.assertNotIn("maxItems", props["tasks"]) # removed — limit is now runtime-configurable
|
||||
|
||||
def test_schema_description_advertises_runtime_limits(self):
|
||||
|
|
@ -522,16 +528,7 @@ class TestToolNamePreservation(unittest.TestCase):
|
|||
)
|
||||
|
||||
def test_build_child_agent_ignores_acp_command_when_binary_missing(self):
|
||||
"""Regression: _build_child_agent must not force provider='copilot-acp'
|
||||
when the override_acp_command binary is not on PATH.
|
||||
|
||||
Without this guard, a model that hallucinates
|
||||
``delegate_task(acp_command="copilot")`` on a host without the Copilot
|
||||
CLI installed (Railway / headless containers / fresh VPS) would route
|
||||
the subagent through CopilotACPClient, which spawns the binary via
|
||||
subprocess and raises RuntimeError. After 3 retries the asyncio loop
|
||||
teardown can take the entire gateway down.
|
||||
"""
|
||||
"""Stale delegation.command config must not force ACP subprocess mode."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
# The crash scenario is a TG/cron agent on a host with no ACP CLI —
|
||||
# parent itself has no acp_command, so clearing the override must NOT
|
||||
|
|
@ -606,68 +603,20 @@ class TestToolNamePreservation(unittest.TestCase):
|
|||
self.assertEqual(captured["provider"], "copilot-acp")
|
||||
self.assertEqual(captured["acp_command"], "copilot")
|
||||
|
||||
def test_schema_prunes_acp_command_when_no_acp_binary(self):
|
||||
"""Schema-level defense: delegate_task tool schema must NOT advertise
|
||||
acp_command / acp_args to the model when no ACP binary is installed.
|
||||
|
||||
Headless deploys (Railway / Fly / Docker / fresh VPS) typically have
|
||||
none of copilot / claude / codex. Without the schema prune, models
|
||||
occasionally hallucinate ``acp_command="copilot"`` from the field's
|
||||
description and crash subagent runs.
|
||||
"""
|
||||
def test_schema_never_exposes_acp_transport_fields(self):
|
||||
"""delegate_task must never make ACP transport model-facing."""
|
||||
from tools.delegate_tool import _build_dynamic_schema_overrides
|
||||
|
||||
with patch("tools.delegate_tool._acp_binary_available", return_value=False):
|
||||
with patch("shutil.which", return_value="/usr/local/bin/copilot"):
|
||||
overrides = _build_dynamic_schema_overrides()
|
||||
|
||||
props = overrides["parameters"]["properties"]
|
||||
self.assertNotIn("acp_command", props, "top-level acp_command must be pruned")
|
||||
self.assertNotIn("acp_args", props, "top-level acp_args must be pruned")
|
||||
self.assertNotIn("acp_command", props)
|
||||
self.assertNotIn("acp_args", props)
|
||||
|
||||
task_item_props = props["tasks"]["items"]["properties"]
|
||||
self.assertNotIn(
|
||||
"acp_command", task_item_props, "per-task acp_command must be pruned"
|
||||
)
|
||||
self.assertNotIn(
|
||||
"acp_args", task_item_props, "per-task acp_args must be pruned"
|
||||
)
|
||||
|
||||
def test_schema_keeps_acp_command_when_binary_available(self):
|
||||
"""Backward compat: when an ACP CLI IS on PATH, schema is unchanged.
|
||||
Users with working ACP setups must still be able to invoke it.
|
||||
"""
|
||||
from tools.delegate_tool import _build_dynamic_schema_overrides
|
||||
|
||||
with patch("tools.delegate_tool._acp_binary_available", return_value=True):
|
||||
overrides = _build_dynamic_schema_overrides()
|
||||
|
||||
props = overrides["parameters"]["properties"]
|
||||
self.assertIn("acp_command", props)
|
||||
self.assertIn("acp_args", props)
|
||||
|
||||
task_item_props = props["tasks"]["items"]["properties"]
|
||||
self.assertIn("acp_command", task_item_props)
|
||||
self.assertIn("acp_args", task_item_props)
|
||||
|
||||
def test_acp_binary_available_checks_known_clis(self):
|
||||
"""_acp_binary_available must check the known ACP CLI names via
|
||||
shutil.which — guards against typos or accidental list trimming.
|
||||
"""
|
||||
from tools.delegate_tool import _KNOWN_ACP_BINARIES, _acp_binary_available
|
||||
|
||||
self.assertIn("copilot", _KNOWN_ACP_BINARIES)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_which(name):
|
||||
calls.append(name)
|
||||
return None
|
||||
|
||||
with patch("shutil.which", side_effect=fake_which):
|
||||
self.assertFalse(_acp_binary_available())
|
||||
|
||||
for name in _KNOWN_ACP_BINARIES:
|
||||
self.assertIn(name, calls)
|
||||
self.assertNotIn("acp_command", task_item_props)
|
||||
self.assertNotIn("acp_args", task_item_props)
|
||||
|
||||
def test_saved_tool_names_set_on_child_before_run(self):
|
||||
"""_run_single_child must set _delegate_saved_tool_names on the child
|
||||
|
|
@ -2281,37 +2230,39 @@ class TestDelegationReasoningEffort(unittest.TestCase):
|
|||
class TestDispatchDelegateTask(unittest.TestCase):
|
||||
"""Tests for the _dispatch_delegate_task helper and full param forwarding."""
|
||||
|
||||
@patch("tools.delegate_tool._load_config", return_value={})
|
||||
@patch("tools.delegate_tool._resolve_delegation_credentials")
|
||||
def test_acp_args_forwarded(self, mock_creds, mock_cfg):
|
||||
"""Both acp_command and acp_args reach delegate_task via the helper."""
|
||||
mock_creds.return_value = {
|
||||
"provider": None, "base_url": None,
|
||||
"api_key": None, "api_mode": None, "model": None,
|
||||
}
|
||||
parent = _make_mock_parent(depth=0)
|
||||
with patch("tools.delegate_tool._build_child_agent") as mock_build:
|
||||
mock_child = MagicMock()
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "done", "completed": True,
|
||||
"api_calls": 1, "messages": [],
|
||||
}
|
||||
mock_child._delegate_saved_tool_names = []
|
||||
mock_child._credential_pool = None
|
||||
mock_child.session_prompt_tokens = 0
|
||||
mock_child.session_completion_tokens = 0
|
||||
mock_child.model = "test"
|
||||
mock_build.return_value = mock_child
|
||||
def test_model_acp_args_not_forwarded(self):
|
||||
"""The live model dispatch path strips hidden ACP transport args."""
|
||||
import run_agent
|
||||
|
||||
delegate_task(
|
||||
goal="test",
|
||||
acp_command="claude",
|
||||
acp_args=["--acp", "--stdio"],
|
||||
parent_agent=parent,
|
||||
captured = {}
|
||||
|
||||
def fake_delegate_task(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "{}"
|
||||
|
||||
parent = _make_mock_parent(depth=0)
|
||||
with patch("tools.delegate_tool.delegate_task", fake_delegate_task):
|
||||
run_agent.AIAgent._dispatch_delegate_task(
|
||||
parent,
|
||||
{
|
||||
"goal": "test",
|
||||
"acp_command": "claude",
|
||||
"acp_args": ["--acp", "--stdio"],
|
||||
"tasks": [
|
||||
{
|
||||
"goal": "nested",
|
||||
"acp_command": "codex",
|
||||
"acp_args": ["--acp"],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
_, kwargs = mock_build.call_args
|
||||
self.assertEqual(kwargs["override_acp_command"], "claude")
|
||||
self.assertEqual(kwargs["override_acp_args"], ["--acp", "--stdio"])
|
||||
|
||||
self.assertNotIn("acp_command", captured)
|
||||
self.assertNotIn("acp_args", captured)
|
||||
self.assertEqual(captured["goal"], "test")
|
||||
self.assertNotIn("acp_command", captured["tasks"][0])
|
||||
self.assertNotIn("acp_args", captured["tasks"][0])
|
||||
|
||||
class TestDelegateEventEnum(unittest.TestCase):
|
||||
"""Tests for DelegateEvent enum and back-compat aliases."""
|
||||
|
|
@ -2600,31 +2551,15 @@ class TestOrchestratorRoleSchema(unittest.TestCase):
|
|||
self.assertIn("role", task_props)
|
||||
self.assertEqual(task_props["role"]["enum"], ["leaf", "orchestrator"])
|
||||
|
||||
def test_acp_command_description_has_do_not_set_guidance(self):
|
||||
# acp_command/acp_args descriptions must NOT bias the model toward
|
||||
# assuming an ACP CLI (Claude, Copilot, etc.) is installed. They must
|
||||
# carry explicit "do not set unless told" guidance so the model doesn't
|
||||
# hallucinate ACP availability (#22013).
|
||||
def test_schema_omits_acp_transport_fields(self):
|
||||
from tools.delegate_tool import DELEGATE_TASK_SCHEMA
|
||||
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
|
||||
|
||||
top_acp_desc = props["acp_command"]["description"]
|
||||
self.assertIn("Do NOT set", top_acp_desc)
|
||||
self.assertIn("explicitly told you", top_acp_desc)
|
||||
|
||||
task_props = props["tasks"]["items"]["properties"]
|
||||
per_task_acp_desc = task_props["acp_command"]["description"]
|
||||
self.assertIn("Do NOT set", per_task_acp_desc)
|
||||
|
||||
def test_acp_command_description_has_no_claude_as_example(self):
|
||||
# Descriptions must not list 'claude' as a canonical example value —
|
||||
# that directly primes the model to attempt Claude ACP even when it is
|
||||
# not installed (#22013).
|
||||
from tools.delegate_tool import DELEGATE_TASK_SCHEMA
|
||||
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
|
||||
top_acp_desc = props["acp_command"]["description"].lower()
|
||||
self.assertNotIn("e.g. 'claude'", top_acp_desc)
|
||||
self.assertNotIn("e.g. \"claude\"", top_acp_desc)
|
||||
self.assertNotIn("acp_command", props)
|
||||
self.assertNotIn("acp_args", props)
|
||||
self.assertNotIn("acp_command", task_props)
|
||||
self.assertNotIn("acp_args", task_props)
|
||||
|
||||
|
||||
# Sentinel used to distinguish "role kwarg omitted" from "role=None".
|
||||
|
|
|
|||
|
|
@ -244,6 +244,46 @@ def test_openai_tts_uses_managed_audio_gateway_when_direct_key_absent(monkeypatc
|
|||
assert captured["close_calls"] == 1
|
||||
|
||||
|
||||
def test_openai_tts_coerces_direct_only_model_on_managed_gateway(monkeypatch, tmp_path):
|
||||
"""A tts.openai.model valid only for direct OpenAI (e.g. tts-1-hd) must be
|
||||
coerced to a managed-supported model, else the gateway 400s with
|
||||
'Unsupported managed OpenAI speech model'."""
|
||||
captured = {}
|
||||
_install_fake_tools_package()
|
||||
_install_fake_openai_module(captured)
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||
|
||||
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
|
||||
output_path = tmp_path / "speech.mp3"
|
||||
tts_tool._generate_openai_tts(
|
||||
"hello world", str(output_path), {"openai": {"model": "tts-1-hd"}}
|
||||
)
|
||||
|
||||
assert captured["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1"
|
||||
assert captured["speech_kwargs"]["model"] == "gpt-4o-mini-tts"
|
||||
|
||||
|
||||
def test_openai_tts_keeps_direct_only_model_with_direct_key(monkeypatch, tmp_path):
|
||||
"""With a direct key, the user's tts-1-hd is honored (not coerced)."""
|
||||
captured = {}
|
||||
_install_fake_tools_package()
|
||||
_install_fake_openai_module(captured)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "openai-direct-key")
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
|
||||
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
|
||||
output_path = tmp_path / "speech.mp3"
|
||||
tts_tool._generate_openai_tts(
|
||||
"hello world", str(output_path), {"openai": {"model": "tts-1-hd"}}
|
||||
)
|
||||
|
||||
assert captured["base_url"] == "https://api.openai.com/v1"
|
||||
assert captured["speech_kwargs"]["model"] == "tts-1-hd"
|
||||
|
||||
|
||||
def test_openai_tts_accepts_openai_api_key_as_direct_fallback(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
_install_fake_tools_package()
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class TestOpenaiTtsSpeed:
|
|||
|
||||
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)):
|
||||
return_value=("test-key", None, False)):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -261,6 +261,56 @@ class TestHandleVisionAnalyze:
|
|||
# (the centralized call_llm router picks the default)
|
||||
assert model is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_yaml_model_takes_priority_over_env(self):
|
||||
"""config.yaml auxiliary.vision.model should be preferred over env var."""
|
||||
with (
|
||||
patch(
|
||||
"tools.vision_tools.vision_analyze_tool", new_callable=AsyncMock
|
||||
) as mock_tool,
|
||||
patch(
|
||||
"tools.vision_tools._should_use_native_vision_fast_path",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={"auxiliary": {"vision": {"model": "qwen3.7-plus"}}},
|
||||
),
|
||||
patch.dict(os.environ, {"AUXILIARY_VISION_MODEL": "env-model"}),
|
||||
):
|
||||
mock_tool.return_value = json.dumps({"result": "ok"})
|
||||
await _handle_vision_analyze(
|
||||
{"image_url": "https://example.com/img.png", "question": "test"}
|
||||
)
|
||||
call_args = mock_tool.call_args
|
||||
model = call_args[0][2] # third positional arg
|
||||
assert model == "qwen3.7-plus"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_var_used_when_config_missing_model(self):
|
||||
"""Env var should be used when config.yaml has no auxiliary.vision.model."""
|
||||
with (
|
||||
patch(
|
||||
"tools.vision_tools.vision_analyze_tool", new_callable=AsyncMock
|
||||
) as mock_tool,
|
||||
patch(
|
||||
"tools.vision_tools._should_use_native_vision_fast_path",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={"auxiliary": {"vision": {}}},
|
||||
),
|
||||
patch.dict(os.environ, {"AUXILIARY_VISION_MODEL": "fallback-model"}),
|
||||
):
|
||||
mock_tool.return_value = json.dumps({"result": "ok"})
|
||||
await _handle_vision_analyze(
|
||||
{"image_url": "https://example.com/img.png", "question": "test"}
|
||||
)
|
||||
call_args = mock_tool.call_args
|
||||
model = call_args[0][2]
|
||||
assert model == "fallback-model"
|
||||
|
||||
def test_empty_args_graceful(self):
|
||||
"""Missing keys should default to empty strings, not raise."""
|
||||
with patch(
|
||||
|
|
|
|||
|
|
@ -668,3 +668,122 @@ def test_web_requires_env_includes_exa_key():
|
|||
from tools.web_tools import _web_requires_env
|
||||
|
||||
assert "EXA_API_KEY" in _web_requires_env()
|
||||
|
||||
|
||||
class TestNonBuiltinProviderAvailability:
|
||||
"""Regression: a plugin-registered WebSearchProvider with no built-in
|
||||
provider credentials must still light up web_search / web_extract tools.
|
||||
|
||||
The web_tools availability gate delegates non-legacy backend names to the
|
||||
web_search_registry's provider ``is_available()``. This class verifies
|
||||
that a custom (non-built-in) provider discovered via the registry is
|
||||
sufficient to make check_web_api_key() return True, _get_backend() return
|
||||
the custom name, the per-capability selection honor it (issue #32698), and
|
||||
the tool registry entries remain active.
|
||||
|
||||
Original tests contributed by @m0n5t3r (PR #28652 / issue #28651).
|
||||
"""
|
||||
|
||||
# All env vars that could make a built-in provider available.
|
||||
_WEB_ENV_KEYS = (
|
||||
"EXA_API_KEY",
|
||||
"PARALLEL_API_KEY",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_SCHEME",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
"TAVILY_API_KEY",
|
||||
"SEARXNG_URL",
|
||||
"BRAVE_SEARCH_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_fake_provider(*, search=True, extract=True):
|
||||
"""Dynamically create a WebSearchProvider subclass.
|
||||
|
||||
Uses a local class definition (not a nested class) to avoid
|
||||
Python 3.13 __bases__ deallocator issue with nested class
|
||||
reassignment.
|
||||
"""
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
|
||||
class FakePluginProvider(WebSearchProvider):
|
||||
@property
|
||||
def name(self):
|
||||
return "fake-plugin-prov"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
def supports_search(self):
|
||||
return search
|
||||
|
||||
def supports_extract(self):
|
||||
return extract
|
||||
|
||||
return FakePluginProvider()
|
||||
|
||||
def setup_method(self):
|
||||
"""Strip all built-in web provider env vars and reset the registry."""
|
||||
for key in self._WEB_ENV_KEYS:
|
||||
os.environ.pop(key, None)
|
||||
from agent.web_search_registry import _reset_for_tests, register_provider
|
||||
_reset_for_tests()
|
||||
register_provider(self._create_fake_provider())
|
||||
|
||||
def teardown_method(self):
|
||||
"""Reset the registry and restore env after each test."""
|
||||
from agent.web_search_registry import _reset_for_tests
|
||||
_reset_for_tests()
|
||||
for key in self._WEB_ENV_KEYS:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def test_check_web_api_key_returns_true_for_custom_provider(self):
|
||||
"""With only a custom provider registered (no built-in creds),
|
||||
check_web_api_key() must return True."""
|
||||
with patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
||||
patch("tools.web_tools._peek_nous_access_token", return_value=None):
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_get_backend_discovers_custom_provider(self):
|
||||
"""_get_backend() must return the custom provider name when it's
|
||||
the only available provider."""
|
||||
with patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
||||
patch("tools.web_tools._peek_nous_access_token", return_value=None):
|
||||
from tools.web_tools import _get_backend
|
||||
assert _get_backend() == "fake-plugin-prov"
|
||||
|
||||
def test_is_backend_available_delegates_to_registry(self):
|
||||
"""_is_backend_available() must consult the registry for a
|
||||
non-legacy backend name."""
|
||||
from tools.web_tools import _is_backend_available
|
||||
assert _is_backend_available("fake-plugin-prov") is True
|
||||
# Unknown, unregistered name -> False (no legacy probe matches).
|
||||
assert _is_backend_available("totally-unknown-backend") is False
|
||||
|
||||
def test_capability_backend_honors_custom_extract_provider(self):
|
||||
"""Per-capability selection (_get_extract_backend) must resolve the
|
||||
custom provider when configured, instead of dead-ending — issue #32698."""
|
||||
with patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
||||
patch("tools.web_tools._peek_nous_access_token", return_value=None), \
|
||||
patch("tools.web_tools._load_web_config",
|
||||
return_value={"extract_backend": "fake-plugin-prov"}):
|
||||
from tools.web_tools import _get_extract_backend
|
||||
assert _get_extract_backend() == "fake-plugin-prov"
|
||||
|
||||
def test_tool_registry_entries_not_filtered_out(self):
|
||||
"""web_search and web_extract tool entries must remain in the
|
||||
registry when only a custom provider is available."""
|
||||
with patch("tools.web_tools._ddgs_package_importable", return_value=False), \
|
||||
patch("tools.web_tools._peek_nous_access_token", return_value=None):
|
||||
import tools.web_tools
|
||||
web_search_entry = tools.web_tools.registry.get_entry("web_search")
|
||||
web_extract_entry = tools.web_tools.registry.get_entry("web_extract")
|
||||
assert web_search_entry is not None, \
|
||||
"web_search tool was filtered out despite custom provider being available"
|
||||
assert web_extract_entry is not None, \
|
||||
"web_extract tool was filtered out despite custom provider being available"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue