Initial commit: hardened DeerFlow factory
Vendored deer-flow upstream (bytedance/deer-flow) plus prompt-injection hardening: - New deerflow.security package: content_delimiter, html_cleaner, sanitizer (8 layers — invisible chars, control chars, symbols, NFC, PUA, tag chars, horizontal whitespace collapse with newline/tab preservation, length cap) - New deerflow.community.searx package: web_search, web_fetch, image_search backed by a private SearX instance, every external string sanitized and wrapped in <<<EXTERNAL_UNTRUSTED_CONTENT>>> delimiters - All native community web providers (ddg_search, tavily, exa, firecrawl, jina_ai, infoquest, image_search) replaced with hard-fail stubs that raise NativeWebToolDisabledError at import time, so a misconfigured tool.use path fails loud rather than silently falling back to unsanitized output - Native client back-doors (jina_client.py, infoquest_client.py) stubbed too - Native-tool tests quarantined under tests/_disabled_native/ (collect_ignore_glob via local conftest.py) - Sanitizer Layer 7 fix: only collapse horizontal whitespace, preserve newlines and tabs so list/table structure survives - Hardened runtime config.yaml references only the searx-backed tools - Factory overlay (backend/) kept in sync with deer-flow tree as a reference / source See HARDENING.md for the full audit trail and verification steps.
This commit is contained in:
431
deer-flow/backend/tests/test_setup_wizard.py
Normal file
431
deer-flow/backend/tests/test_setup_wizard.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""Unit tests for the Setup Wizard (scripts/wizard/).
|
||||
|
||||
Run from repo root:
|
||||
cd backend && uv run pytest tests/test_setup_wizard.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
from wizard.providers import LLM_PROVIDERS, SEARCH_PROVIDERS, WEB_FETCH_PROVIDERS
|
||||
from wizard.steps import search as search_step
|
||||
from wizard.writer import (
|
||||
build_minimal_config,
|
||||
read_env_file,
|
||||
write_config_yaml,
|
||||
write_env_file,
|
||||
)
|
||||
|
||||
|
||||
class TestProviders:
|
||||
def test_llm_providers_not_empty(self):
|
||||
assert len(LLM_PROVIDERS) >= 8
|
||||
|
||||
def test_llm_providers_have_required_fields(self):
|
||||
for p in LLM_PROVIDERS:
|
||||
assert p.name
|
||||
assert p.display_name
|
||||
assert p.use
|
||||
assert ":" in p.use, f"Provider '{p.name}' use path must contain ':'"
|
||||
assert p.models
|
||||
assert p.default_model in p.models
|
||||
|
||||
def test_search_providers_have_required_fields(self):
|
||||
for sp in SEARCH_PROVIDERS:
|
||||
assert sp.name
|
||||
assert sp.display_name
|
||||
assert sp.use
|
||||
assert ":" in sp.use
|
||||
|
||||
def test_search_and_fetch_include_firecrawl(self):
|
||||
assert any(provider.name == "firecrawl" for provider in SEARCH_PROVIDERS)
|
||||
assert any(provider.name == "firecrawl" for provider in WEB_FETCH_PROVIDERS)
|
||||
|
||||
def test_web_fetch_providers_have_required_fields(self):
|
||||
for provider in WEB_FETCH_PROVIDERS:
|
||||
assert provider.name
|
||||
assert provider.display_name
|
||||
assert provider.use
|
||||
assert ":" in provider.use
|
||||
assert provider.tool_name == "web_fetch"
|
||||
|
||||
def test_at_least_one_free_search_provider(self):
|
||||
"""At least one search provider needs no API key."""
|
||||
free = [sp for sp in SEARCH_PROVIDERS if sp.env_var is None]
|
||||
assert free, "Expected at least one free (no-key) search provider"
|
||||
|
||||
def test_at_least_one_free_web_fetch_provider(self):
|
||||
free = [provider for provider in WEB_FETCH_PROVIDERS if provider.env_var is None]
|
||||
assert free, "Expected at least one free (no-key) web fetch provider"
|
||||
|
||||
|
||||
class TestBuildMinimalConfig:
|
||||
def test_produces_valid_yaml(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI / gpt-4o",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
assert data is not None
|
||||
assert "models" in data
|
||||
assert len(data["models"]) == 1
|
||||
model = data["models"][0]
|
||||
assert model["name"] == "gpt-4o"
|
||||
assert model["use"] == "langchain_openai:ChatOpenAI"
|
||||
assert model["model"] == "gpt-4o"
|
||||
assert model["api_key"] == "$OPENAI_API_KEY"
|
||||
|
||||
def test_gemini_uses_gemini_api_key_field(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_google_genai:ChatGoogleGenerativeAI",
|
||||
model_name="gemini-2.0-flash",
|
||||
display_name="Gemini",
|
||||
api_key_field="gemini_api_key",
|
||||
env_var="GEMINI_API_KEY",
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
model = data["models"][0]
|
||||
assert "gemini_api_key" in model
|
||||
assert model["gemini_api_key"] == "$GEMINI_API_KEY"
|
||||
assert "api_key" not in model
|
||||
|
||||
def test_search_tool_included(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
search_use="deerflow.community.tavily.tools:web_search_tool",
|
||||
search_extra_config={"max_results": 5},
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
search_tool = next(t for t in data.get("tools", []) if t["name"] == "web_search")
|
||||
assert search_tool["max_results"] == 5
|
||||
|
||||
def test_openrouter_defaults_are_preserved(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="google/gemini-2.5-flash-preview",
|
||||
display_name="OpenRouter",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENROUTER_API_KEY",
|
||||
extra_model_config={
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
model = data["models"][0]
|
||||
assert model["base_url"] == "https://openrouter.ai/api/v1"
|
||||
assert model["request_timeout"] == 600.0
|
||||
assert model["max_retries"] == 2
|
||||
assert model["max_tokens"] == 8192
|
||||
assert model["temperature"] == 0.7
|
||||
|
||||
def test_web_fetch_tool_included(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
web_fetch_use="deerflow.community.jina_ai.tools:web_fetch_tool",
|
||||
web_fetch_extra_config={"timeout": 10},
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
fetch_tool = next(t for t in data.get("tools", []) if t["name"] == "web_fetch")
|
||||
assert fetch_tool["timeout"] == 10
|
||||
|
||||
def test_no_search_tool_when_not_configured(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
tool_names = [t["name"] for t in data.get("tools", [])]
|
||||
assert "web_search" not in tool_names
|
||||
assert "web_fetch" not in tool_names
|
||||
|
||||
def test_sandbox_included(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
assert "sandbox" in data
|
||||
assert "use" in data["sandbox"]
|
||||
assert data["sandbox"]["use"] == "deerflow.sandbox.local:LocalSandboxProvider"
|
||||
assert data["sandbox"]["allow_host_bash"] is False
|
||||
|
||||
def test_bash_tool_disabled_by_default(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
tool_names = [t["name"] for t in data.get("tools", [])]
|
||||
assert "bash" not in tool_names
|
||||
|
||||
def test_can_enable_container_sandbox_and_bash(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
sandbox_use="deerflow.community.aio_sandbox:AioSandboxProvider",
|
||||
include_bash_tool=True,
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
assert data["sandbox"]["use"] == "deerflow.community.aio_sandbox:AioSandboxProvider"
|
||||
assert "allow_host_bash" not in data["sandbox"]
|
||||
tool_names = [t["name"] for t in data.get("tools", [])]
|
||||
assert "bash" in tool_names
|
||||
|
||||
def test_can_disable_write_tools(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
include_write_tools=False,
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
tool_names = [t["name"] for t in data.get("tools", [])]
|
||||
assert "write_file" not in tool_names
|
||||
assert "str_replace" not in tool_names
|
||||
|
||||
def test_config_version_present(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
config_version=5,
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
assert data["config_version"] == 5
|
||||
|
||||
def test_cli_provider_does_not_emit_fake_api_key(self):
|
||||
content = build_minimal_config(
|
||||
provider_use="deerflow.models.openai_codex_provider:CodexChatModel",
|
||||
model_name="gpt-5.4",
|
||||
display_name="Codex CLI",
|
||||
api_key_field="api_key",
|
||||
env_var=None,
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
model = data["models"][0]
|
||||
assert "api_key" not in model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# writer.py — env file helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnvFileHelpers:
|
||||
def test_write_and_read_new_file(self, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
write_env_file(env_file, {"OPENAI_API_KEY": "sk-test123"})
|
||||
pairs = read_env_file(env_file)
|
||||
assert pairs["OPENAI_API_KEY"] == "sk-test123"
|
||||
|
||||
def test_update_existing_key(self, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("OPENAI_API_KEY=old-key\n")
|
||||
write_env_file(env_file, {"OPENAI_API_KEY": "new-key"})
|
||||
pairs = read_env_file(env_file)
|
||||
assert pairs["OPENAI_API_KEY"] == "new-key"
|
||||
# Should not duplicate
|
||||
content = env_file.read_text()
|
||||
assert content.count("OPENAI_API_KEY") == 1
|
||||
|
||||
def test_preserve_existing_keys(self, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("TAVILY_API_KEY=tavily-val\n")
|
||||
write_env_file(env_file, {"OPENAI_API_KEY": "sk-new"})
|
||||
pairs = read_env_file(env_file)
|
||||
assert pairs["TAVILY_API_KEY"] == "tavily-val"
|
||||
assert pairs["OPENAI_API_KEY"] == "sk-new"
|
||||
|
||||
def test_preserve_comments(self, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("# My .env file\nOPENAI_API_KEY=old\n")
|
||||
write_env_file(env_file, {"OPENAI_API_KEY": "new"})
|
||||
content = env_file.read_text()
|
||||
assert "# My .env file" in content
|
||||
|
||||
def test_read_ignores_comments(self, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("# comment\nKEY=value\n")
|
||||
pairs = read_env_file(env_file)
|
||||
assert "# comment" not in pairs
|
||||
assert pairs["KEY"] == "value"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# writer.py — write_config_yaml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWriteConfigYaml:
|
||||
def test_generated_config_loadable_by_appconfig(self, tmp_path):
|
||||
"""The generated config.yaml must be parseable (basic YAML validity)."""
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
write_config_yaml(
|
||||
config_path,
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI / gpt-4o",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
)
|
||||
assert config_path.exists()
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
assert isinstance(data, dict)
|
||||
assert "models" in data
|
||||
|
||||
def test_copies_example_defaults_for_unconfigured_sections(self, tmp_path):
|
||||
example_path = tmp_path / "config.example.yaml"
|
||||
example_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"config_version": 5,
|
||||
"log_level": "info",
|
||||
"token_usage": {"enabled": False},
|
||||
"tool_groups": [{"name": "web"}, {"name": "file:read"}, {"name": "file:write"}, {"name": "bash"}],
|
||||
"tools": [
|
||||
{
|
||||
"name": "web_search",
|
||||
"group": "web",
|
||||
"use": "deerflow.community.ddg_search.tools:web_search_tool",
|
||||
"max_results": 5,
|
||||
},
|
||||
{
|
||||
"name": "web_fetch",
|
||||
"group": "web",
|
||||
"use": "deerflow.community.jina_ai.tools:web_fetch_tool",
|
||||
"timeout": 10,
|
||||
},
|
||||
{
|
||||
"name": "image_search",
|
||||
"group": "web",
|
||||
"use": "deerflow.community.image_search.tools:image_search_tool",
|
||||
"max_results": 5,
|
||||
},
|
||||
{"name": "ls", "group": "file:read", "use": "deerflow.sandbox.tools:ls_tool"},
|
||||
{"name": "write_file", "group": "file:write", "use": "deerflow.sandbox.tools:write_file_tool"},
|
||||
{"name": "bash", "group": "bash", "use": "deerflow.sandbox.tools:bash_tool"},
|
||||
],
|
||||
"sandbox": {
|
||||
"use": "deerflow.sandbox.local:LocalSandboxProvider",
|
||||
"allow_host_bash": False,
|
||||
},
|
||||
"summarization": {"max_tokens": 2048},
|
||||
},
|
||||
sort_keys=False,
|
||||
)
|
||||
)
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
write_config_yaml(
|
||||
config_path,
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI / gpt-4o",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
)
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
assert data["log_level"] == "info"
|
||||
assert data["token_usage"]["enabled"] is False
|
||||
assert data["tool_groups"][0]["name"] == "web"
|
||||
assert data["summarization"]["max_tokens"] == 2048
|
||||
assert any(tool["name"] == "image_search" and tool["max_results"] == 5 for tool in data["tools"])
|
||||
|
||||
def test_config_version_read_from_example(self, tmp_path):
|
||||
"""write_config_yaml should read config_version from config.example.yaml if present."""
|
||||
|
||||
example_path = tmp_path / "config.example.yaml"
|
||||
example_path.write_text("config_version: 99\n")
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
write_config_yaml(
|
||||
config_path,
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="gpt-4o",
|
||||
display_name="OpenAI",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENAI_API_KEY",
|
||||
)
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
assert data["config_version"] == 99
|
||||
|
||||
def test_model_base_url_from_extra_config(self, tmp_path):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
write_config_yaml(
|
||||
config_path,
|
||||
provider_use="langchain_openai:ChatOpenAI",
|
||||
model_name="google/gemini-2.5-flash-preview",
|
||||
display_name="OpenRouter",
|
||||
api_key_field="api_key",
|
||||
env_var="OPENROUTER_API_KEY",
|
||||
extra_model_config={"base_url": "https://openrouter.ai/api/v1"},
|
||||
)
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
assert data["models"][0]["base_url"] == "https://openrouter.ai/api/v1"
|
||||
|
||||
|
||||
class TestSearchStep:
|
||||
def test_reuses_api_key_for_same_provider(self, monkeypatch):
|
||||
monkeypatch.setattr(search_step, "print_header", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(search_step, "print_success", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(search_step, "print_info", lambda *_args, **_kwargs: None)
|
||||
|
||||
choices = iter([3, 1])
|
||||
prompts: list[str] = []
|
||||
|
||||
def fake_choice(_prompt, _options, default=0):
|
||||
return next(choices)
|
||||
|
||||
def fake_secret(prompt):
|
||||
prompts.append(prompt)
|
||||
return "shared-api-key"
|
||||
|
||||
monkeypatch.setattr(search_step, "ask_choice", fake_choice)
|
||||
monkeypatch.setattr(search_step, "ask_secret", fake_secret)
|
||||
|
||||
result = search_step.run_search_step()
|
||||
|
||||
assert result.search_provider is not None
|
||||
assert result.fetch_provider is not None
|
||||
assert result.search_provider.name == "exa"
|
||||
assert result.fetch_provider.name == "exa"
|
||||
assert result.search_api_key == "shared-api-key"
|
||||
assert result.fetch_api_key == "shared-api-key"
|
||||
assert prompts == ["EXA_API_KEY"]
|
||||
Reference in New Issue
Block a user