mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
* fix(config): close out #4124 review follow-ups Extracts the (mtime, size, sha256) content-signature helper that was duplicated between config/app_config.py and mcp/cache.py into a new config/file_signature.py, and fixes ExtensionsConfig.resolve_config_path() to return None instead of raising FileNotFoundError when an explicit config_path argument or DEER_FLOW_EXTENSIONS_CONFIG_PATH points at a file that has since been deleted -- the exact resolution mode Docker dev/prod uses per AGENTS.md, so the MCP tools-cache staleness check could raise instead of degrading to "not stale". Both were flagged by willem-bd in review on #4124 and explicitly deferred there ("flagging for visibility", "leaving the extraction as the follow-up you suggested"). * fix(config): keep explicit extensions-config paths fail-loud resolve_config_path() previously turned every missing-file case into a clean None, including an explicit config_path argument or DEER_FLOW_EXTENSIONS_CONFIG_PATH (the exact mode Docker dev/prod uses). That silently downgrades a bad Docker mount, typo, or deleted production config to "no extensions" instead of surfacing the misconfiguration, per fancyboi999's review [P1] and willem-bd's follow-up notes on this PR. Restores FileNotFoundError for the two explicit modes (config_path argument, DEER_FLOW_EXTENSIONS_CONFIG_PATH); only the fallback search mode (no explicit path/env var, nothing found in the usual locations) still returns None, since that is the legitimate "extensions were never configured" case. The one caller that needs the old fail-soft behavior -- the MCP tools-cache staleness check, which re-resolves the path on every get_cached_mcp_tools() call -- gets a narrow, local catch instead (deerflow.mcp.cache._resolve_config_path) so a config file going missing mid-run still degrades the cache to "not stale" rather than crashing a hot per-request path. Also hoists the double os.getenv() read in the env-var branch into a local, per willem-bd's nit. Adds resolver-level tests for both explicit-path and env-var raises, a dedicated search-mode None regression test, and updates the existing MCP-cache docstrings to describe the corrected split.
280 lines
12 KiB
Python
280 lines
12 KiB
Python
"""Runtime path policy tests for standalone harness usage."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from deerflow.config import app_config as app_config_module
|
|
from deerflow.config import extensions_config as extensions_config_module
|
|
from deerflow.config import skills_config as skills_config_module
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.extensions_config import ExtensionsConfig
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.config.runtime_paths import project_root
|
|
from deerflow.config.skills_config import SkillsConfig
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
|
|
|
|
def _clear_path_env(monkeypatch):
|
|
for name in (
|
|
"DEER_FLOW_CONFIG_PATH",
|
|
"DEER_FLOW_EXTENSIONS_CONFIG_PATH",
|
|
"DEER_FLOW_HOME",
|
|
"DEER_FLOW_PROJECT_ROOT",
|
|
"DEER_FLOW_SKILLS_PATH",
|
|
):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
def test_default_runtime_paths_resolve_from_current_project(tmp_path: Path, monkeypatch):
|
|
_clear_path_env(monkeypatch)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}),
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / "extensions_config.json").write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
|
(tmp_path / "skills").mkdir()
|
|
|
|
assert AppConfig.resolve_config_path() == tmp_path / "config.yaml"
|
|
assert ExtensionsConfig.resolve_config_path() == tmp_path / "extensions_config.json"
|
|
assert Paths().base_dir == tmp_path / ".deer-flow"
|
|
assert SkillsConfig().get_skills_path() == tmp_path / "skills"
|
|
assert get_or_new_skill_storage(skills_path=SkillsConfig().get_skills_path()).get_skills_root_path() == tmp_path / "skills"
|
|
|
|
|
|
def test_deer_flow_project_root_overrides_current_directory(tmp_path: Path, monkeypatch):
|
|
_clear_path_env(monkeypatch)
|
|
project_root = tmp_path / "project"
|
|
other_cwd = tmp_path / "other"
|
|
project_root.mkdir()
|
|
other_cwd.mkdir()
|
|
monkeypatch.chdir(other_cwd)
|
|
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project_root))
|
|
|
|
(project_root / "config.yaml").write_text(
|
|
yaml.safe_dump({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}),
|
|
encoding="utf-8",
|
|
)
|
|
(project_root / "mcp_config.json").write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
|
|
|
assert AppConfig.resolve_config_path() == project_root / "config.yaml"
|
|
assert ExtensionsConfig.resolve_config_path() == project_root / "mcp_config.json"
|
|
assert Paths().base_dir == project_root / ".deer-flow"
|
|
assert SkillsConfig(path="custom-skills").get_skills_path() == project_root / "custom-skills"
|
|
|
|
|
|
def test_deer_flow_skills_path_overrides_project_default(tmp_path: Path, monkeypatch):
|
|
_clear_path_env(monkeypatch)
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("DEER_FLOW_SKILLS_PATH", "team-skills")
|
|
|
|
assert SkillsConfig().get_skills_path() == tmp_path / "team-skills"
|
|
assert get_or_new_skill_storage(skills_path=SkillsConfig().get_skills_path()).get_skills_root_path() == tmp_path / "team-skills"
|
|
|
|
|
|
def test_deer_flow_project_root_must_exist(tmp_path: Path, monkeypatch):
|
|
_clear_path_env(monkeypatch)
|
|
missing_root = tmp_path / "missing"
|
|
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(missing_root))
|
|
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
project_root()
|
|
|
|
|
|
def test_deer_flow_project_root_must_be_directory(tmp_path: Path, monkeypatch):
|
|
_clear_path_env(monkeypatch)
|
|
project_root_file = tmp_path / "project-root"
|
|
project_root_file.write_text("", encoding="utf-8")
|
|
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project_root_file))
|
|
|
|
with pytest.raises(ValueError, match="not a directory"):
|
|
project_root()
|
|
|
|
|
|
def test_app_config_falls_back_to_legacy_when_project_root_lacks_config(tmp_path: Path, monkeypatch):
|
|
"""When DEER_FLOW_PROJECT_ROOT is unset and cwd has no config.yaml, the
|
|
legacy backend/repo-root candidates must be used for monorepo compatibility."""
|
|
_clear_path_env(monkeypatch)
|
|
cwd = tmp_path / "cwd"
|
|
cwd.mkdir()
|
|
monkeypatch.chdir(cwd)
|
|
|
|
legacy_backend = tmp_path / "legacy-backend"
|
|
legacy_repo = tmp_path / "legacy-repo"
|
|
legacy_backend.mkdir()
|
|
legacy_repo.mkdir()
|
|
legacy_backend_config = legacy_backend / "config.yaml"
|
|
legacy_backend_config.write_text(
|
|
yaml.safe_dump({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}),
|
|
encoding="utf-8",
|
|
)
|
|
repo_root_config = legacy_repo / "config.yaml"
|
|
repo_root_config.write_text("", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(
|
|
app_config_module,
|
|
"_legacy_config_candidates",
|
|
lambda: (legacy_backend_config, repo_root_config),
|
|
)
|
|
|
|
assert AppConfig.resolve_config_path() == legacy_backend_config
|
|
|
|
|
|
def test_skills_config_falls_back_to_legacy_when_project_root_lacks_skills(tmp_path: Path, monkeypatch):
|
|
"""When DEER_FLOW_PROJECT_ROOT is unset and cwd has no `skills/`, the legacy
|
|
repo-root candidate must be used so monorepo runs (cwd=backend/) keep finding
|
|
`<repo>/skills` instead of `<repo>/backend/skills` (regression test for #2694)."""
|
|
_clear_path_env(monkeypatch)
|
|
cwd = tmp_path / "cwd"
|
|
cwd.mkdir()
|
|
monkeypatch.chdir(cwd)
|
|
|
|
legacy_skills = tmp_path / "legacy-repo" / "skills"
|
|
legacy_skills.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(
|
|
skills_config_module,
|
|
"_legacy_skills_candidates",
|
|
lambda: (legacy_skills,),
|
|
)
|
|
|
|
assert SkillsConfig().get_skills_path() == legacy_skills
|
|
|
|
|
|
def test_skills_config_returns_project_default_when_neither_exists(tmp_path: Path, monkeypatch):
|
|
"""When nothing exists, fall back to the project-root default path so callers
|
|
surface a stable empty location instead of silently picking a stale legacy dir."""
|
|
_clear_path_env(monkeypatch)
|
|
cwd = tmp_path / "cwd"
|
|
cwd.mkdir()
|
|
monkeypatch.chdir(cwd)
|
|
|
|
monkeypatch.setattr(skills_config_module, "_legacy_skills_candidates", lambda: ())
|
|
|
|
assert SkillsConfig().get_skills_path() == cwd / "skills"
|
|
|
|
|
|
def test_extensions_config_falls_back_to_legacy_when_project_root_lacks_file(tmp_path: Path, monkeypatch):
|
|
"""ExtensionsConfig should hit the legacy backend/repo-root locations when
|
|
the caller project root has no extensions_config.json/mcp_config.json."""
|
|
_clear_path_env(monkeypatch)
|
|
cwd = tmp_path / "cwd"
|
|
cwd.mkdir()
|
|
monkeypatch.chdir(cwd)
|
|
|
|
fake_backend = tmp_path / "fake-backend"
|
|
fake_repo = tmp_path / "fake-repo"
|
|
fake_backend.mkdir()
|
|
fake_repo.mkdir()
|
|
legacy_extensions = fake_backend / "extensions_config.json"
|
|
legacy_extensions.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
|
|
|
fake_paths_module_file = fake_backend / "packages" / "harness" / "deerflow" / "config" / "extensions_config.py"
|
|
fake_paths_module_file.parent.mkdir(parents=True)
|
|
fake_paths_module_file.write_text("", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(extensions_config_module, "__file__", str(fake_paths_module_file))
|
|
|
|
assert ExtensionsConfig.resolve_config_path() == legacy_extensions
|
|
|
|
|
|
def test_extensions_config_explicit_path_missing_file_raises(tmp_path: Path, monkeypatch):
|
|
"""An explicit ``config_path`` argument pointing at a file that does not
|
|
exist (e.g. it existed earlier and was deleted) must raise
|
|
``FileNotFoundError`` identifying the explicit `config_path` argument as
|
|
the culprit, not silently degrade to ``None``.
|
|
|
|
An explicit ``config_path`` is an operator assertion that one specific
|
|
file must be used (PR #4275 review, fancyboi999 [P1]): a bad path, typo,
|
|
or deleted file must surface as a loud, actionable error instead of
|
|
silently constructing an empty extensions config (no MCP servers, no
|
|
skills). Only the fallback *search* mode (no explicit argument, no env
|
|
var) treats "nothing found" as the legitimate optional-extensions case
|
|
and returns ``None`` — see
|
|
``test_extensions_config_falls_back_to_legacy_when_project_root_lacks_file``
|
|
and ``test_extensions_config_search_finds_nothing_returns_none`` below.
|
|
"""
|
|
_clear_path_env(monkeypatch)
|
|
missing = tmp_path / "extensions_config.json"
|
|
# Never created (equivalent to "existed, then got deleted" from the
|
|
# resolver's point of view -- it only sees that the path doesn't exist).
|
|
|
|
with pytest.raises(FileNotFoundError, match="config_path"):
|
|
ExtensionsConfig.resolve_config_path(config_path=str(missing))
|
|
|
|
|
|
def test_extensions_config_env_var_missing_file_raises(tmp_path: Path, monkeypatch):
|
|
"""``DEER_FLOW_EXTENSIONS_CONFIG_PATH`` pointing at a file that has since
|
|
been deleted must raise ``FileNotFoundError`` identifying the environment
|
|
variable as the culprit, not silently return ``None``.
|
|
|
|
This is the exact resolution mode Docker dev/prod uses (see
|
|
backend/AGENTS.md: "Docker development ... points `DEER_FLOW_CONFIG_PATH`
|
|
/ `DEER_FLOW_EXTENSIONS_CONFIG_PATH`" at the mounted config directory), so
|
|
a bad mount or deleted file at this explicit, operator-configured path is
|
|
a real misconfiguration that must surface loudly (PR #4275 review,
|
|
fancyboi999 [P1]) instead of silently starting with every MCP server and
|
|
skill absent.
|
|
|
|
``deerflow.mcp.cache._resolve_config_path`` calls
|
|
``ExtensionsConfig.resolve_config_path()`` with no args and therefore
|
|
hits this exact branch whenever the env var is set; that module has its
|
|
own narrower catch around this specific exception so the MCP tools-cache
|
|
staleness check does not crash on every request once the file goes
|
|
missing after a successful init — see
|
|
``test_config_deleted_after_init_via_real_env_resolution_does_not_raise``
|
|
in ``test_mcp_cache.py`` for that regression.
|
|
"""
|
|
_clear_path_env(monkeypatch)
|
|
cfg = tmp_path / "extensions_config.json"
|
|
cfg.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(cfg))
|
|
|
|
assert ExtensionsConfig.resolve_config_path() == cfg # sanity: resolves while present
|
|
|
|
cfg.unlink()
|
|
|
|
with pytest.raises(FileNotFoundError, match="DEER_FLOW_EXTENSIONS_CONFIG_PATH"):
|
|
ExtensionsConfig.resolve_config_path()
|
|
|
|
|
|
def test_extensions_config_search_finds_nothing_returns_none(tmp_path: Path, monkeypatch):
|
|
"""The fallback *search* mode (no explicit ``config_path``, no
|
|
``DEER_FLOW_EXTENSIONS_CONFIG_PATH``) must still return ``None`` — not
|
|
raise — when none of the search locations (project root, legacy
|
|
backend/repo-root) have an extensions config file.
|
|
|
|
This is the one resolution mode where "not found" is the expected,
|
|
non-error case (extensions are entirely optional throughout the
|
|
application), so it must keep its pre-existing ``None`` contract even
|
|
though the explicit `config_path`/`DEER_FLOW_EXTENSIONS_CONFIG_PATH`
|
|
branches now raise ``FileNotFoundError`` for the analogous "missing file"
|
|
condition (see ``test_extensions_config_explicit_path_missing_file_raises``
|
|
and ``test_extensions_config_env_var_missing_file_raises`` above).
|
|
Regression guard for the original #4124 fix:
|
|
``deerflow.mcp.cache._is_cache_stale`` depends on this fallback ``None``
|
|
to treat "never configured" as "not stale".
|
|
"""
|
|
_clear_path_env(monkeypatch)
|
|
cwd = tmp_path / "cwd"
|
|
cwd.mkdir()
|
|
monkeypatch.chdir(cwd)
|
|
|
|
fake_backend = tmp_path / "fake-backend-empty"
|
|
fake_repo = tmp_path / "fake-repo-empty"
|
|
fake_backend.mkdir()
|
|
fake_repo.mkdir()
|
|
# No extensions_config.json / mcp_config.json anywhere: not in cwd, not in
|
|
# the legacy backend dir, not in the legacy repo root.
|
|
|
|
fake_paths_module_file = fake_backend / "packages" / "harness" / "deerflow" / "config" / "extensions_config.py"
|
|
fake_paths_module_file.parent.mkdir(parents=True)
|
|
fake_paths_module_file.write_text("", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(extensions_config_module, "__file__", str(fake_paths_module_file))
|
|
|
|
assert ExtensionsConfig.resolve_config_path() is None
|