From 230d8f03d72b1b7bb8adf5249e7163885cf843cf Mon Sep 17 00:00:00 2001 From: Shxiao Date: Sun, 27 Sep 2026 12:18:03 +0900 Subject: [PATCH] fix(memory): validate Honcho's allow_insecure_http as a boolean (#5887) * fix(memory): validate Honcho's allow_insecure_http as a boolean * docs(memory): list the full Pydantic boolean vocabulary for allow_insecure_http --- .../agents/memory/backends/honcho/README.md | 7 ++ .../agents/memory/backends/honcho/config.py | 27 +++++++- backend/tests/test_honcho_memory_backend.py | 69 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md b/backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md index ed852bc11e..29e2de7f94 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md +++ b/backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md @@ -33,6 +33,13 @@ A configured `api_key` over a plain-`http://` base URL is rejected at startup unless you explicitly set `allow_insecure_http: true` (local-development opt-in, same posture as the mem0 backend). Use HTTPS for any non-local deployment. +`allow_insecure_http` also accepts an environment reference such as +`allow_insecure_http: $HONCHO_ALLOW_INSECURE`. The value is validated with +Pydantic's boolean vocabulary, case-insensitively: `true`, `t`, `y`, `yes`, +`on` and `1` enable the opt-in, while `false`, `f`, `n`, `no`, `off` and `0` +keep it off. Any other value is rejected when the backend loads its +configuration, so a typo cannot silently enable the opt-in. + Config errors (bad URL, insecure key combination) fail fast at Gateway startup; connectivity is deliberately not probed, so a temporarily unreachable Honcho does not block startup — reads then degrade per `failure_policy.read`. diff --git a/backend/packages/harness/deerflow/agents/memory/backends/honcho/config.py b/backend/packages/harness/deerflow/agents/memory/backends/honcho/config.py index 366bdc7c8c..102e1da600 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/honcho/config.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/honcho/config.py @@ -15,6 +15,8 @@ from math import isfinite from typing import Any from urllib.parse import urlsplit +from pydantic import TypeAdapter, ValidationError + _ID_RE = re.compile(r"[^a-zA-Z0-9_-]+") @@ -58,6 +60,29 @@ def _number[T](cfg: dict[str, Any], key: str, default: T, cast: Callable[[Any], raise ValueError(f"Honcho backend: {key} must be a number, got {type(value).__name__}") from None +def _bool(cfg: dict[str, Any], key: str, default: bool) -> bool: + """Narrow an operator-supplied boolean knob. + + ``AppConfig.resolve_env_variables`` substitutes a ``$VAR`` reference with + the raw environment string, and a quoted YAML scalar stays a string, so a + configured ``false`` can arrive as ``"false"``. Truthiness would read that + as ``True`` -- for ``allow_insecure_http`` it silently opts into sending the + API key over plaintext HTTP. ``TypeAdapter(bool)`` reads the literal with + Pydantic's boolean vocabulary instead (``true``/``t``/``y``/``yes``/``on``/ + ``1`` and their false counterparts, case-insensitive), the same vocabulary + the sandbox providers accept (see the tenki ``sticky`` knob), and the knob + is named in the error, so a typo fails at config load instead of degrading + to ``True``. + """ + value = cfg.get(key, default) + if value is None: + return default + try: + return TypeAdapter(bool).validate_python(value) + except ValidationError: + raise ValueError(f"Honcho backend: {key} must be a boolean, got {type(value).__name__}") from None + + def _parse_override_map(cfg: dict[str, Any], key: str) -> dict[str, str]: """Overrides map raw user ids to explicit workspace/peer ids; an empty or null VALUE is always a config mistake (empty string is falsy and would @@ -115,7 +140,7 @@ class HonchoConfig: failure_policy = _mapping(cfg.get("failure_policy"), "failure_policy") base_url = str(cfg.get("base_url", "http://localhost:8000")).rstrip("/") api_key = cfg.get("api_key") or None - allow_insecure = bool(cfg.get("allow_insecure_http", False)) + allow_insecure = _bool(cfg, "allow_insecure_http", False) # The parsed scheme, not startswith("http://"): a caller can write # "HTTP://internal:8000" and urlsplit/httpx both treat that as plain # HTTP, so a case-sensitive prefix test leaks the key unencrypted. diff --git a/backend/tests/test_honcho_memory_backend.py b/backend/tests/test_honcho_memory_backend.py index 2600bfd894..528f9b453e 100644 --- a/backend/tests/test_honcho_memory_backend.py +++ b/backend/tests/test_honcho_memory_backend.py @@ -7,11 +7,13 @@ from types import SimpleNamespace import httpx import pytest +import yaml from deerflow.agents.memory.backends.honcho.client import HonchoClient, HonchoRequestError from deerflow.agents.memory.backends.honcho.config import HonchoConfig, sanitize_id from deerflow.agents.memory.backends.honcho.honcho_manager import HonchoMemoryManager, _stable_id from deerflow.agents.memory.manager import MemoryManagerError, MemoryReadError +from deerflow.config.app_config import AppConfig class TestHonchoConfig: @@ -55,6 +57,73 @@ class TestHonchoConfig: cfg = HonchoConfig.from_backend_config({"base_url": "http://internal:8000", "api_key": "sk-x", "allow_insecure_http": True}) assert cfg.api_key == "sk-x" + def test_allow_insecure_http_false_keeps_the_guard(self): + """An explicit ``False`` must not read as an opt-in.""" + with pytest.raises(ValueError, match="allow_insecure_http"): + HonchoConfig.from_backend_config({"base_url": "http://internal:8000", "api_key": "sk-x", "allow_insecure_http": False}) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("false", False), + ("f", False), + ("F", False), + ("n", False), + ("no", False), + ("off", False), + ("0", False), + ("FALSE", False), + ("true", True), + ("t", True), + ("T", True), + ("y", True), + ("Y", True), + ("yes", True), + ("on", True), + ("1", True), + ], + ) + def test_allow_insecure_http_from_environment(self, monkeypatch, tmp_path, value, expected): + """``$VAR`` substitution yields the raw environment string, so the knob + must be validated as a boolean: string truthiness turns ``"false"`` into + ``True`` and silently sends the API key over plaintext HTTP. The + accepted literals are Pydantic's boolean vocabulary, so the documented + list is pinned here rather than in prose only.""" + monkeypatch.setenv("TEST_HONCHO_ALLOW_INSECURE", value) + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "models": [], + "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "memory": { + "backend_config": { + "base_url": "http://internal:8000", + "api_key": "sk-x", + "allow_insecure_http": "$TEST_HONCHO_ALLOW_INSECURE", + } + }, + } + ), + encoding="utf-8", + ) + + config = AppConfig.from_file(config_path) + backend_config = config.memory.backend_config + assert backend_config["allow_insecure_http"] == value + + if expected: + assert HonchoConfig.from_backend_config(backend_config).allow_insecure_http is True + else: + # The opt-in stays off, so the plaintext-http key guard still fires. + with pytest.raises(ValueError, match="allow_insecure_http"): + HonchoConfig.from_backend_config(backend_config) + + @pytest.mark.parametrize("value", ["not-a-boolean", "2", "", [], "tru"]) + def test_allow_insecure_http_rejects_a_non_boolean(self, value): + with pytest.raises(ValueError, match="allow_insecure_http must be a boolean"): + HonchoConfig.from_backend_config({"base_url": "http://internal:8000", "allow_insecure_http": value}) + def test_http_without_api_key_is_fine(self): cfg = HonchoConfig.from_backend_config({"base_url": "http://host.docker.internal:8000"}) assert cfg.api_key is None