mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 00:19:14 +00:00
fix(sandbox): force UTF-8 console for PowerShell so CJK output is not garbled (#5440)
* fix(sandbox): force UTF-8 console for PowerShell so CJK output is not garbled LocalSandbox captures PowerShell output through a UTF-8 pipe reader (errors=replace), but Windows PowerShell 5.1 writes console output in the legacy OEM codepage (GBK on zh-CN Windows) unless told otherwise, so every CJK character in tool output arrives as mojibake and the decode never raises. Prepend a UTF-8 preamble ([Console]::InputEncoding/[Console]::OutputEncoding/$OutputEncoding) to the -Command payload so both directions of the console are UTF-8 before the user command runs. * fix(sandbox): pair PowerShell UTF-8 capture and guard console setup --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
6c697f3067
commit
93f9ed3d8f
@ -1421,7 +1421,7 @@ an explicit **Load full file** action before fetching the remainder or mounting
|
||||
the full code editor. Active HTML, XHTML, and SVG artifacts remain forced
|
||||
downloads at the Gateway boundary.
|
||||
|
||||
With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes, so host-native CLI launchers retain their normal MSYS compatibility.
|
||||
With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes, so host-native CLI launchers retain their normal MSYS compatibility. When the local sandbox falls back to PowerShell, it captures output as UTF-8 so CJK text does not depend on the Gateway host locale.
|
||||
|
||||
Docker AIO sandboxes default to their existing open egress behavior for
|
||||
compatibility. Operators can set `sandbox.network.mode` to `isolated` or
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.
|
||||
**Implementations**:
|
||||
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-user/thread `LocalSandbox` (id `local:{user_id}:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Shared runs use category mappings; a policy-scoped run replaces them with one `/mnt/skills` root mapping to the coherent thread view, so structured file tools resolve through one managed boundary. This is not a host filesystem security boundary: an enabled host `bash` subprocess can use canonical paths without `PathMapping`, so `supports_agent_skill_isolation` is dynamic and explicit Agent policies fail closed while host bash is enabled. Host-to-virtual output masking scans dynamic per-user/per-thread roots directly instead of compiling path-specific regexes, so evicted thread IDs do not remain in Python's global regex caches; a separate 256-entry root cache prevents repeated `realpath()` walks for every glob/grep match while bounding dynamic-path retention, and only the small process-stable skill/integration source set uses a bounded compiled cache. The shared `path_patterns.py` tail stops at `:`, so `$PATH`-style lists mask every entry; a mount symlink resolving outside all mounts keeps its mount path. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths.
|
||||
PowerShell fallback pairs guarded console UTF-8 setters with explicit UTF-8 pipe decoding; cmd/MSYS retain locale decoding. Encoding regressions include real-pipe probes and Windows-only PowerShell roundtrips with and without a console.
|
||||
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (`_renew_owned_leases`). Text appends use the AIO file API's native append mode rather than a client-side read-modify-write, so a failed pre-read cannot turn an append into an overwrite. `reset()` closes the per-instance acquire serializer so replacing the singleton cannot retain its executor workers; full remote sandbox teardown remains `shutdown()`. `uses_thread_data_mounts` defaults to backend detection (`LocalContainerBackend=True`, remote/provisioner backends=False), while the optional `sandbox.thread_data_mounts` boolean takes precedence for deployments that guarantee the Gateway and sandbox share the same thread user-data directories. Setting it `true` skips upload-time sandbox acquire/sync; a false positive leaves uploads unavailable to the sandbox. An explicit Agent policy uses four thread projection category mounts and a distinct deterministic sandbox identity, preventing reuse of an older container created with shared mounts. `skills.container_path` is a provider-startup snapshot shared by mount construction, sandbox identity, the remote Gateway request, and provisioner validation; custom roots are identity-scoped so a container or Pod created for one destination cannot be reused after the root changes. The Gateway and provisioner independently require one canonical absolute root that does not overlap reserved platform mounts, and both derive the four category allowlist entries from that root. The provisioner accepts all four category overrides; when all are present it suppresses the default hostPath or skills-PVC mount. With `USERDATA_PVC_NAME`, the thread projection categories use subpaths on that shared data PVC. Readiness probes and `agent_sandbox` clients classify loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames as direct control-plane destinations and set `trust_env=False`; external FQDNs and public IPs retain environment proxy support.
|
||||
- `E2BSandboxProvider` (`packages/harness/deerflow/community/e2b_sandbox/`) provides E2B remote isolation.
|
||||
New unrestricted sandboxes receive a one-shot upload from the enabled-only
|
||||
|
||||
@ -523,7 +523,11 @@ class LocalSandbox(Sandbox):
|
||||
timed_out = False
|
||||
if os.name == "nt":
|
||||
if self._is_powershell(shell):
|
||||
args = [shell, "-NoProfile", "-Command", resolved_command]
|
||||
# Pair PowerShell's output encoding with the pipe decoder.
|
||||
# Console setters can fail without an attached console; guard
|
||||
# them independently so setup errors do not pollute tool output.
|
||||
utf8_preamble = "try{[Console]::InputEncoding=[System.Text.Encoding]::UTF8}catch{};try{[Console]::OutputEncoding=[System.Text.Encoding]::UTF8}catch{};$OutputEncoding=[System.Text.Encoding]::UTF8;"
|
||||
args = [shell, "-NoProfile", "-Command", utf8_preamble + resolved_command]
|
||||
elif self._is_cmd_shell(shell):
|
||||
args = [shell, "/c", resolved_command]
|
||||
else:
|
||||
@ -536,7 +540,10 @@ class LocalSandbox(Sandbox):
|
||||
"MSYS2_ARG_CONV_EXCL": exclusions,
|
||||
}
|
||||
|
||||
stdout, stderr, returncode, timed_out = self._run_windows_command(args, timeout, sandbox_env)
|
||||
if self._is_powershell(shell):
|
||||
stdout, stderr, returncode, timed_out = self._run_windows_command(args, timeout, sandbox_env, encoding="utf-8")
|
||||
else:
|
||||
stdout, stderr, returncode, timed_out = self._run_windows_command(args, timeout, sandbox_env)
|
||||
else:
|
||||
args = [shell, "-c", resolved_command]
|
||||
stdout, stderr, returncode, timed_out = self._run_posix_command(args, timeout, sandbox_env)
|
||||
@ -563,8 +570,10 @@ class LocalSandbox(Sandbox):
|
||||
args: list[str],
|
||||
timeout: float,
|
||||
env: dict[str, str] | None = None,
|
||||
*,
|
||||
encoding: str | None = None,
|
||||
) -> tuple[str, str, int, bool]:
|
||||
"""Run a Windows command with bounded capture and process-tree timeout."""
|
||||
"""Run with bounded capture, a process-tree timeout, and locale decoding unless overridden."""
|
||||
timed_out = False
|
||||
stdout_read_fd, stdout_write_fd = os.pipe()
|
||||
stderr_read_fd, stderr_write_fd = os.pipe()
|
||||
@ -594,7 +603,8 @@ class LocalSandbox(Sandbox):
|
||||
# The write fd may already be closed by the exception cleanup above.
|
||||
pass
|
||||
|
||||
encoding = locale.getpreferredencoding(False)
|
||||
if encoding is None:
|
||||
encoding = locale.getpreferredencoding(False)
|
||||
stdout_capture, stdout_thread = LocalSandbox._start_pipe_drain(
|
||||
stdout_read_fd,
|
||||
"deerflow-bash-stdout-drain",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import builtins
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@ -36,6 +37,61 @@ def test_bounded_pipe_capture_preserves_posix_newlines_by_default():
|
||||
assert capture.read() == "crlf\r\nbare-cr\rlf\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", [None, "utf-8"])
|
||||
def test_windows_pipe_capture_uses_explicit_encoding_or_locale(monkeypatch, encoding):
|
||||
"""Exercise real pipes with a legacy locale, even on a POSIX test host."""
|
||||
monkeypatch.setattr(local_sandbox.locale, "getpreferredencoding", lambda _: "cp936")
|
||||
if os.name != "nt":
|
||||
monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0, raising=False)
|
||||
expected = "你好 日本語"
|
||||
payload = (expected + "\r\n").encode(encoding or "cp936")
|
||||
|
||||
stdout, stderr, returncode, timed_out = LocalSandbox._run_windows_command(
|
||||
[sys.executable, "-c", f"import os; p=bytes.fromhex('{payload.hex()}'); os.write(1,p); os.write(2,p)"],
|
||||
10,
|
||||
encoding=encoding,
|
||||
)
|
||||
|
||||
assert stdout == stderr == expected + "\n"
|
||||
assert returncode == 0
|
||||
assert timed_out is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Requires real Windows PowerShell")
|
||||
@pytest.mark.parametrize("shell_name", ["powershell.exe", "pwsh.exe"])
|
||||
@pytest.mark.parametrize("no_console", [False, True], ids=["inherited-console", "no-console"])
|
||||
def test_windows_powershell_cjk_roundtrip(shell_name, no_console):
|
||||
shell = shutil.which(shell_name)
|
||||
if shell is None:
|
||||
pytest.skip(f"{shell_name} is not installed")
|
||||
probe = r"""
|
||||
import sys
|
||||
import deerflow.sandbox.local.local_sandbox as local_sandbox
|
||||
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
||||
|
||||
# Keep this regression effective even on an English or UTF-8 Windows runner.
|
||||
local_sandbox.locale.getpreferredencoding = lambda _: "cp936"
|
||||
LocalSandbox._get_shell = staticmethod(lambda: sys.argv[1])
|
||||
expected = "\u4f60\u597d \u65e5\u672c\u8a9e"
|
||||
command = f"Write-Output '{expected}'; [Console]::Error.WriteLine('{expected}'); exit 3"
|
||||
output = LocalSandbox("encoding-probe").execute_command(command, timeout=15)
|
||||
assert output == expected + "\n\nStd Error:\n" + expected + "\n\nExit Code: 3", ascii(output)
|
||||
"""
|
||||
env = {**os.environ, "PYTHONUTF8": "0"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", probe, shell],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=env,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if no_console else 0,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX capture semantics")
|
||||
def test_posix_command_capture_preserves_newlines():
|
||||
stdout, stderr, returncode, timed_out = LocalSandbox._run_posix_command(
|
||||
@ -181,10 +237,10 @@ def test_get_shell_uses_cmd_as_last_windows_fallback(monkeypatch):
|
||||
|
||||
|
||||
def test_execute_command_uses_powershell_command_mode_on_windows(monkeypatch):
|
||||
calls: list[tuple[list[str], float, dict[str, str]]] = []
|
||||
calls: list[tuple[list[str], float, dict[str, str], str | None]] = []
|
||||
|
||||
def fake_run(args, timeout, env):
|
||||
calls.append((args, timeout, env))
|
||||
def fake_run(args, timeout, env, *, encoding=None):
|
||||
calls.append((args, timeout, env, encoding))
|
||||
return "ok", "", 0, False
|
||||
|
||||
monkeypatch.setattr(local_sandbox.os, "name", "nt")
|
||||
@ -204,14 +260,39 @@ def test_execute_command_uses_powershell_command_mode_on_windows(monkeypatch):
|
||||
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Write-Output hello",
|
||||
"try{[Console]::InputEncoding=[System.Text.Encoding]::UTF8}catch{};try{[Console]::OutputEncoding=[System.Text.Encoding]::UTF8}catch{};$OutputEncoding=[System.Text.Encoding]::UTF8;Write-Output hello",
|
||||
],
|
||||
600,
|
||||
{"PATH": r"C:\Windows"},
|
||||
"utf-8",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_execute_command_forces_utf8_console_for_powershell_cjk_output(monkeypatch):
|
||||
"""PowerShell 5.1 defaults console output to the OEM codepage (GBK on
|
||||
zh-CN); without the UTF-8 preamble, CJK output is garbled by the UTF-8
|
||||
pipe reader even though decoding never raises (errors=replace)."""
|
||||
calls: list[tuple[list[str], float, dict[str, str], str | None]] = []
|
||||
|
||||
def fake_run(args, timeout, env, *, encoding=None):
|
||||
calls.append((args, timeout, env, encoding))
|
||||
return "你好", "", 0, False
|
||||
|
||||
monkeypatch.setattr(local_sandbox.os, "name", "nt")
|
||||
monkeypatch.setattr(local_sandbox.os, "environ", {"PATH": r"C:\Windows"})
|
||||
monkeypatch.setattr(LocalSandbox, "_get_shell", staticmethod(lambda: "pwsh"))
|
||||
monkeypatch.setattr(LocalSandbox, "_run_windows_command", staticmethod(fake_run))
|
||||
|
||||
output = LocalSandbox("t").execute_command("Write-Output 你好")
|
||||
|
||||
assert output == "你好"
|
||||
cmd = calls[0][0][3]
|
||||
assert cmd.startswith("try{[Console]::InputEncoding=[System.Text.Encoding]::UTF8}catch{};try{[Console]::OutputEncoding=[System.Text.Encoding]::UTF8}catch{};$OutputEncoding=[System.Text.Encoding]::UTF8;")
|
||||
assert cmd.endswith("Write-Output 你好")
|
||||
assert calls[0][3] == "utf-8"
|
||||
|
||||
|
||||
def test_execute_command_keeps_msys_path_conversion_for_host_commands_on_windows(monkeypatch):
|
||||
calls: list[tuple[list[str], float, dict[str, str]]] = []
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user