From 9b3397a0ea82b4851edfedd724da9b9e58661725 Mon Sep 17 00:00:00 2001 From: Fish <413281162@qq.com> Date: Sun, 18 Oct 2026 07:45:41 +0800 Subject: [PATCH] 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> --- README.md | 2 +- backend/tests/test_detect_uv_extras.py | 45 +++++++++ backend/tests/test_serve_config_presence.py | 102 ++++++++++++++++++++ scripts/AGENTS.md | 7 ++ scripts/detect_uv_extras.py | 6 +- scripts/serve.sh | 1 + 6 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_serve_config_presence.py diff --git a/README.md b/README.md index d7df6a1943..422f31c477 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/tests/test_detect_uv_extras.py b/backend/tests/test_detect_uv_extras.py index df5364c3c7..2fb108a5f2 100644 --- a/backend/tests/test_detect_uv_extras.py +++ b/backend/tests/test_detect_uv_extras.py @@ -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"] diff --git a/backend/tests/test_serve_config_presence.py b/backend/tests/test_serve_config_presence.py new file mode 100644 index 0000000000..20e299ac1d --- /dev/null +++ b/backend/tests/test_serve_config_presence.py @@ -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 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index d56179eb73..f182cd42f8 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -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 diff --git a/scripts/detect_uv_extras.py b/scripts/detect_uv_extras.py index 09d3af3629..5de3cae7f8 100755 --- a/scripts/detect_uv_extras.py +++ b/scripts/detect_uv_extras.py @@ -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 diff --git a/scripts/serve.sh b/scripts/serve.sh index 4f3546cd22..4da1628a12 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -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