fix(scripts): honor project root when detecting optional extras (#6934)

* fix(scripts): honor project root when detecting optional extras

* fix(scripts): admit project-root config during startup

---------

Co-authored-by: wxhking <186391338+wxhking@users.noreply.github.com>
This commit is contained in:
Fish 2026-10-18 07:45:41 +08:00 committed by GitHub
parent 63ccc6ebfb
commit 9b3397a0ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 161 additions and 2 deletions

View File

@ -234,7 +234,7 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
Without an explicit home, a configured `DEER_FLOW_PROJECT_ROOT` selects its
`.deer-flow` directory; external paths use that variable name in the report.
> **Advanced / manual configuration**: If you prefer to edit `config.yaml` directly, run `make config` instead to copy the full template. Optional dependency auto-detection accepts UTF-8 configuration files with or without a byte-order mark (BOM). See `config.example.yaml` for the complete reference including CLI-backed providers (Codex CLI, Claude Code OAuth), OpenRouter, Responses API, subagent runtime caps such as `subagents.max_total_per_run`, and more.
> **Advanced / manual configuration**: If you prefer to edit `config.yaml` directly, run `make config` instead to copy the full template. Optional dependency auto-detection accepts UTF-8 configuration files with or without a byte-order mark (BOM). It checks an existing `DEER_FLOW_CONFIG_PATH` first, then `DEER_FLOW_PROJECT_ROOT/config.yaml`, before the checkout's default locations. Local startup also accepts the project-root configuration when the checkout has no `config.yaml`. See `config.example.yaml` for the complete reference including CLI-backed providers (Codex CLI, Claude Code OAuth), OpenRouter, Responses API, subagent runtime caps such as `subagents.max_total_per_run`, and more.
Optional per-model pricing must use one currency across all priced models.
DeerFlow disables Console cost estimates when currencies are mixed rather

View File

@ -7,6 +7,8 @@ future opt-in extras) are not wiped on every restart — see Issue #2754.
from __future__ import annotations
import importlib.util
import subprocess
import sys
from pathlib import Path
import pytest
@ -27,7 +29,9 @@ def isolated_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("UV_EXTRAS", raising=False)
monkeypatch.delenv("DEER_FLOW_CONFIG_PATH", raising=False)
monkeypatch.delenv("DEER_FLOW_PROJECT_ROOT", raising=False)
monkeypatch.delenv("DEER_FLOW_STREAM_BRIDGE_REDIS_URL", raising=False)
monkeypatch.delenv("DEER_FLOW_SANDBOX_OWNERSHIP_REDIS_URL", raising=False)
return tmp_path
@ -498,3 +502,44 @@ def test_resolve_extras_root_config_takes_precedence(isolated_cwd):
(sub / "config.yaml").write_text("database:\n backend: postgres\n")
# Root config.yaml is checked first, matching the precedence in serve.sh.
assert detect.resolve_extras() == []
@pytest.mark.parametrize("checkout_config", ["config.yaml", "backend/config.yaml", None])
def test_resolve_extras_prefers_project_root_config(isolated_cwd, monkeypatch, checkout_config):
if checkout_config is not None:
local = isolated_cwd / checkout_config
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("database:\n backend: sqlite\n", encoding="utf-8")
project = isolated_cwd / "external project"
project.mkdir()
config = project / "config.yaml"
config.write_text("database:\n backend: postgres\nmodels:\n - use: langchain_ollama:ChatOllama\n", encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project))
assert detect.find_config_file() == config
assert detect.resolve_extras() == ["ollama", "postgres"]
# The detector runs before dependency sync, so its CLI must work without site packages.
result = subprocess.run([sys.executable, "-S", str(DETECT_SCRIPT_PATH)], capture_output=True, text=True, check=True)
assert result.stdout == "--extra ollama --extra postgres"
def test_resolve_extras_explicit_config_overrides_project_root(isolated_cwd, monkeypatch):
project = isolated_cwd / "external project"
project.mkdir()
(project / "config.yaml").write_text("database:\n backend: postgres\n", encoding="utf-8")
explicit = isolated_cwd / "explicit.yaml"
explicit.write_text("stream_bridge:\n type: redis\n", encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project))
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(explicit))
assert detect.find_config_file() == explicit
assert detect.resolve_extras() == ["redis"]
def test_resolve_extras_project_without_config_keeps_legacy_fallback(isolated_cwd, monkeypatch):
project = isolated_cwd / "external project"
project.mkdir()
(isolated_cwd / "config.yaml").write_text("database:\n backend: postgres\n", encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(project))
assert detect.resolve_extras() == ["postgres"]

View File

@ -0,0 +1,102 @@
"""Exercise startup config admission before real optional dependency detection."""
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from support.shell import require_script_bash
REPO_ROOT = Path(__file__).resolve().parents[2]
# Keep the complete launcher and detector, replacing process/service operations.
# Stop at dependency synchronization, before any service can be launched.
_PRELUDE = """
git() { return 1; }
lsof() { return 1; }
pgrep() { return 1; }
nginx() { return 0; }
sleep() { return 0; }
python3() { "$TEST_PYTHON" "$@"; }
env() {
if [ "$1" = python3 ]; then shift; python3 "$@"; else command env "$@"; fi
}
uv() { printf '%s\\n' "$@" > "$TEST_UV_ARGS"; return 73; }
source "$1" "$2"
"""
@pytest.mark.parametrize("mode", ["--dev", "--prod"])
@pytest.mark.parametrize("config_source", ["project-env", "project-dotenv", "explicit", "checkout", "legacy", "missing", "project-directory"])
def test_serve_config_presence_reaches_dependency_sync(tmp_path: Path, mode: str, config_source: str) -> None:
bash = require_script_bash()
checkout = tmp_path / "checkout"
scripts = checkout / "scripts"
scripts.mkdir(parents=True)
(checkout / "backend").mkdir()
for name in ("serve.sh", "detect_uv_extras.py"):
shutil.copy2(REPO_ROOT / "scripts" / name, scripts / name)
(scripts / "nginx-local-conf.sh").write_text("printf 'test-nginx.conf\\n'\n", encoding="utf-8")
(scripts / "cleanup-containers.sh").write_text("exit 0\n", encoding="utf-8")
(scripts / "config-upgrade.sh").write_text('printf upgraded > "$TEST_UPGRADE_MARKER"\n', encoding="utf-8")
env = os.environ.copy()
for key in (
"DEER_FLOW_CONFIG_PATH",
"DEER_FLOW_PROJECT_ROOT",
"DEER_FLOW_HOME",
"DEER_FLOW_ENV_FILE",
"DEER_FLOW_STREAM_BRIDGE_REDIS_URL",
"DEER_FLOW_SANDBOX_OWNERSHIP_REDIS_URL",
"UV_EXTRAS",
"BASH_ENV",
"ENV",
):
env.pop(key, None)
project = tmp_path / "external project"
project.mkdir()
env["DEER_FLOW_PROJECT_ROOT"] = project.as_posix()
expected_extras = []
if config_source in {"project-env", "project-dotenv"}:
(project / "config.yaml").write_text("models:\n - use: langchain_ollama:ChatOllama\n", encoding="utf-8")
expected_extras = ["--extra", "ollama"]
if config_source == "project-dotenv":
env.pop("DEER_FLOW_PROJECT_ROOT")
(checkout / ".env").write_text(f"DEER_FLOW_PROJECT_ROOT={shlex.quote(project.as_posix())}\n", encoding="utf-8")
elif config_source == "project-directory":
(project / "config.yaml").mkdir()
elif config_source != "missing":
config = {"explicit": tmp_path / "explicit.yaml", "checkout": checkout / "config.yaml", "legacy": checkout / "backend" / "config.yaml"}[config_source]
config.write_text("database:\n backend: sqlite\n", encoding="utf-8")
if config_source == "explicit":
env["DEER_FLOW_CONFIG_PATH"] = config.as_posix()
marker = tmp_path / "upgrade.txt"
uv_args = tmp_path / "uv-args.txt"
env.update(TEST_PYTHON=Path(sys.executable).as_posix(), TEST_UPGRADE_MARKER=marker.as_posix(), TEST_UV_ARGS=uv_args.as_posix())
result = subprocess.run(
[bash, "-c", _PRELUDE, "startup-config-test", (scripts / "serve.sh").as_posix(), mode],
cwd=checkout,
env=env,
capture_output=True,
encoding="utf-8",
errors="replace",
timeout=30,
check=False,
)
assert result.returncode == 1, result.stdout + result.stderr
if config_source in {"missing", "project-directory"}:
assert "No DeerFlow config file found" in result.stdout
assert not marker.exists()
assert not uv_args.exists()
else:
assert "No DeerFlow config file found" not in result.stdout, result.stdout + result.stderr
assert marker.read_text(encoding="utf-8") == "upgraded"
assert uv_args.read_text(encoding="utf-8").splitlines() == ["sync", "--locked", "--quiet", "--all-packages", *expected_extras]
assert "Backend dependency install failed" in result.stdout

View File

@ -72,6 +72,13 @@ indentless lists are supported; nested option names and block-scalar text
must not enable the browser extra. Keep the detector standard-library-only
because it runs before dependency synchronization. Read UTF-8 config files
with or without a leading BOM so the first section remains detectable.
`detect_uv_extras.py` checks an existing `DEER_FLOW_CONFIG_PATH` first, then
`DEER_FLOW_PROJECT_ROOT/config.yaml`, before the legacy checkout candidates.
Keep this lookup standard-library-only; test shadowing checkout configs and
the CLI under `python -S` in `backend/tests/test_detect_uv_extras.py`.
`serve.sh`'s config-presence gate also accepts the project-root file when the
checkout has no config. `tests/test_serve_config_presence.py` runs the full
launcher through dependency detection with service operations stubbed.
`setup-sandbox.sh` also strips the leading BOM and normalizes CRLF before selecting the image;
keep its shell filter compatible with GNU and BSD sed.
An Apple Container pull that succeeds on macOS must not fail the setup step

View File

@ -65,12 +65,16 @@ def parse_env_extras(value: str) -> list[str]:
def find_config_file() -> Path | None:
"""Locate config.yaml using the same precedence as serve.sh."""
"""Prefer explicit config and project-root settings before legacy locations."""
explicit = os.environ.get("DEER_FLOW_CONFIG_PATH")
if explicit:
candidate = Path(explicit)
if candidate.is_file():
return candidate
if project_root := os.environ.get("DEER_FLOW_PROJECT_ROOT"):
candidate = Path(project_root) / "config.yaml"
if candidate.is_file():
return candidate
for path in (Path("config.yaml"), Path("backend/config.yaml")):
if path.is_file():
return path

View File

@ -387,6 +387,7 @@ fi
if ! { \
[ -n "$DEER_FLOW_CONFIG_PATH" ] && [ -f "$DEER_FLOW_CONFIG_PATH" ] || \
[ -f "$DEER_FLOW_PROJECT_ROOT/config.yaml" ] || \
[ -f backend/config.yaml ] || \
[ -f config.yaml ]; \
}; then