deer-flow/backend/tests/test_extensions_config_raw_writes.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

177 lines
6.7 KiB
Python

"""Runtime writers must round-trip ``extensions_config.json`` without expanding ``$VAR``.
``ExtensionsConfig.from_file()`` resolves every ``$VAR`` string into the live
environment value, and an unset variable into ``""``. Serializing that model
back to disk therefore persists secrets in plaintext and permanently erases
references to variables that are not set in the writing process. Every
read-modify-write must operate on the raw on-disk JSON instead; the MCP router
already does, and these tests pin the skill-state writers to the same contract.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.gateway.routers import skills as skills_router
from deerflow.config.extensions_config import (
ExtensionsConfig,
McpServerConfig,
SkillStateConfig,
read_raw_extensions_config,
set_raw_skill_enabled,
validate_raw_extensions_config,
)
SECRET = "ghp_live_secret_value"
def _raw_config_with_placeholders() -> dict:
return {
"mcpServers": {
"github": {
"enabled": True,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {"GITHUB_TOKEN": "$DEERFLOW_TEST_GH_TOKEN", "OPTIONAL": "$DEERFLOW_TEST_UNSET_VAR"},
},
"remote": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": {"Authorization": "$DEERFLOW_TEST_GH_TOKEN"},
},
},
"mcpInterceptors": {"auth": "$DEERFLOW_TEST_GH_TOKEN"},
"skills": {"existing-skill": {"enabled": True}},
}
@pytest.fixture
def placeholder_env(monkeypatch):
monkeypatch.setenv("DEERFLOW_TEST_GH_TOKEN", SECRET)
monkeypatch.delenv("DEERFLOW_TEST_UNSET_VAR", raising=False)
def _write_json(path: Path, data: object) -> None:
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
# ---------------------------------------------------------------------------
# Harness helpers
# ---------------------------------------------------------------------------
def test_read_raw_keeps_placeholders(tmp_path: Path, placeholder_env) -> None:
config_path = tmp_path / "extensions_config.json"
_write_json(config_path, _raw_config_with_placeholders())
assert read_raw_extensions_config(config_path) == _raw_config_with_placeholders()
def test_read_raw_rejects_invalid_json(tmp_path: Path) -> None:
config_path = tmp_path / "extensions_config.json"
config_path.write_text("{not json", encoding="utf-8")
with pytest.raises(ValueError, match="not valid JSON"):
read_raw_extensions_config(config_path)
def test_read_raw_rejects_non_object(tmp_path: Path) -> None:
config_path = tmp_path / "extensions_config.json"
_write_json(config_path, ["not", "an", "object"])
with pytest.raises(ValueError, match="JSON object"):
read_raw_extensions_config(config_path)
def test_validate_raw_resolves_like_runtime_without_mutating_input(placeholder_env) -> None:
raw = _raw_config_with_placeholders()
validated = validate_raw_extensions_config(raw)
assert validated.mcp_servers["github"].env == {"GITHUB_TOKEN": SECRET, "OPTIONAL": ""}
assert raw == _raw_config_with_placeholders()
def test_validate_raw_rejects_runtime_invalid_candidate() -> None:
with pytest.raises(ValidationError):
validate_raw_extensions_config({"mcpServers": []})
def test_set_raw_skill_enabled_creates_skills_map() -> None:
raw: dict = {"mcpServers": {}}
set_raw_skill_enabled(raw, "demo-skill", False)
assert raw == {"mcpServers": {}, "skills": {"demo-skill": {"enabled": False}}}
def test_set_raw_skill_enabled_keeps_sibling_entry_keys() -> None:
raw: dict = {"skills": {"demo-skill": {"enabled": True, "note": "operator comment"}}}
set_raw_skill_enabled(raw, "demo-skill", False)
assert raw["skills"]["demo-skill"] == {"enabled": False, "note": "operator comment"}
def test_set_raw_skill_enabled_rejects_non_object_skills() -> None:
with pytest.raises(ValueError, match="skills"):
set_raw_skill_enabled({"skills": ["demo-skill"]}, "demo-skill", False)
# ---------------------------------------------------------------------------
# Gateway skill toggle writer
# ---------------------------------------------------------------------------
def _patch_gateway_writer(monkeypatch, config_path: Path, cached: ExtensionsConfig | None = None) -> None:
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda _path=None: config_path))
monkeypatch.setattr(skills_router, "reload_extensions_config", lambda: None)
monkeypatch.setattr(skills_router, "get_extensions_config", lambda: cached or ExtensionsConfig())
def test_gateway_skill_toggle_preserves_placeholders(tmp_path: Path, monkeypatch, placeholder_env) -> None:
config_path = tmp_path / "extensions_config.json"
_write_json(config_path, _raw_config_with_placeholders())
_patch_gateway_writer(monkeypatch, config_path)
skills_router._write_extensions_skill_state(None, "demo-skill", False, rebuild_public_projection=False)
expected = _raw_config_with_placeholders()
expected["skills"]["demo-skill"] = {"enabled": False}
written_text = config_path.read_text(encoding="utf-8")
assert json.loads(written_text) == expected
assert SECRET not in written_text
def test_gateway_skill_toggle_new_file_does_not_serialize_resolved_cache(tmp_path: Path, monkeypatch, placeholder_env) -> None:
config_path = tmp_path / "extensions_config.json"
cached = ExtensionsConfig(
mcp_servers={"github": McpServerConfig(command="npx", env={"GITHUB_TOKEN": SECRET})},
skills={"existing-skill": SkillStateConfig(enabled=False)},
)
_patch_gateway_writer(monkeypatch, config_path, cached)
skills_router._write_extensions_skill_state(None, "demo-skill", True, rebuild_public_projection=False)
written_text = config_path.read_text(encoding="utf-8")
assert json.loads(written_text) == {"skills": {"existing-skill": {"enabled": False}, "demo-skill": {"enabled": True}}}
assert SECRET not in written_text
assert "demo-skill" not in cached.skills
def test_gateway_skill_toggle_leaves_invalid_config_untouched(tmp_path: Path, monkeypatch) -> None:
config_path = tmp_path / "extensions_config.json"
original = json.dumps({"mcpServers": [], "skills": {}})
config_path.write_text(original, encoding="utf-8")
_patch_gateway_writer(monkeypatch, config_path)
with pytest.raises(ValidationError):
skills_router._write_extensions_skill_state(None, "demo-skill", False, rebuild_public_projection=False)
assert config_path.read_text(encoding="utf-8") == original