Merge remote-tracking branch 'origin/main' into bb/skills-renovate

This commit is contained in:
Brooklyn Nicholson 2026-07-03 13:59:26 -05:00
commit 65415b1a12
119 changed files with 13920 additions and 336 deletions

View file

@ -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 ────────────────────────────────────────────────────────

View file

@ -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 ──────────────────────────────────────────────

View file

@ -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

View file

@ -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()

View 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

View file

@ -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

View file

@ -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"]

View 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)

View 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

View file

@ -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.
"""

View file

@ -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

View file

@ -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 ──────────────────────────────────────

View 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

View 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> "

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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")

View file

@ -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))

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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 = []

View file

@ -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".

View file

@ -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()

View file

@ -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

View file

@ -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(

View file

@ -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"