diff --git a/modules/i18n_messages/infra.py b/modules/i18n_messages/infra.py index cab7dc25..a6f4bb52 100644 --- a/modules/i18n_messages/infra.py +++ b/modules/i18n_messages/infra.py @@ -152,6 +152,68 @@ MESSAGES = { "en-US": "bubblewrap is not installed inside the WSL sandbox distro '{distro}'. Re-run scripts/setup-wsl-sandbox.ps1 or run 'apk add bubblewrap' inside the distro", }, + # ── modules/sandbox_setup_manager.py(沙箱环境一键安装) ── + "sandbox.setup_wsl_missing": { + "zh-CN": "未检测到可用的 WSL2(Windows Subsystem for Linux)", + "en-US": "No usable WSL2 (Windows Subsystem for Linux) detected", + }, + "sandbox.setup_distro_missing": { + "zh-CN": "未找到沙箱发行版 '{distro}'", + "en-US": "Sandbox distro '{distro}' not found", + }, + "sandbox.setup_bwrap_missing": { + "zh-CN": "沙箱发行版 '{distro}' 缺少 bubblewrap 组件", + "en-US": "Sandbox distro '{distro}' is missing bubblewrap", + }, + "sandbox.setup_not_windows": { + "zh-CN": "仅 Windows 宿主机模式支持一键安装沙箱", + "en-US": "One-click sandbox setup is only supported on Windows host mode", + }, + "sandbox.setup_not_host_mode": { + "zh-CN": "当前不是宿主机模式,无法安装沙箱", + "en-US": "Not in host mode; sandbox setup unavailable", + }, + "sandbox.setup_script_missing": { + "zh-CN": "安装脚本 scripts/setup-wsl-sandbox.ps1 不存在", + "en-US": "Setup script scripts/setup-wsl-sandbox.ps1 not found", + }, + "sandbox.setup_already_running": { + "zh-CN": "安装任务正在运行中", + "en-US": "A setup task is already running", + }, + "sandbox.setup_enabling_wsl_log": { + "zh-CN": "正在请求管理员授权…", + "en-US": "Requesting administrator approval...", + }, + "sandbox.setup_wsl_installing_log": { + "zh-CN": "授权通过,正在下载并安装 WSL2 组件…", + "en-US": "Approval granted; downloading and installing WSL2 components...", + }, + "sandbox.setup_wsl_enabled_log": { + "zh-CN": "WSL2 已启用,继续安装沙箱发行版…", + "en-US": "WSL2 enabled, continuing with sandbox distro setup...", + }, + "sandbox.setup_wsl_enable_failed": { + "zh-CN": "启用 WSL2 失败(可能未通过管理员授权)", + "en-US": "Failed to enable WSL2 (administrator approval may have been denied)", + }, + "sandbox.setup_uac_timeout": { + "zh-CN": "等待管理员授权超时", + "en-US": "Timed out waiting for administrator approval", + }, + "sandbox.setup_script_failed": { + "zh-CN": "安装脚本执行失败,详情见日志", + "en-US": "Setup script failed; see log for details", + }, + "sandbox.setup_verify_failed": { + "zh-CN": "安装完成但验收检测未通过: {detail}", + "en-US": "Setup finished but verification failed: {detail}", + }, + "sandbox.setup_bad_distro_name": { + "zh-CN": "发行版名称 '{distro}' 含非法字符(仅允许字母、数字、点、下划线、连字符)", + "en-US": "Distro name '{distro}' contains illegal characters (only letters, digits, dot, underscore, hyphen allowed)", + }, + # ── modules/user_container_manager.py(用户容器管理器) ── "container_mgr.quota_exhausted": { "zh-CN": "资源繁忙:容器配额已用尽,请稍候再试。", diff --git a/modules/sandbox_setup_manager.py b/modules/sandbox_setup_manager.py new file mode 100644 index 00000000..533d1108 --- /dev/null +++ b/modules/sandbox_setup_manager.py @@ -0,0 +1,462 @@ +# modules/sandbox_setup_manager.py - Windows WSL 沙箱环境检测与一键安装管理 +# +# 背景:Windows 宿主机模式的命令沙箱依赖专用 WSL2 发行版(默认 astrion-sandbox, +# Alpine + bubblewrap,关闭 interop)。此前缺失时只在首次执行命令时被动报错, +# 本模块提供: +# 1. get_sandbox_status() —— 主动分级检测(wsl_missing / distro_missing / bwrap_missing / ready) +# 2. start_setup() —— 后台线程执行 scripts/setup-wsl-sandbox.ps1,逐步解析进度 +# 3. get_setup_progress() —— 前端轮询进度(阶段 / 步骤 / 日志尾部 / 下载字节数) +# +# 注意: +# - 安装进程由后端 server 直接在宿主机拉起(direct),不走 run_command 沙箱链路 +# (沙箱尚未建立,属于"鸡生蛋"场景,由前端用户显式点击触发)。 +# - WSL 功能未启用时先经 UAC 提权执行 wsl --install --no-distribution,可能需要重启。 +# - 进度仅保存在内存(一次性操作,无需持久化)。 + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import threading +import time +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional + +from modules.host_sandbox_runner import WSL_DEFAULT_SANDBOX_DISTRO, _wsl_distro_name +from modules.i18n import tr + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_SETUP_SCRIPT = _REPO_ROOT / "scripts" / "setup-wsl-sandbox.ps1" + +# 与 scripts/setup-wsl-sandbox.ps1 默认 RootfsUrl 保持一致(用于 HEAD 估算下载总量) +_ROOTFS_URL = ( + "https://mirrors.aliyun.com/alpine/v3.21/releases/x86_64/" + "alpine-minirootfs-3.21.3-x86_64.tar.gz" +) +# ps1 中下载的临时文件路径(%TEMP%\astrion-alpine-minirootfs.tar.gz) +_ROOTFS_TEMP_NAME = "astrion-alpine-minirootfs.tar.gz" + +_STEP_RE = re.compile(r"^==>\s*\[(\d+)/(\d+)\]\s*(.+)$") +_STEP_TOTAL = 6 +_LOG_TAIL_MAX = 30 +_STATUS_CACHE_TTL = 10.0 + +# 阶段常量 +PHASE_IDLE = "idle" +PHASE_ENABLING_WSL = "enabling_wsl" +PHASE_INSTALLING_WSL = "installing_wsl" +PHASE_INSTALLING = "installing" +PHASE_VERIFYING = "verifying" +PHASE_DONE = "done" +PHASE_NEEDS_REBOOT = "needs_reboot" +PHASE_ERROR = "error" + + +def _wsl_env() -> Dict[str, str]: + """子进程环境:强制 wsl.exe 输出 UTF-8(默认 UTF-16 会乱码)。""" + env = dict(os.environ) + env["WSL_UTF8"] = "1" + return env + + +def _run_probe(argv: List[str], timeout: float = 20.0) -> subprocess.CompletedProcess: + # stdin=DEVNULL 必须:wsl --uninstall 后 stub 会输出交互式提示并等待按键 60 秒, + # 继承 stdin 时探测直接卡死;关闭 stdin 后 0.1s 返回 rc=1(实测验证)。 + return subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + env=_wsl_env(), + timeout=timeout, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + + +def _wsl_available() -> bool: + """WSL 功能是否可用(wsl.exe 存在且能正常列出发行版)。 + + 未安装任何发行版时 `wsl -l -q` 返回空但 exit==0; + WSL 功能未启用 / 虚拟机平台缺失时 exit!=0。 + """ + wsl = shutil.which("wsl.exe") + if not wsl: + return False + try: + proc = _run_probe([wsl, "-l", "-q"]) + return proc.returncode == 0 + except Exception: + return False + + +def _distro_usable(distro: str) -> bool: + wsl = shutil.which("wsl.exe") + if not wsl: + return False + try: + return _run_probe([wsl, "-d", distro, "-e", "true"]).returncode == 0 + except Exception: + return False + + +def _bwrap_ready(distro: str) -> bool: + wsl = shutil.which("wsl.exe") + if not wsl: + return False + try: + return _run_probe([wsl, "-d", distro, "-e", "bwrap", "--version"]).returncode == 0 + except Exception: + return False + + +class SandboxSetupManager: + """沙箱状态检测 + 一键安装任务管理(进程级单例)。""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._status_cache: Optional[Dict[str, Any]] = None + self._status_cache_at = 0.0 + self._progress: Dict[str, Any] = self._fresh_progress() + self._worker: Optional[threading.Thread] = None + + # ------------------------------------------------------------------ status + + @staticmethod + def _applicable() -> bool: + if sys.platform != "win32": + return False + try: + from config import TERMINAL_SANDBOX_MODE + except Exception: + return False + return (TERMINAL_SANDBOX_MODE or "").lower() == "host" + + def get_sandbox_status(self, force: bool = False) -> Dict[str, Any]: + """分级检测沙箱状态,带短 TTL 缓存(前端多处同时调用时防抖)。""" + with self._lock: + if ( + not force + and self._status_cache is not None + and time.time() - self._status_cache_at < _STATUS_CACHE_TTL + ): + cached = dict(self._status_cache) + cached["setup_running"] = self._progress.get("active", False) + return cached + + result: Dict[str, Any] = { + "applicable": False, + "platform": sys.platform, + "state": "not_applicable", + "distro_name": _wsl_distro_name() if self._applicable() else WSL_DEFAULT_SANDBOX_DISTRO, + "detail": "", + } + if self._applicable(): + result["applicable"] = True + distro = result["distro_name"] + if not _wsl_available(): + result["state"] = "wsl_missing" + result["detail"] = tr("sandbox.setup_wsl_missing") + elif not _distro_usable(distro): + result["state"] = "distro_missing" + result["detail"] = tr("sandbox.setup_distro_missing", distro=distro) + elif not _bwrap_ready(distro): + result["state"] = "bwrap_missing" + result["detail"] = tr("sandbox.setup_bwrap_missing", distro=distro) + else: + result["state"] = "ready" + + with self._lock: + self._status_cache = result + self._status_cache_at = time.time() + out = dict(result) + out["setup_running"] = self._progress.get("active", False) + return out + + def invalidate_status_cache(self) -> None: + with self._lock: + self._status_cache = None + self._status_cache_at = 0.0 + + # ----------------------------------------------------------------- progress + + @staticmethod + def _fresh_progress() -> Dict[str, Any]: + return { + "active": False, + "phase": PHASE_IDLE, + "step_index": 0, + "step_total": _STEP_TOTAL, + "step_title": "", + "log_tail": [], + "download_bytes": None, + "download_total": None, + "error": None, + "error_kind": None, + "updated_at": time.time(), + } + + def get_setup_progress(self) -> Dict[str, Any]: + with self._lock: + return dict(self._progress) + + def _update_progress(self, **fields: Any) -> None: + with self._lock: + self._progress.update(fields) + self._progress["updated_at"] = time.time() + + def _append_log(self, line: str) -> None: + line = line.rstrip() + if not line: + return + with self._lock: + tail: List[str] = self._progress["log_tail"] + tail.append(line) + if len(tail) > _LOG_TAIL_MAX: + del tail[: len(tail) - _LOG_TAIL_MAX] + self._progress["updated_at"] = time.time() + + # -------------------------------------------------------------------- setup + + def start_setup(self, enable_wsl_if_needed: bool) -> Dict[str, Any]: + """启动安装后台线程。返回 {"started": bool, "error": str|None}。""" + if sys.platform != "win32": + return {"started": False, "error": tr("sandbox.setup_not_windows")} + if not _SETUP_SCRIPT.exists(): + return {"started": False, "error": tr("sandbox.setup_script_missing")} + with self._lock: + if self._progress.get("active"): + return {"started": False, "error": tr("sandbox.setup_already_running")} + self._progress = self._fresh_progress() + self._progress["active"] = True + self._progress["phase"] = PHASE_INSTALLING + + self._worker = threading.Thread( + target=self._run_setup, + args=(enable_wsl_if_needed,), + name="sandbox-setup", + daemon=True, + ) + self._worker.start() + return {"started": True, "error": None} + + def _finish(self, phase: str, error: Optional[str] = None, error_kind: Optional[str] = None) -> None: + self._update_progress(active=False, phase=phase, error=error, error_kind=error_kind) + self.invalidate_status_cache() + + def _run_setup(self, enable_wsl_if_needed: bool) -> None: + try: + # 阶段一:WSL 功能缺失时先提权安装(UAC 弹窗由用户在系统层确认) + if not _wsl_available(): + if not enable_wsl_if_needed: + self._finish(PHASE_ERROR, tr("sandbox.setup_wsl_missing"), "wsl_enable_failed") + return + if not self._enable_wsl(): + return # _enable_wsl 内部已 _finish + + # 阶段二:跑安装脚本(6 步) + self._update_progress(phase=PHASE_INSTALLING) + download_total = self._probe_rootfs_size() + if download_total: + self._update_progress(download_total=download_total) + if not self._run_setup_script(): + return # 内部已 _finish + + # 阶段三:验收(强制重新探测) + self._update_progress(phase=PHASE_VERIFYING) + status = self.get_sandbox_status(force=True) + if status.get("state") == "ready": + self._finish(PHASE_DONE) + else: + self._finish( + PHASE_ERROR, + tr("sandbox.setup_verify_failed", detail=status.get("detail") or status.get("state")), + "verify_failed", + ) + except Exception as exc: # 兜底,防止线程无声死亡 + self._finish(PHASE_ERROR, f"{type(exc).__name__}: {exc}", "unexpected") + + def _enable_wsl(self) -> bool: + """UAC 提权执行 wsl --install --no-distribution。返回是否可继续安装。 + + Start-Process -Verb RunAs 在 UAC 弹窗期间阻塞:用户确认后返回进程对象、 + 拒绝时抛异常(exit 3)。利用这个时序用标记行把「等待授权」与 + 「下载安装 WSL 组件」拆成两个阶段,reader 线程实时解析,前端及时切换: + ASTRION_UAC_CONFIRMED —— UAC 已通过,进入组件下载安装 + ASTRION_WSL_INSTALL_EXIT —— wsl --install 进程退出码 + ASTRION_UAC_CANCELLED —— 用户拒绝授权(exit 3) + """ + self._update_progress(phase=PHASE_ENABLING_WSL) + self._append_log(tr("sandbox.setup_enabling_wsl_log")) + # 前缀先设控制台输出编码为 UTF-8:PS 5.1 默认按 GBK 输出,后端按 UTF-8 读会乱码 + ps_command = ( + "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + "try { $p = Start-Process wsl.exe -Verb RunAs " + "-ArgumentList '--install','--no-distribution' -PassThru -ErrorAction Stop } " + "catch { Write-Host 'ASTRION_UAC_CANCELLED'; exit 3 }; " + "Write-Host 'ASTRION_UAC_CONFIRMED'; $p.WaitForExit(); " + 'Write-Host "ASTRION_WSL_INSTALL_EXIT=$($p.ExitCode)"' + ) + proc = subprocess.Popen( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_command], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + install_exit: List[int] = [] + + def _reader() -> None: + assert proc.stdout is not None + for raw in proc.stdout: + line = raw.strip() + if not line: + continue + if line == "ASTRION_UAC_CONFIRMED": + self._update_progress(phase=PHASE_INSTALLING_WSL) + self._append_log(tr("sandbox.setup_wsl_installing_log")) + elif line.startswith("ASTRION_WSL_INSTALL_EXIT="): + try: + install_exit.append(int(line.split("=", 1)[1])) + except ValueError: + pass + elif line != "ASTRION_UAC_CANCELLED": # 由 returncode==3 统一判定,不进日志 + self._append_log(line) + + reader = threading.Thread(target=_reader, name="sandbox-setup-uac", daemon=True) + reader.start() + try: + proc.wait(timeout=900) + except subprocess.TimeoutExpired: + proc.kill() + self._finish(PHASE_ERROR, tr("sandbox.setup_uac_timeout"), "uac_cancelled") + return False + reader.join(timeout=5) + + if proc.returncode == 3: + self._finish(PHASE_ERROR, tr("sandbox.setup_wsl_enable_failed"), "uac_cancelled") + return False + if proc.returncode != 0 or not install_exit or install_exit[-1] != 0: + self._finish(PHASE_ERROR, tr("sandbox.setup_wsl_enable_failed"), "wsl_enable_failed") + return False + # 提权安装完成后复查:仍不可用 → 大概率需要重启(虚拟机平台刚启用) + if not _wsl_available(): + self._finish(PHASE_NEEDS_REBOOT) + return False + self._append_log(tr("sandbox.setup_wsl_enabled_log")) + return True + + def _run_setup_script(self) -> bool: + """执行 setup-wsl-sandbox.ps1,逐行解析进度。返回是否成功。""" + if not _SETUP_SCRIPT.exists(): + self._finish(PHASE_ERROR, tr("sandbox.setup_script_missing"), "script_failed") + return False + download_stop = threading.Event() + download_thread = threading.Thread( + target=self._poll_download_size, + args=(download_stop,), + name="sandbox-setup-dlsize", + daemon=True, + ) + download_thread.start() + try: + # 与检测逻辑保持同名:检测哪个发行版就装哪个(HOST_SANDBOX_WSL_DISTRO 自定义场景)。 + # 发行版名来自环境变量,拼入 -Command 字符串前必须白名单校验,防命令注入。 + distro = _wsl_distro_name() + if not re.fullmatch(r"[A-Za-z0-9._-]+", distro): + self._finish( + PHASE_ERROR, + tr("sandbox.setup_bad_distro_name", distro=distro), + "script_failed", + ) + return False + # 非默认名使用独立安装目录,避免与既有发行版的 VHDX 目录冲突(--import 要求空目录)。 + # 用 -Command 包装并先设输出编码:-File 模式下若脚本解析失败(语法/编码问题), + # 脚本内的 OutputEncoding 设置来不及生效,错误消息会按 GBK 输出而后端读成乱码。 + ps_parts = [f"& '{_SETUP_SCRIPT}'", "-DistroName", f"'{distro}'"] + if distro != WSL_DEFAULT_SANDBOX_DISTRO: + install_dir = Path.home() / ".astrion" / f"wsl-sandbox-{distro}" + ps_parts += ["-InstallDir", f"'{install_dir}'"] + ps_command = ( + "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + + " ".join(ps_parts) + ) + argv = [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + ps_command, + ] + proc = subprocess.Popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + env=_wsl_env(), + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + assert proc.stdout is not None + for raw_line in proc.stdout: + line = raw_line.rstrip() + m = _STEP_RE.match(line.strip()) + if m: + self._update_progress( + step_index=int(m.group(1)), + step_total=int(m.group(2)), + step_title=m.group(3).strip(), + ) + self._append_log(line) + proc.wait() + if proc.returncode != 0: + self._finish(PHASE_ERROR, tr("sandbox.setup_script_failed"), "script_failed") + return False + return True + except Exception as exc: + self._finish(PHASE_ERROR, f"{type(exc).__name__}: {exc}", "script_failed") + return False + finally: + download_stop.set() + + def _poll_download_size(self, stop: threading.Event) -> None: + """安装阶段周期性 stat rootfs 临时文件大小,供前端展示下载量。""" + temp_dir = os.environ.get("TEMP") or os.environ.get("TMP") or "" + if not temp_dir: + return + target = Path(temp_dir) / _ROOTFS_TEMP_NAME + while not stop.wait(1.0): + with self._lock: + if self._progress.get("step_index") != 3: + continue + try: + if target.exists(): + self._update_progress(download_bytes=target.stat().st_size) + except OSError: + pass + + @staticmethod + def _probe_rootfs_size() -> Optional[int]: + """HEAD 请求 rootfs 下载地址估算总量(失败返回 None,前端退化为只显示已下载量)。""" + try: + req = urllib.request.Request(_ROOTFS_URL, method="HEAD") + with urllib.request.urlopen(req, timeout=8) as resp: + length = resp.headers.get("Content-Length") + return int(length) if length else None + except Exception: + return None + + +sandbox_setup_manager = SandboxSetupManager() diff --git a/scripts/setup-wsl-sandbox.ps1 b/scripts/setup-wsl-sandbox.ps1 index c6db9c7f..ee71f2d3 100644 --- a/scripts/setup-wsl-sandbox.ps1 +++ b/scripts/setup-wsl-sandbox.ps1 @@ -1,4 +1,4 @@ -# setup-wsl-sandbox.ps1 — 创建 astrion WSL2+bwrap 专用沙箱发行版 +# setup-wsl-sandbox.ps1 — 创建 astrion WSL2+bwrap 专用沙箱发行版 # # 用法(PowerShell,普通用户即可,无需管理员): # powershell -ExecutionPolicy Bypass -File scripts\setup-wsl-sandbox.ps1 @@ -22,21 +22,31 @@ param( $ErrorActionPreference = "Stop" $env:WSL_UTF8 = "1" +# 控制台与输出流统一 UTF-8:后端以管道读取本脚本输出,默认 GBK 会导致中文乱码 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 function Invoke-Wsl { - param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Args) - $output = & wsl.exe @Args 2>&1 | Where-Object { $_ -notmatch "localhost 代理|localhost proxy" } + # PS 5.1 下 ValueFromRemainingArguments 会把 -d/-e 等 wsl 参数误绑定到函数通用参数 + # (-e 前缀匹配 -ErrorAction/-ErrorVariable 报二义性错误),故改为单个数组传参, + # 调用处一律:Invoke-Wsl @('-d', $Name, '-e', 'true') + param([string[]]$WslArgs) + # PS 5.1 坑:全局 EAP=Stop 时原生命令写 stderr(如 wsl 的 localhost 代理警告) + # 会直接抛 NativeCommandError 终止脚本,Where 过滤来不及生效。 + # 函数内降为 Continue(函数作用域,不影响全局 Stop 对其它命令的保护)。 + $ErrorActionPreference = 'Continue' + $output = & wsl.exe @WslArgs 2>&1 | Where-Object { $_ -notmatch "localhost 代理|localhost proxy" } return @{ Code = $LASTEXITCODE; Output = ($output -join "`n") } } Write-Host "==> [1/6] 检查 WSL 环境" -$null = Invoke-Wsl --status -if ($LASTEXITCODE -ne 0) { +$wslStatus = Invoke-Wsl @('--status') +if ($wslStatus.Code -ne 0) { throw "WSL 不可用。请先启用 WSL2(wsl --install --no-distribution 或安装 Docker Desktop 后重试)。" } Write-Host "==> [2/6] 检查发行版 '$DistroName' 是否已存在" -$probe = Invoke-Wsl -d $DistroName -e true +$probe = Invoke-Wsl @('-d', $DistroName, '-e', 'true') if ($probe.Code -eq 0) { Write-Host " 发行版已存在,跳过导入(如需重建:wsl --unregister $DistroName)" } else { @@ -49,38 +59,44 @@ if ($probe.Code -eq 0) { Write-Host "==> [4/6] 导入为 WSL2 发行版" New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null - $null = Invoke-Wsl --import $DistroName $InstallDir $rootfs --version 2 - $check = Invoke-Wsl -d $DistroName -e true + $null = Invoke-Wsl @('--import', $DistroName, $InstallDir, $rootfs, '--version', '2') + $check = Invoke-Wsl @('-d', $DistroName, '-e', 'true') if ($check.Code -ne 0) { throw "发行版导入失败: $($check.Output)" } } Write-Host "==> [5/6] 写入沙箱配置(关闭 interop / 固定 DNS / 国内镜像)" # 关闭 Windows 互操作:防止沙箱内经 cmd.exe 逃逸宿主机(PoC 已验证该逃逸路径) -$null = Invoke-Wsl -d $DistroName -- sh -c "printf '[network]\ngenerateResolvConf = false\n\n[interop]\nenabled = false\nappendWindowsPath = false\n' > /etc/wsl.conf" -$null = Invoke-Wsl -d $DistroName -- sh -c "rm -f /etc/resolv.conf && printf 'nameserver 223.5.5.5\nnameserver 119.29.29.29\n' > /etc/resolv.conf" -$null = Invoke-Wsl -d $DistroName -- sh -c "printf '$ApkMirror/main\n$ApkMirror/community\n' > /etc/apk/repositories" +$conf = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "printf '[network]\ngenerateResolvConf = false\n\n[interop]\nenabled = false\nappendWindowsPath = false\n' > /etc/wsl.conf") +if ($conf.Code -ne 0) { throw "写入 wsl.conf 失败: $($conf.Output)" } +# 坑:首次导入的发行版内 /etc/resolv.conf 是 WSL 生成的 bind mount,直接 rm 会 EBUSY 失败 +# (旧脚本未检查返回码被静默吞掉,terminate 后 generateResolvConf=false 生效, +# 不再自动生成 resolv.conf → 文件缺失 → DNS 完全失效)。必须先 umount 再重建真实文件。 +$resolv = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "umount /etc/resolv.conf 2>/dev/null; rm -f /etc/resolv.conf; printf 'nameserver 223.5.5.5\nnameserver 119.29.29.29\n' > /etc/resolv.conf && cat /etc/resolv.conf") +if ($resolv.Code -ne 0) { throw "写入 resolv.conf 失败: $($resolv.Output)" } +$apkRepo = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "printf '$ApkMirror/main\n$ApkMirror/community\n' > /etc/apk/repositories") +if ($apkRepo.Code -ne 0) { throw "写入 apk 镜像源失败: $($apkRepo.Output)" } # 使 wsl.conf 生效 -$null = Invoke-Wsl --terminate $DistroName +$null = Invoke-Wsl @('--terminate', $DistroName) Start-Sleep -Seconds 2 Write-Host "==> [6/6] 安装沙箱工具链(bubblewrap / bash / python3 / git)" -$apk = Invoke-Wsl -d $DistroName -- sh -c "apk update && apk add bubblewrap bash ncurses-libs python3 git" +$apk = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "apk update && apk add bubblewrap bash ncurses-libs python3 git") if ($apk.Code -ne 0) { throw "apk 安装失败: $($apk.Output)" } # 与 Windows 端 Git for Windows 默认的 core.autocrlf=true 对齐: # Windows 检出的工作区文件是 CRLF,沙箱 git 不开 autocrlf 会把全部文件误判为已修改 # (全量 +/-),且全量内容读取+xdiff 会让 git 命令慢到触发执行超时。 -$null = Invoke-Wsl -d $DistroName -e git config --system core.autocrlf true +$null = Invoke-Wsl @('-d', $DistroName, '-e', 'git', 'config', '--system', 'core.autocrlf', 'true') # 9P 挂载的 inode/ctime 语义与 Windows git 写入的索引不匹配,会导致沙箱 git 对全部 # 文件做内容重读(全树 status/diff >20s);放宽 stat 校验后只需秒级。 -$null = Invoke-Wsl -d $DistroName -e git config --system core.checkStat minimal -$null = Invoke-Wsl -d $DistroName -e git config --system core.trustctime false +$null = Invoke-Wsl @('-d', $DistroName, '-e', 'git', 'config', '--system', 'core.checkStat', 'minimal') +$null = Invoke-Wsl @('-d', $DistroName, '-e', 'git', 'config', '--system', 'core.trustctime', 'false') # 验收 -$verify = Invoke-Wsl -d $DistroName -- sh -c "bwrap --version && bash -c 'echo bash-ok' && python3 --version && git --version" +$verify = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "bwrap --version && bash -c 'echo bash-ok' && python3 --version && git --version") Write-Host $verify.Output -$escape = Invoke-Wsl -d $DistroName -- bwrap --die-with-parent --new-session --unshare-all --ro-bind / / --proc /proc --dev /dev -- bash -c "cmd.exe /c echo escape 2>&1 || echo interop-blocked" +$escape = Invoke-Wsl @('-d', $DistroName, '--', 'bwrap', '--die-with-parent', '--new-session', '--unshare-all', '--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '--', 'bash', '-c', "cmd.exe /c echo escape 2>&1 || echo interop-blocked") if ($escape.Output -match "interop-blocked|not found") { Write-Host " interop 逃逸封堵验证: OK" } else { diff --git a/scripts/start-sandbox-test-instance.cmd b/scripts/start-sandbox-test-instance.cmd new file mode 100644 index 00000000..3e5e9c7c --- /dev/null +++ b/scripts/start-sandbox-test-instance.cmd @@ -0,0 +1,38 @@ +@echo off +rem start-sandbox-test-instance.cmd — 启动「沙箱安装向导」的隔离测试实例(Windows) +rem +rem 用途:验证「未安装沙箱时的居中弹窗 + 一键安装流程」。 +rem 本机 astrion-sandbox 已就绪时,正常实例不会弹窗;本脚本通过 +rem HOST_SANDBOX_WSL_DISTRO 指向一个不存在的测试发行版,使检测判定为缺失。 +rem +rem 可选参数: +rem wsl-missing 额外模拟「WSL 未启用」分支:在 PATH 最前置入一个含同名 +rem 0 字节假 wsl.exe 的目录(E:\astrion\sandbox-test-fake), +rem which/Start-Process 优先命中它而失败,等效于挪走系统 +rem wsl.exe,但不碰系统文件,删目录即恢复。 +rem +rem 隔离手段(主程序 8091/8092 完全不受影响): +rem 1. setlocal —— 环境变量仅存在于本脚本进程及其子进程, +rem 脚本退出即失效,外部 cmd 窗口与其他服务实例读不到; +rem 2. ASTRION_DATA_ROOT —— 数据目录独立(全新初始化,不动现有对话); +rem 3. --port 8093 —— 端口独立。 +rem +rem 测试结束后清理: +rem wsl --unregister astrion-sandbox-test +rem rmdir /s /q E:\astrion\sandbox-test-data +rem rmdir /s /q E:\astrion\sandbox-test-fake +rem (若存在 %USERPROFILE%\.astrion\wsl-sandbox-astrion-sandbox-test 残留目录一并删除) + +setlocal +set ASTRION_DATA_ROOT=E:\astrion\sandbox-test-data +set HOST_SANDBOX_WSL_DISTRO=astrion-sandbox-test + +if /i "%~1"=="wsl-missing" ( + if not exist E:\astrion\sandbox-test-fake mkdir E:\astrion\sandbox-test-fake + if not exist E:\astrion\sandbox-test-fake\wsl.exe type nul > E:\astrion\sandbox-test-fake\wsl.exe + set "PATH=E:\astrion\sandbox-test-fake;%PATH%" + echo [测试模式] 已模拟 wsl.exe 缺失:PATH 优先命中 0 字节假文件 +) + +python -m server.app --port 8093 +endlocal diff --git a/server/status/__init__.py b/server/status/__init__.py index 6abbdd8e..c2b2a325 100644 --- a/server/status/__init__.py +++ b/server/status/__init__.py @@ -9,3 +9,4 @@ from server.status.file_open import * from server.status.docker import * from server.status.host_workspace import * from server.status.app import * +from server.status.sandbox import * diff --git a/server/status/sandbox.py b/server/status/sandbox.py new file mode 100644 index 00000000..2c0ba8ff --- /dev/null +++ b/server/status/sandbox.py @@ -0,0 +1,58 @@ +# server/status/sandbox.py - Windows WSL 沙箱环境检测与一键安装 API +# +# - GET /api/sandbox/status 分级检测沙箱状态(前端进页面自动调用) +# - POST /api/sandbox/setup 启动一键安装(后台线程跑 setup-wsl-sandbox.ps1) +# - GET /api/sandbox/setup/status 轮询安装进度(阶段 / 步骤 / 日志 / 下载量) +# +# 仅 Windows + 宿主机模式(TERMINAL_SANDBOX_MODE=host 且会话 host_mode)适用; +# 其它环境 applicable=false,前端不展示任何提示。 +# 安装进程由 server 直接在宿主机拉起(沙箱尚未建立的"鸡生蛋"场景, +# 由用户在前端显式点击触发,不经 run_command 沙箱链路)。 + +from __future__ import annotations + +from flask import jsonify, request, session + +from server.status import status_bp +from server.auth_helpers import api_login_required +from modules.sandbox_setup_manager import sandbox_setup_manager +from modules.i18n import tr + + +def _is_host_mode_request() -> bool: + try: + from config import TERMINAL_SANDBOX_MODE + except Exception: + return False + return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host" + + +@status_bp.route('/api/sandbox/status') +@api_login_required +def get_sandbox_status(): + """分级检测沙箱状态。force=1 时绕过短缓存实测。""" + if not _is_host_mode_request(): + return jsonify({"success": True, "data": {"applicable": False, "state": "not_applicable"}}) + force = request.args.get("force") == "1" + data = sandbox_setup_manager.get_sandbox_status(force=force) + return jsonify({"success": True, "data": data}) + + +@status_bp.route('/api/sandbox/setup', methods=['POST']) +@api_login_required +def start_sandbox_setup(): + """启动一键安装。body: {"enable_wsl_if_needed": bool}""" + if not _is_host_mode_request(): + return jsonify({"success": False, "error": tr("sandbox.setup_not_host_mode")}), 400 + payload = request.get_json(silent=True) or {} + result = sandbox_setup_manager.start_setup(bool(payload.get("enable_wsl_if_needed"))) + if not result["started"]: + return jsonify({"success": False, "error": result["error"]}), 409 + return jsonify({"success": True, "data": sandbox_setup_manager.get_setup_progress()}) + + +@status_bp.route('/api/sandbox/setup/status') +@api_login_required +def get_sandbox_setup_status(): + """轮询安装进度。""" + return jsonify({"success": True, "data": sandbox_setup_manager.get_setup_progress()}) diff --git a/static/src/App.vue b/static/src/App.vue index 437f0576..ad909257 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -554,6 +554,7 @@ @submit="submitPlanApproval" @minimize="minimizePlanApprovalDialog" /> + import('../components/overlay/PlanApprovalDialog.vue') ); +const SandboxSetupDialog = defineAsyncComponent( + () => import('../components/overlay/SandboxSetupDialog.vue') +); const TutorialOverlay = defineAsyncComponent( () => import('../components/overlay/TutorialOverlay.vue') ); @@ -66,6 +69,7 @@ export const appComponents = { VersioningDialog, UserQuestionDialog, PlanApprovalDialog, + SandboxSetupDialog, TutorialOverlay, NewUserTutorialPrompt, GoalProgressDialog, diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index 447d8e4a..0e260caa 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { useChatActionStore } from '../stores/chatActions'; +import { useSandboxSetupStore } from '../stores/sandboxSetup'; import { normalizeScrollLock } from '../composables/useScrollControl'; import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap'; import { debugLog } from './methods/common'; @@ -48,6 +49,8 @@ export async function mounted() { this.fetchTerminalCount(); this.startTerminalCountIdleRefresh(); this.checkTutorialPrompt(); + // Windows 宿主机模式下检测沙箱环境,缺失时弹出居中的安装向导(不分 sandbox/direct 执行环境) + void useSandboxSetupStore().autoCheck(); if (initialDataPromise && typeof initialDataPromise.then === 'function') { initialDataPromise .then(() => { diff --git a/static/src/components/overlay/SandboxSetupDialog.vue b/static/src/components/overlay/SandboxSetupDialog.vue new file mode 100644 index 00000000..4932acf6 --- /dev/null +++ b/static/src/components/overlay/SandboxSetupDialog.vue @@ -0,0 +1,613 @@ + + + + + diff --git a/static/src/components/personalization/tabs/GeneralTab.vue b/static/src/components/personalization/tabs/GeneralTab.vue index 494bd728..d58858ef 100644 --- a/static/src/components/personalization/tabs/GeneralTab.vue +++ b/static/src/components/personalization/tabs/GeneralTab.vue @@ -1,9 +1,31 @@