deer-flow/backend/tests/test_configured_extensions.py
Hyeonsang Cho bc4a33aba7
fix(skills): stop persisting resolved secrets when toggling skills (#5357)
Toggling a skill wrote resolved secrets into extensions_config.json. The
Gateway skill toggle and DeerFlowClient.update_skill loaded the file with
ExtensionsConfig.from_file(), which replaces every "$VAR" string with the
environment value (and an unset variable with ""), then serialized that
model back through to_file_dict(). A "$GITHUB_TOKEN" reference was
persisted as the plaintext token, and an unset reference was erased for
good. DeerFlowClient.update_mcp_config had the same flaw for every key
other than mcpServers.

Every writer now does a raw read-modify-write, the way the MCP router
already did: read_raw_extensions_config reads the on-disk JSON,
set_raw_skill_enabled changes only the target entry, and
validate_raw_extensions_config checks the candidate the way the runtime
will load it before the atomic write. When the file does not exist yet,
the Gateway seeds it with the cached skill states only, never the
resolved cached model. The MCP router's raw loader and candidate
validation delegate to the same helpers, so the rule lives in one place,
and to_file_dict() is removed so the unsafe serialization has no entry
point left.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 11:24:46 +08:00

176 lines
6.2 KiB
Python

"""Config-declared extension middleware loading, including constructor kwargs."""
import json
from datetime import date, datetime
from types import SimpleNamespace
import pytest
from langchain.agents.middleware import AgentMiddleware
from pydantic import ValidationError
from deerflow.agents.middlewares.configured_extensions import load_configured_extension_middlewares
from deerflow.config.extensions_config import (
ConfiguredMiddlewareSpec,
ExtensionsConfig,
atomic_write_extensions_config,
read_raw_extensions_config,
set_raw_skill_enabled,
validate_raw_extensions_config,
)
class RecordingMiddleware(AgentMiddleware):
"""Test double that records constructor kwargs."""
def __init__(self, max_tool_calls: int = 10):
super().__init__()
self.max_tool_calls = max_tool_calls
class ZeroArgMiddleware(AgentMiddleware):
def __init__(self):
super().__init__()
def _config(*entries) -> SimpleNamespace:
return SimpleNamespace(extensions=SimpleNamespace(middlewares=list(entries)))
def test_string_entry_still_zero_arg():
loaded = load_configured_extension_middlewares(_config(f"{__name__}:ZeroArgMiddleware"))
assert len(loaded) == 1
assert isinstance(loaded[0], ZeroArgMiddleware)
def test_dict_entry_passes_constructor_kwargs():
entry = ConfiguredMiddlewareSpec.model_validate({"class": f"{__name__}:RecordingMiddleware", "kwargs": {"max_tool_calls": 3}})
loaded = load_configured_extension_middlewares(_config(entry))
assert len(loaded) == 1
assert isinstance(loaded[0], RecordingMiddleware)
assert loaded[0].max_tool_calls == 3
def test_raw_dict_entry_passes_constructor_kwargs():
loaded = load_configured_extension_middlewares(_config({"class": f"{__name__}:RecordingMiddleware", "kwargs": {"max_tool_calls": 2}}))
assert len(loaded) == 1
assert isinstance(loaded[0], RecordingMiddleware)
assert loaded[0].max_tool_calls == 2
def test_malformed_raw_dict_fails_at_load():
with pytest.raises(ValidationError):
load_configured_extension_middlewares(_config({"class": f"{__name__}:RecordingMiddleware", "apply_to": "lead"}))
def test_empty_kwargs_matches_zero_arg_constructor():
entry = ConfiguredMiddlewareSpec.model_validate({"class": f"{__name__}:RecordingMiddleware"})
loaded = load_configured_extension_middlewares(_config(entry))
assert loaded[0].max_tool_calls == 10
def test_unknown_constructor_kwarg_fails_loudly():
entry = ConfiguredMiddlewareSpec.model_validate({"class": f"{__name__}:ZeroArgMiddleware", "kwargs": {"not_a_param": 1}})
with pytest.raises(TypeError):
load_configured_extension_middlewares(_config(entry))
def test_extensions_config_keeps_string_entries():
config = ExtensionsConfig.model_validate({"middlewares": ["pkg:Middleware"]})
assert config.middlewares == ["pkg:Middleware"]
def test_extensions_config_parses_class_and_kwargs():
config = ExtensionsConfig.model_validate(
{
"middlewares": [
"pkg:Plain",
{"class": "pkg:WithArgs", "kwargs": {"max_tool_calls": 5}},
]
}
)
assert config.middlewares[0] == "pkg:Plain"
spec = config.middlewares[1]
assert isinstance(spec, ConfiguredMiddlewareSpec)
assert spec.class_path == "pkg:WithArgs"
assert spec.kwargs == {"max_tool_calls": 5}
def test_extensions_config_rejects_unknown_entry_fields():
with pytest.raises(ValidationError):
ExtensionsConfig.model_validate({"middlewares": [{"class": "pkg:Middleware", "apply_to": "lead"}]})
def test_extensions_config_rejects_blank_class_path():
with pytest.raises(ValidationError):
ExtensionsConfig.model_validate({"middlewares": [{"class": " "}]})
def test_extensions_config_rejects_blank_string_entry():
with pytest.raises(ValidationError):
ExtensionsConfig.model_validate({"middlewares": [" "]})
def test_extensions_config_strips_string_entries():
config = ExtensionsConfig.model_validate({"middlewares": [" pkg:Plain "]})
assert config.middlewares == ["pkg:Plain"]
def test_kwargs_yaml_date_normalizes_to_iso_string():
spec = ConfiguredMiddlewareSpec.model_validate({"class": "pkg:Mw", "kwargs": {"cutoff": date(2026, 1, 1)}})
assert spec.kwargs == {"cutoff": "2026-01-01"}
assert json.loads(json.dumps(spec.kwargs)) == {"cutoff": "2026-01-01"}
def test_kwargs_yaml_datetime_normalizes_to_iso_string():
spec = ConfiguredMiddlewareSpec.model_validate({"class": "pkg:Mw", "kwargs": {"cutoff": datetime(2026, 1, 1, 12, 0, 0)}})
assert spec.kwargs == {"cutoff": "2026-01-01T12:00:00"}
def test_kwargs_reject_non_json_values():
with pytest.raises(ValidationError, match="JSON types"):
ConfiguredMiddlewareSpec.model_validate({"class": "pkg:Mw", "kwargs": {"hook": object()}})
def test_kwargs_reject_nan():
with pytest.raises(ValidationError, match="JSON types"):
ConfiguredMiddlewareSpec.model_validate({"class": "pkg:Mw", "kwargs": {"n": float("nan")}})
def test_raw_file_round_trips_kwargs_entries(tmp_path, monkeypatch):
monkeypatch.setenv("DEERFLOW_TEST_MIDDLEWARE_TOKEN", "test-secret")
config_path = tmp_path / "extensions_config.json"
raw = {
"middlewares": [
"pkg:Plain",
{"class": "pkg:WithArgs", "kwargs": {"max_tool_calls": 5, "token": "$DEERFLOW_TEST_MIDDLEWARE_TOKEN"}},
]
}
config_path.write_text(json.dumps(raw), encoding="utf-8")
candidate = read_raw_extensions_config(config_path)
set_raw_skill_enabled(candidate, "demo", False)
validate_raw_extensions_config(candidate)
atomic_write_extensions_config(config_path, candidate)
dumped = read_raw_extensions_config(config_path)
restored = ExtensionsConfig.from_file(config_path)
assert dumped["middlewares"] == raw["middlewares"]
assert dumped["skills"] == {"demo": {"enabled": False}}
assert dumped["middlewares"][0] == "pkg:Plain"
assert dumped["middlewares"][1]["class"] == "pkg:WithArgs"
assert dumped["middlewares"][1]["kwargs"] == {"max_tool_calls": 5, "token": "$DEERFLOW_TEST_MIDDLEWARE_TOKEN"}
assert restored.middlewares[1].class_path == "pkg:WithArgs"
assert restored.middlewares[1].kwargs == {"max_tool_calls": 5, "token": "test-secret"}