From 14f6df0e03442fdaab1d598e06d71447d1c93ee5 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 5 Aug 2026 03:07:19 +0300 Subject: [PATCH] fix(hindsight): validate and normalize api_url, fail fast on bad config A schemeless api_url (e.g. "host:8888") made every hindsight_* tool fail with a cryptic "Failed to search memory: host:8888/v1/..." error because the client concatenates base_url + path into an invalid URL. - _normalize_api_url(): auto-add missing scheme (http for local modes, https for cloud) with a warning; reject invalid schemes (ftp://, ...) with an actionable error naming the config file - is_available(): return False and log the reason when api_url is invalid, so the provider is not registered at all - initialize(): disable the provider on invalid scheme (belt-and-braces) - handle_tool_call(): clear message when the provider is disabled - _diagnose_scheme_mismatch(): detect https-to-http local daemon mixups via a one-shot /version probe (local_external + https only) - tool errors now include the exception type Adds tests/plugins/memory/test_hindsight_api_url_normalization.py (27 tests). --- plugins/memory/hindsight/__init__.py | 164 ++++++++- .../test_hindsight_api_url_normalization.py | 343 ++++++++++++++++++ 2 files changed, 501 insertions(+), 6 deletions(-) create mode 100644 tests/plugins/memory/test_hindsight_api_url_normalization.py diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 03632b98da710..5c43cac56a2d9 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -44,6 +44,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional +from urllib.parse import urlsplit from agent.secret_scope import get_secret @@ -113,6 +114,108 @@ def _parse_int_setting(value: Any, default: int) -> int: return default +def _normalize_api_url(url: Any, mode: str) -> str | None: + """Validate/normalize a Hindsight ``api_url`` so it is a usable request target. + + The hindsight client builds request URLs by string concatenation + (``f"{base_url}/v1/..."``), so a schemeless value like ``host:8888`` + produces an invalid URL and fails at call time with a cryptic error + ("Failed to search memory: host:8888/v1/..."). Normalizing here moves the + failure earlier and fixes the common misconfiguration automatically: + + * missing scheme -> prepend ``http://`` (local modes) or ``https://`` (cloud) + * invalid scheme -> raise ``ValueError`` with an actionable message + * empty/None/whitespace-only -> return ``None`` so callers fall back to + their configured default URL (a blank value is "unset", not "broken") + + Returns the normalized URL string, or None when no usable value is given. + """ + if url is None: + return None + raw = str(url).strip() + if not raw: + logger.warning( + "Hindsight api_url is blank/whitespace-only; falling back to the " + "default URL. Remove 'api_url' from " + "%s to silence this warning.", + get_hermes_home() / "hindsight" / "config.json", + ) + return None + if "://" not in raw: + assumed = "http" if mode in {"local_embedded", "local_external"} else "https" + logger.warning( + "Hindsight api_url %r has no scheme; assuming %s://. " + "Set 'api_url' to include the scheme explicitly to silence this warning.", + raw, assumed, + ) + return f"{assumed}://{raw}" + scheme = urlsplit(raw).scheme.lower() + if scheme not in {"http", "https"}: + raise ValueError( + f"Invalid Hindsight api_url {raw!r}: scheme must be http:// or https:// " + f"(got {scheme or ''}://). Fix 'api_url' in " + f"{get_hermes_home() / 'hindsight' / 'config.json'}." + ) + return raw + + +def _diagnose_scheme_mismatch(api_url: str | None, api_key: str | None = None, + timeout: float = 2.0) -> str | None: + """Return a fix hint when *api_url* uses the wrong http/https scheme. + + Normalization only catches a *missing* or *invalid* scheme; a wrong but + valid one (e.g. ``https://`` pointed at a plain-HTTP daemon) passes + through and fails at call time with an opaque SSL error. This probes the + configured URL and, when it is unreachable, retries with the opposite + scheme — a successful swapped-scheme probe is a definitive diagnosis the + user can act on. + + The swapped-scheme probe runs WITHOUT the API key: the configured URL is + the intended target (the key is safe there), but the swapped host never + saw the key, and downgrading https->http must never send credentials over + plaintext. Hindsight's ``/version`` endpoint answers unauthenticated, so + the diagnosis still works. + + Results are cached per URL per process: the answer cannot change within a + process lifetime, and re-probing on every session ``initialize()`` adds + latency and a network dependency to session startup. + + Returns None when the configured URL works, when both schemes fail (the + server is simply down), or when the URL is not http(s). + """ + if not api_url: + return None + with _scheme_diagnosis_lock: + if api_url in _scheme_diagnosis_cache: + return _scheme_diagnosis_cache[api_url] + parsed = urlsplit(api_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + hint = None + elif _fetch_hindsight_api_version(api_url, api_key, timeout=timeout) is not None: + hint = None + else: + other = "http" if parsed.scheme == "https" else "https" + swapped = api_url.replace(f"{parsed.scheme}://", f"{other}://", 1) + # No api_key on purpose: the swapped host is not the configured + # target and must never receive credentials (see docstring). + if _fetch_hindsight_api_version(swapped, timeout=timeout) is None: + hint = None + else: + hint = ( + f"api_url {api_url!r} uses {parsed.scheme}:// but the Hindsight " + f"server answers on {other}:// — set 'api_url' to {swapped!r} " + f"in {get_hermes_home() / 'hindsight' / 'config.json'}." + ) + with _scheme_diagnosis_lock: + # Re-check after acquiring the lock in case a concurrent probe filled it. + cached = _scheme_diagnosis_cache.get(api_url) + if cached is None: + _scheme_diagnosis_cache[api_url] = hint + else: + hint = cached + return hint + + # Env var the embedded daemon manager reads (at import time, as a module-level # constant) to size the grace window it waits for a slow /health before # declaring a daemon stale and killing it. Default upstream is 30s; on @@ -221,6 +324,12 @@ def _ensure_cloud_client_dependency() -> None: _append_capability_cache: Dict[str, bool] = {} _append_capability_lock = threading.Lock() +# Scheme-mismatch diagnosis results, keyed by api_url. The probe result can't +# change within a process lifetime, so diagnosing once per URL avoids a 2s +# network probe on every session initialize() for local_external+https. +_scheme_diagnosis_cache: Dict[str, str | None] = {} +_scheme_diagnosis_lock = threading.Lock() + def _meets_minimum_version(actual: str | None, required: str) -> bool: """Return True if *actual* ≥ *required* (semver). False on missing/invalid.""" @@ -874,6 +983,21 @@ def is_available(self) -> bool: if mode in {"local", "local_embedded"}: available, _ = _check_local_runtime() return available + # Validate the effective api_url up front so a broken URL hides + # the provider (with a logged reason) instead of registering + # tools that fail with a cryptic error on first use. + default_url = _DEFAULT_LOCAL_URL if mode == "local_external" else _DEFAULT_API_URL + api_url = cfg.get("api_url") or os.environ.get("HINDSIGHT_API_URL", default_url) + try: + # Normalize ONCE and check the normalized value, matching what + # initialize() will actually use: a whitespace-only api_url is + # blank after normalization and must not register the provider + # as "available with a URL" while initialize() falls back to + # the default (or worse, holds an empty URL). + api_url = _normalize_api_url(api_url, mode) or default_url + except ValueError as exc: + logger.error("Hindsight provider unavailable: %s", exc) + return False if mode == "local_external": return True has_key = bool( @@ -881,7 +1005,7 @@ def is_available(self) -> bool: or cfg.get("api_key") or get_secret("HINDSIGHT_API_KEY", "") ) - has_url = bool(cfg.get("api_url") or os.environ.get("HINDSIGHT_API_URL", "")) + has_url = bool(api_url) return has_key or has_url except Exception: return False @@ -1166,10 +1290,10 @@ def get_config_schema(self): return [ {"key": "mode", "description": "Connection mode", "default": "cloud", "choices": ["cloud", "local_embedded", "local_external"]}, # Cloud mode - {"key": "api_url", "description": "Hindsight Cloud API URL", "default": _DEFAULT_API_URL, "when": {"mode": "cloud"}}, + {"key": "api_url", "description": "Hindsight Cloud API URL (scheme auto-added if missing)", "default": _DEFAULT_API_URL, "when": {"mode": "cloud"}}, {"key": "api_key", "description": "Hindsight Cloud API key", "secret": True, "env_var": "HINDSIGHT_API_KEY", "url": "https://ui.hindsight.vectorize.io", "when": {"mode": "cloud"}}, # Local external mode - {"key": "api_url", "description": "Hindsight API URL", "default": _DEFAULT_LOCAL_URL, "when": {"mode": "local_external"}}, + {"key": "api_url", "description": "Hindsight API URL (scheme auto-added if missing)", "default": _DEFAULT_LOCAL_URL, "when": {"mode": "local_external"}}, {"key": "api_key", "description": "API key (optional)", "secret": True, "env_var": "HINDSIGHT_API_KEY", "when": {"mode": "local_external"}}, # Local embedded mode {"key": "llm_provider", "description": "LLM provider", "default": "openai", "choices": ["openai", "anthropic", "gemini", "groq", "openrouter", "minimax", "ollama", "lmstudio", "openai_compatible"], "when": {"mode": "local_embedded"}}, @@ -1644,6 +1768,28 @@ def initialize(self, session_id: str, **kwargs) -> None: self._api_key = self._config.get("apiKey") or self._config.get("api_key") or get_secret("HINDSIGHT_API_KEY", "") default_url = _DEFAULT_LOCAL_URL if self._mode in {"local_embedded", "local_external"} else _DEFAULT_API_URL self._api_url = self._config.get("api_url") or os.environ.get("HINDSIGHT_API_URL", default_url) + try: + # ``or default_url``: normalization returns None for blank values, + # which means "unset" — fall back to the mode default so a + # whitespace-only config can never leave an empty/broken URL. + self._api_url = _normalize_api_url(self._api_url, self._mode) or default_url + except ValueError as exc: + logger.error( + "Hindsight misconfiguration: %s Disabling the provider — fix " + "'api_url' and start a new session to enable it.", + exc, + ) + self._mode = "disabled" + return + if self._mode == "local_external" and urlsplit(self._api_url).scheme.lower() == "https": + # https:// pointed at a plain-HTTP daemon passes validation but + # fails at call time with an opaque SSL error. Probe once per + # session (only for https in local_external — the realistic + # mistake — so http/cloud configs never touch the network here) + # and surface the exact fix instead of a cryptic failure. + hint = _diagnose_scheme_mismatch(self._api_url, self._api_key, timeout=2.0) + if hint: + logger.error("Hindsight misconfiguration: %s", hint) self._llm_base_url = self._config.get("llm_base_url", "") banks = cfg_get(self._config, "banks", "hermes", default={}) @@ -2173,6 +2319,12 @@ def get_tool_schemas(self) -> List[Dict[str, Any]]: return [RETAIN_SCHEMA, RECALL_SCHEMA, REFLECT_SCHEMA] def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: + if self._mode == "disabled": + return tool_error( + "Hindsight provider is disabled: its configuration is invalid " + "(see Hermes logs for details). Fix 'api_url' in " + f"{get_hermes_home() / 'hindsight' / 'config.json'} and start a new session." + ) if tool_name == "hindsight_retain": content = args.get("content", "") if not content: @@ -2196,7 +2348,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: return json.dumps({"result": "Memory stored successfully."}) except Exception as e: logger.warning("hindsight_retain failed: %s", e, exc_info=True) - return tool_error(f"Failed to store memory: {e}") + return tool_error(f"Failed to store memory: {type(e).__name__}: {e}") elif tool_name == "hindsight_recall": query = args.get("query", "") @@ -2223,7 +2375,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: return json.dumps({"result": "\n".join(lines)}) except Exception as e: logger.warning("hindsight_recall failed: %s", e, exc_info=True) - return tool_error(f"Failed to search memory: {e}") + return tool_error(f"Failed to search memory: {type(e).__name__}: {e}") elif tool_name == "hindsight_reflect": query = args.get("query", "") @@ -2241,7 +2393,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: return json.dumps({"result": resp.text or "No relevant memories found."}) except Exception as e: logger.warning("hindsight_reflect failed: %s", e, exc_info=True) - return tool_error(f"Failed to reflect: {e}") + return tool_error(f"Failed to reflect: {type(e).__name__}: {e}") return tool_error(f"Unknown tool: {tool_name}") diff --git a/tests/plugins/memory/test_hindsight_api_url_normalization.py b/tests/plugins/memory/test_hindsight_api_url_normalization.py new file mode 100644 index 0000000000000..9a76f63ba4989 --- /dev/null +++ b/tests/plugins/memory/test_hindsight_api_url_normalization.py @@ -0,0 +1,343 @@ +"""Tests for Hindsight api_url normalization and fail-fast validation. + +Covers the schemeless-URL failure mode (a config like +``"api_url": "host:8888"`` made every hindsight_* tool fail with a cryptic +"Failed to search memory: host:8888/v1/..." error) and the disabled-provider +guard for genuinely invalid URLs. +""" + +import json +import pytest + +from plugins.memory.hindsight import ( + HindsightMemoryProvider, + _diagnose_scheme_mismatch, + _normalize_api_url, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """Ensure no stale Hindsight env vars leak between tests.""" + for key in ( + "HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_MODE", + "HINDSIGHT_BANK_ID", "HINDSIGHT_BUDGET", "HINDSIGHT_TIMEOUT", + ): + monkeypatch.delenv(key, raising=False) + # Per-process diagnosis cache must not leak between tests that monkeypatch + # the probe with different behaviors. + from plugins.memory.hindsight import _scheme_diagnosis_cache + _scheme_diagnosis_cache.clear() + + +def _make_provider(tmp_path, monkeypatch, config: dict) -> HindsightMemoryProvider: + """Write *config* as the profile config.json and build an initialized provider.""" + config_path = tmp_path / "hindsight" / "config.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(config)) + + monkeypatch.setattr( + "plugins.memory.hindsight.get_hermes_home", lambda: tmp_path + ) + + provider = HindsightMemoryProvider() + provider.initialize(session_id="test-session", hermes_home=str(tmp_path), platform="cli") + return provider + + +# --------------------------------------------------------------------------- +# _normalize_api_url unit tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", ["local_external", "local_embedded"]) +def test_normalize_adds_http_for_local_modes(mode): + assert _normalize_api_url("pve1-docker.ts.rmg7.com:8888", mode) == \ + "http://pve1-docker.ts.rmg7.com:8888" + assert _normalize_api_url("localhost:8888", mode) == "http://localhost:8888" + assert _normalize_api_url("127.0.0.1:8888", mode) == "http://127.0.0.1:8888" + + +def test_normalize_adds_https_for_cloud(): + assert _normalize_api_url("api.hindsight.vectorize.io", "cloud") == \ + "https://api.hindsight.vectorize.io" + + +@pytest.mark.parametrize( + "url", + [ + "http://localhost:8888", + "https://api.hindsight.vectorize.io", + "http://pve1-docker.ts.rmg7.com:8888/", + ], +) +def test_normalize_keeps_explicit_scheme(url): + assert _normalize_api_url(url, "cloud") == url + + +def test_normalize_strips_surrounding_whitespace(): + assert _normalize_api_url(" localhost:8888 ", "local_external") == \ + "http://localhost:8888" + + +@pytest.mark.parametrize("empty", [None, ""]) +def test_normalize_passthrough_empty(empty): + assert _normalize_api_url(empty, "cloud") is None + + +def test_normalize_whitespace_only_returns_none(): + # Whitespace-only input is "unset": None lets callers fall back to defaults. + assert _normalize_api_url(" ", "cloud") is None + + +@pytest.mark.parametrize("bad", ["ftp://example.com", "gopher://example.com", "hindsight://local"]) +def test_normalize_rejects_invalid_scheme(bad): + with pytest.raises(ValueError, match="http:// or https://"): + _normalize_api_url(bad, "cloud") + + +# --------------------------------------------------------------------------- +# Provider-level behavior +# --------------------------------------------------------------------------- + + +def test_provider_normalizes_schemeless_local_url(tmp_path, monkeypatch): + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": "pve1-docker.ts.rmg7.com:8888", + "apiKey": "test-key", + }) + assert provider._api_url == "http://pve1-docker.ts.rmg7.com:8888" + + +def test_provider_normalizes_schemeless_cloud_url(tmp_path, monkeypatch): + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "cloud", + "api_url": "api.hindsight.vectorize.io", + "apiKey": "test-key", + }) + assert provider._api_url == "https://api.hindsight.vectorize.io" + + +def test_provider_whitespace_api_url_falls_back_to_default(tmp_path, monkeypatch): + """Whitespace-only api_url is \"unset\" — provider uses the mode default URL.""" + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": " ", + "apiKey": "test-key", + }) + assert provider._api_url == "http://localhost:8888" + + +def test_provider_whitespace_api_url_cloud_falls_back_to_default(tmp_path, monkeypatch): + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "cloud", + "api_url": " \t ", + "apiKey": "test-key", + }) + assert provider._api_url == "https://api.hindsight.vectorize.io" + + +def test_is_available_consistent_with_whitespace_api_url(tmp_path, monkeypatch): + """is_available must agree with initialize(): whitespace-only -> default URL.""" + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": " ", + "apiKey": "test-key", + }) + assert provider.is_available() + assert provider._api_url == "http://localhost:8888" + + +def test_provider_disables_on_invalid_scheme(tmp_path, monkeypatch): + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": "ftp://example.com:8888", + "apiKey": "test-key", + }) + assert provider._mode == "disabled" + + +def test_disabled_provider_tools_return_actionable_error(tmp_path, monkeypatch): + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": "ftp://example.com:8888", + "apiKey": "test-key", + }) + err = provider.handle_tool_call("hindsight_recall", {"query": "anything"}) + assert "disabled" in err + assert "config.json" in err + + +def test_is_available_false_on_invalid_scheme(tmp_path, monkeypatch): + """A broken api_url must hide the provider before tools are registered.""" + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": "ftp://example.com:8888", + "apiKey": "test-key", + }) + assert not provider.is_available() + + +def test_is_available_true_on_schemeless_local_url(tmp_path, monkeypatch): + """A schemeless local URL is auto-normalized, so the provider stays available.""" + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": "pve1-docker.ts.rmg7.com:8888", + "apiKey": "test-key", + }) + assert provider.is_available() + + +def test_recall_error_includes_exception_type(tmp_path, monkeypatch): + """Tool errors surface the exception type, not just a bare message.""" + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": "http://localhost:9999", + "apiKey": "test-key", + }) + + class _BoomClient: + async def arecall(self, **kwargs): + raise ValueError("boom") + + provider._client = _BoomClient() + err = provider.handle_tool_call("hindsight_recall", {"query": "anything"}) + assert "ValueError: boom" in err + + +# --------------------------------------------------------------------------- +# Scheme-mismatch diagnosis (https:// to a plain-HTTP daemon) +# --------------------------------------------------------------------------- + + +def _fake_probe_http_only(url, key=None, timeout=5.0): + """Probe that answers only on plain HTTP — the user's https-vs-http case.""" + return "0.8.6" if url.startswith("http://") else None + + +def test_diagnose_no_mismatch_when_configured_works(monkeypatch): + calls = [] + def fake_probe(url, key=None, timeout=5.0): + calls.append(url) + return "0.8.6" + monkeypatch.setattr("plugins.memory.hindsight._fetch_hindsight_api_version", fake_probe) + assert _diagnose_scheme_mismatch("http://host:8888", "k") is None + assert calls == ["http://host:8888"] + + +def test_diagnose_detects_https_to_http_server(monkeypatch): + monkeypatch.setattr( + "plugins.memory.hindsight._fetch_hindsight_api_version", _fake_probe_http_only + ) + hint = _diagnose_scheme_mismatch("https://pve1-docker.ts.rmg7.com:8888", "k") + assert hint is not None + assert "https://" in hint + assert "http://pve1-docker.ts.rmg7.com:8888" in hint + + +def test_diagnose_detects_http_to_https_server(monkeypatch): + def fake_probe(url, key=None, timeout=5.0): + return "0.8.6" if url.startswith("https://") else None + monkeypatch.setattr("plugins.memory.hindsight._fetch_hindsight_api_version", fake_probe) + hint = _diagnose_scheme_mismatch("http://host:8443", "k") + assert hint is not None + assert "https://host:8443" in hint + + +def test_diagnose_both_schemes_fail_returns_none(monkeypatch): + monkeypatch.setattr( + "plugins.memory.hindsight._fetch_hindsight_api_version", + lambda url, key=None, timeout=5.0: None, + ) + assert _diagnose_scheme_mismatch("https://host:8888", "k") is None + + +def test_diagnose_swapped_probe_never_receives_api_key(monkeypatch): + """The swapped-scheme host must never receive credentials (key leak #2).""" + seen = {} + + def fake_probe(url, key=None, timeout=5.0): + seen[url] = key + return "0.8.6" if url.startswith("http://") else None + + monkeypatch.setattr("plugins.memory.hindsight._fetch_hindsight_api_version", fake_probe) + hint = _diagnose_scheme_mismatch("https://host:8888", "secret-key") + assert hint is not None + # The configured URL is the intended target — the key is safe there. + assert seen.get("https://host:8888") == "secret-key" + # The swapped host (http:// here, a downgrade to plaintext) gets no key. + assert seen.get("http://host:8888") is None + + +def test_diagnose_cached_per_process(monkeypatch): + """The probe runs once per URL per process — no re-probe on later calls.""" + calls = [] + + def fake_probe(url, key=None, timeout=5.0): + calls.append(url) + return None # both schemes fail -> hint None + + monkeypatch.setattr("plugins.memory.hindsight._fetch_hindsight_api_version", fake_probe) + assert _diagnose_scheme_mismatch("https://host:8888") is None + assert len(calls) == 2 # configured + swapped + assert _diagnose_scheme_mismatch("https://host:8888") is None + assert len(calls) == 2 # served from cache — no extra probes + + +def test_diagnose_cached_hint_reused(monkeypatch): + """A cached positive diagnosis is returned without re-probing.""" + calls = [] + + def fake_probe(url, key=None, timeout=5.0): + calls.append(url) + return "0.8.6" if url.startswith("http://") else None + + monkeypatch.setattr("plugins.memory.hindsight._fetch_hindsight_api_version", fake_probe) + first = _diagnose_scheme_mismatch("https://host:8888", "k") + second = _diagnose_scheme_mismatch("https://host:8888", "k") + assert first is not None + assert second == first + assert len(calls) == 2 # only the first call probed both schemes + + +def test_diagnose_ignores_non_http_scheme(monkeypatch): + calls = [] + def fake_probe(url, key=None, timeout=5.0): + calls.append(url) + return "0.8.6" + monkeypatch.setattr("plugins.memory.hindsight._fetch_hindsight_api_version", fake_probe) + assert _diagnose_scheme_mismatch("ftp://host:8888", "k") is None + assert calls == [] + + +def test_provider_logs_scheme_mismatch_but_stays_enabled(tmp_path, monkeypatch, caplog): + """https-to-http daemon: log the exact fix, do NOT disable the provider.""" + monkeypatch.setattr( + "plugins.memory.hindsight._fetch_hindsight_api_version", _fake_probe_http_only + ) + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": "https://pve1-docker.ts.rmg7.com:8888", + "apiKey": "test-key", + }) + assert provider._mode == "local_external" # not disabled + assert "http://pve1-docker.ts.rmg7.com:8888" in caplog.text + + +def test_provider_does_not_probe_plain_http_url(tmp_path, monkeypatch): + """http:// configs must never touch the network during init.""" + def boom_probe(url, key=None, timeout=5.0): + raise AssertionError(f"probe called for {url}") + monkeypatch.setattr("plugins.memory.hindsight._fetch_hindsight_api_version", boom_probe) + provider = _make_provider(tmp_path, monkeypatch, { + "mode": "local_external", + "api_url": "http://localhost:9999", + "apiKey": "test-key", + }) + assert provider._api_url == "http://localhost:9999"