diff --git a/CHANGELOG.md b/CHANGELOG.md index eb34e3ce81..70116de2a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ This section accumulates work toward the **2.3.0** milestone ### Fixed +- **gateway:** A caller-supplied thread ID that is a Windows reserved device + name (`NUL`, `CON`, `COM1`, ...) is now rejected at the shared + `validate_thread_id()` boundary. Such IDs previously passed validation but + failed workspace creation on Windows hosts with `FileNotFoundError`. The + check reuses `windows_incompatible_segment()` and applies on every platform, + so threads with reserved-name IDs created on Linux hosts will fail + validation on read/resume/upload routes after upgrade (deletion still + works). ([#7892]) + - **extensions:** Context-compaction observers now learn which task a compaction happened in. Inside a lead run or subagent they receive that task's store instead of a detached one, and `CompactionEvent` gains @@ -16012,3 +16021,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7781]: https://github.com/bytedance/deer-flow/pull/7781 [#7792]: https://github.com/bytedance/deer-flow/pull/7792 [#7831]: https://github.com/bytedance/deer-flow/pull/7831 +[#7892]: https://github.com/bytedance/deer-flow/pull/7892 diff --git a/backend/packages/harness/deerflow/utils/thread_id.py b/backend/packages/harness/deerflow/utils/thread_id.py index 1bae01348a..cd35ad37e3 100644 --- a/backend/packages/harness/deerflow/utils/thread_id.py +++ b/backend/packages/harness/deerflow/utils/thread_id.py @@ -8,6 +8,8 @@ from typing import Annotated from pydantic import AfterValidator, StringConstraints +from deerflow.utils.host_paths import windows_incompatible_segment + THREAD_ID_PATTERN = r"^[A-Za-z0-9_-]{1,64}$" _THREAD_ID_RE = re.compile(THREAD_ID_PATTERN) @@ -20,6 +22,15 @@ def validate_thread_id(thread_id: str) -> str: """ if not isinstance(thread_id, str) or _THREAD_ID_RE.fullmatch(thread_id) is None: raise ValueError("Invalid thread_id: expected 1-64 ASCII letters, digits, hyphens, or underscores") + # Windows reserves device names such as NUL in every directory; creating a + # thread workspace named "NUL" fails with FileNotFoundError on Windows + # hosts. Reuse the canonical Windows-name check that uploads, sandbox + # writes and skill support files already go through, so thread IDs follow + # the same documented "rejected on every platform when a name is created" + # contract instead of keeping a second name list in sync. + reason = windows_incompatible_segment(thread_id) + if reason is not None: + raise ValueError(f"Invalid thread_id {thread_id!r}: {reason}") return thread_id diff --git a/backend/tests/test_thread_id_validation.py b/backend/tests/test_thread_id_validation.py index cb987360ac..4ef630f2e3 100644 --- a/backend/tests/test_thread_id_validation.py +++ b/backend/tests/test_thread_id_validation.py @@ -27,6 +27,18 @@ def test_validate_thread_id_accepts_canonical_ids(thread_id: str) -> None: "has space", "line\nbreak", "线程", + # Windows reserved device names break workspace creation on Windows + # hosts (e.g. Paths.ensure_thread_dirs raising FileNotFoundError for + # "NUL"), so they are rejected at the shared validation boundary via + # windows_incompatible_segment(). + "NUL", + "nul", + "Nul", + "CON", + "PRN", + "AUX", + "COM1", + "LPT9", ], ) def test_validate_thread_id_rejects_noncanonical_ids(thread_id: str) -> None: @@ -36,6 +48,28 @@ def test_validate_thread_id_rejects_noncanonical_ids(thread_id: str) -> None: validate_thread_id(thread_id) +@pytest.mark.parametrize( + "thread_id", + [ + "NUL", + "nul", + "Nul", + "CON", + "PRN", + "AUX", + "COM1", + "LPT9", + ], +) +def test_validate_thread_id_rejects_windows_reserved_names(thread_id: str) -> None: + """Reserved device names hit the windows_incompatible_segment branch, not + just the character-pattern branch.""" + from deerflow.utils.thread_id import validate_thread_id + + with pytest.raises(ValueError, match="reserved Windows device name"): + validate_thread_id(thread_id) + + @pytest.mark.parametrize("thread_id", [1, {}, []]) def test_validate_thread_id_rejects_non_strings(thread_id: object) -> None: from deerflow.utils.thread_id import validate_thread_id