使用 Windows Computer Use Sandbox

更新时间:
复制 MD 格式

通过 Desktop SDK 创建 Windows 沙箱,启动 noVNC 实时画面,并验证键盘输入与文件读写结果。

背景信息

Windows Computer Use Sandbox 是 Agent Sandbox 提供的可由 Desktop SDK(e2b-desktop)操作的 Windows 桌面环境,适用于必须操作 Windows 桌面应用的 Agent 任务。本文创建一个 Windows 沙箱,启动 noVNC(基于 HTML5 的 VNC 客户端,用于在浏览器中查看和操作远程桌面)实时画面,在记事本中输入文本,并读取文件确认操作结果。

如需操作 Linux 桌面,请参见使用 Computer Use Sandbox。

Windows Desktop 镜像只能由官方提供,暂不支持自行制作或导入 Windows 镜像。使用前需先获取官方 Windows Desktop 镜像地址,再基于该镜像构建模板。

适用范围

  1. 已获取官方提供的 Windows Desktop 镜像地址。该镜像目前处于邀测阶段,需联系技术支持获取。

  2. 已安装 Python 3.12。

  3. 已创建 API Key,并确认与模板同地域的接入地址。具体操作,请参见创建 API Key。

  4. (仅在需要保存和恢复沙箱时必备)已为账号开通 Snapshot 白名单。Snapshot 仅支持第二代运行时(micro-sandbox),支持地域见使用约束。

  5. (仅在需要通过浏览器查看 noVNC 实时画面时必备)已为沙箱绑定自定义域名。具体操作,请参见自定义域名。

  6. (仅在运行更多操作的 Snapshot 生命周期脚本时必备)已安装 uv,可执行 pip install uv 安装。

使用说明

沙箱的公网访问与控制面鉴权由两组独立参数和令牌分别控制:

  • allow_public_traffic(Python SDK 参数名,JS/TS SDK 与 REST 中为 allowPublicTraffic)默认为 True,表示公网 URL 无需流量令牌即可访问;设为 False 时,所有公网请求必须携带 e2b-traffic-access-token 请求头,query 参数无效。

  • secure 默认为 True,控制 SDK 与沙箱内守护进程(envd)之间的控制面鉴权,其鉴权令牌与流量令牌是两套独立的令牌。步骤三示例省略默认的 network 参数,并将 secure=True 显式写出。

警告

公网 URL 应视为访问凭证。未设置应用层鉴权时,持有 URL 即可完全控制沙箱桌面,请勿公开分享,也不要写入日志或前端代码。

需要限制访问时,请参见网络访问控制与安全责任共担模型。受限 API Key 在 Enforce 模式下需具备以下权限。

权限

置位条件

sandbox.network.ingress

Python SDK 只要传入 network 就会附带发送 allowPublicTraffic,必然置位;JS/TS SDK 与 REST 仅在显式设置 allowPublicTraffic 或 maskRequestHost 时置位。

sandbox.network.egress

SDK 默认发送 allow_internet_access,默认置位。

受限模式下,服务端等非浏览器客户端可直接在 WebSocket 握手请求头中携带 e2b-traffic-access-token;运行在浏览器页面内的客户端(如网页版 noVNC)无法自定义握手头,可通过 URL 查询参数注入该令牌,形如 vnc.html?AccessToken=<TOKEN>。

操作步骤

步骤一:准备环境与配置

  1. 创建 Python 虚拟环境,并安装 E2B SDK、Desktop SDK 等依赖:

    python3.12 -m venv .venv
    source .venv/bin/activate
    pip install e2b==2.31.0 e2b-desktop==2.4.2 python-dotenv
  2. 在项目目录中创建 .env 文件,填入接入参数与镜像地址。本文示例使用华北 2(北京)地域,其它地域的取值请参见使用约束。

    E2B_API_KEY=<YOUR-API-KEY>
    E2B_API_URL=https://api.cn-beijing.sandbox.aliyuncs.com
    E2B_DOMAIN=cn-beijing.sandbox.aliyuncs.com
    E2B_WINDOWS_DESKTOP_IMAGE=<YOUR-WINDOWS-DESKTOP-IMAGE>
    .env 中包含密钥,应通过 .gitignore 排除,不要提交到版本库。SDK 会自动读取这些环境变量,无需在方法调用中显式传入。

步骤二:构建 Windows Desktop 模板

  1. 将以下代码保存为 build_windows_template.py,基于官方 Windows Desktop 镜像构建模板。

    """基于官方 Windows Desktop 镜像构建模板。"""
    
    import os
    
    from dotenv import load_dotenv
    from e2b import Template, default_build_logger
    
    load_dotenv()
    
    image = os.environ["E2B_WINDOWS_DESKTOP_IMAGE"]
    build = Template.build(
        Template().from_image(image),
        name="my-windows-desktop",
        cpu_count=8,
        memory_mb=8192,
        on_build_logs=default_build_logger(),
    )
    print(f"template_id: {build.template_id}")
    
  2. 运行模板构建:

    python build_windows_template.py
  3. 构建完成后,将输出的模板 ID 写入 .env:

    E2B_WINDOWS_DESKTOP_TEMPLATE=<YOUR-WINDOWS-DESKTOP-TEMPLATE-ID>

步骤三:运行示例

  1. 将以下代码保存为 windows_desktop_demo.py。示例创建 Windows 沙箱、开启 noVNC 实时画面、在记事本中输入文本并读取文件校验。

    """使用 Desktop SDK 操作 Windows,并通过 noVNC 查看实时画面。"""
    
    from __future__ import annotations
    
    import os
    import time
    
    from dotenv import load_dotenv
    from e2b import CommandExitException
    from e2b_desktop import Sandbox
    
    DEMO_PATH = "/tmp/e2b-desktop-demo.txt"
    DEMO_TEXT = "E2B Desktop SDK + Windows + noVNC\r\n中文输入与实时画面演示成功!"
    
    
    def wait_for_notepad(desktop: Sandbox) -> str:
        """等待记事本窗口出现,并返回窗口 ID。"""
        for _ in range(40):
            try:
                windows = desktop.get_application_windows("Notepad")
                if windows and windows != [""]:
                    return windows[0]
            except CommandExitException:
                pass
            time.sleep(0.25)
        raise RuntimeError("10 秒内未找到 Notepad 窗口")
    
    
    def main() -> None:
        load_dotenv()
        template = os.environ["E2B_WINDOWS_DESKTOP_TEMPLATE"]
    
        desktop: Sandbox | None = None
        try:
            desktop = Sandbox.create(
                template=template,
                resolution=(1920, 1080),
                timeout=900,
                secure=True,
            )
            width, height = desktop.get_screen_size()
            print(f"Windows Desktop 已创建:{desktop.sandbox_id},{width}x{height}")
    
            desktop.stream.start(require_auth=False)
            stream_url = desktop.stream.get_url()
            print(f"在浏览器中打开 noVNC 地址:\n{stream_url}")
    
            desktop.files.write(DEMO_PATH, "等待 SDK 输入……")
            desktop.commands.run(
                "Get-Process -Name notepad -ErrorAction SilentlyContinue "
                "| Stop-Process -Force; exit 0"
            )
            desktop.launch("notepad.exe", DEMO_PATH)
    
            window_id = wait_for_notepad(desktop)
            print(f"已启动窗口:{desktop.get_window_title(window_id)}")
    
            desktop.commands.run(f"xdotool windowactivate --sync {window_id}")
            desktop.press(["ctrl", "a"])
            desktop.write(DEMO_TEXT, chunk_size=64, delay_in_ms=20)
            desktop.press(["ctrl", "s"])
            desktop.wait(500)
    
            content = desktop.files.read(DEMO_PATH)
            if "中文输入与实时画面演示成功" not in content:
                raise RuntimeError("SDK 键盘输入未写入演示文件")
            print("键盘输入与文件校验成功。")
    
            input("按 Enter 结束示例并释放沙箱……")
        finally:
            if desktop is not None:
                desktop.kill()
    
    
    if __name__ == "__main__":
        main()
    
  2. 运行示例:

    python windows_desktop_demo.py
  3. 程序输出 noVNC 地址后,在浏览器中打开该地址查看 Windows 桌面实时画面。示例会打开记事本、输入文本并读取文件校验结果;按 Enter 后,finally 块会销毁沙箱并释放资源。

示例使用 desktop.stream.start(require_auth=False) 便于本地调试,生成的 noVNC 地址无需口令即可访问。默认平台域名 *.sandbox.aliyuncs.com 会给该页面响应附加 Content-Disposition: attachment,浏览器表现为下载 vnc.html 而非打开页面,需先绑定自定义域名。对外场景请改为鉴权模式,noVNC 地址的生成方式与口令拼接规则见以下折叠面板,令牌与握手请求头的完整说明见使用说明。

展开查看 noVNC 地址与鉴权说明

noVNC 地址是怎么来的

地址由 desktop.stream.get_url() 生成。其中 /vnc.html 路径部分在 desktop.stream.start() 时就已固定写入(端口为 noVNC 的 6080),get_url() 负责在其后拼接查询串:

https://6080-<sandbox_id>.<domain>/vnc.html?autoconnect=true&resize=scale
  • 6080 是 noVNC 服务端口,由 SDK 固定,不是任意端口。

  • <sandbox_id> 为沙箱 ID,<domain> 为平台域名或已绑定的自定义域名。

  • autoconnect=true、resize=scale 为 get_url() 的默认参数,即使不传实参也会附加;可通过 get_url(auto_connect=..., view_only=..., resize=...) 调整。

关于鉴权

get_auth_key() 必须在 require_auth=True 之后调用,否则抛出 RuntimeError。对外场景请改为鉴权模式,并手动把口令传给 get_url()(password= 查询串只有在传入 auth_key 时才会附加):

desktop.stream.start(require_auth=True)
auth_key = desktop.stream.get_auth_key()          # 必须在 require_auth=True 之后调用,否则抛 RuntimeError
url = desktop.stream.get_url(auth_key=auth_key)   # 口令以 &password=<auth_key> 附加到地址

生成的带口令地址形如:

https://6080-<sandbox_id>.<domain>/vnc.html?autoconnect=true&resize=scale&password=<auth_key>
重要

若只调用 desktop.stream.start(require_auth=True) 而使用不带实参的 desktop.stream.get_url(),返回地址不会包含 password=,需在打开的页面中手动输入口令。

步骤四:验证运行结果

示例执行成功时,终端依次输出沙箱创建、窗口启动与校验通过等信息,预期输出:

Windows Desktop 已创建:<SANDBOX-ID>,1920x1080
在浏览器中打开 noVNC 地址:
<NOVNC-URL>
已启动窗口:<WINDOW-TITLE>
键盘输入与文件校验成功。

同时,noVNC 页面中的记事本窗口显示写入的中文文本,/tmp/e2b-desktop-demo.txt 内容与输入一致。

更多操作

使用 Snapshot 保存和恢复 Windows 沙箱

如果需要重复启动已完成桌面初始化的 Windows 环境,可以先从运行中的 Windows 沙箱创建 Snapshot,再使用 Snapshot ID 创建新的沙箱。示例依次执行以下操作:

  1. 使用官方 Windows Desktop 镜像构建第二代运行时模板。

  2. 使用模板 ID 创建源沙箱。

  3. 等待 Windows 命令服务和 noVNC 首帧就绪,然后创建 Snapshot。

  4. 使用 Snapshot ID 创建恢复沙箱,并验证 Windows 命令服务。

  5. 输出恢复沙箱的 noVNC 地址。

重要

Snapshot 当前处于邀测阶段,仅支持已开通白名单的账号和第二代运行时(micro-sandbox)。Windows Desktop 镜像由官方提供,暂不支持自行制作或导入。Snapshot 的默认保留时间、命名、删除和计费规则,请参见快照(Snapshot)。

准备配置

示例默认使用 16 vCPU、16448 MiB 内存和 61440 MiB 系统盘。API、镜像及 VPC(Virtual Private Cloud,专有网络)资源必须属于同一地域。将以下内容保存为脚本同目录的 .env 文件,并替换尖括号中的占位值。此处的 .env 与步骤一创建的 .env 同名,但镜像地址的变量名不同:此处为 ROOTFS_IMAGE,步骤一为 E2B_WINDOWS_DESKTOP_IMAGE。若本脚本与步骤一至步骤三的示例位于同一目录,需将两处的变量合并到同一个 .env 文件中,避免互相覆盖。

E2B_API_KEY=<YOUR-API-KEY>
E2B_API_URL=https://api.cn-beijing.sandbox.aliyuncs.com
E2B_DOMAIN=cn-beijing.sandbox.aliyuncs.com
ROOTFS_IMAGE=<YOUR-WINDOWS-DESKTOP-IMAGE>

# 使用镜像所在 VPC 构建模板时配置以下三项。
E2B_TEMPLATE_SOURCE_VPC_ID=<YOUR-VPC-ID>
E2B_TEMPLATE_SOURCE_VSWITCH_IDS=<YOUR-VSWITCH-ID>
E2B_TEMPLATE_SOURCE_SECURITY_GROUP_ID=<YOUR-SECURITY-GROUP-ID>

E2B_TEMPLATE_CPU_COUNT=16
E2B_TEMPLATE_MEMORY_MB=16448
E2B_TEMPLATE_DISK_SIZE=61440
E2B_TEMPLATE_BUILD_MODE=micro
E2B_TEMPLATE_OS_TYPE=windows-osworld
E2B_REQUEST_TIMEOUT_SECONDS=600
SANDBOX_TIMEOUT_SECONDS=900
TEMPLATE_BUILD_TIMEOUT_SECONDS=3600
NOVNC_READY_TIMEOUT_SECONDS=600

# 默认销毁源沙箱并保留恢复沙箱,便于打开 noVNC 检查恢复结果。
KEEP_SOURCE_SANDBOX=false
CLEANUP_RESTORED_SANDBOX=false

运行完整生命周期脚本

将以下代码保存为 windows_snapshot_lifecycle.py。脚本使用 PEP 723 声明固定版本依赖,需先安装 uv,再通过 uv run 执行。

展开查看代码示例

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "e2b==2.45.1",
#     "httpx==0.28.1",
#     "python-dotenv==1.2.3",
# ]
# ///
"""E2B Windows Template/Sandbox/Snapshot 完整生命周期 demo(不绑定 region)。

region / endpoint / 镜像 / VPC 等全部由脚本同目录的 .env 指定,切换 region 只改
配置文件即可,无需改动脚本。

运行方式:
    uv run windows_snapshot_lifecycle.py

流程:
    1. 使用 Windows 镜像构建 Template
    2. 使用步骤 1 输出的 template_id 创建源 Sandbox
    3. 使用步骤 2 输出的 sandbox_id 创建 Snapshot
    4. 使用步骤 3 输出的 snapshot_id 恢复新 Sandbox
    5. 输出恢复 Sandbox 的 noVNC 链接

配置全部读取脚本同目录的 .env,并强制使用 override=True,确保 .env 中的值
覆盖当前进程已有的同名环境变量。恢复 Sandbox 默认保留,方便立即访问 noVNC;
可设置 CLEANUP_RESTORED_SANDBOX=true 在脚本结束时自动清理。
"""

from __future__ import annotations
from e2b.exceptions import SandboxException
from e2b.connection_config import ConnectionConfig
from e2b.api.client_sync import get_api_client
from e2b.api.client.api.snapshots import get_snapshots
from e2b import ApiParams, BuildInfo, Sandbox, Template, default_build_logger
import httpx

import base64
import hashlib
import os
import socket
import struct
import time
from pathlib import Path
from urllib.parse import quote

from dotenv import load_dotenv


# =============================================================================
# 配置加载:使用 override=True,确保 .env 覆盖进程中的同名变量
# =============================================================================

ENV_PATH = Path(os.getenv("ENV_FILE", Path(
    __file__).with_name(".env"))).resolve()
if not ENV_PATH.is_file():
    raise FileNotFoundError(f".env file not found: {ENV_PATH}")
load_dotenv(dotenv_path=ENV_PATH, override=True)


# =============================================================================
# 通用配置与工具
# =============================================================================

SNAPSHOT_DISCOVERY_TIMEOUT_SECONDS = 90
WINDOWS_SERVICE_PORT = int(os.getenv("WINDOWS_SERVICE_PORT", "5000"))
NOVNC_PORT = int(os.getenv("NOVNC_PORT", "6080"))

WINDOWS_READY_COMMAND = "curl -fsS --connect-timeout 1 --max-time 3 http://localhost:5000/health"


def require_env(name: str) -> str:
    value = os.getenv(name)
    if not value:
        raise RuntimeError(
            f"missing required env var: {name} (请在 {ENV_PATH} 中配置)")
    return value


def env_bool(name: str, default: bool = False) -> bool:
    value = os.getenv(name)
    return (
        default
        if value is None
        else value.strip().lower() in {"1", "true", "yes", "on"}
    )


def milestone(started_at: float, message: str) -> None:
    print(message)


def get_api_params() -> ApiParams:
    """构造 Template、Sandbox 和 Snapshot 共用的控制面参数。"""
    headers = {
        "X-E2B-Template-Build-Mode": os.getenv("E2B_TEMPLATE_BUILD_MODE", "micro"),
        "X-E2B-Template-Alpha-OS-Type": os.getenv(
            "E2B_TEMPLATE_OS_TYPE", "windows-osworld"
        ),
        "X-E2B-Template-Alpha-Port": str(WINDOWS_SERVICE_PORT),
        "X-E2B-Template-Alpha-Disk-Size": os.getenv("E2B_TEMPLATE_DISK_SIZE", "61440"),
        "X-E2B-Template-Alpha-Ready-Command":  WINDOWS_READY_COMMAND,
    }
    optional_headers = {
        "X-E2B-Template-Source-VPC-ID": os.getenv("E2B_TEMPLATE_SOURCE_VPC_ID"),
        "X-E2B-Template-Source-VSwitch-IDs": os.getenv(
            "E2B_TEMPLATE_SOURCE_VSWITCH_IDS"
        ),
        "X-E2B-Template-Source-Security-Group-ID": os.getenv(
            "E2B_TEMPLATE_SOURCE_SECURITY_GROUP_ID"
        ),
    }
    headers.update(
        {key: value for key, value in optional_headers.items() if value})

    return ApiParams(
        request_timeout=int(os.getenv("E2B_REQUEST_TIMEOUT_SECONDS", "600")),
        headers=headers,
        api_key=require_env("E2B_API_KEY"),
        domain=require_env("E2B_DOMAIN"),
        api_url=require_env("E2B_API_URL"),
        validate_api_key=False,
    )


# =============================================================================
# Step 1:使用 Windows 镜像构建 Template
# =============================================================================


def build_windows_template(started_at: float) -> BuildInfo:
    rootfs_image = require_env("ROOTFS_IMAGE")
    cpu_count = int(os.getenv("E2B_TEMPLATE_CPU_COUNT", "16"))
    memory_mb = int(os.getenv("E2B_TEMPLATE_MEMORY_MB", "16448"))
    os_type = os.getenv("E2B_TEMPLATE_OS_TYPE", "windows-osworld")
    name_prefix = os.getenv("E2B_TEMPLATE_NAME_PREFIX",
                            "windows-snapshot-lifecycle")
    template_name = f"{name_prefix}-{os_type}-{time.time_ns()}"

    milestone(
        started_at, f"Step 1/5 building Windows Template from {rootfs_image}")
    template = Template().build_in_background(
        template=Template()
        .from_image(image=rootfs_image),
        name=template_name,
        cpu_count=cpu_count,
        memory_mb=memory_mb,
        skip_cache=env_bool("E2B_TEMPLATE_SKIP_CACHE", False),
        on_build_logs=default_build_logger(),
        **get_api_params(),
    )
    milestone(
        started_at,
        f"Template build accepted: template_id={template.template_id}, build_id={template.build_id}",
    )

    deadline = time.monotonic() + int(
        os.getenv("TEMPLATE_BUILD_TIMEOUT_SECONDS", "3600")
    )
    interval = int(os.getenv("TEMPLATE_BUILD_POLL_INTERVAL_SECONDS", "10"))
    while time.monotonic() < deadline:
        status = Template.get_build_status(
            template, logs_offset=0, **get_api_params())
        state = status.status.value
        if state == "ready":
            milestone(
                started_at, f"Step 1/5 complete: template_id={template.template_id}"
            )
            return template
        if state == "error":
            raise RuntimeError(f"template build failed: {status}")
        if state not in {"building", "waiting"}:
            raise RuntimeError(f"unexpected template build status: {status}")
        print(
            f"template_id={template.template_id} status={state}; waiting {interval}s..."
        )
        time.sleep(interval)

    raise TimeoutError(
        f"template build timed out: template_id={template.template_id}")


# =============================================================================
# Step 2 / Step 4:使用 Template ID 或 Snapshot ID 创建 Sandbox
# =============================================================================


def find_recent_sandbox(source_id: str) -> Sandbox | None:
    """创建响应断开后,查找由同一 Template/Snapshot 创建的最近实例。"""
    try:
        paginator = Sandbox.list(limit=50, **get_api_params())
        candidates = []
        while paginator.has_next:
            for info in paginator.next_items():
                if info.template_id == source_id and str(info.state) not in {
                    "killed",
                    "dead",
                    "error",
                }:
                    candidates.append(info)
        if not candidates:
            return None
        latest = max(candidates, key=lambda info: info.started_at)
        return Sandbox.connect(
            latest.sandbox_id,
            timeout=int(os.getenv("SANDBOX_TIMEOUT_SECONDS", "900")),
            **get_api_params(),
        )
    except Exception as exc:
        print(f"sandbox recovery lookup failed: {type(exc).__name__}: {exc}")
        return None


def create_sandbox(source_id: str, started_at: float, step: int) -> Sandbox:
    """从 template_id 或 snapshot_id 创建 Sandbox,并处理 Windows 创建断连。"""
    timeout = int(os.getenv("SANDBOX_TIMEOUT_SECONDS", "900"))
    retry_waits = (30, 60, 90, 120)

    for attempt in range(1, len(retry_waits) + 2):
        try:
            sandbox = Sandbox.create(
                template=source_id,
                timeout=timeout,
                **get_api_params(),
            )
            milestone(
                started_at,
                f"Step {step}/5 complete: source_id={source_id}, sandbox_id={sandbox.sandbox_id}",
            )
            return sandbox
        except httpx.RemoteProtocolError as exc:
            if attempt > len(retry_waits):
                raise
            wait_seconds = retry_waits[attempt - 1]
            print(
                f"Sandbox.create response disconnected ({attempt}/5): {exc}; "
                f"waiting {wait_seconds}s before recovery"
            )
            time.sleep(wait_seconds)
            recovered = find_recent_sandbox(source_id)
            if recovered is not None:
                milestone(
                    started_at,
                    f"Step {step}/5 recovered: source_id={source_id}, "
                    f"sandbox_id={recovered.sandbox_id}",
                )
                return recovered

    raise RuntimeError(f"failed to create sandbox from source_id={source_id}")


def kill_sandbox(sandbox: Sandbox | None, label: str) -> None:
    if sandbox is None:
        return
    try:
        sandbox.kill()
        print(f"cleaned {label}: sandbox_id={sandbox.sandbox_id}")
    except Exception as exc:
        print(
            f"warning: failed to clean {label} sandbox_id={sandbox.sandbox_id}: "
            f"{type(exc).__name__}: {exc}"
        )


# =============================================================================
# Windows 数据面:等待服务并写入/验证快照标记
# =============================================================================


def execute_windows(sandbox: Sandbox, command: list[str]) -> dict:
    access_token = sandbox._envd_access_token
    if not access_token:
        raise RuntimeError(
            f"sandbox has no access token: sandbox_id={sandbox.sandbox_id}"
        )

    response = httpx.post(
        f"http://{sandbox.get_host(WINDOWS_SERVICE_PORT)}/execute",
        headers={"X-Access-Token": access_token},
        json={"command": command},
        timeout=30,
    )
    if response.is_error:
        raise RuntimeError(
            f"Windows /execute failed: status={response.status_code}, body={response.text}"
        )
    result = response.json()
    if result.get("returncode") != 0 or result.get("status") != "success":
        raise RuntimeError(
            f"Windows command failed: command={command}, result={result}"
        )
    return result


def wait_for_windows_service(
    sandbox: Sandbox, timeout: int = 180, interval: int = 5
) -> None:
    deadline = time.monotonic() + timeout
    last_error: Exception | None = None
    while time.monotonic() < deadline:
        try:
            execute_windows(sandbox, ["whoami"])
            return
        except Exception as exc:
            last_error = exc
            time.sleep(interval)
    raise TimeoutError(
        f"Windows service not ready: sandbox_id={sandbox.sandbox_id}, error={last_error}"
    )


# =============================================================================
# Step 3:使用源 sandbox_id 创建 Snapshot
# =============================================================================


def find_snapshot_id(snapshots: list, snapshot_name: str) -> str | None:
    expected_suffix = f"/{snapshot_name}:default"
    for snapshot in snapshots:
        if any(
            name == snapshot_name or name.endswith(expected_suffix)
            for name in snapshot.names
        ):
            return snapshot.snapshot_id
    return None


def lookup_snapshot_id(snapshot_name: str, sandbox_id: str) -> str | None:
    client = get_api_client(ConnectionConfig(**get_api_params()))
    response = get_snapshots.sync_detailed(
        client=client,
        sandbox_id=sandbox_id,
        name=snapshot_name,
        limit=100,
    )
    if response.status_code == 200 and isinstance(response.parsed, list):
        return find_snapshot_id(response.parsed, snapshot_name)
    return None


def discover_snapshot_after_disconnect(snapshot_name: str, sandbox_id: str) -> str:
    """create_snapshot 响应断开时查询服务端结果,避免重放非幂等请求。"""
    deadline = time.monotonic() + SNAPSHOT_DISCOVERY_TIMEOUT_SECONDS
    while time.monotonic() < deadline:
        snapshot_id = lookup_snapshot_id(snapshot_name, sandbox_id)
        if snapshot_id:
            return snapshot_id
        time.sleep(2)
    raise TimeoutError(
        f"snapshot not discoverable after disconnected response: "
        f"sandbox_id={sandbox_id}, name={snapshot_name}"
    )


def create_snapshot(sandbox: Sandbox, started_at: float) -> str:
    wait_for_windows_service(sandbox)
    probe_novnc_first_frame(
        sandbox,
        started_at,
        timeout=int(os.getenv("NOVNC_READY_TIMEOUT_SECONDS", "600")),
    )
    snapshot_name = f"windows-snapshot-{time.time_ns()}"
    milestone(
        started_at,
        f"Step 3/5 creating Snapshot from sandbox_id={sandbox.sandbox_id}",
    )

    max_attempts = int(os.getenv("SNAPSHOT_CREATE_MAX_ATTEMPTS", "12"))
    retry_interval = int(
        os.getenv("SNAPSHOT_CREATE_RETRY_INTERVAL_SECONDS", "10"))
    snapshot_id = ""
    for attempt in range(1, max_attempts + 1):
        try:
            snapshot_info = sandbox.create_snapshot(
                name=snapshot_name, **get_api_params()
            )
            snapshot_id = snapshot_info.snapshot_id
            if not snapshot_id:
                raise RuntimeError(
                    f"snapshot creation returned no ID: {snapshot_info}")
            break
        except httpx.RemoteProtocolError as exc:
            print(f"snapshot response disconnected; discovering result: {exc}")
            snapshot_id = discover_snapshot_after_disconnect(
                snapshot_name, sandbox.sandbox_id
            )
            break
        except SandboxException as exc:
            if "503" not in str(exc) or attempt >= max_attempts:
                raise
            snapshot_id = lookup_snapshot_id(
                snapshot_name, sandbox.sandbox_id) or ""
            if snapshot_id:
                break
            print(
                f"snapshot is temporarily unavailable ({attempt}/{max_attempts}); "
                f"retrying in {retry_interval}s"
            )
            time.sleep(retry_interval)

    milestone(
        started_at,
        f"Step 3/5 complete: sandbox_id={sandbox.sandbox_id}, snapshot_id={snapshot_id}",
    )
    return snapshot_id


# =============================================================================
# Step 5:输出由 snapshot_id 恢复的 Sandbox noVNC 链接
# =============================================================================


class WebSocketBinaryStream:
    """用于 noVNC/RFB 就绪探测的最小 WebSocket 二进制流。"""

    def __init__(self, sock: socket.socket, initial_data: bytes = b"") -> None:
        self.sock = sock
        self.raw_buffer = bytearray(initial_data)
        self.data_buffer = bytearray()

    def _recv_raw_exact(self, size: int) -> bytes:
        while len(self.raw_buffer) < size:
            chunk = self.sock.recv(65536)
            if not chunk:
                raise ConnectionError("WebSocket closed by peer")
            self.raw_buffer.extend(chunk)
        result = bytes(self.raw_buffer[:size])
        del self.raw_buffer[:size]
        return result

    def send_binary(self, payload: bytes, opcode: int = 0x2) -> None:
        mask = os.urandom(4)
        length = len(payload)
        header = bytearray([0x80 | opcode])
        if length < 126:
            header.append(0x80 | length)
        elif length <= 0xFFFF:
            header.append(0x80 | 126)
            header.extend(struct.pack("!H", length))
        else:
            header.append(0x80 | 127)
            header.extend(struct.pack("!Q", length))
        masked = bytes(value ^ mask[index % 4]
                       for index, value in enumerate(payload))
        self.sock.sendall(bytes(header) + mask + masked)

    def _recv_data_frame(self) -> bytes:
        while True:
            first, second = self._recv_raw_exact(2)
            opcode = first & 0x0F
            masked = bool(second & 0x80)
            length = second & 0x7F
            if length == 126:
                length = struct.unpack("!H", self._recv_raw_exact(2))[0]
            elif length == 127:
                length = struct.unpack("!Q", self._recv_raw_exact(8))[0]
            mask = self._recv_raw_exact(4) if masked else b""
            payload = self._recv_raw_exact(length)
            if masked:
                payload = bytes(
                    value ^ mask[index % 4] for index, value in enumerate(payload)
                )
            if opcode == 0x8:
                raise ConnectionError("WebSocket close frame received")
            if opcode == 0x9:
                self.send_binary(payload, opcode=0xA)
                continue
            if opcode in {0x0, 0x2}:
                return payload

    def read_exact(self, size: int) -> bytes:
        while len(self.data_buffer) < size:
            self.data_buffer.extend(self._recv_data_frame())
        result = bytes(self.data_buffer[:size])
        del self.data_buffer[:size]
        return result

    def discard_exact(self, size: int) -> None:
        remaining = size
        while remaining:
            if self.data_buffer:
                consumed = min(remaining, len(self.data_buffer))
                del self.data_buffer[:consumed]
                remaining -= consumed
                continue
            self.data_buffer.extend(self._recv_data_frame())


def connect_websocket(
    host: str, access_token: str, timeout: float
) -> WebSocketBinaryStream:
    sock = socket.create_connection((host, 80), timeout=timeout)
    sock.settimeout(timeout)
    key = base64.b64encode(os.urandom(16)).decode("ascii")
    request = (
        f"GET /websockify?accessToken={access_token} HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        "Upgrade: websocket\r\n"
        "Connection: Upgrade\r\n"
        f"Origin: http://{host}\r\n"
        f"Sec-WebSocket-Key: {key}\r\n"
        "Sec-WebSocket-Version: 13\r\n"
        "Sec-WebSocket-Protocol: binary\r\n\r\n"
    )
    sock.sendall(request.encode("ascii"))
    response = bytearray()
    while b"\r\n\r\n" not in response:
        chunk = sock.recv(4096)
        if not chunk:
            raise ConnectionError("connection closed during WebSocket upgrade")
        response.extend(chunk)
    headers, initial_data = bytes(response).split(b"\r\n\r\n", 1)
    status_line = headers.split(b"\r\n", 1)[0]
    if b" 101 " not in status_line:
        sock.close()
        raise ConnectionError(
            f"WebSocket upgrade failed: {status_line.decode(errors='replace')}"
        )
    expected_accept = base64.b64encode(
        hashlib.sha1(
            (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
    )
    if expected_accept.lower() not in headers.lower():
        sock.close()
        raise ConnectionError("invalid Sec-WebSocket-Accept response")
    return WebSocketBinaryStream(sock, initial_data)


def probe_novnc_first_frame(
    sandbox: Sandbox, started_at: float, timeout: int = 600
) -> None:
    """完成 RFB 握手并读取首帧,确认 Windows 桌面已真正启动。"""
    host = f"{NOVNC_PORT}-{sandbox.sandbox_id}.{require_env('E2B_DOMAIN')}"
    access_token = sandbox._envd_access_token
    if not access_token:
        raise RuntimeError(
            f"sandbox has no access token: sandbox_id={sandbox.sandbox_id}"
        )

    deadline = time.monotonic() + timeout
    last_error: Exception | None = None
    while time.monotonic() < deadline:
        stream: WebSocketBinaryStream | None = None
        try:
            stream = connect_websocket(
                host, access_token, min(5, deadline - time.monotonic())
            )
            version = stream.read_exact(12)
            if not version.startswith(b"RFB "):
                raise RuntimeError(f"unexpected RFB version: {version!r}")
            stream.send_binary(version)

            security_type_count = stream.read_exact(1)[0]
            if security_type_count == 0:
                reason_length = struct.unpack("!I", stream.read_exact(4))[0]
                reason = stream.read_exact(
                    reason_length).decode(errors="replace")
                raise RuntimeError(f"RFB server rejected connection: {reason}")
            security_types = list(stream.read_exact(security_type_count))
            if 1 not in security_types:
                raise RuntimeError(
                    f"unsupported RFB security types: {security_types}")
            stream.send_binary(b"\x01")
            security_result = struct.unpack("!I", stream.read_exact(4))[0]
            if security_result != 0:
                raise RuntimeError(
                    f"RFB security negotiation failed: {security_result}"
                )

            stream.send_binary(b"\x01")
            width, height = struct.unpack("!HH", stream.read_exact(4))
            pixel_format = stream.read_exact(16)
            bytes_per_pixel = pixel_format[0] // 8
            name_length = struct.unpack("!I", stream.read_exact(4))[0]
            desktop_name = stream.read_exact(
                name_length).decode(errors="replace")

            stream.send_binary(struct.pack("!BBHi", 2, 0, 1, 0))
            stream.send_binary(struct.pack(
                "!BBHHHH", 3, 0, 0, 0, width, height))
            while True:
                message_type = stream.read_exact(1)[0]
                if message_type == 0:
                    header = stream.read_exact(3)
                    rectangle_count = struct.unpack("!H", header[1:])[0]
                    if rectangle_count:
                        for _ in range(rectangle_count):
                            rectangle = stream.read_exact(12)
                            _, _, rect_width, rect_height, encoding = struct.unpack(
                                "!HHHHi", rectangle
                            )
                            if encoding != 0:
                                raise RuntimeError(
                                    f"unexpected framebuffer encoding: {encoding}"
                                )
                            stream.discard_exact(
                                rect_width * rect_height * bytes_per_pixel
                            )
                        milestone(
                            started_at,
                            f"noVNC first frame ready: {width}x{height} {desktop_name!r}",
                        )
                        return
                elif message_type == 1:
                    header = stream.read_exact(5)
                    color_count = struct.unpack("!H", header[3:])[0]
                    stream.read_exact(color_count * 6)
                elif message_type == 2:
                    continue
                elif message_type == 3:
                    header = stream.read_exact(5)
                    text_length = struct.unpack("!I", header[1:])[0]
                    stream.read_exact(text_length)
                else:
                    raise RuntimeError(
                        f"unexpected RFB server message type: {message_type}"
                    )
        except (OSError, ConnectionError, TimeoutError) as exc:
            last_error = exc
            time.sleep(1)
        finally:
            if stream is not None:
                stream.sock.close()

    raise TimeoutError(
        f"noVNC first frame not ready: sandbox_id={sandbox.sandbox_id}, "
        f"error={last_error}"
    )


def get_novnc_url(sandbox: Sandbox) -> str:
    access_token = sandbox._envd_access_token
    if not access_token:
        raise RuntimeError(
            f"sandbox has no access token: sandbox_id={sandbox.sandbox_id}"
        )
    websocket_path = quote(f"websockify?accessToken={access_token}", safe="")
    scheme = os.getenv("SANDBOX_PUBLIC_SCHEME", "http")
    return (
        f"{scheme}://{NOVNC_PORT}-{sandbox.sandbox_id}.{require_env('E2B_DOMAIN')}"
        f"/vnc.html#autoconnect=true&resize=scale&path={websocket_path}"
    )


def wait_for_novnc(sandbox: Sandbox, timeout: int = 600, interval: int = 3) -> str:
    novnc_url = get_novnc_url(sandbox)
    deadline = time.monotonic() + timeout
    last_error: Exception | None = None
    while time.monotonic() < deadline:
        try:
            response = httpx.get(novnc_url, timeout=5)
            if response.status_code == 200 and "noVNC" in response.text:
                return novnc_url
            last_error = RuntimeError(f"HTTP {response.status_code}")
        except (httpx.HTTPError, OSError) as exc:
            last_error = exc
        time.sleep(interval)
    raise TimeoutError(
        f"noVNC not ready: sandbox_id={sandbox.sandbox_id}, error={last_error}"
    )


# =============================================================================
# 主流程:严格串联 5 个步骤,并按配置清理资源
# =============================================================================


def main() -> None:
    started_at = time.perf_counter()
    source_sandbox: Sandbox | None = None
    restored_sandbox: Sandbox | None = None
    workflow_succeeded = False

    template_id = ""
    snapshot_id = ""
    novnc_url = ""

    try:
        # Step 1: Windows image -> Template
        template = build_windows_template(started_at)
        template_id = template.template_id

        # Step 2: template_id -> source Sandbox
        source_sandbox = create_sandbox(template_id, started_at, step=2)

        # Step 3: source sandbox_id -> Snapshot
        snapshot_id = create_snapshot(source_sandbox, started_at)

        # Step 4: snapshot_id -> restored Sandbox
        restored_sandbox = create_sandbox(snapshot_id, started_at, step=4)
        wait_for_windows_service(restored_sandbox)
        identity = str(
            execute_windows(restored_sandbox, ["whoami"]).get("output", "")
        ).strip()
        milestone(
            started_at,
            f"Restored Sandbox is ready: snapshot_id={snapshot_id}, "
            f"restored_sandbox_id={restored_sandbox.sandbox_id}, whoami={identity!r}",
        )

        # Step 5: output restored Sandbox noVNC link
        probe_novnc_first_frame(
            restored_sandbox,
            started_at,
            timeout=int(os.getenv("NOVNC_READY_TIMEOUT_SECONDS", "600")),
        )
        novnc_url = wait_for_novnc(restored_sandbox)
        milestone(
            started_at,
            f"Step 5/5 complete: snapshot_id={snapshot_id}, noVNC is ready",
        )
        workflow_succeeded = True
    finally:
        if not env_bool("KEEP_SOURCE_SANDBOX", False):
            kill_sandbox(source_sandbox, "source")

        cleanup_restored = env_bool("CLEANUP_RESTORED_SANDBOX", False)
        if restored_sandbox is not None and (
            cleanup_restored or not workflow_succeeded
        ):
            kill_sandbox(restored_sandbox, "restored")

    if not workflow_succeeded or restored_sandbox is None:
        raise RuntimeError(
            "Windows Template/Sandbox/Snapshot lifecycle did not complete"
        )

    print("\nSUCCESS: Windows Template -> Sandbox -> Snapshot -> Restore -> noVNC\n")
    print(f"template_id={template_id}")
    print(f"snapshot_id={snapshot_id}")
    print(f"restored_sandbox_id={restored_sandbox.sandbox_id}")
    print(f"restored_sandbox_novnc_url={novnc_url}")
    if not env_bool("CLEANUP_RESTORED_SANDBOX", False):
        print(
            "restored sandbox is intentionally kept alive; "
            "set CLEANUP_RESTORED_SANDBOX=true to clean it automatically"
        )


if __name__ == "__main__":
    main()

运行脚本:

uv run windows_snapshot_lifecycle.py

脚本成功后会输出 Template ID、Snapshot ID、恢复沙箱 ID 和 noVNC 地址。源沙箱默认在流程结束时销毁;恢复沙箱默认保留,便于立即检查恢复后的桌面。检查完成后请手动销毁恢复沙箱,或在运行前设置 CLEANUP_RESTORED_SANDBOX=true 自动清理。

警告

示例输出的 noVNC 地址包含沙箱访问令牌,应视为临时访问凭证。请勿将其写入持久日志、提交到代码仓库或公开分享。生产环境应通过受控后端代理,或采用带鉴权的 Desktop Stream 对外提供访问。

相关文档