feat(windows): 沙箱最小根文件系统与崩溃排查调试埋点
This commit is contained in:
parent
77be044c44
commit
04c9db8a5b
@ -252,15 +252,15 @@ class ModeMixin:
|
|||||||
"- 命令解释器:Linux bash(例如可用 ls、cat、grep、find、git、python3、curl 等常见 Linux 工具)\n"
|
"- 命令解释器:Linux bash(例如可用 ls、cat、grep、find、git、python3、curl 等常见 Linux 工具)\n"
|
||||||
f"- 当前工作区:{ws_win}(Windows 视角)= {ws_wsl}(沙箱内视角)\n"
|
f"- 当前工作区:{ws_win}(Windows 视角)= {ws_wsl}(沙箱内视角)\n"
|
||||||
f"- 命令的工作目录已默认落在 {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 程序在沙箱内不存在:cmd、powershell、bat 脚本、.exe、Windows 版 python/node 均无法运行\n"
|
||||||
"- 【禁止】不要写 Windows 风格路径,bash 会把反斜杠当作转义符\n"
|
"- 【禁止】不要写 Windows 风格路径,bash 会把反斜杠当作转义符\n"
|
||||||
"\n"
|
"\n"
|
||||||
"### 文件权限\n"
|
"### 文件权限\n"
|
||||||
"- 工作区内:可读可写\n"
|
"- 工作区内:可读可写\n"
|
||||||
"- 工作区外:全部只读(包括 Windows 各盘与 Linux 系统目录),写入会报 Read-only file system\n"
|
"- 工作区外:数据在沙箱内不存在(Windows 各盘与其余目录均未挂载),访问报 No such file or directory;仅通往工作区的空壳目录链可见\n"
|
||||||
"- /tmp 与 HOME 目录可写(npm/pip 等工具的缓存可正常使用)\n"
|
"- Linux 系统目录(/bin /usr /lib /etc 等,沙箱自带工具链):只读\n"
|
||||||
"- 用户敏感目录(~/.ssh 等凭据)已被屏蔽,不可读取"
|
"- /tmp 与 HOME 目录可写(npm/pip 等工具的缓存可正常使用)"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_windows_mode_rules(self, mode: str) -> str:
|
def _build_windows_mode_rules(self, mode: str) -> str:
|
||||||
@ -268,7 +268,7 @@ class ModeMixin:
|
|||||||
if mode == "sandbox":
|
if mode == "sandbox":
|
||||||
return (
|
return (
|
||||||
"- 所有命令默认在 WSL2 Linux 沙箱中执行\n"
|
"- 所有命令默认在 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"
|
"- 命令报 command not found:先检查是否误用了 Windows 程序;Linux 工具缺失时可建议用户安装\n"
|
||||||
"- 需要 Windows 原生工具链的任务(如为 Windows 版 node 安装依赖):明确告知用户需切换到“完全访问权限”执行"
|
"- 需要 Windows 原生工具链的任务(如为 Windows 版 node 安装依赖):明确告知用户需切换到“完全访问权限”执行"
|
||||||
)
|
)
|
||||||
@ -297,11 +297,11 @@ class ModeMixin:
|
|||||||
"你后续的终端命令将不再在 Windows 中执行,而是在 WSL2 Linux 沙箱中以 bash 执行。请立即调整命令写法:\n"
|
"你后续的终端命令将不再在 Windows 中执行,而是在 WSL2 Linux 沙箱中以 bash 执行。请立即调整命令写法:\n"
|
||||||
"\n"
|
"\n"
|
||||||
"1. 解释器变为 Linux bash:使用 ls、cat、grep 等 Linux 命令;cmd、powershell、.exe 等 Windows 程序从此刻起不可用\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"
|
"3. 禁止再写 Windows 风格路径,反斜杠会被 bash 当作转义符\n"
|
||||||
"4. 写权限:仅工作区、/tmp、HOME 可写,其余全部只读\n"
|
"4. 写权限:仅工作区、/tmp、HOME 可写;Linux 系统目录只读;其余路径在沙箱内不存在\n"
|
||||||
f"{network_part}"
|
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"
|
"\n"
|
||||||
"此前的命令如果是按 Windows 环境编写的,请按上述规则改写后重新执行。"
|
"此前的命令如果是按 Windows 环境编写的,请按上述规则改写后重新执行。"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,6 +18,11 @@ from modules.host_sandbox_runner import (
|
|||||||
build_host_sandbox_plan,
|
build_host_sandbox_plan,
|
||||||
host_sandbox_enabled,
|
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"}
|
TERMINAL_STATUSES = {"completed", "failed", "timeout", "cancelled"}
|
||||||
@ -289,6 +294,7 @@ class BackgroundCommandManager:
|
|||||||
rec["pid"] = process.pid
|
rec["pid"] = process.pid
|
||||||
rec["updated_at"] = time.time()
|
rec["updated_at"] = time.time()
|
||||||
self._processes[command_id] = process
|
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):
|
def _reader(stream, collector, rec_key: str):
|
||||||
try:
|
try:
|
||||||
@ -322,6 +328,7 @@ class BackgroundCommandManager:
|
|||||||
status = "timeout"
|
status = "timeout"
|
||||||
message = f"命令执行超时 ({timeout}秒)"
|
message = f"命令执行超时 ({timeout}秒)"
|
||||||
# 跨平台终止:POSIX killpg(SIGINT→SIGKILL),Windows CTRL_BREAK→taskkill
|
# 跨平台终止: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)
|
self._terminate_pid(process.pid)
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
@ -429,6 +436,7 @@ class BackgroundCommandManager:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _terminate_pid(self, pid: Any) -> bool:
|
def _terminate_pid(self, pid: Any) -> bool:
|
||||||
|
sbx_debug.sbx_log_stack("TERMINATE-PID", pid=pid) # [SBX-DEBUG]
|
||||||
normalized = self._coerce_pid(pid)
|
normalized = self._coerce_pid(pid)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -7,12 +7,11 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional
|
||||||
from modules.host_sandbox_policy import (
|
from modules.host_sandbox_policy import (
|
||||||
get_macos_writable_paths,
|
get_macos_writable_paths,
|
||||||
get_macos_deny_read_paths,
|
get_macos_deny_read_paths,
|
||||||
get_macos_deny_read_regexes,
|
get_macos_deny_read_regexes,
|
||||||
get_windows_deny_read_paths,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -395,13 +394,21 @@ def _build_linux_common_plan(
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# Windows:WSL2 + bubblewrap 沙箱
|
# 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,
|
# - 使用专用沙箱发行版(默认 astrion-sandbox),必须关闭 interop,
|
||||||
# 否则沙箱内可经 cmd.exe 逃逸到 Windows 宿主机;
|
# 否则沙箱内可经 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
|
# - 网络档位:full → --share-net;restricted(仅回环)/ none → 不 share-net
|
||||||
# (unshare-net 下 lo 自动可用,语义对齐 macOS 的 restricted);
|
# (unshare-net 下 lo 自动可用,语义对齐 macOS 的 restricted);
|
||||||
# - 敏感路径用 --tmpfs(目录)/ --ro-bind /dev/null(文件)掩蔽;
|
# - 敏感路径掩蔽清单(windows_deny_read_paths)不再需要:数据根本不进命名
|
||||||
|
# 空间;该清单仍由 server/chat/permission.py 用于原生读工具的禁读判断;
|
||||||
# - wsl.exe 的 localhost 代理警告经 stderr_ignore_regexes 由执行器过滤。
|
# - wsl.exe 的 localhost 代理警告经 stderr_ignore_regexes 由执行器过滤。
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -468,26 +475,6 @@ def _ensure_wsl_sandbox_distro() -> str:
|
|||||||
return name
|
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(
|
def _build_windows_bwrap_argv(
|
||||||
ws_wsl: str,
|
ws_wsl: str,
|
||||||
shell_cmd: List[str],
|
shell_cmd: List[str],
|
||||||
@ -504,16 +491,26 @@ def _build_windows_bwrap_argv(
|
|||||||
# full → 共享网络;restricted(仅回环)与 none → unshare-net(lo 仍可用)
|
# full → 共享网络;restricted(仅回环)与 none → unshare-net(lo 仍可用)
|
||||||
if permission == NETWORK_PERMISSION_FULL:
|
if permission == NETWORK_PERMISSION_FULL:
|
||||||
argv.append("--share-net")
|
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]
|
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 += [
|
argv += [
|
||||||
"--chdir", ws_wsl,
|
"--chdir", ws_wsl,
|
||||||
"--proc", "/proc",
|
"--proc", "/proc",
|
||||||
"--dev", "/dev",
|
"--dev", "/dev",
|
||||||
"--tmpfs", "/tmp",
|
"--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,
|
*shell_cmd,
|
||||||
]
|
]
|
||||||
return argv
|
return argv
|
||||||
@ -534,8 +531,11 @@ def _build_windows_wsl_plan(
|
|||||||
argv = _build_windows_bwrap_argv(ws_wsl, shell_cmd, readonly, network_permission)
|
argv = _build_windows_bwrap_argv(ws_wsl, shell_cmd, readonly, network_permission)
|
||||||
plan_env = dict(env or {})
|
plan_env = dict(env or {})
|
||||||
plan_env["WSL_UTF8"] = "1"
|
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(
|
return SandboxPlan(
|
||||||
command=[wsl, "-d", distro, "--", *argv],
|
command=[wsl, "-d", distro, "-e", *argv],
|
||||||
env=plan_env,
|
env=plan_env,
|
||||||
cwd=str(work_path),
|
cwd=str(work_path),
|
||||||
stderr_ignore_regexes=list(_WSL_STDERR_IGNORE),
|
stderr_ignore_regexes=list(_WSL_STDERR_IGNORE),
|
||||||
|
|||||||
147
modules/sbx_debug.py
Normal file
147
modules/sbx_debug.py
Normal file
@ -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 = "<no 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)
|
||||||
@ -40,6 +40,11 @@ from modules.host_sandbox_runner import (
|
|||||||
build_host_sandbox_readonly_plan,
|
build_host_sandbox_readonly_plan,
|
||||||
host_sandbox_enabled,
|
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:
|
if TYPE_CHECKING:
|
||||||
from modules.user_container_manager import ContainerHandle
|
from modules.user_container_manager import ContainerHandle
|
||||||
@ -56,6 +61,7 @@ class RunMixin:
|
|||||||
子进程以 start_new_session=True 启动(映射为 CREATE_NEW_PROCESS_GROUP),
|
子进程以 start_new_session=True 启动(映射为 CREATE_NEW_PROCESS_GROUP),
|
||||||
可用 CTRL_BREAK_EVENT 发送到该进程组,失败则直接强杀。
|
可用 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":
|
if os.name == "nt":
|
||||||
try:
|
try:
|
||||||
process.send_signal(signal.CTRL_BREAK_EVENT)
|
process.send_signal(signal.CTRL_BREAK_EVENT)
|
||||||
@ -79,6 +85,7 @@ class RunMixin:
|
|||||||
POSIX 用 killpg(SIGKILL);Windows 没有 SIGKILL,用 taskkill /F /T 终止整棵进程树,
|
POSIX 用 killpg(SIGKILL);Windows 没有 SIGKILL,用 taskkill /F /T 终止整棵进程树,
|
||||||
避免 shell 子进程被杀后孙进程残留为孤儿。
|
避免 shell 子进程被杀后孙进程残留为孤儿。
|
||||||
"""
|
"""
|
||||||
|
sbx_debug.sbx_log_stack("KILL-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG]
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
@ -161,6 +168,7 @@ class RunMixin:
|
|||||||
network_permission: Optional[str] = None,
|
network_permission: Optional[str] = None,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
start_ts = time.time()
|
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:
|
try:
|
||||||
process = None
|
process = None
|
||||||
exec_cmd = None
|
exec_cmd = None
|
||||||
@ -234,6 +242,7 @@ class RunMixin:
|
|||||||
os.close(seccomp_fd)
|
os.close(seccomp_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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:
|
elif use_host_sandbox:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -298,11 +307,13 @@ class RunMixin:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
timed_out = False
|
timed_out = False
|
||||||
|
sbx_debug.sbx_log("WAIT-BEGIN", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG]
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(process.wait(), timeout=timeout)
|
await asyncio.wait_for(process.wait(), timeout=timeout)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
timed_out = True
|
timed_out = True
|
||||||
|
sbx_debug.sbx_log_stack("TIMEOUT-INTERRUPT", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG]
|
||||||
await self._interrupt_subprocess(process)
|
await self._interrupt_subprocess(process)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(process.wait(), timeout=2)
|
await asyncio.wait_for(process.wait(), timeout=2)
|
||||||
@ -311,6 +322,7 @@ class RunMixin:
|
|||||||
await process.wait()
|
await process.wait()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# 用户主动停止任务或会话断开,立即终止子进程
|
# 用户主动停止任务或会话断开,立即终止子进程
|
||||||
|
sbx_debug.sbx_log_stack("CANCELLED-KILL", pid=getattr(process, "pid", None)) # [SBX-DEBUG]
|
||||||
await self._kill_subprocess(process)
|
await self._kill_subprocess(process)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
@ -319,6 +331,7 @@ class RunMixin:
|
|||||||
# (Task was destroyed but it is pending / unclosed transport 告警)
|
# (Task was destroyed but it is pending / unclosed transport 告警)
|
||||||
await _finish_reader_tasks(force=timed_out)
|
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:
|
if not timed_out:
|
||||||
try:
|
try:
|
||||||
@ -385,6 +398,7 @@ class RunMixin:
|
|||||||
"elapsed_ms": int((time.time() - start_ts) * 1000)
|
"elapsed_ms": int((time.time() - start_ts) * 1000)
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
sbx_debug.sbx_log_stack("RUN-EXCEPTION", error=str(exc)[:200]) # [SBX-DEBUG]
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user