diff --git a/backend/packages/harness/deerflow/config/secret_file.py b/backend/packages/harness/deerflow/config/secret_file.py index aed283d4e4..5c645a6239 100644 --- a/backend/packages/harness/deerflow/config/secret_file.py +++ b/backend/packages/harness/deerflow/config/secret_file.py @@ -96,7 +96,11 @@ def read_or_create_secret_file( def _read(path: Path) -> str | None: try: - return path.read_text(encoding="utf-8").strip() + # utf-8-sig: a secret restored or edited on Windows may carry a BOM. + # It is not whitespace, so the strip below keeps it, validation then + # rejects the value, and a credentials key file bricks every stored + # secret. A BOM is never part of a legitimate secret. + return path.read_text(encoding="utf-8-sig").strip() except FileNotFoundError: return None diff --git a/backend/packages/harness/deerflow/env.py b/backend/packages/harness/deerflow/env.py index 5cd54739a2..5498eb66fa 100644 --- a/backend/packages/harness/deerflow/env.py +++ b/backend/packages/harness/deerflow/env.py @@ -26,7 +26,10 @@ def load_selected_env_file() -> bool: if not selected or not path.is_file(): raise ValueError("Expected a regular file") # Read fully before parsing so decode failures cannot partially load env. - contents = path.read_text(encoding="utf-8") + # utf-8-sig consumes a leading BOM (Notepad / PowerShell-written files): + # plain utf-8 keeps U+FEFF, and dotenv then parses the first variable + # under a BOM-prefixed key that no consumer reads. + contents = path.read_text(encoding="utf-8-sig") except (OSError, ValueError): raise ValueError("DEER_FLOW_ENV_FILE must point to a readable UTF-8 regular file. Check the path and permissions; relative paths use the backend process working directory.") from None diff --git a/backend/tests/test_env_file.py b/backend/tests/test_env_file.py index aaffd94e4d..d6d9727506 100644 --- a/backend/tests/test_env_file.py +++ b/backend/tests/test_env_file.py @@ -67,6 +67,21 @@ def test_process_environment_wins(tmp_path, value): assert json.loads(result.stdout) == [value, None] +@pytest.mark.parametrize("entrypoint", ENTRYPOINTS) +def test_a_bom_prefixed_selection_file_loads_its_first_variable(tmp_path, entrypoint): + """Windows Notepad and the PowerShell 5.1 ``Set-Content -Encoding UTF8`` + switch write a UTF-8 BOM. Plain utf-8 decoding keeps U+FEFF, and dotenv + then parses the first variable under a BOM-prefixed key no consumer + reads — a silently dropped first variable. The BOM is an encoding + artifact, so the selection loader consumes it.""" + selected = tmp_path / "bom.env" + selected.write_bytes(b"\xef\xbb\xbf" + b"ENV_FILE_TEST_VALUE=bom-selected\nENV_FILE_TEST_DEFAULT_ONLY=from-bom\n") + assert selected.read_bytes().startswith(b"\xef\xbb\xbf"), "fixture must carry a real BOM" + result = run_startup(tmp_path, entrypoint, selected) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == ["bom-selected", "from-bom"] + + @pytest.mark.parametrize("entrypoint", ENTRYPOINTS) def test_unset_preserves_default_loading(tmp_path, entrypoint): result = run_startup(tmp_path, entrypoint) diff --git a/backend/tests/test_secret_file.py b/backend/tests/test_secret_file.py index 43b7e70250..1d638df58c 100644 --- a/backend/tests/test_secret_file.py +++ b/backend/tests/test_secret_file.py @@ -43,6 +43,28 @@ def test_reuses_an_existing_value_without_generating(tmp_path: Path) -> None: assert path.read_text(encoding="utf-8") == "existing-value\n" +def test_a_bom_prefixed_secret_still_validates(tmp_path: Path) -> None: + """A secret file saved by a Windows editor may carry a UTF-8 BOM. + + U+FEFF is not whitespace, so the strip never removes it and validation + rejects the value — for ``.credentials_key`` that refuses startup and + leaves every stored secret unreadable. The BOM is an encoding artifact, + never part of a legitimate secret, so the reader consumes it. + """ + path = tmp_path / ".secret" + path.write_bytes(b"\xef\xbb\xbf" + b"existing-value\n") + + def validate(value: str) -> None: + if value != "existing-value": + raise ValueError("not the expected secret") + + def generate() -> str: + raise AssertionError("a readable secret must never be regenerated") + + assert read_or_create_secret_file(path, generate, validate=validate) == "existing-value" + assert path.read_bytes().startswith(b"\xef\xbb\xbf"), "reading must not rewrite the file" + + def test_a_peer_that_publishes_first_wins(tmp_path: Path) -> None: """Deterministic race: the peer creates the file between our existence check and our create.