diff --git a/README.md b/README.md index 55be0c9cac..355df27cd2 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,8 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta values override shell exports, and a project-root override alone still uses `backend/.deer-flow` first. Simple variable references such as `DEER_FLOW_HOME="$PWD/backend/.deer-flow"` use the checkout as `PWD`; - single-quoted references remain literal. For standalone Gateway launches using `backend/.env` + single-quoted references remain literal. If the checkout `.env` cannot be read or decoded + as UTF-8, thread diagnostics retain shell exports and default storage paths. For standalone Gateway launches using `backend/.env` or `DEER_FLOW_ENV_FILE`, export the effective `DEER_FLOW_HOME` when collecting the bundle and ensure the checkout `.env` does not override it. Doctor's internal tool probes also decode UTF-8 with replacement for invalid diff --git a/backend/tests/test_support_bundle.py b/backend/tests/test_support_bundle.py index f6af247e70..fa5f3240ef 100644 --- a/backend/tests/test_support_bundle.py +++ b/backend/tests/test_support_bundle.py @@ -25,6 +25,32 @@ def _zip_text(zip_path, name: str) -> str: return zf.read(name).decode("utf-8") +@pytest.mark.parametrize("failure", ["permission", "encoding"]) +@pytest.mark.parametrize("configured_home", [False, True]) +def test_unreadable_dotenv_keeps_thread_diagnostics(tmp_path, monkeypatch, failure, configured_home): + project = tmp_path / "checkout" + project.mkdir() + home = tmp_path / "data" if configured_home else project / ".deer-flow" + (home / "threads" / "broken-env" / "user-data").mkdir(parents=True) + env_file = project / ".env" + # A valid prefix must not partially change runtime paths when decoding fails. + env_file.write_bytes(b"DEER_FLOW_HOME=wrong-home\n" + b"#" * 10000 + b"\xff") + if configured_home: + monkeypatch.setenv("DEER_FLOW_HOME", str(home)) + if failure == "permission": + original_open = Path.open + + def guarded_open(path, *args, **kwargs): + if path == env_file: + raise PermissionError("Cannot read .env") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(Path, "open", guarded_open) + + summary = support_bundle.collect_thread_summary(project, "broken-env") + assert summary["found"] is True + + @pytest.mark.parametrize("layout", ["threads/thread-home", "users/alice/threads/thread-home"]) def test_thread_summary_uses_configured_runtime_home(tmp_path, monkeypatch, layout): project = tmp_path / "checkout" diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 827ae60e39..1d7117cec9 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -4,7 +4,9 @@ Thread manifests use a nonempty `DEER_FLOW_HOME` exclusively, resolving relative values from the checkout like the local launcher. Read root `.env` path settings without exporting secrets; dotenv overrides shell exports, including empty values. Expand unquoted leading tildes, preserving quoted literals. If python-dotenv is -unavailable, retain shell/legacy lookup so troubleshooting remains usable. +unavailable or `.env` cannot be read/decoded as UTF-8, retain shell/legacy lookup +so troubleshooting remains usable. Read the complete file before applying any +assignments so a decoding failure cannot partially override shell values. Resolve `$NAME`, `${NAME}` and `${NAME:-literal}` in unquoted/double-quoted values using a private environment with checkout `PWD` and earlier dotenv assignments. Single-quoted values and escaped dollars stay literal. Parse the diff --git a/scripts/support_bundle.py b/scripts/support_bundle.py index 4aaac50bfe..fdb3461fb9 100644 --- a/scripts/support_bundle.py +++ b/scripts/support_bundle.py @@ -12,6 +12,7 @@ import subprocess import sys import zipfile from datetime import UTC, datetime +from io import StringIO from pathlib import Path from typing import Any @@ -283,14 +284,16 @@ def _thread_data_roots(project_root: Path) -> list[tuple[Path, str]]: if env_file.is_file(): try: from dotenv.parser import parse_stream - except ImportError: - # Diagnostics must still work in an incomplete backend environment. + + contents = env_file.read_text(encoding="utf-8-sig") + except (ImportError, OSError, UnicodeError): + # Diagnostics must still work with a broken environment or unreadable .env. pass else: # serve.sh changes to the checkout before sourcing .env. Keep its # assignment order in a private mapping; never export parsed secrets. environment = {**os.environ, "PWD": str(project_root.resolve())} - with env_file.open(encoding="utf-8-sig") as stream: + with StringIO(contents) as stream: for binding in parse_stream(stream): if not binding.key or binding.value is None or binding.error: continue