# 模板机预装 + Sysprep 批量部署指南

> **Audience**: VM 模板维护者 / GuestOps 自动化工程师 / 私有云批量装机场景
> **Agent 版本**: 0.3.0+(Master-Worker 架构。0.2.x 不兼容,会架构倒退)
> **关联**: [CAPABILITIES.md](./CAPABILITIES.md) · [DESIGN-0.3.0-master-worker.md](../DESIGN-0.3.0-master-worker.md)

---

## 1. 场景与黄金法则

### 场景

1. 准备一个 Windows 模板 VM(golden image)
2. 把 `m2-gateway.exe` 预装到模板
3. `sysprep /generalize /oobe /shutdown` 把模板"通用化"
4. 用此模板批量克隆 N 个生产 VM(VMware / Hyper-V / 公有云镜像)
5. 每个克隆 VM 首启时由 `SetupComplete.cmd`(或 GuestOps / cloud-init)注入 per-VM 配置 + 触发 agent 装机

### 黄金法则

> **模板机阶段不能让 agent 产生任何 per-machine 状态。**
> 否则 sysprep 之后所有克隆 VM 共享同一个 host_id / 同一份 DPAPI 加密密钥 → 灾难。

具体:

- ❌ 模板机**不要跑** `--install-template`
- ❌ 模板机**不要启动**任何 agent 进程
- ❌ 模板机**不要写**真实的 `setup.conf`(host_id / bid / secretKey 必须 per-VM)
- ❌ 模板机**不要装** `m2-gateway-svc` service 或 `m2-gateway-worker` scheduled task
- ❌ 模板机**不要把** `agent_privkey` 写成 DPAPI 加密形式(sysprep 重生 DPAPI key,解不开)

模板机阶段**只做一件事:把 binary 复制到 `C:\Program Files\m2-gateway\m2-gateway.exe`**。

---

## 2. 模板机准备 Checklist(`sysprep /generalize` 之前)

### 2.1 安装 binary

```cmd
mkdir "C:\Program Files\m2-gateway"
curl -L -o "C:\Program Files\m2-gateway\m2-gateway.exe" ^
  https://api.gateway.wechas.com/static/m2-gateway.exe
```

只 copy 文件,**不跑任何参数**。

### 2.2(可选)做一次冒烟测试 → 测完必须彻底清理

如果你想在模板机阶段先在 Audit Mode 验证 agent 能装能跑,**必须在 sysprep 之前跑彻底清理脚本**,把所有 per-machine 状态恢复到「只剩 binary」起点:

```cmd
:: ============ 模板机回滚到"只剩 binary"状态(测完冲 sysprep 前必跑) ============
@echo off
echo Stopping service + worker...
sc stop m2-gateway-svc 2>nul
timeout /t 2 /nobreak >nul
taskkill /f /im m2-gateway.exe 2>nul

echo Removing service...
sc delete m2-gateway-svc 2>nul

echo Removing scheduled tasks...
schtasks /delete /tn m2-gateway-worker /f 2>nul
schtasks /delete /tn m2gw-worker-test /f 2>nul
schtasks /delete /tn m2-gateway /f 2>nul

echo Removing 0.2.x HKLM\Run leftover...
reg delete "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v m2-gateway /f 2>nul

echo Removing per-machine state...
del /f /q "%ProgramData%\m2-gateway\setup.conf" 2>nul
rmdir /s /q "%ProgramData%\m2-gateway\Logs" 2>nul
del /f /q "%TEMP%\m2gw-*" 2>nul
del /f /q "%ProgramData%\m2-gateway\selfupdate-helper.cmd" 2>nul

echo Removing firewall rule...
netsh advfirewall firewall delete rule name="m2-gateway" 2>nul

echo Cleanup done. Binary remains at C:\Program Files\m2-gateway\m2-gateway.exe.
echo Verify:
dir "C:\Program Files\m2-gateway"
sc query m2-gateway-svc 2>nul && echo WARN: service still exists || echo OK: no service
reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v m2-gateway 2>nul && echo WARN: HKLM\Run still set || echo OK: no HKLM\Run
```

把这段保存为 `prep-cleanup.cmd`,sysprep 前跑一遍。

### 2.3 Sysprep 前验证清单

跑 sysprep 前确认:

```cmd
:: 应该返"OK: no service" / "OK: no HKLM\Run"
sc query m2-gateway-svc 2>nul && echo FAIL: service exists || echo OK: no service
schtasks /query /tn m2-gateway-worker 2>nul && echo FAIL: task exists || echo OK: no task
reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v m2-gateway 2>nul && echo FAIL: HKLM\Run exists || echo OK
dir "%ProgramData%\m2-gateway\setup.conf" 2>nul && echo FAIL: setup.conf exists || echo OK
dir "C:\Program Files\m2-gateway\m2-gateway.exe" >nul 2>&1 && echo OK: binary exists || echo FAIL: binary missing
tasklist /fi "imagename eq m2-gateway.exe" 2>nul | findstr m2-gateway.exe && echo FAIL: process running || echo OK
```

全部 OK 才能 sysprep。

### 2.4 触发 Sysprep

```cmd
C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe /shutdown ^
  /unattend:C:\Windows\System32\Sysprep\unattend.xml
```

`/shutdown` 让模板机关机以便镜像化保存。

---

## 3. 克隆 VM 首启 — Specialize 阶段做的事

每个克隆 VM 首次启动时,需要某个机制注入 per-VM 配置 + 触发 agent 装机。

### 3.1 中枢侧准备 per-VM 配置

为每个新 VM,通过中枢 admin API `POST /v1/admin/hosts` 或 `POST /v1/api/hosts`(clientapi)创建 host 壳,拿回 **wechas 段**字段;**autologon 段** 由调用方(GuestOps 编排 / m2aicli provision)自己生成(per-VM 随机管理员密码)并塞进同一份 user_data。

**wechas 段(中枢颁发)**:

| 字段 | 类型 | 说明 |
|---|---|---|
| `host_id` | uuid | host 唯一 ID |
| `bid` | int64 | L1 协议 bid |
| `secret_key` | 32-char string | L1 AES-CBC 密钥 |
| `install_token` | string | 一次性安装 token,TTL 见 §3.5 |
| `agent_privkey_seed` | base64 string | **明文 seed**(注意:不是 DPAPI 加密形式) |
| `agent_pubkey` | base64 string | 中枢已存的对应公钥 |
| `ws_url` | string | `wss://api.gateway.wechas.com/v1/internal/agent/ws` |
| `api_endpoint` | string | `https://api.gateway.wechas.com` |

**autologon 段(可选,仅变体 B 自管 AutoLogon 时用)**:

| 字段 | 类型 | 说明 |
|---|---|---|
| `username` | string | 默认 `Administrator`,也可以是 `unattend.xml` 里建的本地账号 |
| `password` | string | per-VM 随机 16+ 字符 |

**何时需要 autologon 段**:

- ❌ **变体 A 简化版主推路径**:**不需要** autologon 段。Vcflux GuestCustomizationSpec / cloudbase-init `SetUserPasswordPlugin` 在 sysprep specialize 阶段已经配好 AutoLogon 注册表,SetupComplete.cmd 完全不读这段。
- ✅ **变体 B 自管 AutoLogon(裸 vSphere / 自建私有云)**:需要。SetupComplete.cmd `§D` 段会读 `autologon.username/password` 写 Winlogon 注册表。

> **重要**:`agent_privkey_seed` 一定要传**明文 seed**,**不要**预先用模板机的 DPAPI 加密(sysprep generalize 重生 DPAPI key → 老密文永远解不开)。agent 0.3.0 启动时(`crypto::migrate_legacy_seed`,M3-07 逻辑)会**自动用克隆 VM 自己的 DPAPI key 包装**写回。

> **变体 A 的 AutoLogon 怎么来的**:模板机 sysprep 后克隆 VM 首启的执行序列是 `specialize` → `oobeSystem` → `SetupComplete.cmd` → `Winlogon` → `AutoLogon`。Vcflux GuestCustomizationSpec / cloudbase-init `SetUserPasswordPlugin` 在 `specialize` 阶段就把 `AutoAdminLogon/DefaultUsername/DefaultPassword/AutoLogonCount` 注册表写好,所以 SetupComplete.cmd 跑完退出后 Windows 自然走到 Winlogon → AutoLogon → user 登录 → AtLogOn task 触发 worker。**没有 reboot,一次启动完成全链路**。

变体 A 完整 user_data JSON schema(SetupComplete 解析的合约):

```json
{
  "schema_version": 1,
  "wechas": {
    "host_id": "cb479620-9da1-4a46-95ee-a626959faa37",
    "bid": 5424469343018764870,
    "secret_key": "7b7057c6f22be82b0cdaed7aea08a266",
    "install_token": "5FLZGFsYZWLN8RuEhofDumVVTAvpxyCA9crwlhqXeFs",
    "agent_privkey_seed": "BASE64SEED==",
    "agent_pubkey": "BASE64PUBKEY=",
    "ws_url": "wss://api.gateway.wechas.com/v1/internal/agent/ws",
    "api_endpoint": "https://api.gateway.wechas.com"
  },
  "engine": { "engine_id": "996m2", "preinstalled": false },
  "m2aicli": { "instance_id": "...", "user_id": "...", "created_at": "..." }
}
```

变体 B 完整 schema(在变体 A 基础上加 `autologon` 段):

```json
{
  "schema_version": 1,
  "wechas": { ...同上 8 字段... },
  "autologon": {
    "username": "Administrator",
    "password": "<16+ 字符随机>"
  }
}
```

调用方可以在同一份 JSON 里追加 `engine.*` / `m2aicli.*` 等自定义节,SetupComplete **只读 `wechas.*` 和(可选)`autologon.*`**,其它字段透传给后续阶段。

### 3.2 完整 `SetupComplete.cmd` 模板(生产可用版)

> **状态**(2026-05-22 更新):m2aicli 团队反馈 Vcflux vSphere adapter 已实装完整 GuestCustomizationSpec,sysprep specialize 阶段自动配 AutoLogon;三家公有云走 cloudbase-init 同样自带 AutoLogon 能力。**变体 A 简化版**成为新主推(只装 agent → 退出),**变体 B 自管 AutoLogon** 保留给裸 vSphere / 自建私有云兜底。

| 变体 | 谁配 AutoLogon | reboot | 适用 |
|---|---|---|---|
| **A 简化版**(主推) | Vcflux GuestCustomizationSpec / cloudbase-init `SetUserPasswordPlugin` | ❌ 不需要 | Vcflux vSphere / 阿里 / 腾讯 / 华为公有云 |
| B 自管版 | SetupComplete.cmd 自己写 Winlogon 注册表 | ✅ 装完 reboot 触发 | 裸 vSphere(无 Vcflux)/ Hyper-V GuestOps / 自建私有云 |

#### 3.2.1 变体 A — 简化版(推荐)

执行顺序:

```
sysprep generalize → 镜像化 → 克隆 VM 启动
  ↓
specialize 阶段 Vcflux/cloudbase-init 配:
  · 灌 administrator 密码
  · 写 Winlogon AutoAdminLogon=1 + DefaultUsername/DefaultPassword + AutoLogonCount=999
  · 落 user_data 到 %ProgramData%\cloudbase-init\userdata.txt
  ↓
SetupComplete.cmd 跑(LocalSystem,user 尚未登录):
  读 userdata.txt → install-template → 退出(无 reboot)
  ↓
Windows 继续 boot → Winlogon 触发 AutoLogon → user session 起
  ↓
AtLogOn task 触发 m2-gateway-worker → ws 上线
```

**SetupComplete.cmd 完整代码**:

```cmd
@echo off
:: m2-gateway 简化版 SetupComplete.cmd(0.3.1+)
:: 假设:Vcflux GuestCustomizationSpec 或 cloudbase-init 已在 sysprep specialize
::       阶段配好 AutoLogon。本脚本只装 agent,装完即退,不 reboot。

setlocal EnableDelayedExpansion
set "LOG=%ProgramData%\m2-gateway-install.log"
set "DONE=%ProgramData%\m2-gateway\install.done"
set "EXE=C:\Program Files\m2-gateway\m2-gateway.exe"
set "CFG=%ProgramData%\cloudbase-init\userdata.txt"

echo [%date% %time%] SetupComplete start (variant A simple) >> "%LOG%"

:: ---- §A. 幂等防护 ----
if exist "%DONE%" (
    echo [%date% %time%] install.done present; skip >> "%LOG%"
    exit /b 0
)

:: ---- §B. 等 cloudbase-init 落 userdata.txt(最多 180s)----
:: 阿里/华为某些镜像 cloudbase-init 启动晚 + IMDS 慢,90s+ 才落地;
:: SetupComplete 跟 cloudbase-init 是并发的,这里轮询等。
set /a WAIT=0
:wait_cfg
if exist "%CFG%" goto :got_cfg
if !WAIT! GEQ 180 (
    echo [%date% %time%] FATAL: %CFG% missing after 180s >> "%LOG%"
    exit /b 2
)
timeout /t 2 /nobreak >nul
set /a WAIT+=2
goto :wait_cfg
:got_cfg

:: ---- §C. 解析 JSON → env ----
:: 只读 wechas.* 8 字段;autologon 段不读(Vcflux/cloudbase-init 已经用过了)。
for /f "tokens=1,* delims==" %%A in ('powershell -NoProfile -Command ^
  "$j=Get-Content -Raw '%CFG%' ^| ConvertFrom-Json;" ^
  "'HOST_ID='+$j.wechas.host_id;" ^
  "'BID='+$j.wechas.bid;" ^
  "'SECRET='+$j.wechas.secret_key;" ^
  "'TOKEN='+$j.wechas.install_token;" ^
  "'SEED='+$j.wechas.agent_privkey_seed;" ^
  "'PUBKEY='+$j.wechas.agent_pubkey;" ^
  "'WS='+$j.wechas.ws_url;" ^
  "'API='+$j.wechas.api_endpoint") do set "%%A=%%B"

if "%HOST_ID%"=="" (echo [%date% %time%] FATAL: HOST_ID missing >> "%LOG%" & exit /b 3)
if "%BID%"==""     (echo [%date% %time%] FATAL: BID missing >> "%LOG%" & exit /b 3)
if "%SECRET%"==""  (echo [%date% %time%] FATAL: SECRET missing >> "%LOG%" & exit /b 3)
if "%TOKEN%"==""   (echo [%date% %time%] FATAL: TOKEN missing >> "%LOG%" & exit /b 3)
if "%SEED%"==""    (echo [%date% %time%] FATAL: SEED missing >> "%LOG%" & exit /b 3)
if "%PUBKEY%"==""  (echo [%date% %time%] FATAL: PUBKEY missing >> "%LOG%" & exit /b 3)
if "%WS%"==""  set "WS=wss://api.gateway.wechas.com/v1/internal/agent/ws"
if "%API%"=="" set "API=https://api.gateway.wechas.com"

if not exist "%EXE%" (
    echo [%date% %time%] FATAL: %EXE% missing >> "%LOG%"
    exit /b 4
)

:: ---- §D. 装 service + worker task(install-template 自己负责)----
echo [%date% %time%] running install-template host_id=%HOST_ID% >> "%LOG%"
"%EXE%" --install-template ^
    --host-id "%HOST_ID%" ^
    --bid %BID% ^
    --secret-key "%SECRET%" ^
    --install-token "%TOKEN%" ^
    --agent-privkey-seed "%SEED%" ^
    --agent-pubkey "%PUBKEY%" ^
    --ws-url "%WS%" ^
    --api-endpoint "%API%" >> "%LOG%" 2>&1

set "RC=%ERRORLEVEL%"
echo [%date% %time%] install-template exit=%RC% >> "%LOG%"
if not %RC% == 0 (
    echo [%date% %time%] FATAL: install-template failed rc=%RC% >> "%LOG%"
    exit /b %RC%
)

:: ---- §E. 写 done marker + 清敏感 env ----
set "SECRET="
set "SEED="
set "TOKEN="

if not exist "%ProgramData%\m2-gateway" mkdir "%ProgramData%\m2-gateway"
echo %date% %time% > "%DONE%"
echo [%date% %time%] install.done written; SetupComplete finished (no reboot) >> "%LOG%"

:: 不 reboot,不 shutdown — Windows 自然继续 boot → Winlogon → AutoLogon →
:: user 登录 → AtLogOn task 自动起 worker → ws 上线。
endlocal
exit /b 0
```

**退出码语义**(简化版):

| 退出码 | 含义 |
|---|---|
| 0 | 成功(install-template 跑通,install.done 写入,Windows 继续 boot 触发 AutoLogon) |
| 2 | cloudbase-init userdata.txt 180s 未落地 |
| 3 | userdata.txt JSON 缺关键字段(host_id / bid / secret / token / seed / pubkey) |
| 4 | `C:\Program Files\m2-gateway\m2-gateway.exe` 模板镜像里没预装 |
| 其它 | install-template 自身 exit code,见 §3.6 |

**前置条件**(镜像构建侧必须保证,详见 [IMAGE-BUILD-SPEC.md](./IMAGE-BUILD-SPEC.md)):

- ✅ `C:\Program Files\m2-gateway\m2-gateway.exe` 已预装,模板机阶段不跑 install-template
- ✅ cloudbase-init 装好,`cloudbase-init.conf` 的 `metadata_services` 指向当前云
- ✅ AutoLogon 由 Vcflux GuestCustomizationSpec / cloudbase-init `SetUserPasswordPlugin` 在 specialize 阶段配置(账号 + 密码 + AutoLogonCount)
- ✅ 防锁屏 / 防 RDP 注销 / 关屏保等组策略在镜像里预配(可选)

#### 3.2.2 变体 B — 自管 AutoLogon(裸 vSphere / 自建私有云兜底)

> 仅在 **没有 Vcflux GuestCustomizationSpec 也没有 cloudbase-init** 的兜底场景使用。比如 IDC 自建 vSphere 老镜像 / Hyper-V GuestOps `transferFile` 推配置 / 完全手工编排的私有云。绝大多数生产路径请用变体 A。

变体 B 跟变体 A 的差异:

| 步骤 | 变体 A | 变体 B |
|---|---|---|
| 配置来源 | 只 cloudbase-init userdata.txt | 4 选 1(A1 GuestOps / A2 IMDS / A3 env / A4 cloudbase-init)|
| AutoLogon | sysprep 已配,SetupComplete 不动 | SetupComplete 自己 reg add `Winlogon\AutoAdminLogon` 等 |
| user_data JSON | 只读 `wechas.*` | 读 `wechas.*` + `autologon.*` |
| reboot | ❌ 不 reboot | ✅ 装完 `shutdown /r /t 5` 触发 AutoLogon |
| 跑几次 pass | 1 次 | 2 次(reboot 前 + reboot 后 verify)|
| 退出码 | 0/2/3/4/+ install-template | 0/2/3/4/+ install-template |

**变体 B 完整模板**(原 0.3.1 草案,自管 AutoLogon):

> **两阶段语义**:被 Windows 跑两次 —— **首次** OOBE 完成后(无 user,LocalSystem),**第二次** AutoLogon reboot 后(同样 LocalSystem,跑前判断 setup.conf 已存在则只 verify,避免死循环)。

```cmd
@echo off
:: =========================================================================
:: m2-gateway agent 首启装机 — SetupComplete.cmd(0.3.0+)
::
:: Audience: sysprep'd Windows VM 首次启动 / AutoLogon reboot 后第二次启动
:: 流程:
::   首次:
::     1) 检查 setup.conf 是否已存在(已存在 = 第二次跑,跳到 §G 仅验证)
::     2) 从配置源(A1/A2/A3/A4)拿 per-VM 配置 JSON
::     3) 配 AutoLogon + 防锁屏 + 防 RDP 注销
::     4) 调 m2-gateway.exe --install-template 装 service + worker task
::     5) 删敏感配置文件 + reboot 触发 AutoLogon
::   第二次(AutoLogon 重启后):
::     6) 跳过 install-template,验证 service+worker+ws connected
::     7) 写完成标记 install.done
:: =========================================================================

setlocal EnableDelayedExpansion
set "LOG=%ProgramData%\m2-gateway-install.log"
set "DONE=%ProgramData%\m2-gateway\install.done"
set "CFG_PATH=%ProgramData%\m2-gateway\setup.conf"
set "EXE=C:\Program Files\m2-gateway\m2-gateway.exe"

echo [%date% %time%] SetupComplete start (pid=%RANDOM%) >> "%LOG%"

:: =========================================================================
:: §A. reboot 死循环防护 — setup.conf 已存在 = 第二次跑了,直接进验证
:: =========================================================================
if exist "%DONE%" (
    echo [%date% %time%] install.done already present; skip everything >> "%LOG%"
    exit /b 0
)
if exist "%CFG_PATH%" (
    echo [%date% %time%] setup.conf exists; second pass after AutoLogon reboot >> "%LOG%"
    goto :verify
)

:: =========================================================================
:: §B. 取 per-VM 配置 JSON — 4 个来源,选一个,其它注释掉
:: =========================================================================

:: --- 选项 A1: GuestOps transferFile 推到镜像内固定路径(vSphere/Hyper-V LAN)---
:: 调用方通过 vSphere GuestOperationsManager.InitiateFileTransferToGuest 把
:: m2gw-config.json 推到 C:\Windows\Setup\Files\ 下。
::
:: set "CFG=C:\Windows\Setup\Files\m2gw-config.json"

:: --- 选项 A2: cloud-init / IMDS user-data(AWS/Azure/GCP)---
:: AWS / Azure / GCP 在 Windows 镜像里跑 cloud-init,user-data 落到固定路径:
::   AWS:    %ProgramData%\Amazon\EC2-Windows\Launch\Sysprep\Unattend.xml (走 unattend 路径)
::   GCP:    %ProgramData%\Google\Compute Engine\sysprep\specialize.cmd  (走 specialize 路径)
::
:: 这两类云推荐**不**走 SetupComplete.cmd,改用厂商自己的 specialize 钩子。
:: 真要用 SetupComplete.cmd 走 IMDS:
::   curl -s -H "Metadata:true" http://169.254.169.254/.../userData > %TEMP%\ud.json
::   set "CFG=%TEMP%\ud.json"

:: --- 选项 A3: vCenter Tools GuestOperationsManager 环境变量直接传 ---
:: 调用方 startProgram(envVariables=["HOST_ID=...", "BID=..." ...]),
:: SetupComplete 不读文件,直接 expect 这些变量已 set。
:: 注意 LocalSystem context **不继承** GuestOps caller 的 env,需要厂商工具支持
:: (vCenter Tools 11+ 支持,旧版不支持)。不推荐,优先 A1/A4。

:: --- 选项 A4: cloudbase-init userdata.txt(阿里云/腾讯云/华为云/vSphere)---
:: cloudbase-init 是社区版 cloud-init,镜像里装好后自动读各云的 user-data:
::   阿里云:   http://100.100.100.200/latest/user-data
::   腾讯云:   http://169.254.0.23/latest/user-data
::   华为云:   http://169.254.169.254/openstack/latest/user_data
::   vSphere:  guestinfo.userdata(VMware Tools)
:: 落到本地 path 都是 %ProgramData%\cloudbase-init\userdata.txt(默认 LocalSystemPlugins).
::
set "CFG=%ProgramData%\cloudbase-init\userdata.txt"

:: ---- 等 cloudbase-init 把 userdata 落地(最多 60s)----
set /a WAIT=0
:wait_cfg
if exist "%CFG%" goto :got_cfg
if !WAIT! GEQ 60 (
    echo [%date% %time%] FATAL: %CFG% missing after 60s >> "%LOG%"
    exit /b 2
)
timeout /t 2 /nobreak >nul
set /a WAIT+=2
goto :wait_cfg
:got_cfg

:: =========================================================================
:: §C. 解析 JSON → 环境变量
:: =========================================================================
:: 用单条 PowerShell 把所有字段输出为 "KEY=VAL" 行,再 for /f 写入 env。
:: 比 8 次 powershell 启动快 ~5x,且避免每个字段都引号转义出错。

for /f "tokens=1,* delims==" %%A in ('powershell -NoProfile -Command ^
  "$j=Get-Content -Raw '%CFG%' ^| ConvertFrom-Json;" ^
  "'HOST_ID='+$j.wechas.host_id;" ^
  "'BID='+$j.wechas.bid;" ^
  "'SECRET='+$j.wechas.secret_key;" ^
  "'TOKEN='+$j.wechas.install_token;" ^
  "'SEED='+$j.wechas.agent_privkey_seed;" ^
  "'PUBKEY='+$j.wechas.agent_pubkey;" ^
  "'WS='+$j.wechas.ws_url;" ^
  "'API='+$j.wechas.api_endpoint;" ^
  "'AUTO_USER='+$j.autologon.username;" ^
  "'AUTO_PASS='+$j.autologon.password") do set "%%A=%%B"

:: ---- 基本校验 ----
if "%HOST_ID%"=="" (echo [%date% %time%] FATAL: HOST_ID missing >> "%LOG%" & exit /b 3)
if "%BID%"==""     (echo [%date% %time%] FATAL: BID missing >> "%LOG%" & exit /b 3)
if "%SECRET%"==""  (echo [%date% %time%] FATAL: SECRET missing >> "%LOG%" & exit /b 3)
if "%TOKEN%"==""   (echo [%date% %time%] FATAL: TOKEN missing >> "%LOG%" & exit /b 3)
if "%SEED%"==""    (echo [%date% %time%] FATAL: SEED missing >> "%LOG%" & exit /b 3)
if "%PUBKEY%"==""  (echo [%date% %time%] FATAL: PUBKEY missing >> "%LOG%" & exit /b 3)
if "%AUTO_USER%"=="" set "AUTO_USER=Administrator"
if "%AUTO_PASS%"=="" (echo [%date% %time%] FATAL: autologon.password missing >> "%LOG%" & exit /b 3)
if "%WS%"==""  set "WS=wss://api.gateway.wechas.com/v1/internal/agent/ws"
if "%API%"=="" set "API=https://api.gateway.wechas.com"

if not exist "%EXE%" (
    echo [%date% %time%] FATAL: %EXE% missing in template image >> "%LOG%"
    exit /b 4
)

:: =========================================================================
:: §D. 配 AutoLogon + 防锁屏 + 防 RDP 注销
::      —— sysprep 后 LocalSystem 跑 SetupComplete 时没 user 登录,
::         worker AtLogOn task 无法触发,所以必须配 AutoLogon + reboot 触发。
:: =========================================================================
set "WL=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
reg add "%WL%" /v AutoAdminLogon  /t REG_SZ /d 1             /f >> "%LOG%" 2>&1
reg add "%WL%" /v DefaultUsername /t REG_SZ /d "%AUTO_USER%" /f >> "%LOG%" 2>&1
reg add "%WL%" /v DefaultPassword /t REG_SZ /d "%AUTO_PASS%" /f >> "%LOG%" 2>&1
:: AutoLogonCount=1 让 Winlogon 只自动登录一次,登完清除;首次触发 worker 后由 user 自己决定是否保留。
:: 如果业务要求"持续 AutoLogon"(进 GameCenter 桌面),把这条删掉。
reg add "%WL%" /v AutoLogonCount  /t REG_DWORD /d 1          /f >> "%LOG%" 2>&1

:: 防自动锁屏(GameCenter / 引擎控制台 UI 不能被锁屏挡)
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Lsa" ^
    /v DisableLockWorkstation /t REG_DWORD /d 1 /f >> "%LOG%" 2>&1
reg add "HKLM\Software\Policies\Microsoft\Windows\Control Panel\Desktop" ^
    /v ScreenSaveActive /t REG_SZ /d 0 /f >> "%LOG%" 2>&1
reg add "HKCU\Control Panel\Desktop" ^
    /v ScreenSaveActive /t REG_SZ /d 0 /f >> "%LOG%" 2>&1

:: 防 RDP 断开自动注销(默认 fResetBroken=1 会在 RDP 关闭时注销 session,
:: 我们要 RDP 关闭后保留 disconnected session,worker 继续跑 — session 状态机 S6)
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" ^
    /v fResetBroken /t REG_DWORD /d 0 /f >> "%LOG%" 2>&1

echo [%date% %time%] AutoLogon + anti-lock + anti-rdp-reset configured >> "%LOG%"

:: =========================================================================
:: §E. 调 install-template 装 service + worker task
:: =========================================================================
echo [%date% %time%] running install-template host_id=%HOST_ID% >> "%LOG%"
"%EXE%" --install-template ^
    --host-id "%HOST_ID%" ^
    --bid %BID% ^
    --secret-key "%SECRET%" ^
    --install-token "%TOKEN%" ^
    --agent-privkey-seed "%SEED%" ^
    --agent-pubkey "%PUBKEY%" ^
    --ws-url "%WS%" ^
    --api-endpoint "%API%" >> "%LOG%" 2>&1

set "RC=%ERRORLEVEL%"
echo [%date% %time%] install-template exit=%RC% >> "%LOG%"
if not %RC% == 0 (
    echo [%date% %time%] FATAL: install-template failed rc=%RC% >> "%LOG%"
    exit /b %RC%
)

:: =========================================================================
:: §F. 清敏感配置 + reboot 触发 AutoLogon
:: =========================================================================
:: 配置文件包含 install_token / secret / seed,装完立即删,避免永久残留磁盘。
:: cloudbase-init userdata.txt 是 cloudbase-init 自己管理,我们不删(它每次启动会重写)。
:: 走 A1 路径(C:\Windows\Setup\Files\m2gw-config.json)的话,这里 del 它。
if /i not "%CFG%"=="%ProgramData%\cloudbase-init\userdata.txt" (
    if exist "%CFG%" del /f /q "%CFG%"
)
:: AUTO_PASS 也从 env 清掉(其实 setlocal 退出会自动清,这里只是显式表达)
set "AUTO_PASS="
set "SECRET="
set "SEED="
set "TOKEN="

echo [%date% %time%] rebooting to trigger AutoLogon ... >> "%LOG%"
shutdown /r /t 5 /f /c "m2-gateway specialize stage: reboot to trigger worker"
exit /b 0

:: =========================================================================
:: §G. 第二次跑(AutoLogon reboot 后)— 验证 service / worker / ws
:: =========================================================================
:verify
echo [%date% %time%] second pass: verifying agent online >> "%LOG%"

:: 等 worker 起 + ws connect(最多 60s)
set /a TRY=0
:verify_loop
sc query m2-gateway-svc 2>nul | findstr /C:"RUNNING" >nul
set "SVC_OK=%errorlevel%"
tasklist /fi "imagename eq m2-gateway.exe" 2>nul | findstr /i "m2-gateway.exe" >nul
set "WORKER_OK=%errorlevel%"

if "%SVC_OK%"=="0" if "%WORKER_OK%"=="0" goto :verify_done
if !TRY! GEQ 30 (
    echo [%date% %time%] WARN: timeout waiting service+worker (svc_rc=%SVC_OK% worker_rc=%WORKER_OK%) >> "%LOG%"
    goto :verify_done
)
timeout /t 2 /nobreak >nul
set /a TRY+=2
goto :verify_loop

:verify_done
if not exist "%ProgramData%\m2-gateway" mkdir "%ProgramData%\m2-gateway"
echo %date% %time% > "%DONE%"
echo [%date% %time%] install.done written; SetupComplete finished >> "%LOG%"
endlocal
exit /b 0
```

**退出码语义**(供 m2aicli `wait_for_agent_online` 错误分类):

| 退出码 | 含义 |
|---|---|
| 0 | 成功(首次 = 装完 reboot;第二次 = 验证完写 install.done) |
| 2 | 配置 JSON 来源未就绪(cloudbase-init userdata.txt 等 60s 未落地) |
| 3 | 配置 JSON 字段缺失 |
| 4 | `C:\Program Files\m2-gateway\m2-gateway.exe` 模板镜像里没预装 |
| 其它 | install-template 自身的 exit code,见 §3.6 |

### 3.3(备选)用 `unattend.xml FirstLogonCommands` 而非 SetupComplete.cmd

如果你不用 SetupComplete.cmd 而用 `unattend.xml` 的 `<FirstLogonCommands>` 节(OOBE specialize 阶段):

```xml
<settings pass="oobeSystem">
  <component name="Microsoft-Windows-Shell-Setup" ...>
    <FirstLogonCommands>
      <SynchronousCommand wcm:action="add">
        <Order>1</Order>
        <Description>m2-gateway install-template</Description>
        <CommandLine>cmd /c C:\Windows\Setup\Scripts\m2gw-install.cmd</CommandLine>
        <RequiresUserInput>false</RequiresUserInput>
      </SynchronousCommand>
    </FirstLogonCommands>
  </component>
</settings>
```

`m2gw-install.cmd` 内容跟 §3.2 SetupComplete.cmd 一样。差别只是触发时机(`SetupComplete.cmd` = OOBE 完成、首登录前;`FirstLogonCommands` = 首登录后)。**推荐 SetupComplete.cmd**,因为不需要 user logon。

---

### 3.4 cloudbase-init 集成最佳实践

公有云(阿里云 / 腾讯云 / 华为云)和 vSphere 私有云推荐用 [cloudbase-init](https://cloudbase.it/cloudbase-init/) 把 per-VM user_data 落到本地磁盘,再由 SetupComplete.cmd §3.2 选项 A4 读取。

#### 3.4.1 模板镜像里装 cloudbase-init

```cmd
:: 下载安装(模板机阶段,sysprep 之前)
curl -L -o %TEMP%\cbi.msi ^
  https://www.cloudbase.it/downloads/CloudbaseInitSetup_Stable_x64.msi
msiexec /i %TEMP%\cbi.msi /qn /norestart RUN_SERVICE_AS_LOCAL_SYSTEM=1
```

> **阿里云 Windows 公共镜像** 默认预装,跳过此步。
> **腾讯云 / 华为云** 部分镜像预装,部分不预装,模板维护者确认。

#### 3.4.2 推荐 `cloudbase-init.conf` 配置

`C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\cloudbase-init.conf`:

```ini
[DEFAULT]
username=Administrator
groups=Administrators
inject_user_password=true
config_drive_raw_hhd=true
config_drive_cdrom=true
config_drive_vfat=true
bsdtar_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\bsdtar.exe
mtools_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\bin\
verbose=true
debug=true
logdir=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\
logfile=cloudbase-init.log
default_log_levels=comtypes=INFO,suds=INFO,iso8601=WARN,requests=WARN
local_scripts_path=C:\Program Files\Cloudbase Solutions\Cloudbase-Init\LocalScripts\

:: 关键:让 cloudbase-init 把 user_data **原样** 落地到 userdata.txt
:: 不要 user_data plugin chain 里执行 user_data 内容(我们要的是文本,不是脚本)
metadata_services=cloudbaseinit.metadata.services.configdrive.ConfigDriveService,
                  cloudbaseinit.metadata.services.httpservice.HttpService,
                  cloudbaseinit.metadata.services.ec2service.EC2Service,
                  cloudbaseinit.metadata.services.maasservice.MaaSHttpService

plugins=cloudbaseinit.plugins.common.mtu.MTUPlugin,
        cloudbaseinit.plugins.windows.ntpclient.NTPClientPlugin,
        cloudbaseinit.plugins.windows.setuserpassword.SetUserPasswordPlugin,
        cloudbaseinit.plugins.windows.createuser.CreateUserPlugin,
        cloudbaseinit.plugins.common.sethostname.SetHostNamePlugin,
        cloudbaseinit.plugins.common.userdata.UserDataPlugin
```

`UserDataPlugin` 会把 IMDS 拿到的 user_data 写到 `%ProgramData%\cloudbase-init\userdata.txt`,正好是 §3.2 选项 A4 读的路径。

#### 3.4.3 SetupComplete.cmd 跟 cloudbase-init 的执行顺序

Windows OOBE 完成后的执行链:

```
OOBE 完成
  ↓
SetupComplete.cmd 触发(LocalSystem)
  ↓
cloudbase-init service 启动(LocalSystem,跟 SetupComplete 并发)
  ↓
两者并发跑,SetupComplete §3.2 选项 A4 内置 60s 轮询等 userdata.txt
  ↓
cloudbase-init UserDataPlugin 写 userdata.txt 完成(~20-60s)
  ↓
SetupComplete 读到 userdata.txt → 装 agent → reboot
  ↓
AutoLogon → worker AtLogOn task 触发 → ws 上线
```

**关键**:SetupComplete 跟 cloudbase-init **不能假设先后**。§3.2 模板里 `:wait_cfg` 轮询 60 秒,保证 cloudbase-init 把 userdata.txt 写好。

#### 3.4.4 各厂商 user_data 大小限制

| 厂商 | 限制 | 备注 |
|---|---|---|
| 阿里云 ECS | 16 KB | base64 encode 后,原文本 ~12 KB |
| 腾讯云 CVM | 16 KB | 同上 |
| 华为云 ECS | 32 KB | 较宽松 |
| AWS EC2 | 16 KB | base64 后 |
| vSphere(`guestinfo.userdata`) | 64 KB | VMware Tools 限制 |

m2-gateway user_data JSON 实测尺寸:

```
host_id (uuid)            ~40 B
bid (i64)                 ~20 B
secret_key (32 char)      ~40 B
install_token (~44 B)     ~60 B
agent_privkey_seed (b64)  ~50 B
agent_pubkey (b64)        ~50 B
ws_url / api_endpoint     ~150 B
autologon                 ~50 B
─────────────────────────────
小计                       ~460 B
```

完整 user_data JSON **< 1 KB**,所有厂商都放得下。即便加上 `engine.*` / `m2aicli.*` 自定义节,也只占限制的 5-10%。

#### 3.4.5 cloudbase-init 版本兼容性

| cloudbase-init 版本 | UserDataPlugin 行为 | 推荐 |
|---|---|---|
| Microsoft 1.1.4+(社区) | 把 user_data 原样写 userdata.txt | ✅ 推荐 |
| 阿里云 fork 0.9.x | 默认会**执行** `#cloud-config` / `#!/bin/sh` 头的 user_data;无头则原样写 | ⚠️ user_data **不要**加 shebang |
| 华为云 fork | 兼容 Microsoft 上游 | ✅ |

**铁律**:user_data JSON 内容**不要**以 `#`(井号)开头,否则 cloudbase-init 阿里云 fork 会当 cloud-config 解析。我们的 schema 是纯 JSON(`{` 开头),天然没这个问题。

---

### 3.5 install_token TTL + 刷新

**默认 TTL**:900 秒(15 分钟,0.3.1 起。0.3.0 是 300 秒,跨云 provision 容错太薄)。
**配置方式**:中枢侧环境变量 `INSTALL_TOKEN_TTL`(单位秒),作用于 `api/internal/config/config.go` 启动加载。生产可以进一步调到 1800/3600 秒,只要 m2aicli `vm_provision.py` 仍带 refresh 兜底即可。
**刷新 endpoint**(已存在,无需新开发):

```
# clientapi(API Key 鉴权)
POST /v1/api/hosts/{host_id}/install-token
Authorization: Bearer ak_xxx.yyy

# user-web(JWT 鉴权)
POST /v1/user/hosts/{host_id}/install-token
Cookie: session=...
```

副作用(两个 endpoint 一致):
1. 旋转 host 的 agent keypair(老的失效)
2. 失效未消费的旧 install_token
3. 颁发新 install_token(返回明文,只此一次)
4. **不重置** secret_key(L1 协议密钥不动)

**典型用法 — 跨云 provision 重试**:

```python
# m2aicli vm_provision.py
def provision_vm(host_id):
    creds = api.create_host(...)  # 拿初始 install_token
    vm = cloud.create_vm(user_data=build_userdata(creds))
    try:
        wait_for_agent_online(host_id, timeout=900)  # 15 min
    except AgentOfflineTimeout:
        # 头一份 token 可能在 cloudbase-init 慢 / 镜像拉取慢期间过期了
        # 刷一份新 token,推 SetupComplete 重跑(GuestOps trigger 或直接重启 VM)
        new_creds = api.refresh_install_token(host_id)
        cloud.update_userdata(vm, build_userdata(new_creds))
        cloud.restart_vm(vm)
        wait_for_agent_online(host_id, timeout=600)
```

---

### 3.6 install-template 退出码标准化(0.3.1+)

> ⚠️ **状态**:0.3.0 只有 `0` / `1` 二值。**0.3.1 加分级 exit code + stderr JSON 输出**,**完全向后兼容**(成功路径不变,失败仍是非 0;老调用方不解析 stderr 不受影响,新调用方可以解析 JSON 精确分类)。
>
> **兼容老协议铁律**(适用所有老调用方):0.3.1 **不增加**任何严格参数校验、**不改变**任何老的成功路径降级行为(比如 DPAPI 失败仍 warn + 写明文 seed)。新增的严格行为只在未来 `--strict-*` opt-in flag 下启用。

退出码契约表:

| code | 含义 | 实际是否会被 0.3.1 产出 | 调用方应对 |
|---|---|---|---|
| 0 | 成功 | ✅ 总是 | 继续 §F reboot |
| 1 | 其它未分类内部错误(anyhow fallback) | ✅ 多数错误目前走这条 | 看 stdout/stderr + service.log |
| 20 | binary copy 失败(`C:\Program Files\m2-gateway\m2-gateway.exe` 占用 / 权限不足) | ✅ | 检查模板机有没有跑过 agent 没清干净 |
| 21 | setup.conf 写入失败(磁盘满 / `%ProgramData%` 权限) | ✅ | 检查磁盘 + ACL |
| 30 | `sc create` 失败(已存在但状态异常 / SCM 权限) | ✅ | 跑 §5.1 cleanup 后重试 |
| 31 | `schtasks /create` 失败 | ✅ | 检查 Task Scheduler service 状态 |
| 32 | 防火墙 `netsh advfirewall` 失败 | ✅ | 一般是 Windows Firewall service 没起 |
| 10 | 参数缺失 | 🟡 **0.3.1 不产出**(opt-in 严格模式预留契约位)| 同 11 |
| 11 | 参数无效 | 🟡 **0.3.1 不产出**(同上)| 调用方仍能从 30/31 等下游失败间接判断 |
| 22 | DPAPI protect 失败 | 🟡 **0.3.1 走 warn + 降级到明文 seed**,不产 exit 22(兼容老协议)| 留意 stderr `warn: DPAPI protect failed, storing plaintext fallback: ...` |

> **0.3.1 实际产出的码集合**:`0` / `1` / `20` / `21` / `30` / `31` / `32`。`10` / `11` / `22` 在契约表里占位,**当前不会被产出** — 它们留给未来 `--strict-args` / `--strict-dpapi` opt-in flag。这样 m2aicli 编排可以按完整表写 case 分支,老调用方不会遇到这些码导致老脚本误判。

**stderr JSON 输出**(失败时,新行为):

```json
{
  "code": "SC_CREATE_FAILED",
  "exit": 30,
  "msg": "SC_CREATE_FAILED: sc create rc=1073: stdout=... stderr=...",
  "hint": "service may exist in failed state; run `sc delete m2-gateway-svc` and retry"
}
```

成功时 stdout 一行(0.3.0 / 0.3.1 一致):

```
install OK [m2-gateway 0.3.1] + setup.conf 已注入用户配置,service 已起 + AtLogOn task 已注册。
```

---

## 4. 验证克隆 VM 装机成功

新 VM 启动后,检查以下都成立:

```cmd
:: 1. service 在跑
sc query m2-gateway-svc | findstr /C:"RUNNING"

:: 2. worker scheduled task 已注册
schtasks /query /tn m2-gateway-worker

:: 3. setup.conf 已生成 + agent_privkey 已 DPAPI 包装(dpapi1: 前缀)
type "%ProgramData%\m2-gateway\setup.conf" | findstr agent_privkey_dpapi

:: 4. HKLM\Run\m2-gateway 老条目不存在
reg query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" /v m2-gateway 2>nul && echo WARN || echo OK

:: 5. ws 上行成功(service.log 应该有 "ws connected status=101")
type "%ProgramData%\m2-gateway\Logs\service.log" | findstr "ws connected"

:: 6. 装机 log
type "%ProgramData%\m2-gateway-install.log"
```

或者在中枢 admin web 看 `https://admin.gateway.wechas.com/hosts/<host_id>`:
- `online: true` / `ws_connected: true`
- "Session 状态 / 能力矩阵" Card 显示 `autologon_active`
- `installed_version: 0.3.0`

---

## 5. 常见坑

### 5.1 模板机里跑过 agent 没清干净 → 所有克隆 VM 抢同一 host_id

**症状**:中枢一直在 kick 旧连接,新连接立即被踢,host 状态闪。

**原因**:模板机里 `setup.conf` 没清,所有克隆 VM 用同一个 host_id 注册。

**修复**:回模板机跑 §2.2 cleanup 脚本,重新 sysprep,重新克隆。

### 5.2 模板机 setup.conf 用了 DPAPI 加密的 agent_privkey → 克隆 VM 全部解密失败

**症状**:agent 启动 panic / log `DPAPI unprotect failed`,ws 不连。

**原因**:模板机 DPAPI key 被 sysprep generalize 清掉,新 VM 用新 key 解不开模板机加密的密文。

**修复**:setup.conf 改成**明文** seed(`agent_privkey_seed` 字段),agent 启动自动包装。如果你用 admin API 创建 host 拿到的本来就是明文 seed,**不要**用任何工具预加密。

### 5.3 install-token 已过期

**症状**:`install-template` 返 401 token_expired 类错。

**原因**:模板从准备到第一个克隆 VM 启动可能数小时甚至几天。

**修复**:
- 中枢侧调长 install-token TTL(admin 改配置)
- 或者:SetupComplete.cmd 启动时**先调中枢 `POST /v1/admin/hosts/<id>/install-token`** 刷一次 token,再用新 token 跑 install-template

### 5.4 模板机预存了 0.2.x 的 HKLM\Run 条目

**症状**:新 VM 启动后,user 登录瞬间 0.2.x 兼容路径被触发,跟 0.3.0 service 抢 :23456。

**原因**:`HKLM\Software\Microsoft\Windows\CurrentVersion\Run` sysprep 默认不清。

**修复**:模板机 §2.2 cleanup 已包含 `reg delete` 这条。或者新 VM 启动后 install-template 内部会自动 `remove_legacy_hklm_run()` 清掉,但**首启几秒内可能有 race**,所以模板机最好先清。

### 5.5 模板镜像太老,binary 是 0.2.x

**症状**:克隆 VM 跑的是 0.2.x agent,没 service + worker 架构。

**修复**:**模板机里始终放最新 0.3.0 binary**。每次中枢 release 新版后,更新模板镜像。也可以:
- 模板放 0.3.0 binary,且**不跑** install-template
- 新 VM 启动后 SetupComplete.cmd 拉 binary URL 检查 vs ProgramFiles binary sha,不一致就先下载新 binary 覆盖再跑 install-template

更激进:模板里**只保留一个 stub launcher**(几 KB),首启时下载 0.3.0 binary。这样模板永远不过期。

### 5.6 GuestOps / vCenter Tools 没传 envVariables 给 SetupComplete.cmd

**症状**:SetupComplete.cmd 跑了但 `HOST_ID=` 是空字符串。

**原因**:`SetupComplete.cmd` 是 Windows 自动跑的,**不继承 GuestOps 调用方的 env**。

**修复**:配置注入用**文件**(§3.2 选项 A1),不要靠 env。文件由 GuestOps `transferFile` API 推到 `C:\Windows\Setup\Files\m2gw-config.json`,SetupComplete.cmd 读取。

### 5.7 SetupComplete.cmd reboot 死循环

**症状**:VM 反复 reboot,每次启动都跑一遍 install-template,中枢看到同一个 host_id 反复抢连接。

**原因**:`SetupComplete.cmd` 被 Windows 在**每次** OOBE 阶段或某些特殊条件下重新触发,如果脚本无脑跑 `--install-template` + `shutdown /r` 就成死循环。

**修复**:§3.2 模板顶部的 `§A. reboot 死循环防护` 已实装,通过 **两个标记文件** 判定:
- `%ProgramData%\m2-gateway\install.done` 存在 → 完成态,直接 `exit 0`
- `%ProgramData%\m2-gateway\setup.conf` 存在(install.done 不存在)→ 第二次跑,进 `:verify` 分支不再装机
- 都不存在 → 首次跑,正常装

任何自己 fork 的 SetupComplete.cmd **必须**保留 §A 这段守门逻辑。

### 5.8 AutoLogon 密码错 / 未匹配本地账号

**症状**:reboot 后看不到 user 登录,worker AtLogOn 不触发,host 永远只有 service 在线没 worker。检查 `eventvwr` 的 Winlogon channel 看到 `1530 The password for ... is incorrect`。

**原因**:`autologon.password` 跟实际本地账号密码不匹配,常见情况:
- `unattend.xml` `<UserAccounts>` 里 set 的密码跟 user_data 里 `autologon.password` 不一致
- `Administrator` 账号被组策略禁用(部分企业镜像默认禁 Administrator)
- 密码含 `&` `<` `>` `"` 等 XML/cmd 特殊字符没转义

**修复**:
- 调用方在生成 user_data 时**同步**写两个地方:unattend.xml(specialize 阶段建账号 / set 密码)+ user_data.autologon(SetupComplete 用)。两边用同一个值。
- 密码字符集限制为 `[A-Za-z0-9!#$%^*()_+-=]` 避免 XML/cmd 转义坑。16 字符随机字母数字+特殊字符强度足够。
- 模板机验证:`net user Administrator` 看账号没被 `Account active = No` 禁用。

如果业务允许,推荐用 `unattend.xml` `<AutoLogon>` 节直接做(而不是 SetupComplete §D reg add),走标准 Windows 路径,密码会做 `<PlainText>false</PlainText>` 自动加密。SetupComplete 反而是后备。

### 5.9 cloudbase-init userdata 落地慢于 SetupComplete

**症状**:`%ProgramData%\m2-gateway-install.log` 里 `FATAL: userdata.txt missing after 60s`,但人工进系统看 userdata.txt 是有的。

**原因**:阿里云 / 华为云某些镜像里 cloudbase-init 启动晚 + IMDS 拉取慢,userdata.txt 落地可能要 90+ 秒。§3.2 选项 A4 默认轮询 60 秒不够。

**修复**:把 §3.2 `set /a WAIT=0` 后的判断阈值从 `60` 调到 `180`(3 分钟)。或者让 SetupComplete **依赖** cloudbase-init service 起来后再跑:在 `sc start cloudbase-init` 之后注册一个 `dependencyOnService`(需要厂商支持),或者干脆把 install 逻辑挪到 cloudbase-init 的 `local_scripts_path` 里以 LocalScriptsPlugin 形式跑 —— 这条路径下 cloudbase-init 已经保证 userdata 就绪。

---

## 6. 模板机准备 + sysprep 完整流程

### 6.0 变体 A 主推流程(Vcflux / cloudbase-init 已配 AutoLogon)

```
模板机准备(一次性,每次 0.3.x release 更新模板)
─────────────────────────────────────────────────
1. 准备空 Windows VM(管理员账户、网络通、域 / workgroup 配好)
2. 装 m2-gateway.exe 到 C:\Program Files\m2-gateway\
3. 装 cloudbase-init + 配 cloudbase-init.conf(三套之一,见 IMAGE-BUILD-SPEC.md)
4. 把简化版 SetupComplete.cmd 放到 C:\Windows\Setup\Scripts\(§3.2.1)
5. 把 unattend.xml 放到 C:\Windows\System32\Sysprep\(让 Vcflux/cloudbase-init
   有钩子注入 AutoLogon + 灌密码)
6. (可选)进 Audit Mode 跑 m2-gateway.exe --install-template ... 冒烟测
7. 跑 prep-cleanup.cmd 清干净所有 per-machine 状态(§2.2)
8. 跑 sysprep 前验证清单(§2.3)全部 OK
9. sysprep /generalize /oobe /shutdown /unattend:...\unattend.xml
10. 把关机后的 VM 镜像化(导出 VMDK / VHD / 上传公有云镜像 marketplace)

每台克隆 VM 装机(批量,N 次)
─────────────────────────────────────────────────
11. 中枢侧:调 POST /v1/api/hosts 拿 per-VM creds(§3.1 wechas 段)
12. 调用方:合成 user_data JSON(只需 wechas.* 段,autologon 段由 Vcflux/
    cloudbase-init 通过 unattend.xml 单独管)
13. 用镜像创建 VM,把 user_data 通过云的 IMDS / vSphere guestinfo 注入,
    AutoLogon 密码通过 Vcflux GuestCustomizationSpec / cloudbase-init
    SetUserPasswordPlugin 单独 set
14. VM 启动 → specialize 阶段 Vcflux/cloudbase-init 配 AutoLogon 注册表
            → cloudbase-init 落 userdata.txt
            → SetupComplete.cmd 跑(LocalSystem,无 user):
                  · 读 userdata.txt → install-template → 写 install.done → exit
15. SetupComplete 退出后 Windows 继续 boot → Winlogon AutoLogon 触发
            → user session 起 → AtLogOn task 起 m2-gateway-worker
            → ws 上线 ✓
16. m2aicli / 调用方:轮询 GET /v1/api/hosts/{id},等 online=true(SLA: 60-90s)
```

变体 A 单 pass 状态图:

```
        ┌───────────────────────────┐
        │ VM 首次启动 (OOBE 完成)    │
        │ specialize 阶段已完成:    │
        │  · Vcflux/cloudbase-init  │
        │    写好 AutoLogon 注册表  │
        │  · userdata.txt 落地      │
        └────────────┬──────────────┘
                     ↓
        ┌───────────────────────────┐
        │ SetupComplete.cmd 单跑    │
        │ §A 检查 install.done?     │ ──[存在]──→ exit 0
        │ §B 等 userdata.txt(180s) │
        │ §C 解析 wechas.* 8 字段   │
        │ §D install-template       │
        │ §E 写 install.done + exit │
        └────────────┬──────────────┘
                     ↓ (无 reboot,Windows 继续 boot)
        ┌───────────────────────────┐
        │ Winlogon → AutoLogon       │
        │  → user session 起        │
        │  → AtLogOn task 触发      │
        │  → m2-gateway-worker 起   │
        │  → ws connected ✓         │
        └───────────────────────────┘
```

### 6.1 变体 B 流程 + 双 pass 状态图(自管 AutoLogon 兜底)

变体 B 流程跟 §6.0 大同小异,差别只在 step 14-15:SetupComplete.cmd 跑两次,首次自己配 AutoLogon + reboot,第二次 verify。SLA 90-120 秒(多一次 reboot)。

```
        ┌───────────────────────────┐
        │ VM 首次启动 (OOBE 完成)    │
        └────────────┬──────────────┘
                     ↓
        ┌───────────────────────────┐
        │ SetupComplete.cmd pass#1  │
        │ §A 检查 install.done?     │ ──[存在]──→ exit 0(误触发兜底)
        │ §A 检查 setup.conf?       │ ──[存在]──→ goto :verify
        │ §B 读 userdata.txt        │
        │ §C 解析 JSON              │
        │ §D 配 AutoLogon+防锁屏    │
        │ §E install-template       │
        │ §F del 配置 + shutdown /r │
        └────────────┬──────────────┘
                     ↓ (reboot)
        ┌───────────────────────────┐
        │ VM 第二次启动             │
        │ Winlogon AutoLogon        │
        │  → user session 起        │
        │  → AtLogOn task 触发      │
        │  → m2-gateway-worker 起   │
        │  → ws connected           │
        └────────────┬──────────────┘
                     ↓
        ┌───────────────────────────┐
        │ SetupComplete.cmd pass#2  │
        │ §A setup.conf 存在        │
        │  → goto :verify           │
        │ §G 轮询验证 60s           │
        │ §G 写 install.done        │
        │  → exit 0 (永久完成态)    │
        └───────────────────────────┘
```

---

## 7. 反馈

文档迭代锚点 `docs/api/TEMPLATE-VM-PREP.md`(本仓库)+ 同步 `marketing-site/public/api-docs/`(公开)。

集成方实战中踩到的新坑欢迎反馈,会更新到第 §5 节。
