From cd982d675180a84cd5c389a454ce0c5c18b2ba8c Mon Sep 17 00:00:00 2001 From: bjtolo <498650411@qq.com> Date: Fri, 3 Jul 2026 11:09:26 +0800 Subject: [PATCH] fix(sandbox): normalize Windows backslash paths to forward slashes in bash commands (#3869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sandbox): normalize Windows backslash paths to forward slashes in bash commands On Windows, `replace_virtual_paths_in_command()` and `LocalSandbox._resolve_paths_in_command()` resolve virtual paths (/mnt/skills, /mnt/user-data, /mnt/acp-workspace, custom mounts) to host paths with backslashes (C:\Users\admin\...). Bash interprets \U, \a, \d, \s, \n, \t as escape sequences, mangling the path. Fix: add `.replace("\\", "/")` to all path resolution callbacks so resolved paths use forward slashes, which bash handles correctly on all platforms and Windows APIs/Python's open() accept natively. Fixes #3865 * test(sandbox): regression tests for Windows backslash path normalization Covers replace_virtual_paths_in_command and LocalSandbox._resolve_paths_in_command with Windows-style backslash paths, asserting no backslashes survive in output. Closes #3865 * test(sandbox): fix ACP test failure, keep 6 passing regression tests Removed test_acp_workspace_no_backslash which triggered path traversal validation. Remaining 6 tests cover user-data (3), skills (1), and LocalSandbox custom-mount (2) paths — all with Windows backslash paths. * test(sandbox): restore ACP Windows path normalization coverage --- .../deerflow/sandbox/local/local_sandbox.py | 4 +- .../harness/deerflow/sandbox/tools.py | 6 +- ...test_sandbox_windows_path_normalization.py | 79 +++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_sandbox_windows_path_normalization.py diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index eaf1ed0ad..ebadbf7b8 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -376,7 +376,9 @@ class LocalSandbox(Sandbox): def replace_match(match: re.Match) -> str: matched_path = match.group(0) - return self._resolve_path(matched_path) + # Normalize to forward slashes so bash doesn't interpret Windows + # backslash sequences (\\U, \\a, \\d, \\s, \\n, \\t) as escapes. + return self._resolve_path(matched_path).replace("\\", "/") return pattern.sub(replace_match, command) diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 1440bb8e3..5217d80d7 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -1049,7 +1049,7 @@ def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState skills_pattern = re.compile(rf"{re.escape(skills_container)}(/[^\s\"';&|<>()]*)?") def replace_skills_match(match: re.Match) -> str: - return _resolve_skills_path(match.group(0)) + return _resolve_skills_path(match.group(0)).replace("\\", "/") result = skills_pattern.sub(replace_skills_match, result) @@ -1060,7 +1060,7 @@ def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState acp_pattern = re.compile(rf"{re.escape(_ACP_WORKSPACE_VIRTUAL_PATH)}(/[^\s\"';&|<>()]*)?") def replace_acp_match(match: re.Match, _tid: str | None = _thread_id) -> str: - return _resolve_acp_workspace_path(match.group(0), _tid) + return _resolve_acp_workspace_path(match.group(0), _tid).replace("\\", "/") result = acp_pattern.sub(replace_acp_match, result) @@ -1071,7 +1071,7 @@ def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState pattern = re.compile(rf"{re.escape(VIRTUAL_PATH_PREFIX)}(/[^\s\"';&|<>()]*)?") def replace_user_data_match(match: re.Match) -> str: - return replace_virtual_path(match.group(0), thread_data) + return replace_virtual_path(match.group(0), thread_data).replace("\\", "/") result = pattern.sub(replace_user_data_match, result) diff --git a/backend/tests/test_sandbox_windows_path_normalization.py b/backend/tests/test_sandbox_windows_path_normalization.py new file mode 100644 index 000000000..211537cd6 --- /dev/null +++ b/backend/tests/test_sandbox_windows_path_normalization.py @@ -0,0 +1,79 @@ +"""Regression tests for Windows backslash path normalization. + +Ensures that replace_virtual_paths_in_command and LocalSandbox._resolve_paths_in_command +return forward-slash paths when the host paths use backslashes (Windows). +""" + +from unittest.mock import patch + +from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping +from deerflow.sandbox.tools import replace_virtual_paths_in_command + +# Windows-style thread data with backslash paths +_WIN_THREAD_DATA = { + "workspace_path": r"C:\Users\admin\deer-flow\backend\.deer-flow\users\user1\threads\t1\user-data\workspace", + "uploads_path": r"C:\Users\admin\deer-flow\backend\.deer-flow\users\user1\threads\t1\user-data\uploads", + "outputs_path": r"C:\Users\admin\deer-flow\backend\.deer-flow\users\user1\threads\t1\user-data\outputs", +} + + +class TestReplaceVirtualPathsWindows: + """replace_virtual_paths_in_command must normalize backslashes to forward slashes.""" + + def test_user_data_workspace_no_backslash(self) -> None: + cmd = "cat /mnt/user-data/workspace/data.json" + result = replace_virtual_paths_in_command(cmd, _WIN_THREAD_DATA) + assert "\\" not in result, f"Backslash in: {result}" + + def test_user_data_outputs_no_backslash(self) -> None: + cmd = "ls /mnt/user-data/outputs/report.html" + result = replace_virtual_paths_in_command(cmd, _WIN_THREAD_DATA) + assert "\\" not in result, f"Backslash in: {result}" + + def test_user_data_subdir_no_backslash(self) -> None: + cmd = "cat /mnt/user-data/workspace/subdir/file.txt" + result = replace_virtual_paths_in_command(cmd, _WIN_THREAD_DATA) + assert "\\" not in result, f"Backslash in: {result}" + + @patch("deerflow.sandbox.tools._get_skills_host_path", return_value=r"C:\Users\admin\deer-flow\skills") + @patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills") + def test_skills_path_no_backslash(self, _mock_container, _mock_host) -> None: + cmd = "python /mnt/skills/custom/skill/scripts/run.py" + result = replace_virtual_paths_in_command(cmd, _WIN_THREAD_DATA) + assert "\\" not in result, f"Backslash in: {result}" + + @patch("deerflow.sandbox.tools._resolve_acp_workspace_path", return_value=r"C:\Users\admin\deer-flow\acp-workspace\data.json") + @patch("deerflow.sandbox.tools._get_acp_workspace_host_path", return_value=r"C:\Users\admin\deer-flow\acp-workspace") + def test_acp_workspace_no_backslash(self, _mock_acp_host, _mock_resolve_acp) -> None: + cmd = "cat /mnt/acp-workspace/data.json" + result = replace_virtual_paths_in_command(cmd, _WIN_THREAD_DATA) + assert "\\" not in result, f"Backslash in: {result}" + assert "C:/Users/admin/deer-flow/acp-workspace/data.json" in result + + +class TestLocalSandboxResolvePathsInCommandWindows: + """LocalSandbox._resolve_paths_in_command must normalize backslashes.""" + + def test_custom_mount_no_backslash(self) -> None: + sandbox = LocalSandbox( + "test", + path_mappings=[ + PathMapping(container_path="/mnt/models", local_path=r"C:\Users\admin\models", read_only=True), + ], + ) + cmd = "cat /mnt/models/weights.bin" + result = sandbox._resolve_paths_in_command(cmd) + assert "\\" not in result, f"Backslash in: {result}" + assert "C:/Users/admin/models/weights.bin" in result + + def test_user_data_no_backslash(self) -> None: + sandbox = LocalSandbox( + "test", + path_mappings=[ + PathMapping(container_path="/mnt/user-data", local_path=r"C:\Users\admin\data"), + ], + ) + cmd = "ls /mnt/user-data/workspace/file.txt" + result = sandbox._resolve_paths_in_command(cmd) + assert "\\" not in result, f"Backslash in: {result}" + assert "C:/Users/admin/data/workspace/file.txt" in result