feat(sandbox): Windows 沙箱环境检测弹窗与一键安装向导

This commit is contained in:
JOJO 2026-09-01 01:01:09 +08:00
parent 6c0a32330c
commit 55b3ee44b7
16 changed files with 1756 additions and 19 deletions

View File

@ -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", "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": "未检测到可用的 WSL2Windows 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用户容器管理器 ── # ── modules/user_container_manager.py用户容器管理器 ──
"container_mgr.quota_exhausted": { "container_mgr.quota_exhausted": {
"zh-CN": "资源繁忙:容器配额已用尽,请稍候再试。", "zh-CN": "资源繁忙:容器配额已用尽,请稍候再试。",

View File

@ -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-8PS 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()

View File

@ -1,4 +1,4 @@
# setup-wsl-sandbox.ps1 — 创建 astrion WSL2+bwrap 专用沙箱发行版 # setup-wsl-sandbox.ps1 — 创建 astrion WSL2+bwrap 专用沙箱发行版
# #
# 用法PowerShell普通用户即可无需管理员 # 用法PowerShell普通用户即可无需管理员
# powershell -ExecutionPolicy Bypass -File scripts\setup-wsl-sandbox.ps1 # powershell -ExecutionPolicy Bypass -File scripts\setup-wsl-sandbox.ps1
@ -22,21 +22,31 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$env:WSL_UTF8 = "1" $env:WSL_UTF8 = "1"
# 控制台与输出流统一 UTF-8后端以管道读取本脚本输出默认 GBK 会导致中文乱码
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
function Invoke-Wsl { function Invoke-Wsl {
param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Args) # PS 5.1 下 ValueFromRemainingArguments 会把 -d/-e 等 wsl 参数误绑定到函数通用参数
$output = & wsl.exe @Args 2>&1 | Where-Object { $_ -notmatch "localhost 代理|localhost proxy" } # -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") } return @{ Code = $LASTEXITCODE; Output = ($output -join "`n") }
} }
Write-Host "==> [1/6] 检查 WSL 环境" Write-Host "==> [1/6] 检查 WSL 环境"
$null = Invoke-Wsl --status $wslStatus = Invoke-Wsl @('--status')
if ($LASTEXITCODE -ne 0) { if ($wslStatus.Code -ne 0) {
throw "WSL 不可用。请先启用 WSL2wsl --install --no-distribution 或安装 Docker Desktop 后重试)。" throw "WSL 不可用。请先启用 WSL2wsl --install --no-distribution 或安装 Docker Desktop 后重试)。"
} }
Write-Host "==> [2/6] 检查发行版 '$DistroName' 是否已存在" Write-Host "==> [2/6] 检查发行版 '$DistroName' 是否已存在"
$probe = Invoke-Wsl -d $DistroName -e true $probe = Invoke-Wsl @('-d', $DistroName, '-e', 'true')
if ($probe.Code -eq 0) { if ($probe.Code -eq 0) {
Write-Host " 发行版已存在跳过导入如需重建wsl --unregister $DistroName" Write-Host " 发行版已存在跳过导入如需重建wsl --unregister $DistroName"
} else { } else {
@ -49,38 +59,44 @@ if ($probe.Code -eq 0) {
Write-Host "==> [4/6] 导入为 WSL2 发行版" Write-Host "==> [4/6] 导入为 WSL2 发行版"
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$null = Invoke-Wsl --import $DistroName $InstallDir $rootfs --version 2 $null = Invoke-Wsl @('--import', $DistroName, $InstallDir, $rootfs, '--version', '2')
$check = Invoke-Wsl -d $DistroName -e true $check = Invoke-Wsl @('-d', $DistroName, '-e', 'true')
if ($check.Code -ne 0) { throw "发行版导入失败: $($check.Output)" } if ($check.Code -ne 0) { throw "发行版导入失败: $($check.Output)" }
} }
Write-Host "==> [5/6] 写入沙箱配置(关闭 interop / 固定 DNS / 国内镜像)" Write-Host "==> [5/6] 写入沙箱配置(关闭 interop / 固定 DNS / 国内镜像)"
# 关闭 Windows 互操作:防止沙箱内经 cmd.exe 逃逸宿主机PoC 已验证该逃逸路径) # 关闭 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" $conf = 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" if ($conf.Code -ne 0) { throw "写入 wsl.conf 失败: $($conf.Output)" }
$null = Invoke-Wsl -d $DistroName -- sh -c "printf '$ApkMirror/main\n$ApkMirror/community\n' > /etc/apk/repositories" # 坑:首次导入的发行版内 /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 生效 # 使 wsl.conf 生效
$null = Invoke-Wsl --terminate $DistroName $null = Invoke-Wsl @('--terminate', $DistroName)
Start-Sleep -Seconds 2 Start-Sleep -Seconds 2
Write-Host "==> [6/6] 安装沙箱工具链bubblewrap / bash / python3 / git" 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)" } if ($apk.Code -ne 0) { throw "apk 安装失败: $($apk.Output)" }
# 与 Windows 端 Git for Windows 默认的 core.autocrlf=true 对齐: # 与 Windows 端 Git for Windows 默认的 core.autocrlf=true 对齐:
# Windows 检出的工作区文件是 CRLF沙箱 git 不开 autocrlf 会把全部文件误判为已修改 # Windows 检出的工作区文件是 CRLF沙箱 git 不开 autocrlf 会把全部文件误判为已修改
# (全量 +/-),且全量内容读取+xdiff 会让 git 命令慢到触发执行超时。 # (全量 +/-),且全量内容读取+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 对全部 # 9P 挂载的 inode/ctime 语义与 Windows git 写入的索引不匹配,会导致沙箱 git 对全部
# 文件做内容重读(全树 status/diff >20s放宽 stat 校验后只需秒级。 # 文件做内容重读(全树 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.checkStat', 'minimal')
$null = Invoke-Wsl -d $DistroName -e git config --system core.trustctime false $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 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") { if ($escape.Output -match "interop-blocked|not found") {
Write-Host " interop 逃逸封堵验证: OK" Write-Host " interop 逃逸封堵验证: OK"
} else { } else {

View File

@ -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

View File

@ -9,3 +9,4 @@ from server.status.file_open import *
from server.status.docker import * from server.status.docker import *
from server.status.host_workspace import * from server.status.host_workspace import *
from server.status.app import * from server.status.app import *
from server.status.sandbox import *

58
server/status/sandbox.py Normal file
View File

@ -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()})

View File

@ -554,6 +554,7 @@
@submit="submitPlanApproval" @submit="submitPlanApproval"
@minimize="minimizePlanApprovalDialog" @minimize="minimizePlanApprovalDialog"
/> />
<SandboxSetupDialog />
<transition name="overlay-fade"> <transition name="overlay-fade">
<VersioningDialog <VersioningDialog
v-if="versioningDialogOpen" v-if="versioningDialogOpen"

View File

@ -33,6 +33,9 @@ const UserQuestionDialog = defineAsyncComponent(
const PlanApprovalDialog = defineAsyncComponent( const PlanApprovalDialog = defineAsyncComponent(
() => import('../components/overlay/PlanApprovalDialog.vue') () => import('../components/overlay/PlanApprovalDialog.vue')
); );
const SandboxSetupDialog = defineAsyncComponent(
() => import('../components/overlay/SandboxSetupDialog.vue')
);
const TutorialOverlay = defineAsyncComponent( const TutorialOverlay = defineAsyncComponent(
() => import('../components/overlay/TutorialOverlay.vue') () => import('../components/overlay/TutorialOverlay.vue')
); );
@ -66,6 +69,7 @@ export const appComponents = {
VersioningDialog, VersioningDialog,
UserQuestionDialog, UserQuestionDialog,
PlanApprovalDialog, PlanApprovalDialog,
SandboxSetupDialog,
TutorialOverlay, TutorialOverlay,
NewUserTutorialPrompt, NewUserTutorialPrompt,
GoalProgressDialog, GoalProgressDialog,

View File

@ -1,5 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { useChatActionStore } from '../stores/chatActions'; import { useChatActionStore } from '../stores/chatActions';
import { useSandboxSetupStore } from '../stores/sandboxSetup';
import { normalizeScrollLock } from '../composables/useScrollControl'; import { normalizeScrollLock } from '../composables/useScrollControl';
import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap'; import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap';
import { debugLog } from './methods/common'; import { debugLog } from './methods/common';
@ -48,6 +49,8 @@ export async function mounted() {
this.fetchTerminalCount(); this.fetchTerminalCount();
this.startTerminalCountIdleRefresh(); this.startTerminalCountIdleRefresh();
this.checkTutorialPrompt(); this.checkTutorialPrompt();
// Windows 宿主机模式下检测沙箱环境,缺失时弹出居中的安装向导(不分 sandbox/direct 执行环境)
void useSandboxSetupStore().autoCheck();
if (initialDataPromise && typeof initialDataPromise.then === 'function') { if (initialDataPromise && typeof initialDataPromise.then === 'function') {
initialDataPromise initialDataPromise
.then(() => { .then(() => {

View File

@ -0,0 +1,613 @@
<template>
<transition name="sandbox-setup-fade" appear>
<div v-if="store.dialogVisible" class="sandbox-setup-overlay">
<section
class="sandbox-setup-card"
role="dialog"
aria-modal="true"
:aria-label="$t('sandbox.setupAriaLabel')"
>
<header class="sandbox-setup-windowbar">
<div class="sandbox-setup-window-title">{{ $t('sandbox.setupTitle') }}</div>
<button
type="button"
class="sandbox-setup-close"
:title="$t('common.close')"
:aria-label="$t('common.close')"
@click="onClose"
>
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</header>
<div class="sandbox-setup-body">
<!-- 未开始说明 + 状态描述 -->
<template v-if="!progress">
<div class="sandbox-setup-hero">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
<path d="M9 12l2 2 4-4" />
</svg>
</div>
<p v-if="stateDescription" class="sandbox-setup-state-desc">{{ stateDescription }}</p>
<p v-else class="sandbox-setup-state-desc">{{ $t('sandbox.stateChecking') }}</p>
<ul class="sandbox-setup-facts">
<li>{{ $t('sandbox.introWhat') }}</li>
<li>{{ $t('sandbox.introEffect') }}</li>
<li>{{ $t('sandbox.introDisk', { path: installPathHint }) }}</li>
<li>{{ $t('sandbox.introUninstall', { distro: distroName }) }}</li>
<li>{{ $t('sandbox.introSecure') }}</li>
</ul>
</template>
<!-- 安装中 / 终态阶段 + 步骤进度 -->
<template v-else>
<div v-if="phaseLineText" class="sandbox-setup-phase-line">
<span class="sandbox-setup-spinner" aria-hidden="true"></span>
{{ phaseLineText }}
</div>
<ul class="sandbox-setup-steps">
<li
v-for="(step, i) in steps"
:key="i"
class="sandbox-setup-step"
:class="stepStatus(i + 1)"
>
<span class="sandbox-setup-step-icon" aria-hidden="true">
<svg v-if="stepStatus(i + 1) === 'done'" viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 10.5l4 4 8-9" />
</svg>
<span v-else-if="stepStatus(i + 1) === 'active'" class="sandbox-setup-spinner small"></span>
<svg v-else-if="stepStatus(i + 1) === 'error'" viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
<span v-else class="sandbox-setup-step-dot"></span>
</span>
<span class="sandbox-setup-step-title">{{ step }}</span>
<span v-if="i + 1 === 3 && progress.step_index === 3 && downloadText" class="sandbox-setup-step-extra">
{{ downloadText }}
</span>
</li>
</ul>
<div class="sandbox-setup-progress">
<div class="sandbox-setup-progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<div v-if="progress.phase === 'done'" class="sandbox-setup-result ok">
{{ $t('sandbox.phaseDone') }}
</div>
<div v-else-if="progress.phase === 'needs_reboot'" class="sandbox-setup-result warn">
{{ $t('sandbox.phaseNeedsReboot') }}
</div>
<div v-else-if="progress.phase === 'error'" class="sandbox-setup-result error">
<p class="sandbox-setup-error-line">{{ progress.error || $t('sandbox.phaseError') }}</p>
<p v-if="progress.error_kind === 'uac_cancelled'" class="sandbox-setup-error-hint">
{{ $t('sandbox.uacCancelledHint') }}
</p>
</div>
<div v-if="progress.log_tail.length" class="sandbox-setup-log-block">
<div class="sandbox-setup-log-label">{{ $t('sandbox.logLabel') }}</div>
<pre ref="logEl" class="sandbox-setup-log">{{ progress.log_tail.join('\n') }}</pre>
</div>
</template>
</div>
<footer v-if="!progress || !progress.active" class="sandbox-setup-footer">
<!-- 未开始 -->
<template v-if="!progress">
<label class="sandbox-setup-never">
<input type="checkbox" :checked="neverChecked" @change="neverChecked = ($event.target as HTMLInputElement).checked" />
<FancyCheck :checked="neverChecked" :size="16" />
<span>{{ $t('sandbox.neverAgain') }}</span>
</label>
<div class="sandbox-setup-spacer"></div>
<button type="button" class="sandbox-setup-btn ghost" @click="onClose">
{{ $t('sandbox.later') }}
</button>
<button
type="button"
class="sandbox-setup-btn primary"
:disabled="store.starting || store.checking || !store.status"
@click="store.startSetup()"
>
{{ store.starting ? $t('common.loading') : $t('sandbox.installNow') }}
</button>
</template>
<!-- 终态 -->
<template v-else>
<div class="sandbox-setup-spacer"></div>
<button
v-if="progress.phase === 'error' || progress.phase === 'needs_reboot'"
type="button"
class="sandbox-setup-btn primary"
:disabled="store.starting"
@click="store.retrySetup()"
>
{{ progress.phase === 'needs_reboot' ? $t('sandbox.rebootDone') : $t('common.retry') }}
</button>
<button type="button" class="sandbox-setup-btn ghost" @click="onClose">
{{ $t('common.close') }}
</button>
</template>
</footer>
</section>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import FancyCheck from '@/components/common/FancyCheck.vue';
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
defineOptions({ name: 'SandboxSetupDialog' });
const { t } = useI18n();
const store = useSandboxSetupStore();
const neverChecked = ref(false);
const logEl = ref<HTMLElement | null>(null);
//
watch(
() => store.progress?.log_tail.length,
async () => {
await nextTick();
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight;
}
);
const progress = computed(() => store.progress);
const distroName = computed(() => store.status?.distro_name || 'astrion-sandbox');
// scripts/setup-wsl-sandbox.ps1 InstallDir
const installPathHint = computed(() => '%USERPROFILE%\\.astrion\\wsl-sandbox');
const stateDescription = computed(() => {
const s = store.status;
if (!s || !s.applicable) return '';
if (s.state === 'wsl_missing') return t('sandbox.stateWslMissing');
if (s.state === 'distro_missing') return t('sandbox.stateDistroMissing');
if (s.state === 'bwrap_missing') return t('sandbox.stateBwrapMissing');
return '';
});
const steps = computed(() => [
t('sandbox.stepCheckWsl'),
t('sandbox.stepCheckDistro'),
t('sandbox.stepDownloadRootfs'),
t('sandbox.stepImportDistro'),
t('sandbox.stepWriteConfig'),
t('sandbox.stepInstallTools')
]);
function stepStatus(step1Based: number): 'pending' | 'active' | 'done' | 'error' {
const p = progress.value;
if (!p) return 'pending';
if (p.phase === 'verifying' || p.phase === 'done') return 'done';
if (p.phase === 'enabling_wsl' || p.step_index === 0) return 'pending';
if (step1Based < p.step_index) return 'done';
if (step1Based === p.step_index) return p.phase === 'error' ? 'error' : 'active';
return 'pending';
}
const downloadFraction = computed(() => {
const p = progress.value;
if (!p || p.step_index !== 3 || !p.download_bytes || !p.download_total) return 0;
return Math.min(1, p.download_bytes / p.download_total);
});
const progressPercent = computed(() => {
const p = progress.value;
if (!p) return 0;
switch (p.phase) {
case 'enabling_wsl':
return 4;
case 'installing_wsl':
return 6;
case 'installing': {
const total = p.step_total || 6;
const doneSteps = Math.max(0, p.step_index - 1);
return Math.min(96, Math.round(((doneSteps + downloadFraction.value) / total) * 100));
}
case 'verifying':
return 96;
case 'done':
return 100;
case 'needs_reboot':
return 8;
case 'error': {
const total = p.step_total || 6;
return Math.round((Math.max(0, p.step_index - 1) / total) * 100);
}
default:
return 0;
}
});
const phaseLineText = computed(() => {
const p = progress.value;
if (!p) return '';
if (p.phase === 'enabling_wsl') return t('sandbox.phaseEnablingWsl');
if (p.phase === 'installing_wsl') return t('sandbox.phaseInstallingWsl');
if (p.phase === 'verifying') return t('sandbox.phaseVerifying');
return '';
});
function formatMB(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
const downloadText = computed(() => {
const p = progress.value;
if (!p || !p.download_bytes) return '';
if (p.download_total) {
return t('sandbox.downloadProgress', {
size: `${formatMB(p.download_bytes)} / ${formatMB(p.download_total)}`
});
}
return t('sandbox.downloadProgress', { size: formatMB(p.download_bytes) });
});
function onClose() {
//
if (store.progress?.active) {
store.dialogVisible = false;
return;
}
store.closeDialog(neverChecked.value);
}
</script>
<style scoped>
.sandbox-setup-overlay {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--overlay-scrim);
}
.sandbox-setup-card {
position: relative;
width: min(560px, calc(100vw - 36px));
max-height: min(720px, calc(100vh - 36px));
max-height: min(720px, calc(100dvh - 36px));
display: flex;
flex-direction: column;
color: var(--text-primary);
background: var(--surface-card);
border: 1px solid var(--border-strong);
border-radius: 16px;
overflow: hidden;
}
.sandbox-setup-windowbar {
position: relative;
height: 40px;
flex: 0 0 40px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--border-default);
}
.sandbox-setup-window-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
user-select: none;
}
.sandbox-setup-close {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.sandbox-setup-close:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.sandbox-setup-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 20px 24px 8px;
scrollbar-width: thin;
}
.sandbox-setup-hero {
display: flex;
justify-content: center;
color: var(--accent);
margin-bottom: 12px;
}
.sandbox-setup-state-desc {
margin: 0 0 12px;
font-size: 13.5px;
font-weight: 600;
line-height: 1.6;
color: var(--text-primary);
}
.sandbox-setup-facts {
margin: 0;
padding: 0 0 0 18px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sandbox-setup-facts li {
font-size: 12.5px;
line-height: 1.6;
color: var(--text-secondary);
}
.sandbox-setup-phase-line {
display: flex;
align-items: center;
gap: 8px;
height: 30px;
margin-bottom: 10px;
font-size: 12.5px;
font-weight: 600;
color: var(--text-secondary);
}
.sandbox-setup-steps {
list-style: none;
margin: 0 0 14px;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.sandbox-setup-step {
display: flex;
align-items: center;
gap: 10px;
height: 30px;
font-size: 13px;
color: var(--text-tertiary);
}
.sandbox-setup-step.active {
color: var(--text-primary);
font-weight: 600;
}
.sandbox-setup-step.done {
color: var(--text-secondary);
}
.sandbox-setup-step.error {
color: var(--state-danger);
font-weight: 600;
}
.sandbox-setup-step-icon {
width: 18px;
height: 18px;
flex: 0 0 18px;
display: flex;
align-items: center;
justify-content: center;
}
.sandbox-setup-step.done .sandbox-setup-step-icon {
color: var(--state-success);
}
.sandbox-setup-step-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--border-strong);
}
.sandbox-setup-step-extra {
margin-left: auto;
font-size: 11.5px;
font-weight: 400;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.sandbox-setup-spinner {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--border-default);
border-top-color: var(--accent);
animation: sandbox-setup-spin 0.9s linear infinite;
}
.sandbox-setup-spinner.small {
width: 11px;
height: 11px;
border-width: 1.6px;
}
@keyframes sandbox-setup-spin {
to {
transform: rotate(360deg);
}
}
.sandbox-setup-progress {
height: 6px;
border-radius: 3px;
background: var(--surface-muted);
overflow: hidden;
margin-bottom: 14px;
}
.sandbox-setup-progress-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width 0.4s ease;
}
.sandbox-setup-result {
border-radius: 10px;
padding: 10px 12px;
font-size: 12.5px;
line-height: 1.6;
margin-bottom: 12px;
}
.sandbox-setup-result.ok {
background: color-mix(in srgb, var(--state-success) 12%, transparent);
color: var(--text-primary);
}
.sandbox-setup-result.warn {
background: color-mix(in srgb, var(--state-warning) 14%, transparent);
color: var(--text-primary);
}
.sandbox-setup-result.error {
background: color-mix(in srgb, var(--state-danger) 12%, transparent);
color: var(--text-primary);
}
.sandbox-setup-error-line {
margin: 0;
font-weight: 600;
}
.sandbox-setup-error-hint {
margin: 6px 0 0;
color: var(--text-secondary);
}
.sandbox-setup-log-block {
margin-bottom: 12px;
}
.sandbox-setup-log-label {
height: 22px;
line-height: 22px;
font-size: 12px;
color: var(--text-tertiary);
user-select: none;
}
.sandbox-setup-log {
margin: 2px 0 0;
max-height: 160px;
overflow-y: auto;
padding: 10px 12px;
border-radius: 8px;
background: var(--surface-muted);
font-size: 11.5px;
line-height: 1.55;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
scrollbar-width: thin;
}
.sandbox-setup-footer {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px 16px;
border-top: 1px solid var(--border-default);
}
.sandbox-setup-never {
display: flex;
align-items: center;
gap: 7px;
height: 30px;
font-size: 12.5px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.sandbox-setup-never input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.sandbox-setup-spacer {
flex: 1 1 auto;
}
.sandbox-setup-btn {
height: 32px;
padding: 0 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
}
.sandbox-setup-btn:disabled {
opacity: 0.55;
cursor: default;
}
.sandbox-setup-btn.ghost {
background: transparent;
border-color: var(--border-default);
color: var(--text-secondary);
}
.sandbox-setup-btn.ghost:hover:not(:disabled) {
background: var(--hover-bg);
color: var(--text-primary);
}
.sandbox-setup-btn.primary {
background: var(--accent);
color: var(--on-accent);
}
.sandbox-setup-btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.sandbox-setup-fade-enter-active,
.sandbox-setup-fade-leave-active {
transition: opacity 0.18s ease;
}
.sandbox-setup-fade-enter-from,
.sandbox-setup-fade-leave-to {
opacity: 0;
}
</style>

View File

@ -1,9 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue'; import { computed, inject, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import FancyCheck from '@/components/common/FancyCheck.vue'; import FancyCheck from '@/components/common/FancyCheck.vue';
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
defineOptions({ name: 'GeneralTab' }); defineOptions({ name: 'GeneralTab' });
const { t } = useI18n();
const sandboxSetup = useSandboxSetupStore();
// store
onMounted(() => {
if (!sandboxSetup.status) void sandboxSetup.fetchStatus();
});
const sandboxStatusText = computed(() => {
const s = sandboxSetup.status;
if (!s || sandboxSetup.checking) return t('sandbox.sectionChecking');
if (!s.applicable) return t('sandbox.sectionUnavailable');
return s.state === 'ready' ? t('sandbox.sectionReady') : t('sandbox.sectionMissing');
});
const sandboxShowWizard = computed(() => {
const s = sandboxSetup.status;
return !!s && s.applicable && s.state !== 'ready';
});
/** /**
* 共享上下文由 PersonalizationDrawer.vue 通过 provide 注入 * 共享上下文由 PersonalizationDrawer.vue 通过 provide 注入
* 解构出的名称与主文件 script 顶层绑定一致模板可直接引用 * 解构出的名称与主文件 script 顶层绑定一致模板可直接引用
@ -84,6 +106,44 @@ const {
</button> </button>
</div> </div>
</div> </div>
<div class="settings-action-row sandbox-env-row">
<span class="settings-row-copy">
<span class="settings-row-title">{{ $t('sandbox.sectionTitle') }}</span>
<span class="settings-row-desc">{{ $t('sandbox.sectionDesc') }}</span>
</span>
<div class="settings-inline-actions">
<span class="settings-mini-status" :class="{ warning: sandboxShowWizard }">{{
sandboxStatusText
}}</span>
<span v-if="sandboxSetup.neverAsk" class="settings-mini-status">{{
$t('sandbox.neverAgainSet')
}}</span>
<button
v-if="sandboxSetup.neverAsk"
type="button"
class="settings-secondary-button"
@click="sandboxSetup.resetNeverAsk()"
>
{{ $t('sandbox.resetNeverAgain') }}
</button>
<button
type="button"
class="settings-secondary-button"
:disabled="sandboxSetup.checking"
@click="sandboxSetup.recheck()"
>
{{ sandboxSetup.checking ? $t('common.refreshing') : $t('sandbox.recheck') }}
</button>
<button
v-if="sandboxShowWizard"
type="button"
class="settings-primary-button"
@click="sandboxSetup.openWizard()"
>
{{ $t('sandbox.openWizard') }}
</button>
</div>
</div>
<div class="settings-action-row danger-zone"> <div class="settings-action-row danger-zone">
<span class="settings-row-copy"> <span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.logoutTitle') }}</span> <span class="settings-row-title">{{ $t('personalization.logoutTitle') }}</span>
@ -99,3 +159,16 @@ const {
</div> </div>
</section> </section>
</template> </template>
<style scoped>
/*
避免默认左右结构中右侧按钮组挤压左侧文案空间 */
.sandbox-env-row {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.sandbox-env-row .settings-inline-actions {
justify-content: flex-start;
}
</style>

View File

@ -25,6 +25,7 @@ import shell from './en-US/shell';
import sidebar from './en-US/sidebar'; import sidebar from './en-US/sidebar';
import auth from './en-US/auth'; import auth from './en-US/auth';
import utils from './en-US/utils'; import utils from './en-US/utils';
import sandbox from './en-US/sandbox';
type DeepString<T> = { [K in keyof T]: T[K] extends string ? string : DeepString<T[K]> }; type DeepString<T> = { [K in keyof T]: T[K] extends string ? string : DeepString<T[K]> };
@ -53,6 +54,7 @@ const enUS: DeepString<typeof zhCN> = {
sidebar, sidebar,
auth, auth,
utils, utils,
sandbox,
}; };
export default enUS; export default enUS;

View File

@ -0,0 +1,55 @@
// Locale namespace: sandbox (en-US)
// Keys must exactly mirror zh-CN/sandbox.ts (enforced by tsc DeepString).
export default {
// ── Dialog frame ──
setupTitle: 'Set Up Sandbox Environment',
setupAriaLabel: 'Sandbox environment setup wizard',
// ── Intro ──
introWhat: 'The sandbox is an isolated command execution environment (based on WSL2). All terminal commands run by the AI execute inside the sandbox, isolated from your system.',
introEffect: 'Without the sandbox, commands that require isolation cannot run and tool calls will fail with errors.',
introDisk: 'An Alpine mini system (~3MB) will be downloaded and installed to {path} (~100-300MB total with toolchain).',
introUninstall: 'You can fully uninstall anytime with: wsl --unregister {distro}.',
introSecure: 'A dedicated sandbox distro is installed (Windows interop disabled); a daily distro like Ubuntu cannot be used instead.',
// ── Detection states ──
stateWslMissing: 'WSL2 is not enabled on this system. Setup will first enable WSL2 (a system administrator prompt will appear, and a reboot may be required afterwards).',
stateDistroMissing: 'WSL2 is ready, but the dedicated sandbox distro is not installed yet. Click install to finish automatically.',
stateBwrapMissing: 'The sandbox distro exists, but the bubblewrap component is missing. Click install to repair automatically.',
stateChecking: 'Checking sandbox environment...',
// ── Phases and steps ──
phaseEnablingWsl: 'Requesting administrator approval to enable WSL2 (please confirm in the system prompt)...',
phaseInstallingWsl: 'Downloading and installing WSL2 components; this may take a few minutes...',
phaseVerifying: 'Verifying installation...',
phaseDone: 'Setup complete. The sandbox environment is ready.',
phaseNeedsReboot: 'WSL2 has been enabled, but a reboot is required to continue. Please reboot and reopen this wizard to finish setup.',
phaseError: 'Setup failed',
stepCheckWsl: 'Check WSL environment',
stepCheckDistro: 'Check distro',
stepDownloadRootfs: 'Download Alpine system',
stepImportDistro: 'Import WSL2 distro',
stepWriteConfig: 'Write sandbox configuration',
stepInstallTools: 'Install sandbox toolchain',
downloadProgress: 'Downloaded {size}',
logLabel: 'Setup log',
// ── Buttons and options ──
installNow: 'Install Now',
later: 'Not Now',
neverAgain: "Don't ask again",
rebootDone: 'I have rebooted, continue setup',
uacCancelledHint: 'Administrator approval was cancelled. Enabling WSL2 requires administrator permission; please retry and choose "Yes" in the system prompt.',
// ── Personalization → General: sandbox section ──
sectionTitle: 'Sandbox Environment',
sectionReady: 'Ready',
sectionMissing: 'Not installed',
sectionChecking: 'Checking...',
sectionUnavailable: 'Not applicable in this environment',
sectionDesc: 'In Windows host mode, commands run isolated inside a WSL2-based sandbox.',
openWizard: 'Open Setup Wizard',
recheck: 'Re-check',
neverAgainSet: '"Don\'t ask again" is on',
resetNeverAgain: 'Re-enable prompts',
};

View File

@ -24,6 +24,7 @@ import shell from './zh-CN/shell';
import sidebar from './zh-CN/sidebar'; import sidebar from './zh-CN/sidebar';
import auth from './zh-CN/auth'; import auth from './zh-CN/auth';
import utils from './zh-CN/utils'; import utils from './zh-CN/utils';
import sandbox from './zh-CN/sandbox';
export default { export default {
common, common,
@ -50,4 +51,5 @@ export default {
sidebar, sidebar,
auth, auth,
utils, utils,
sandbox,
} as const; } as const;

View File

@ -0,0 +1,58 @@
// 文案命名空间sandboxzh-CN 源语言)
// 沙箱环境检测与一键安装向导Windows 宿主机模式)。
// 使用方static/src/components/overlay/SandboxSetupDialog.vue
// static/src/components/personalization/tabs/GeneralTab.vue
// static/src/stores/sandboxSetup.ts
export default {
// ── 弹窗框架 ──
setupTitle: '安装沙箱环境',
setupAriaLabel: '沙箱环境安装向导',
// ── 说明区 ──
introWhat: '沙箱是一个隔离的命令执行环境(基于 WSL2。AI 执行的所有终端命令都在沙箱内运行,与您的系统隔离。',
introEffect: '未安装沙箱时,需要隔离执行的命令将无法运行,工具调用会直接报错。',
introDisk: '将下载约 3MB 的 Alpine 迷你系统并安装到 {path}(含工具链总占用约 100~300MB。',
introUninstall: '可随时通过命令 wsl --unregister {distro} 完全卸载。',
introSecure: '安装的是专用沙箱发行版(已关闭 Windows 互操作),不能使用已安装的 Ubuntu 等日常发行版代替。',
// ── 状态描述(检测分级) ──
stateWslMissing: '检测到系统尚未启用 WSL2。安装过程需要先启用 WSL2系统将弹出管理员授权授权后可能需要重启电脑。',
stateDistroMissing: '检测到 WSL2 已就绪,但尚未安装沙箱专用发行版。点击安装即可自动完成。',
stateBwrapMissing: '检测到沙箱发行版存在,但缺少 bubblewrap 组件。点击安装将自动修复。',
stateChecking: '正在检测沙箱环境…',
// ── 阶段与步骤 ──
phaseEnablingWsl: '正在请求管理员授权以启用 WSL2请在系统弹窗中确认…',
phaseInstallingWsl: '正在下载并安装 WSL2 组件,可能需要几分钟…',
phaseVerifying: '正在验收安装结果…',
phaseDone: '安装完成,沙箱环境已就绪。',
phaseNeedsReboot: 'WSL2 已启用,但需要重启电脑后才能继续。请重启后重新打开本向导完成安装。',
phaseError: '安装失败',
stepCheckWsl: '检查 WSL 环境',
stepCheckDistro: '检查发行版',
stepDownloadRootfs: '下载 Alpine 系统',
stepImportDistro: '导入 WSL2 发行版',
stepWriteConfig: '写入沙箱配置',
stepInstallTools: '安装沙箱工具链',
downloadProgress: '已下载 {size}',
logLabel: '安装日志',
// ── 按钮与选项 ──
installNow: '立即安装',
later: '暂不安装',
neverAgain: '不再提示',
rebootDone: '我已重启,继续安装',
uacCancelledHint: '已取消管理员授权。启用 WSL2 需要管理员权限,请点击重试并在系统弹窗中选择"是"。',
// ── 个人空间 → 通用:沙箱环境区块 ──
sectionTitle: '沙箱环境',
sectionReady: '已就绪',
sectionMissing: '未安装',
sectionChecking: '检测中…',
sectionUnavailable: '当前环境不适用',
sectionDesc: 'Windows 宿主机模式下,命令在基于 WSL2 的沙箱中隔离执行。',
openWizard: '打开安装向导',
recheck: '重新检测',
neverAgainSet: '已选择"不再提示"',
resetNeverAgain: '恢复提示',
};

View File

@ -0,0 +1,289 @@
// static/src/stores/sandboxSetup.ts - 沙箱环境检测与一键安装向导状态
//
// 职责:
// - 进页面自动检测Windows 宿主机模式且沙箱未就绪时弹出安装向导,不分 sandbox/direct 执行环境)
// - 「暂不安装」= 12 小时内不再弹sessionStorage 时间戳);「不再提示」= 持久关闭localStorage
// - 安装任务进度轮询1s完成后刷新检测状态
//
// 使用方app/lifecycle.ts自动检测、overlay/SandboxSetupDialog.vue弹窗
// personalization/tabs/GeneralTab.vue沙箱环境区块
import { defineStore } from 'pinia';
const NEVER_KEY = 'agents_sandbox_setup_never';
const SNOOZE_KEY = 'agents_sandbox_setup_snoozed';
const POLL_INTERVAL_MS = 1000;
/** 12
* sessionStorage
* WSL
*
* '1' 1970 */
const SNOOZE_WINDOW_MS = 12 * 60 * 60 * 1000;
function readSnoozed(): boolean {
try {
const raw = sessionStorage.getItem(SNOOZE_KEY);
if (!raw) return false;
const at = Number(raw);
if (!Number.isFinite(at) || Date.now() - at > SNOOZE_WINDOW_MS) {
sessionStorage.removeItem(SNOOZE_KEY);
return false;
}
return true;
} catch {
return false;
}
}
export type SandboxState =
| 'ready'
| 'wsl_missing'
| 'distro_missing'
| 'bwrap_missing'
| 'not_applicable'
| 'error';
export interface SandboxStatus {
applicable: boolean;
platform: string;
state: SandboxState;
distro_name: string;
detail: string;
setup_running: boolean;
}
export type SetupPhase =
| 'idle'
| 'enabling_wsl'
| 'installing_wsl'
| 'installing'
| 'verifying'
| 'done'
| 'needs_reboot'
| 'error';
export interface SetupProgress {
active: boolean;
phase: SetupPhase;
step_index: number;
step_total: number;
step_title: string;
log_tail: string[];
download_bytes: number | null;
download_total: number | null;
error: string | null;
error_kind: string | null;
updated_at: number;
}
interface SandboxSetupState {
status: SandboxStatus | null;
checking: boolean;
dialogVisible: boolean;
progress: SetupProgress | null;
starting: boolean;
snoozed: boolean;
pollTimer: ReturnType<typeof setInterval> | null;
}
export const useSandboxSetupStore = defineStore('sandboxSetup', {
state: (): SandboxSetupState => ({
status: null,
checking: false,
dialogVisible: false,
progress: null,
starting: false,
snoozed: readSnoozed(),
pollTimer: null
}),
getters: {
neverAsk(): boolean {
try {
return localStorage.getItem(NEVER_KEY) === '1';
} catch {
return false;
}
},
/** 沙箱未就绪(仅统计 applicable 且非 ready 的三种缺失状态) */
missing(): boolean {
const s = this.status;
if (!s || !s.applicable) return false;
return s.state === 'wsl_missing' || s.state === 'distro_missing' || s.state === 'bwrap_missing';
},
/** 进页面是否应自动弹出向导 */
shouldAutoPrompt(): boolean {
if (this.dialogVisible || this.snoozed || this.neverAsk) return false;
if (this.progress?.active) return true; // 刷新页面时安装仍在进行,恢复弹窗
return this.missing;
},
/** 安装是否处于终态done/needs_reboot/error */
setupFinished(): boolean {
const p = this.progress;
return !!p && !p.active && p.phase !== 'idle';
}
},
actions: {
async fetchStatus(force = false): Promise<void> {
if (this.checking) return;
this.checking = true;
try {
const resp = await fetch(`/api/sandbox/status${force ? '?force=1' : ''}`, {
credentials: 'same-origin'
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data?.success) {
this.status = data.data as SandboxStatus;
}
} catch {
// 检测失败保持沉默(不打扰正常使用)
} finally {
this.checking = false;
}
},
/** 进页面自动检测:未就绪且未被打断偏好时弹出向导 */
async autoCheck(): Promise<void> {
await this.fetchStatus();
// 安装任务可能因页面刷新而仍在后台运行:恢复进度并重新打开弹窗
if (this.status?.setup_running) {
this.dialogVisible = true;
this.startPolling();
return;
}
if (this.shouldAutoPrompt) {
this.dialogVisible = true;
}
},
async recheck(): Promise<void> {
await this.fetchStatus(true);
},
openWizard(): void {
this.dialogVisible = true;
if (this.progress?.active) this.startPolling();
if (!this.status) void this.fetchStatus();
},
/** 「暂不安装」12 小时内不再自动弹出 */
snooze(): void {
this.snoozed = true;
try {
sessionStorage.setItem(SNOOZE_KEY, String(Date.now()));
} catch {
/* ignore */
}
this.dialogVisible = false;
},
setNeverAsk(): void {
try {
localStorage.setItem(NEVER_KEY, '1');
} catch {
/* ignore */
}
},
resetNeverAsk(): void {
try {
localStorage.removeItem(NEVER_KEY);
} catch {
/* ignore */
}
},
/** 「不再提示」勾选后随暂不安装一起生效;安装中不允许关闭 */
closeDialog(never: boolean): void {
if (this.progress?.active) return;
if (never) this.setNeverAsk();
this.snooze();
},
async startSetup(): Promise<void> {
if (this.starting || this.progress?.active) return;
this.starting = true;
try {
const resp = await fetch('/api/sandbox/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ enable_wsl_if_needed: true })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data?.success) {
this.progress = data.data as SetupProgress;
this.startPolling();
} else {
// 启动失败(如并发):以进度对象呈现错误
this.progress = {
active: false,
phase: 'error',
step_index: 0,
step_total: 6,
step_title: '',
log_tail: [],
download_bytes: null,
download_total: null,
error: data?.error || 'start failed',
error_kind: 'start_failed',
updated_at: Date.now() / 1000
};
}
} catch {
this.progress = {
active: false,
phase: 'error',
step_index: 0,
step_total: 6,
step_title: '',
log_tail: [],
download_bytes: null,
download_total: null,
error: 'network error',
error_kind: 'start_failed',
updated_at: Date.now() / 1000
};
} finally {
this.starting = false;
}
},
startPolling(): void {
if (this.pollTimer) return;
void this.pollOnce();
this.pollTimer = setInterval(() => void this.pollOnce(), POLL_INTERVAL_MS);
},
stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
},
async pollOnce(): Promise<void> {
try {
const resp = await fetch('/api/sandbox/setup/status', { credentials: 'same-origin' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) return;
this.progress = data.data as SetupProgress;
if (!this.progress.active) {
this.stopPolling();
if (this.progress.phase === 'done') {
// 安装成功:刷新检测状态(应为 ready
void this.fetchStatus(true);
}
}
} catch {
/* 轮询失败静默,下个周期重试 */
}
},
/** 安装失败/需重启后重试:清空进度重新发起 */
async retrySetup(): Promise<void> {
this.progress = null;
await this.startSetup();
}
}
});