fix: add Windows shell fallback for local sandbox (#1505)

* fix: add Windows shell fallback for local sandbox

* fix: handle PowerShell execution on Windows

* fix: handle Windows local shell execution

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Admire 2026-03-29 21:31:29 +08:00 committed by GitHub
parent 92c7a20cb7
commit 68c9e09a7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 207 additions and 18 deletions

View File

@ -1,3 +1,4 @@
import ntpath
import os import os
import shutil import shutil
import subprocess import subprocess
@ -8,6 +9,36 @@ from deerflow.sandbox.sandbox import Sandbox
class LocalSandbox(Sandbox): class LocalSandbox(Sandbox):
@staticmethod
def _shell_name(shell: str) -> str:
"""Return the executable name for a shell path or command."""
return shell.replace("\\", "/").rsplit("/", 1)[-1].lower()
@staticmethod
def _is_powershell(shell: str) -> bool:
"""Return whether the selected shell is a PowerShell executable."""
return LocalSandbox._shell_name(shell) in {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
@staticmethod
def _is_cmd_shell(shell: str) -> bool:
"""Return whether the selected shell is cmd.exe."""
return LocalSandbox._shell_name(shell) in {"cmd", "cmd.exe"}
@staticmethod
def _find_first_available_shell(candidates: tuple[str, ...]) -> str | None:
"""Return the first executable shell path or command found from candidates."""
for shell in candidates:
if os.path.isabs(shell):
if os.path.isfile(shell) and os.access(shell, os.X_OK):
return shell
continue
shell_from_path = shutil.which(shell)
if shell_from_path is not None:
return shell_from_path
return None
def __init__(self, id: str, path_mappings: dict[str, str] | None = None): def __init__(self, id: str, path_mappings: dict[str, str] | None = None):
""" """
Initialize local sandbox with optional path mappings. Initialize local sandbox with optional path mappings.
@ -137,32 +168,59 @@ class LocalSandbox(Sandbox):
@staticmethod @staticmethod
def _get_shell() -> str: def _get_shell() -> str:
"""Detect available shell executable with fallback. """Detect available shell executable with fallback."""
shell = LocalSandbox._find_first_available_shell(("/bin/zsh", "/bin/bash", "/bin/sh", "sh"))
if shell is not None:
return shell
Returns the first available shell in order of preference: if os.name == "nt":
/bin/zsh /bin/bash /bin/sh first `sh` found on PATH. system_root = os.environ.get("SystemRoot", r"C:\Windows")
Raises a RuntimeError if no suitable shell is found. shell = LocalSandbox._find_first_available_shell(
""" (
for shell in ("/bin/zsh", "/bin/bash", "/bin/sh"): "pwsh",
if os.path.isfile(shell) and os.access(shell, os.X_OK): "pwsh.exe",
"powershell",
"powershell.exe",
ntpath.join(system_root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
"cmd.exe",
)
)
if shell is not None:
return shell return shell
shell_from_path = shutil.which("sh")
if shell_from_path is not None: raise RuntimeError("No suitable shell executable found. Tried /bin/zsh, /bin/bash, /bin/sh, `sh` on PATH, then PowerShell and cmd.exe fallbacks for Windows.")
return shell_from_path
raise RuntimeError("No suitable shell executable found. Tried /bin/zsh, /bin/bash, /bin/sh, and `sh` on PATH.") raise RuntimeError("No suitable shell executable found. Tried /bin/zsh, /bin/bash, /bin/sh, and `sh` on PATH.")
def execute_command(self, command: str) -> str: def execute_command(self, command: str) -> str:
# Resolve container paths in command before execution # Resolve container paths in command before execution
resolved_command = self._resolve_paths_in_command(command) resolved_command = self._resolve_paths_in_command(command)
shell = self._get_shell()
result = subprocess.run( if os.name == "nt":
resolved_command, if self._is_powershell(shell):
executable=self._get_shell(), args = [shell, "-NoProfile", "-Command", resolved_command]
shell=True, elif self._is_cmd_shell(shell):
capture_output=True, args = [shell, "/c", resolved_command]
text=True, else:
timeout=600, args = [shell, "-c", resolved_command]
)
result = subprocess.run(
args,
shell=False,
capture_output=True,
text=True,
timeout=600,
)
else:
result = subprocess.run(
resolved_command,
executable=shell,
shell=True,
capture_output=True,
text=True,
timeout=600,
)
output = result.stdout output = result.stdout
if result.stderr: if result.stderr:
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr output += f"\nStd Error:\n{result.stderr}" if output else result.stderr

View File

@ -1,4 +1,5 @@
import builtins import builtins
from types import SimpleNamespace
import deerflow.sandbox.local.local_sandbox as local_sandbox import deerflow.sandbox.local.local_sandbox as local_sandbox
from deerflow.sandbox.local.local_sandbox import LocalSandbox from deerflow.sandbox.local.local_sandbox import LocalSandbox
@ -31,3 +32,133 @@ def test_write_file_uses_utf8_on_windows_locale(tmp_path, monkeypatch):
LocalSandbox("t").write_file(str(path), text) LocalSandbox("t").write_file(str(path), text)
assert path.read_text(encoding="utf-8") == text assert path.read_text(encoding="utf-8") == text
def test_get_shell_prefers_posix_shell_from_path_before_windows_fallback(monkeypatch):
monkeypatch.setattr(local_sandbox.os, "name", "nt")
monkeypatch.setattr(LocalSandbox, "_find_first_available_shell", lambda candidates: r"C:\Program Files\Git\bin\sh.exe" if candidates == ("/bin/zsh", "/bin/bash", "/bin/sh", "sh") else None)
assert LocalSandbox._get_shell() == r"C:\Program Files\Git\bin\sh.exe"
def test_get_shell_uses_powershell_fallback_on_windows(monkeypatch):
calls: list[tuple[str, ...]] = []
def fake_find(candidates: tuple[str, ...]) -> str | None:
calls.append(candidates)
if candidates == ("/bin/zsh", "/bin/bash", "/bin/sh", "sh"):
return None
return r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
monkeypatch.setattr(local_sandbox.os, "name", "nt")
monkeypatch.setattr(local_sandbox.os, "environ", {"SystemRoot": r"C:\Windows"})
monkeypatch.setattr(LocalSandbox, "_find_first_available_shell", fake_find)
assert LocalSandbox._get_shell() == r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
assert calls[1] == (
"pwsh",
"pwsh.exe",
"powershell",
"powershell.exe",
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"cmd.exe",
)
def test_get_shell_uses_cmd_as_last_windows_fallback(monkeypatch):
def fake_find(candidates: tuple[str, ...]) -> str | None:
if candidates == ("/bin/zsh", "/bin/bash", "/bin/sh", "sh"):
return None
return r"C:\Windows\System32\cmd.exe"
monkeypatch.setattr(local_sandbox.os, "name", "nt")
monkeypatch.setattr(local_sandbox.os, "environ", {"SystemRoot": r"C:\Windows"})
monkeypatch.setattr(LocalSandbox, "_find_first_available_shell", fake_find)
assert LocalSandbox._get_shell() == r"C:\Windows\System32\cmd.exe"
def test_execute_command_uses_powershell_command_mode_on_windows(monkeypatch):
calls: list[tuple[object, dict]] = []
def fake_run(*args, **kwargs):
calls.append((args[0], kwargs))
return SimpleNamespace(stdout="ok", stderr="", returncode=0)
monkeypatch.setattr(local_sandbox.os, "name", "nt")
monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"))
monkeypatch.setattr(local_sandbox.subprocess, "run", fake_run)
output = LocalSandbox("t").execute_command("Write-Output hello")
assert output == "ok"
assert calls == [
(
[
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"-NoProfile",
"-Command",
"Write-Output hello",
],
{
"shell": False,
"capture_output": True,
"text": True,
"timeout": 600,
},
)
]
def test_execute_command_uses_posix_shell_command_mode_on_windows(monkeypatch):
calls: list[tuple[object, dict]] = []
def fake_run(*args, **kwargs):
calls.append((args[0], kwargs))
return SimpleNamespace(stdout="ok", stderr="", returncode=0)
monkeypatch.setattr(local_sandbox.os, "name", "nt")
monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: r"C:\Program Files\Git\bin\sh.exe"))
monkeypatch.setattr(local_sandbox.subprocess, "run", fake_run)
output = LocalSandbox("t").execute_command("echo hello")
assert output == "ok"
assert calls == [
(
[r"C:\Program Files\Git\bin\sh.exe", "-c", "echo hello"],
{
"shell": False,
"capture_output": True,
"text": True,
"timeout": 600,
},
)
]
def test_execute_command_uses_cmd_command_mode_on_windows(monkeypatch):
calls: list[tuple[object, dict]] = []
def fake_run(*args, **kwargs):
calls.append((args[0], kwargs))
return SimpleNamespace(stdout="ok", stderr="", returncode=0)
monkeypatch.setattr(local_sandbox.os, "name", "nt")
monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: r"C:\Windows\System32\cmd.exe"))
monkeypatch.setattr(local_sandbox.subprocess, "run", fake_run)
output = LocalSandbox("t").execute_command("echo hello")
assert output == "ok"
assert calls == [
(
[r"C:\Windows\System32\cmd.exe", "/c", "echo hello"],
{
"shell": False,
"capture_output": True,
"text": True,
"timeout": 600,
},
)
]