From 04c9db8a5b9f5e01efe3a2c0795ffdb72a4413a3 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 31 Jul 2026 17:51:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(windows):=20=E6=B2=99=E7=AE=B1=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E6=A0=B9=E6=96=87=E4=BB=B6=E7=B3=BB=E7=BB=9F=E4=B8=8E?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E6=8E=92=E6=9F=A5=E8=B0=83=E8=AF=95=E5=9F=8B?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/main_terminal_parts/context/mode.py | 16 +-- modules/background_command_manager.py | 8 ++ modules/host_sandbox_runner.py | 58 ++++----- modules/sbx_debug.py | 147 +++++++++++++++++++++++ modules/terminal_ops/run.py | 14 +++ 5 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 modules/sbx_debug.py diff --git a/core/main_terminal_parts/context/mode.py b/core/main_terminal_parts/context/mode.py index 3b616024..e23081a0 100644 --- a/core/main_terminal_parts/context/mode.py +++ b/core/main_terminal_parts/context/mode.py @@ -252,15 +252,15 @@ class ModeMixin: "- 命令解释器:Linux bash(例如可用 ls、cat、grep、find、git、python3、curl 等常见 Linux 工具)\n" f"- 当前工作区:{ws_win}(Windows 视角)= {ws_wsl}(沙箱内视角)\n" f"- 命令的工作目录已默认落在 {ws_wsl},操作工作区内文件请直接使用相对路径\n" - "- 引用其他 Windows 路径时必须转换:盘符小写、反斜杠变正斜杠,例如 D:\\tools\\a.txt → /mnt/d/tools/a.txt\n" + "- 沙箱内只能访问当前工作区:其他 Windows 路径(如 D:\\tools\\a.txt)未挂载进沙箱、根本不存在,需要操作时请把文件复制进工作区,或请用户调整工作区\n" "- 【禁止】Windows 程序在沙箱内不存在:cmd、powershell、bat 脚本、.exe、Windows 版 python/node 均无法运行\n" "- 【禁止】不要写 Windows 风格路径,bash 会把反斜杠当作转义符\n" "\n" "### 文件权限\n" "- 工作区内:可读可写\n" - "- 工作区外:全部只读(包括 Windows 各盘与 Linux 系统目录),写入会报 Read-only file system\n" - "- /tmp 与 HOME 目录可写(npm/pip 等工具的缓存可正常使用)\n" - "- 用户敏感目录(~/.ssh 等凭据)已被屏蔽,不可读取" + "- 工作区外:数据在沙箱内不存在(Windows 各盘与其余目录均未挂载),访问报 No such file or directory;仅通往工作区的空壳目录链可见\n" + "- Linux 系统目录(/bin /usr /lib /etc 等,沙箱自带工具链):只读\n" + "- /tmp 与 HOME 目录可写(npm/pip 等工具的缓存可正常使用)" ) def _build_windows_mode_rules(self, mode: str) -> str: @@ -268,7 +268,7 @@ class ModeMixin: if mode == "sandbox": return ( "- 所有命令默认在 WSL2 Linux 沙箱中执行\n" - "- 命令报 Read-only file system / Permission denied:说明操作超出沙箱授权边界,这是用户刻意设置的。不要尝试绕过(换路径、提权等),应向用户说明并请求调整权限或切换执行环境\n" + "- 命令报 Read-only file system / Permission denied,或访问工作区外路径报 No such file or directory:说明操作超出沙箱授权边界,这是用户刻意设置的。不要尝试绕过(换路径、提权等),应向用户说明并请求调整权限或切换执行环境\n" "- 命令报 command not found:先检查是否误用了 Windows 程序;Linux 工具缺失时可建议用户安装\n" "- 需要 Windows 原生工具链的任务(如为 Windows 版 node 安装依赖):明确告知用户需切换到“完全访问权限”执行" ) @@ -297,11 +297,11 @@ class ModeMixin: "你后续的终端命令将不再在 Windows 中执行,而是在 WSL2 Linux 沙箱中以 bash 执行。请立即调整命令写法:\n" "\n" "1. 解释器变为 Linux bash:使用 ls、cat、grep 等 Linux 命令;cmd、powershell、.exe 等 Windows 程序从此刻起不可用\n" - f"2. 路径必须转换:当前工作区为 {ws_wsl}(即 Windows 的 {ws_win}),工作目录已默认落在此处,工作区内操作请用相对路径;其他 Windows 路径按 D:\\a\\b → /mnt/d/a/b 转换\n" + f"2. 路径必须转换:当前工作区为 {ws_wsl}(即 Windows 的 {ws_win}),工作目录已默认落在此处,工作区内操作请用相对路径;沙箱内只能访问工作区,其他 Windows 路径均未挂载、无法读写\n" "3. 禁止再写 Windows 风格路径,反斜杠会被 bash 当作转义符\n" - "4. 写权限:仅工作区、/tmp、HOME 可写,其余全部只读\n" + "4. 写权限:仅工作区、/tmp、HOME 可写;Linux 系统目录只读;其余路径在沙箱内不存在\n" f"{network_part}" - f"{final_no}. 遇到 Read-only file system / Permission denied 不要绕过,向用户说明并请求调整\n" + f"{final_no}. 遇到 Read-only file system / Permission denied / 区外路径 No such file or directory 不要绕过,向用户说明并请求调整\n" "\n" "此前的命令如果是按 Windows 环境编写的,请按上述规则改写后重新执行。" ) diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index 3711d055..f8088220 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -18,6 +18,11 @@ from modules.host_sandbox_runner import ( build_host_sandbox_plan, host_sandbox_enabled, ) +from modules import sbx_debug # [SBX-DEBUG] +try: # [SBX-DEBUG] + sbx_debug.install_ctrl_event_probes("bg_manager") # [SBX-DEBUG] +except Exception: # [SBX-DEBUG] + pass # [SBX-DEBUG] TERMINAL_STATUSES = {"completed", "failed", "timeout", "cancelled"} @@ -289,6 +294,7 @@ class BackgroundCommandManager: rec["pid"] = process.pid rec["updated_at"] = time.time() self._processes[command_id] = process + sbx_debug.sbx_log("BG-SPAWNED", pid=getattr(process, "pid", None), command=str(command)[:200], timeout=timeout, console_pids=sbx_debug.console_pids()) # [SBX-DEBUG] def _reader(stream, collector, rec_key: str): try: @@ -322,6 +328,7 @@ class BackgroundCommandManager: status = "timeout" message = f"命令执行超时 ({timeout}秒)" # 跨平台终止:POSIX killpg(SIGINT→SIGKILL),Windows CTRL_BREAK→taskkill + sbx_debug.sbx_log_stack("BG-TIMEOUT-TERMINATE", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG] self._terminate_pid(process.pid) try: process.wait(timeout=2) @@ -429,6 +436,7 @@ class BackgroundCommandManager: return True def _terminate_pid(self, pid: Any) -> bool: + sbx_debug.sbx_log_stack("TERMINATE-PID", pid=pid) # [SBX-DEBUG] normalized = self._coerce_pid(pid) if not normalized: return False diff --git a/modules/host_sandbox_runner.py b/modules/host_sandbox_runner.py index 93091b76..901fe0a7 100644 --- a/modules/host_sandbox_runner.py +++ b/modules/host_sandbox_runner.py @@ -7,12 +7,11 @@ import shutil import subprocess from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional from modules.host_sandbox_policy import ( get_macos_writable_paths, get_macos_deny_read_paths, get_macos_deny_read_regexes, - get_windows_deny_read_paths, ) @@ -395,13 +394,21 @@ def _build_linux_common_plan( # ────────────────────────────────────────────────────────────── # Windows:WSL2 + bubblewrap 沙箱 # -# 设计要点(依据 .wsl-poc 概念验证,见 wsl2-sandbox-poc-report.md): +# 设计要点(依据 .wsl-poc 与 .wsl-exp 两轮实验,见 wsl2-sandbox-poc-report.md +# 与项目记忆 wsl_sandbox_minimal_root): # - 使用专用沙箱发行版(默认 astrion-sandbox),必须关闭 interop, # 否则沙箱内可经 cmd.exe 逃逸到 Windows 宿主机; -# - 隔离原语与 Linux 方案同构:bwrap --unshare-all + ro-bind / + 工作区 bind; +# - 最小根文件系统:只挂载发行版的 Linux 系统目录(/bin /sbin /usr /lib /etc, +# 纯工具链、无用户数据)+ 工作区 bind;不挂载 / 本身与任何 /mnt/<盘>, +# 工作区外的数据在命名空间内根本不存在(报 No such file or directory)。 +# bwrap 是挂载命名空间构造器而非访问过滤器,因此可以实现 macOS Seatbelt +# 做不到的“指令正常运行 + 默认拒绝的完美读限制”; +# - bwrap 为挂载点自动创建的中间父目录是会话内可写 tmpfs(不落盘、无安全 +# 问题但缺报错语义),启动后先 mount -o remount,ro / 恢复只读报错; # - 网络档位:full → --share-net;restricted(仅回环)/ none → 不 share-net # (unshare-net 下 lo 自动可用,语义对齐 macOS 的 restricted); -# - 敏感路径用 --tmpfs(目录)/ --ro-bind /dev/null(文件)掩蔽; +# - 敏感路径掩蔽清单(windows_deny_read_paths)不再需要:数据根本不进命名 +# 空间;该清单仍由 server/chat/permission.py 用于原生读工具的禁读判断; # - wsl.exe 的 localhost 代理警告经 stderr_ignore_regexes 由执行器过滤。 # ────────────────────────────────────────────────────────────── @@ -468,26 +475,6 @@ def _ensure_wsl_sandbox_distro() -> str: return name -def _windows_deny_read_targets() -> List[Tuple[str, bool]]: - """敏感路径掩蔽清单:返回 ``(wsl路径, 是否目录)``,仅包含实际存在的路径。""" - targets: List[Tuple[str, bool]] = [] - for raw in get_windows_deny_read_paths(): - try: - p = Path(raw).expanduser() - except Exception: - continue - if not p.is_absolute(): - continue - try: - if p.is_dir(): - targets.append((_win_path_to_wsl(p), True)) - elif p.exists(): - targets.append((_win_path_to_wsl(p), False)) - except OSError: - continue - return targets - - def _build_windows_bwrap_argv( ws_wsl: str, shell_cmd: List[str], @@ -504,16 +491,26 @@ def _build_windows_bwrap_argv( # full → 共享网络;restricted(仅回环)与 none → unshare-net(lo 仍可用) if permission == NETWORK_PERMISSION_FULL: argv.append("--share-net") - argv += ["--ro-bind", "/", "/"] + # 最小根文件系统:只挂沙箱发行版的 Linux 系统目录(纯工具链、无用户数据)。 + # 不挂载 / 本身与任何 /mnt/<盘>——工作区外的数据在命名空间内不存在。 + # 注意:若日后改用 glibc 发行版(如 Ubuntu),需补 --ro-bind /lib64 /lib64。 + for sysdir in ("/bin", "/sbin", "/usr", "/lib", "/etc"): + argv += ["--ro-bind", sysdir, sysdir] argv += (["--ro-bind"] if readonly else ["--bind"]) + [ws_wsl, ws_wsl] - for target, is_dir in _windows_deny_read_targets(): - argv += ["--tmpfs", target] if is_dir else ["--ro-bind", "/dev/null", target] argv += [ "--chdir", ws_wsl, "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", + "--tmpfs", "/var/tmp", + "--dir", "/root", "--", + # bwrap 为挂载点自动创建的中间父目录(如 /mnt/e)是会话内可写 tmpfs + # (写入不落盘、退出即消失,无安全问题),但写入不报错、与 mac 的审批 + # 关键词语义不一致;启动后先把根 remount 为只读,再 exec 真正的命令。 + # $0="bwrap-sh" 仅作占位,$@ 从 shell_cmd 开始,exec "$@" 按 argv 原样 + # 透传,避免对用户命令做字符串拼接(切勿加 shift,否则会丢掉 argv[0])。 + "bash", "-c", 'mount -o remount,ro / 2>/dev/null; exec "$@"', "bwrap-sh", *shell_cmd, ] return argv @@ -534,8 +531,11 @@ def _build_windows_wsl_plan( argv = _build_windows_bwrap_argv(ws_wsl, shell_cmd, readonly, network_permission) plan_env = dict(env or {}) plan_env["WSL_UTF8"] = "1" + # 必须用 -e(exec,不经默认 shell)而非 --:-- 形式会把尾部交给 /bin/sh 重新解析, + # 带空格/引号的参数会被拆散(bash -c 后的位置参数全部丢失),-e 原样传递 argv + # (.wsl-exp/test_argprobe2.py 实测:P4/P6(--) 参数丢失,P5/P8(-e) 完整)。 return SandboxPlan( - command=[wsl, "-d", distro, "--", *argv], + command=[wsl, "-d", distro, "-e", *argv], env=plan_env, cwd=str(work_path), stderr_ignore_regexes=list(_WSL_STDERR_IGNORE), diff --git a/modules/sbx_debug.py b/modules/sbx_debug.py new file mode 100644 index 00000000..7195f557 --- /dev/null +++ b/modules/sbx_debug.py @@ -0,0 +1,147 @@ +# modules/sbx_debug.py - 沙箱崩溃排查的临时调试日志模块(诊断完成后移除) +# +# 双通道日志: +# 1) UDP 发送到独立监控进程(后端死亡前消息已送达,监控进程独立存活) +# 2) 文件追加 .wsl-exp/sbx_debug.log(每行立即落盘,保底冗余) +# 环境变量: +# SBX_DEBUG=0 关闭全部调试日志 +# SBX_DEBUG_PORT=9956 UDP 监控端口 +# SBX_DEBUG_SWALLOW_CTRL=0 控制台事件不吞掉(记录后恢复默认行为=真实死亡) + +from __future__ import annotations + +import datetime +import os +import signal +import socket +import threading +import traceback + +_ENABLED = os.environ.get("SBX_DEBUG", "1").strip().lower() not in {"0", "false", "no", "off"} +_PORT = int(os.environ.get("SBX_DEBUG_PORT", "8092") or "8092") # UDP,与后端 TCP 端口不冲突 +_SWALLOW = os.environ.get("SBX_DEBUG_SWALLOW_CTRL", "1").strip().lower() not in {"0", "false", "no", "off"} + +_LOCK = threading.Lock() +_SOCK = None +_PROBES_INSTALLED = False + + +def _log_path() -> str: + try: + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + except Exception: + root = os.getcwd() + d = os.path.join(root, ".wsl-exp") + try: + os.makedirs(d, exist_ok=True) + except Exception: + pass + return os.path.join(d, "sbx_debug.log") + + +def _get_sock(): + global _SOCK + if _SOCK is None: + try: + _SOCK = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + except Exception: + _SOCK = False + return _SOCK or None + + +def sbx_log(event: str, **fields) -> None: + """写一条调试日志(任何异常都吞掉,绝不影响主流程)。""" + if not _ENABLED: + return + try: + ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3] + parts = [f"{k}={fields[k]!r}" for k in sorted(fields)] + line = f"[{ts}] [pid={os.getpid()}] [th={threading.current_thread().name}] {event}" + if parts: + line += " | " + " ".join(parts) + data = (line + "\n").encode("utf-8", "replace") + with _LOCK: + sock = _get_sock() + if sock is not None: + try: + sock.sendto(data, ("127.0.0.1", _PORT)) + except Exception: + pass + try: + with open(_log_path(), "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + except Exception: + pass + + +def sbx_log_stack(event: str, **fields) -> None: + """写一条带调用堆栈的调试日志。""" + try: + stack = "".join(traceback.format_stack(limit=14)[:-2]) + except Exception: + stack = "" + fields["stack"] = stack + sbx_log(event, **fields) + + +def console_pids(): + """返回当前控制台附加的进程 pid 列表(Windows),用于判断子进程是否共享后端控制台。""" + if os.name != "nt": + return [] + try: + import ctypes + arr = (ctypes.c_ulong * 64)() + got = ctypes.windll.kernel32.GetConsoleProcessList(arr, 64) + if not got: + return [] + return [int(arr[i]) for i in range(min(got, 64))] + except Exception: + return ["?"] + + +def install_ctrl_event_probes(tag: str = "backend") -> None: + """安装 SIGINT/SIGBREAK 探针:控制台事件到达时先记录日志。 + + SBX_DEBUG_SWALLOW_CTRL=1(默认):记录后吞掉事件,进程存活,可继续观察后续行为; + =0:记录后恢复原处理器语义(SIGINT→KeyboardInterrupt;SIGBREAK→默认终止)。 + 只能在主线程安装,失败静默。 + """ + global _PROBES_INSTALLED + if not _ENABLED or os.name != "nt" or _PROBES_INSTALLED: + return + if threading.current_thread() is not threading.main_thread(): + sbx_log("CTRL-PROBES-SKIPPED", reason="not main thread", tag=tag) + return + + def _make_handler(sig, name): + prev = signal.getsignal(sig) + + def _handler(signum, frame): + sbx_log_stack("CTRL-EVENT-RECEIVED", signal=name, tag=tag, swallowed=_SWALLOW) + if _SWALLOW: + return + if callable(prev): + prev(signum, frame) + return + try: + signal.signal(sig, signal.SIG_DFL) + os.kill(os.getpid(), sig) + except Exception: + os._exit(128 + int(signum)) + + return _handler + + try: + signal.signal(signal.SIGINT, _make_handler(signal.SIGINT, "SIGINT(CTRL_C)")) + except Exception: + pass + try: + sigbreak = getattr(signal, "SIGBREAK", None) + if sigbreak is not None: + signal.signal(sigbreak, _make_handler(sigbreak, "SIGBREAK(CTRL_BREAK)")) + except Exception: + pass + _PROBES_INSTALLED = True + sbx_log("CTRL-PROBES-INSTALLED", tag=tag, swallow=_SWALLOW, port=_PORT) diff --git a/modules/terminal_ops/run.py b/modules/terminal_ops/run.py index 01621cee..db50e813 100644 --- a/modules/terminal_ops/run.py +++ b/modules/terminal_ops/run.py @@ -40,6 +40,11 @@ from modules.host_sandbox_runner import ( build_host_sandbox_readonly_plan, host_sandbox_enabled, ) +from modules import sbx_debug # [SBX-DEBUG] +try: # [SBX-DEBUG] + sbx_debug.install_ctrl_event_probes("terminal_ops") # [SBX-DEBUG] +except Exception: # [SBX-DEBUG] + pass # [SBX-DEBUG] if TYPE_CHECKING: from modules.user_container_manager import ContainerHandle @@ -56,6 +61,7 @@ class RunMixin: 子进程以 start_new_session=True 启动(映射为 CREATE_NEW_PROCESS_GROUP), 可用 CTRL_BREAK_EVENT 发送到该进程组,失败则直接强杀。 """ + sbx_debug.sbx_log_stack("INTERRUPT-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG] if os.name == "nt": try: process.send_signal(signal.CTRL_BREAK_EVENT) @@ -79,6 +85,7 @@ class RunMixin: POSIX 用 killpg(SIGKILL);Windows 没有 SIGKILL,用 taskkill /F /T 终止整棵进程树, 避免 shell 子进程被杀后孙进程残留为孤儿。 """ + sbx_debug.sbx_log_stack("KILL-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG] if os.name == "nt": try: await asyncio.to_thread( @@ -161,6 +168,7 @@ class RunMixin: network_permission: Optional[str] = None, ) -> Dict: start_ts = time.time() + sbx_debug.sbx_log("RUN-CMD-ENTER", command=str(command)[:200], timeout=timeout, mode=getattr(self, "host_execution_mode", None), sandbox_write=sandbox_write_access) # [SBX-DEBUG] try: process = None exec_cmd = None @@ -234,6 +242,7 @@ class RunMixin: os.close(seccomp_fd) except OSError: pass + sbx_debug.sbx_log("SANDBOX-SPAWNED", pid=getattr(process, "pid", None), argv0=(cmd_args[0] if cmd_args else None), argv_tail=str(cmd_args[-3:])[:160], console_pids=sbx_debug.console_pids()) # [SBX-DEBUG] elif use_host_sandbox: return { "success": False, @@ -298,11 +307,13 @@ class RunMixin: pass timed_out = False + sbx_debug.sbx_log("WAIT-BEGIN", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG] try: try: await asyncio.wait_for(process.wait(), timeout=timeout) except asyncio.TimeoutError: timed_out = True + sbx_debug.sbx_log_stack("TIMEOUT-INTERRUPT", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG] await self._interrupt_subprocess(process) try: await asyncio.wait_for(process.wait(), timeout=2) @@ -311,6 +322,7 @@ class RunMixin: await process.wait() except asyncio.CancelledError: # 用户主动停止任务或会话断开,立即终止子进程 + sbx_debug.sbx_log_stack("CANCELLED-KILL", pid=getattr(process, "pid", None)) # [SBX-DEBUG] await self._kill_subprocess(process) raise finally: @@ -319,6 +331,7 @@ class RunMixin: # (Task was destroyed but it is pending / unclosed transport 告警) await _finish_reader_tasks(force=timed_out) + sbx_debug.sbx_log("WAIT-END", pid=getattr(process, "pid", None), returncode=getattr(process, "returncode", None), timed_out=timed_out, stdout_len=sum(len(c) for c in stdout_buf), stderr_len=sum(len(c) for c in stderr_buf)) # [SBX-DEBUG] # 非超时场景下兜底再读一次,防止剩余缓冲未被读取 if not timed_out: try: @@ -385,6 +398,7 @@ class RunMixin: "elapsed_ms": int((time.time() - start_ts) * 1000) } except Exception as exc: + sbx_debug.sbx_log_stack("RUN-EXCEPTION", error=str(exc)[:200]) # [SBX-DEBUG] return { "success": False, "status": "error",