feat: add reusable Parallels Windows test setup (#107881)

* test: route Parallels Windows setup

* test: own Parallels Windows baseline setup

* docs: point Windows proof to Parallels sidecar
This commit is contained in:
Dallin Romney
2026-07-14 23:15:57 -07:00
committed by GitHub
parent 75a48c0033
commit c421a3b2e6
4 changed files with 716 additions and 3 deletions
@@ -1,6 +1,6 @@
---
name: openclaw-parallels-smoke
description: Run, rerun, debug, or interpret OpenClaw Parallels install, onboarding, gateway smoke, and upgrade checks.
description: Prepare, snapshot, run, rerun, debug, or interpret OpenClaw Parallels guest install, onboarding, gateway smoke, and upgrade checks across macOS, Windows, and Linux.
---
# OpenClaw Parallels Smoke
@@ -120,8 +120,27 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
## Windows flow
- This repo owns the general Windows VM lifecycle: remote `prlctl` management, clean-state checks, WSL 2, Git/Node, snapshot creation/restoration, and OpenClaw smoke. Assume Parallels Desktop is installed/activated and a Windows 11 VM has been downloaded, then run:
```bash
pnpm test:parallels:windows:prepare -- inventory
pnpm test:parallels:windows:prepare -- prepare
pnpm test:parallels:windows:prepare -- verify
```
- `prepare` inventories before mutation, requires Parallels Tools and a logged-in desktop session, rejects OpenClaw CLI/app/tray/process state and WSL distros, creates a dated power-off clean-OS snapshot only on an unprepared guest, enables WSL/Virtual Machine Platform, installs the signed Microsoft WSL package, sets WSL 2 as default, installs Git and Node/npm, verifies the Windows hypervisor is active and no reboot is pending, and creates a power-off `pre-openclaw-native-e2e-<date>` snapshot.
- When today's E2E snapshot already exists, `prepare` restores and verifies that snapshot instead of trusting its name. Treat `prepare` as destructive to post-snapshot guest changes, just like an explicit restore.
- Package elevation resolves the exact version and SHA-256 from Microsoft's official WinGet manifests, validates the expected Authenticode publisher, and copies into a freshly ACL-restricted SYSTEM directory before execution. Keep this generic mechanism here; companion-specific package choices stay in the Windows app repo.
- Restore `clean`, `e2e`, an exact name, or an id:
```bash
pnpm test:parallels:windows:prepare -- restore --snapshot e2e
```
- Restoring discards all post-snapshot changes. Inventory first and do not restore while another developer or lane owns the VM. `e2e` selects the newest `pre-openclaw-native-e2e-*`; use an exact snapshot for historical reproduction.
- For native companion work, read `../../../../openclaw-windows-node/.agents/skills/openclaw-proof-validation/PARALLELS.md`. That optional macOS sidecar reuses this controller and adds only .NET, Windows SDK, WebView2, the app checkout, app-layer snapshots, build/tests, and native proof.
- Preferred entrypoint: `pnpm test:parallels:windows`
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
- Use the newest verified `pre-openclaw-native-e2e-*` snapshot and pass its exact name with `--snapshot-hint`.
- Default upgrade coverage on Windows should now include: fresh snapshot -> site installer pinned to the requested stable tag -> `openclaw update --channel dev` on the guest. Keep the older host-tgz upgrade path only when the caller explicitly passes `--target-package-spec`.
- Optional exact npm-tag baseline on Windows: `bash scripts/e2e/parallels-windows-smoke.sh --mode upgrade --target-package-spec openclaw@<tag> --json`. That lane installs the published npm tarball as baseline, then runs `openclaw update --channel dev`.
- Optional forward-fix Windows validation: `bash scripts/e2e/parallels-windows-smoke.sh --mode upgrade --upgrade-from-packed-main --json`. That lane installs the packed current-main npm tgz as baseline, then runs `openclaw update --channel dev`.
@@ -146,6 +165,11 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- If standalone Windows upgrade fails with a gateway token mismatch but `pnpm test:parallels:npm-update` passes, trust the mismatch as a standalone ref-onboard ordering bug first; the npm-update helper does not re-run ref-mode onboard on the same guest.
- Keep onboarding and status output ASCII-clean in logs; fancy punctuation becomes mojibake in current capture paths.
- If you hit an older run with `rc=255` plus an empty `fresh.install-main.log` or `upgrade.install-main.log`, treat it as a likely `prlctl exec` transport drop after guest start-up, not immediate proof of an npm/package failure.
- If WSL features are enabled but `wsl.exe --version` fails, rerun `prepare`; the inbox features and the signed WSL package are separate prerequisites. If the default reverts to 1, set `wsl.exe --set-default-version 2` and rerun `verify`.
- If `winget` detaches or prints nothing over `prlctl`, call it through `cmd.exe /d /s /c`. Avoid remote UAC: download as the desktop user, then let the controller verify the trusted manifest hash/publisher and install from its protected SYSTEM staging directory.
- If snapshot restore reports incompatible saved CPU state, make a power-off replacement snapshot from the known-good disk state and use its exact name. Never bypass snapshot restore for a two-lane fresh+upgrade claim.
- If baseline verification reports OpenClaw state, restore `clean` or remove the product state deliberately; never bless a dirty guest. Check `%APPDATA%` and `%LOCALAPPDATA%` for both stable and dev companion identities plus Inno uninstall registration.
- Long Windows installers can remain quiet while healthy. Inspect `tasklist` and installer/MSI logs before declaring a hang; keep long operations behind a background runner with short host-bounded polling calls.
## Linux flow
+1
View File
@@ -1928,6 +1928,7 @@
"test:parallels:linux": "bash scripts/e2e/parallels-linux-smoke.sh",
"test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh",
"test:parallels:npm-update": "bash scripts/e2e/parallels-npm-update-smoke.sh",
"test:parallels:windows:prepare": "bash scripts/e2e/parallels-windows-prepare.sh",
"test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh",
"test:perf:budget": "node scripts/test-perf-budget.mjs",
"test:perf:changed:bench": "node scripts/bench-test-changed.mjs",
+660
View File
@@ -0,0 +1,660 @@
#!/usr/bin/env bash
set -euo pipefail
VM_NAME="Windows 11"
OPENCLAW_PARALLELS_WINDOWS_API=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TODAY="$(date +%F)"
CLEAN_SNAPSHOT="windows-11-clean-os-${TODAY}"
BASELINE_SNAPSHOT="pre-openclaw-native-e2e-${TODAY}"
GUEST_PROFILE=""
GUEST_PROFILE_PS=""
GUEST_ARCH=""
WINGET_EXPECTED_HASH=""
WINDOWS_REBOOT_REQUIRED=0
WINDOWS_REBOOT_STARTED=0
SNAPSHOT=""
SECURE_STAGE_DIR="C:/ProgramData/OpenClawPrerequisiteInstallers"
COMMAND="help"
if [[ "${OPENCLAW_PARALLELS_WINDOWS_LIBRARY_ONLY:-0}" != "1" ]]; then
COMMAND="${1:-help}"
if [[ $# -gt 0 ]]; then
shift
fi
fi
say() {
printf '[parallels-windows] %s\n' "$*"
}
die() {
printf '[parallels-windows] error: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'EOF'
Usage: scripts/e2e/parallels-windows-prepare.sh <command> [options]
Commands:
inventory List the VM, hardware facts, and snapshots.
prepare Create a clean snapshot, provision the reusable OpenClaw baseline, and snapshot it.
verify Verify base prerequisites and prove the guest contains no OpenClaw product state.
restore Restore a snapshot by exact name or id.
Options:
--vm <name> Parallels VM name. Default: Windows 11
--clean-snapshot <name> Clean-OS snapshot name.
--baseline-snapshot <name> Reusable E2E snapshot name.
--snapshot <name-or-id> Snapshot for restore.
-h, --help Show this help.
prepare assumes Parallels Desktop is installed/activated and a Windows 11 VM already exists.
It installs only reusable OpenClaw prerequisites: WSL platform/package, Git, and Node/npm.
It refuses to create the baseline when the guest contains an OpenClaw CLI, app package, process,
tray state, or WSL distro.
EOF
}
if [[ "${OPENCLAW_PARALLELS_WINDOWS_LIBRARY_ONLY:-0}" != "1" ]]; then
while [[ $# -gt 0 ]]; do
case "$1" in
--vm)
VM_NAME="${2:?missing value for --vm}"
shift 2
;;
--clean-snapshot)
CLEAN_SNAPSHOT="${2:?missing value for --clean-snapshot}"
shift 2
;;
--baseline-snapshot)
BASELINE_SNAPSHOT="${2:?missing value for --baseline-snapshot}"
shift 2
;;
--snapshot)
SNAPSHOT="${2:?missing value for --snapshot}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
fi
require_host_tools() {
local tool
for tool in prlctl curl python3 ruby; do
command -v "$tool" >/dev/null 2>&1 || die "missing host tool: $tool"
done
}
vm_exists() {
prlctl status "$VM_NAME" >/dev/null 2>&1
}
vm_state() {
prlctl status "$VM_NAME" 2>/dev/null | awk '{print $NF}'
}
ensure_vm_running() {
local state
state="$(vm_state)"
case "$state" in
running)
;;
suspended|paused)
say "Resuming VM: $VM_NAME"
run_bounded 120 prlctl resume "$VM_NAME" >/dev/null || die "could not resume VM within 120 seconds"
;;
stopped)
say "Starting VM: $VM_NAME"
run_bounded 120 prlctl start "$VM_NAME" >/dev/null || die "could not start VM within 120 seconds"
;;
*)
die "unsupported VM state for $VM_NAME: ${state:-unknown}"
;;
esac
}
guest_user_cmd() {
prlctl exec "$VM_NAME" --current-user cmd.exe /d /s /c "$1"
}
guest_user_ps() {
prlctl exec "$VM_NAME" --current-user powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$1"
}
guest_system_ps() {
prlctl exec "$VM_NAME" powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$1"
}
run_bounded() {
local timeout_seconds="$1"
shift
python3 - "$timeout_seconds" "$@" <<'PY'
import subprocess
import sys
timeout = float(sys.argv[1])
try:
completed = subprocess.run(sys.argv[2:], timeout=timeout)
except subprocess.TimeoutExpired:
raise SystemExit(124)
raise SystemExit(completed.returncode)
PY
}
run_windows_installer() {
local exit_code=0
run_bounded 1800 "$@" || exit_code=$?
# Windows success-with-reboot codes cross the POSIX boundary modulo 256.
# Accept 1641/3010 only for explicit DISM/installer calls; preserve all other failures.
case "$exit_code" in
0) return 0 ;;
105) WINDOWS_REBOOT_STARTED=1; return 0 ;;
194) WINDOWS_REBOOT_REQUIRED=1; return 0 ;;
124) die "Windows installer transport exceeded 30 minutes" ;;
*) return "$exit_code" ;;
esac
}
finish_installer_reboot() {
if [[ "$WINDOWS_REBOOT_STARTED" == "1" ]]; then
say "Installer initiated a reboot; waiting for the desktop session"
wait_for_guest
elif [[ "$WINDOWS_REBOOT_REQUIRED" == "1" ]]; then
restart_guest
fi
WINDOWS_REBOOT_REQUIRED=0
WINDOWS_REBOOT_STARTED=0
}
powershell_literal_content() {
python3 -c 'import sys; print(sys.argv[1].replace("\x27", "\x27\x27"))' "$1"
}
reset_secure_stage_dir() {
guest_system_ps "
\$stageDir = '${SECURE_STAGE_DIR}'
if (Test-Path -LiteralPath \$stageDir) {
\$item = Get-Item -LiteralPath \$stageDir -Force
if (\$item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Refusing reparse-point installer staging directory' }
Remove-Item -LiteralPath \$stageDir -Recurse -Force
}
New-Item -ItemType Directory -Path \$stageDir | Out-Null
& icacls.exe \$stageDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
if (\$LASTEXITCODE -ne 0) { throw 'Could not protect installer staging directory' }
\$allowed = @('S-1-5-18', 'S-1-5-32-544')
\$unexpected = (Get-Acl -LiteralPath \$stageDir).Access | Where-Object { \$allowed -notcontains \$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value }
if (\$unexpected) { throw 'Installer staging directory contains an unexpected access rule' }
"
}
guest_user_ps_bounded() {
local timeout_seconds="$1"
shift
run_bounded "$timeout_seconds" prlctl exec "$VM_NAME" --current-user powershell.exe \
-NoProfile -ExecutionPolicy Bypass -Command "$1"
}
guest_user_cmd_bounded() {
local timeout_seconds="$1"
shift
run_bounded "$timeout_seconds" prlctl exec "$VM_NAME" --current-user cmd.exe /d /s /c "$1"
}
wait_for_guest() {
local attempt
for attempt in $(seq 1 20); do
if guest_user_cmd_bounded 10 'echo ready' >/dev/null 2>&1; then
return 0
fi
sleep 3
done
die "desktop user did not become available in $VM_NAME within about 260 seconds"
}
restart_guest() {
say "Restarting VM"
run_bounded 180 prlctl restart "$VM_NAME" >/dev/null || die "could not restart VM within 180 seconds"
wait_for_guest
}
set_guest_paths() {
if [[ -z "$GUEST_PROFILE" ]]; then
GUEST_PROFILE="$(guest_user_cmd 'echo %USERPROFILE%' | tr -d '\r' | tail -n 1)"
GUEST_PROFILE="${GUEST_PROFILE//\\//}"
fi
[[ -n "$GUEST_PROFILE" ]] || die "could not detect the current Windows profile"
if [[ -z "$GUEST_ARCH" ]]; then
GUEST_ARCH="$(guest_user_cmd 'echo %PROCESSOR_ARCHITECTURE%' | tr -d '\r' | tail -n 1 | tr '[:upper:]' '[:lower:]')"
[[ "$GUEST_ARCH" == "amd64" ]] && GUEST_ARCH="x64"
fi
GUEST_PROFILE_PS="$(powershell_literal_content "$GUEST_PROFILE")"
}
snapshot_json() {
prlctl snapshot-list "$VM_NAME" --json 2>/dev/null || true
}
snapshot_id() {
local selector="$1"
snapshot_json | python3 -c '
import json, sys
requested = sys.argv[1]
selector = requested.strip("{}")
raw = sys.stdin.read().strip()
data = json.loads(raw) if raw else {}
for snapshot_id, item in data.items():
if snapshot_id.strip("{}") == selector or item.get("name") == requested:
print(snapshot_id)
raise SystemExit(0)
prefixes = {
"clean": "windows-11-clean-os-",
"e2e": "pre-openclaw-native-e2e-",
}
prefix = prefixes.get(requested.lower())
if prefix:
matches = [
(item.get("date", ""), snapshot_id)
for snapshot_id, item in data.items()
if item.get("name", "").startswith(prefix)
]
if matches:
print(max(matches)[1])
raise SystemExit(0)
raise SystemExit(1)
' "$selector"
}
snapshot_exists() {
snapshot_id "$1" >/dev/null 2>&1
}
create_snapshot() {
local name="$1"
local description="$2"
if snapshot_exists "$name"; then
say "Snapshot already exists: $name"
return
fi
local restart_after=0
if [[ "$(vm_state)" != "stopped" ]]; then
# Reusable snapshots must not retain CPU/runtime state, which can become incompatible after
# a Parallels or host update. Preserve the caller's running state after the disk snapshot.
ensure_vm_running
say "Powering off VM before reusable snapshot"
run_bounded 180 prlctl stop "$VM_NAME" --acpi >/dev/null || die "could not power off VM within 180 seconds"
restart_after=1
fi
say "Creating snapshot: $name"
if ! run_bounded 900 prlctl snapshot "$VM_NAME" --name "$name" --description "$description"; then
[[ "$restart_after" == "1" ]] && ensure_vm_running
die "snapshot creation exceeded 15 minutes"
fi
if [[ "$restart_after" == "1" ]]; then
ensure_vm_running
wait_for_guest
fi
}
create_clean_snapshot_if_raw() {
if snapshot_exists "$CLEAN_SNAPSHOT"; then
say "Snapshot already exists: $CLEAN_SNAPSHOT"
return
fi
if guest_user_cmd 'where git.exe >nul 2>nul || where node.exe >nul 2>nul || where dotnet.exe >nul 2>nul || wsl.exe --version >nul 2>nul' >/dev/null 2>&1; then
say "Skipping clean-OS snapshot because reusable prerequisites are already installed"
return
fi
create_snapshot "$CLEAN_SNAPSHOT" "Clean Windows baseline before OpenClaw development prerequisites."
}
restore_snapshot() {
local selector="$1"
local id
id="$(snapshot_id "$selector")" || die "snapshot not found: $selector"
say "Restoring snapshot: $selector ($id)"
run_bounded 900 prlctl snapshot-switch "$VM_NAME" --id "$id" || die "snapshot restore exceeded 15 minutes"
ensure_vm_running
wait_for_guest
}
inventory() {
prlctl list -a
printf '\n'
prlctl list -i "$VM_NAME" | grep -E '^(Name|State|OS|GuestTools| Nested virtualization| cpu | memory )' || true
printf '\nSnapshots:\n'
prlctl snapshot-list "$VM_NAME" --tree
printf '\n'
snapshot_json
}
clean_state_script() {
cat <<'PS'
$dirty = [System.Collections.Generic.List[string]]::new()
if (Get-Command openclaw.cmd -ErrorAction SilentlyContinue) { $dirty.Add('openclaw.cmd on PATH') }
if (Test-Path (Join-Path $env:USERPROFILE '.openclaw')) { $dirty.Add('OpenClaw CLI state directory exists') }
if (Test-Path (Join-Path $env:APPDATA 'OpenClawTray')) { $dirty.Add('OpenClawTray AppData exists') }
if (Test-Path (Join-Path $env:APPDATA 'OpenClawTray-Dev')) { $dirty.Add('OpenClawTray-Dev AppData exists') }
if (Test-Path (Join-Path $env:LOCALAPPDATA 'OpenClawTray')) { $dirty.Add('OpenClaw Companion install/state directory exists') }
if (Test-Path (Join-Path $env:LOCALAPPDATA 'OpenClawTray-Dev')) { $dirty.Add('OpenClaw Companion dev install/state directory exists') }
if (Get-AppxPackage -Name '*OpenClaw*' -ErrorAction SilentlyContinue) { $dirty.Add('OpenClaw app package installed') }
if (Get-Process -Name '*OpenClaw*' -ErrorAction SilentlyContinue) { $dirty.Add('OpenClaw process running') }
$uninstallRoots = @(
'HKCU:/Software/Microsoft/Windows/CurrentVersion/Uninstall/*',
'HKLM:/Software/Microsoft/Windows/CurrentVersion/Uninstall/*',
'HKLM:/Software/WOW6432Node/Microsoft/Windows/CurrentVersion/Uninstall/*'
)
if (Get-ItemProperty $uninstallRoots -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like 'OpenClaw Companion*' }) {
$dirty.Add('OpenClaw Companion uninstall registration exists')
}
$distros = @(wsl.exe -l -q 2>$null | Where-Object { $_.Trim() })
if ($distros.Count -gt 0) { $dirty.Add('WSL distro exists: ' + ($distros -join ', ')) }
if ($dirty.Count -gt 0) {
$dirty | ForEach-Object { Write-Error $_ }
exit 1
}
Write-Host 'clean product state: yes'
PS
}
assert_clean_product_state() {
guest_user_ps "$(clean_state_script)"
}
pending_reboot_script() {
cat <<'PS'
$pending = @(
(Test-Path 'HKLM:/SOFTWARE/Microsoft/Windows/CurrentVersion/Component Based Servicing/RebootPending'),
(Test-Path 'HKLM:/SOFTWARE/Microsoft/Windows/CurrentVersion/WindowsUpdate/Auto Update/RebootRequired'),
[bool](Get-ItemProperty 'HKLM:/SYSTEM/CurrentControlSet/Control/Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
)
if ($pending -contains $true) { Write-Error 'Windows reports a pending reboot'; exit 1 }
Write-Host 'pending reboot: no'
PS
}
assert_no_pending_reboot() {
guest_system_ps "$(pending_reboot_script)"
}
feature_state() {
guest_system_ps "(Get-WindowsOptionalFeature -Online -FeatureName '$1').State" | tr -d '\r' | tail -n 1
}
ensure_wsl_features() {
local changed=0
if [[ "$(feature_state Microsoft-Windows-Subsystem-Linux)" != "Enabled" ]]; then
say "Enabling Microsoft-Windows-Subsystem-Linux"
run_windows_installer prlctl exec "$VM_NAME" dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
changed=1
fi
if [[ "$(feature_state VirtualMachinePlatform)" != "Enabled" ]]; then
say "Enabling VirtualMachinePlatform"
run_windows_installer prlctl exec "$VM_NAME" dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
changed=1
fi
if [[ "$changed" == "1" ]]; then
restart_guest
WINDOWS_REBOOT_REQUIRED=0
WINDOWS_REBOOT_STARTED=0
fi
}
resolve_wsl_msi_url() {
local arch="$1"
curl -fsSL https://api.github.com/repos/microsoft/WSL/releases/latest | python3 -c '
import json, re, sys
arch = sys.argv[1].lower()
data = json.load(sys.stdin)
pattern = re.compile(rf"^wsl\..*\.{re.escape(arch)}\.msi$", re.I)
for asset in data.get("assets", []):
if pattern.match(asset.get("name", "")):
print(asset["browser_download_url"])
raise SystemExit(0)
raise SystemExit("No matching signed WSL MSI asset found")
' "$arch"
}
ensure_wsl_package() {
if guest_user_cmd 'wsl.exe --version' >/dev/null 2>&1; then
guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null
return
fi
local guest_arch asset_arch url signature wsl_msi
guest_arch="$(guest_user_cmd 'echo %PROCESSOR_ARCHITECTURE%' | tr -d '\r' | tail -n 1)"
case "$(printf '%s' "$guest_arch" | tr '[:lower:]' '[:upper:]')" in
ARM64) asset_arch="arm64" ;;
AMD64) asset_arch="x64" ;;
*) die "unsupported Windows architecture for WSL package: $guest_arch" ;;
esac
url="$(resolve_wsl_msi_url "$asset_arch")"
say "Installing signed Microsoft WSL package for $guest_arch"
wsl_msi="${SECURE_STAGE_DIR}/WSL.msi"
reset_secure_stage_dir
run_bounded 720 prlctl exec "$VM_NAME" curl.exe -fL --connect-timeout 20 --max-time 600 "$url" -o "$wsl_msi" || die "WSL download transport exceeded 12 minutes"
signature="$(guest_system_ps "\$signature = Get-AuthenticodeSignature '${wsl_msi}'; if (\$signature.Status -eq 'Valid' -and \$signature.SignerCertificate.Subject -match 'Microsoft Corporation') { 'Valid' } else { \$signature.Status.ToString() + ': ' + \$signature.SignerCertificate.Subject }" | tr -d '\r' | tail -n 1)"
[[ "$signature" == "Valid" ]] || die "WSL MSI signature was not valid Microsoft code: $signature"
run_windows_installer prlctl exec "$VM_NAME" msiexec.exe /i 'C:\ProgramData\OpenClawPrerequisiteInstallers\WSL.msi' /qn /norestart '/L*v' 'C:\Windows\Temp\openclaw-wsl-install.log'
finish_installer_reboot
guest_user_cmd 'wsl.exe --version' >/dev/null || {
guest_system_ps "Get-Content 'C:/Windows/Temp/openclaw-wsl-install.log' -Tail 80" >&2 || true
die "WSL package install did not produce a working wsl.exe"
}
guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null
guest_system_ps "Remove-Item -LiteralPath '${wsl_msi}','C:/Windows/Temp/openclaw-wsl-install.log' -Force -ErrorAction SilentlyContinue"
}
resolve_winget_manifest() {
local package_id="$1"
local package_path versions_json version version_json installer_url
package_path="$(python3 -c 'import sys; package=sys.argv[1]; print(package[0].lower() + "/" + package.replace(".", "/"))' "$package_id")"
versions_json="$(curl -fsSL "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/${package_path}")"
version="$(python3 -c '
import json, re, sys
items = json.load(sys.stdin)
versions = [item["name"] for item in items if item.get("type") == "dir"]
def key(value):
return tuple((0, int(part)) if part.isdigit() else (1, part.lower()) for part in re.split(r"[._+-]", value))
print(max(versions, key=key))
' <<<"$versions_json")"
version_json="$(curl -fsSL "https://api.github.com/repos/microsoft/winget-pkgs/contents/manifests/${package_path}/${version}")"
installer_url="$(python3 -c '
import json, sys
for item in json.load(sys.stdin):
if item.get("name", "").endswith(".installer.yaml"):
print(item["download_url"])
raise SystemExit(0)
raise SystemExit("installer manifest not found")
' <<<"$version_json")"
curl -fsSL "$installer_url" | ruby -ryaml -rdate -e '
manifest = YAML.safe_load(STDIN.read, permitted_classes: [Date], aliases: false)
arch = ARGV[0]
installers = manifest.fetch("Installers")
preferred = installers.select { |item| item["Architecture"] == arch && [nil, "machine"].include?(item["Scope"]) }
fallback_arches = arch == "arm64" ? ["arm64", "neutral", "x64", "x86"] : [arch, "neutral", "x86"]
preferred = installers.select { |item| fallback_arches.include?(item["Architecture"]) } if preferred.empty?
installer = preferred.find { |item| item["Scope"] == "machine" } || preferred.first
abort "matching machine installer not found" unless installer
puts [manifest.fetch("PackageVersion"), installer.fetch("InstallerSha256")].join("|")
' "$GUEST_ARCH"
}
winget_download() {
local package_id="$1"
local manifest_fact version
manifest_fact="$(resolve_winget_manifest "$package_id")"
version="${manifest_fact%%|*}"
WINGET_EXPECTED_HASH="${manifest_fact#*|}"
local download_dir="${GUEST_PROFILE//\//\\}\\Downloads\\OpenClawPrereqs"
guest_user_ps "Remove-Item -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs' -Recurse -Force -ErrorAction SilentlyContinue"
guest_user_cmd "if not exist \"${download_dir}\" mkdir \"${download_dir}\" & winget.exe download --id ${package_id} -e --version \"${version}\" --scope machine --download-directory \"${download_dir}\" --accept-source-agreements --accept-package-agreements --disable-interactivity"
}
downloaded_installer() {
local pattern="$1"
guest_user_ps "Get-ChildItem -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs' -File | Where-Object { \$_.Name -like '${pattern}' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName" | tr -d '\r' | tail -n 1
}
stage_installer() {
local source_path="$1"
local package_name="$2"
local signer_pattern="$3"
local expected_hash="$4"
local source_base64
source_base64="$(python3 -c 'import base64, sys; print(base64.b64encode(sys.argv[1].encode()).decode())' "$source_path")"
reset_secure_stage_dir
# Winget verifies its manifest hash, but the download directory stays user-writable. Recheck the
# expected signer and hash after copying into an ACL-restricted directory before SYSTEM execution.
guest_system_ps "
\$source = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${source_base64}'))
\$stageDir = '${SECURE_STAGE_DIR}'
\$sourceSignature = Get-AuthenticodeSignature -LiteralPath \$source
if (\$sourceSignature.Status -ne 'Valid' -or \$sourceSignature.SignerCertificate.Subject -notmatch '${signer_pattern}') {
throw 'Unexpected or invalid Authenticode signer for ${package_name}: ' + \$sourceSignature.SignerCertificate.Subject
}
\$sourceHash = (Get-FileHash -LiteralPath \$source -Algorithm SHA256).Hash
if (\$sourceHash -ne '${expected_hash}') { throw 'WinGet manifest hash mismatch for ${package_name}' }
\$destination = Join-Path \$stageDir ('${package_name}' + [IO.Path]::GetExtension(\$source))
Copy-Item -LiteralPath \$source -Destination \$destination -Force
\$destinationHash = (Get-FileHash -LiteralPath \$destination -Algorithm SHA256).Hash
\$destinationSignature = Get-AuthenticodeSignature -LiteralPath \$destination
if (\$sourceHash -ne \$destinationHash -or \$destinationSignature.Status -ne 'Valid' -or \$destinationSignature.SignerCertificate.Subject -notmatch '${signer_pattern}') {
Remove-Item -LiteralPath \$destination -Force -ErrorAction SilentlyContinue
throw 'Staged installer verification failed for ${package_name}'
}
Write-Output \$destination
" | tr -d '\r' | tail -n 1
}
wait_for_check() {
local label="$1"
local command="$2"
local attempt
for attempt in $(seq 1 120); do
if guest_user_cmd "$command" >/dev/null 2>&1; then
say "$label ready"
return
fi
sleep 3
done
die "$label did not become ready within 360 seconds"
}
ensure_git() {
if guest_user_cmd 'where git.exe' >/dev/null 2>&1; then
return
fi
winget_download Git.Git
local installer
installer="$(downloaded_installer 'Git_*_inno_*.exe')"
[[ -n "$installer" ]] || die "winget did not download the Git installer"
installer="$(stage_installer "$installer" Git 'Johannes Schindelin|Open Source Developer|Git for Windows' "$WINGET_EXPECTED_HASH")"
say "Installing Git"
run_windows_installer prlctl exec "$VM_NAME" "$installer" /VERYSILENT /NORESTART /SP- /ALLUSERS
finish_installer_reboot
wait_for_check Git 'where git.exe'
}
ensure_node() {
if guest_user_cmd 'where node.exe' >/dev/null 2>&1; then
return
fi
winget_download OpenJS.NodeJS.LTS
local installer
installer="$(downloaded_installer 'Node.js*Machine*.msi')"
[[ -n "$installer" ]] || die "winget did not download the Node.js installer"
installer="$(stage_installer "$installer" NodeJS 'OpenJS Foundation' "$WINGET_EXPECTED_HASH")"
say "Installing Node.js LTS"
run_windows_installer prlctl exec "$VM_NAME" msiexec.exe /i "$installer" /qn /norestart
finish_installer_reboot
wait_for_check Node.js 'where node.exe'
}
cleanup_installers() {
guest_user_ps "if (Test-Path -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs') { Remove-Item -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs' -Recurse -Force }"
guest_system_ps "if (Test-Path -LiteralPath '${SECURE_STAGE_DIR}') { Remove-Item -LiteralPath '${SECURE_STAGE_DIR}' -Recurse -Force }"
}
verify_baseline() {
set_guest_paths
guest_user_cmd 'git --version && node --version && npm --version'
guest_user_cmd 'wsl.exe --version' || die "MSI-backed WSL is unavailable"
guest_user_cmd 'wsl.exe --status' || die "WSL status failed"
guest_system_ps "if (-not (Get-CimInstance Win32_ComputerSystem).HypervisorPresent) { throw 'Windows hypervisor is not active; WSL 2 workloads cannot start' }"
local wsl_default
wsl_default="$(guest_user_ps "Get-ItemPropertyValue 'HKCU:/Software/Microsoft/Windows/CurrentVersion/Lxss' -Name DefaultVersion -ErrorAction SilentlyContinue" | tr -d '\r' | tail -n 1)"
[[ "$wsl_default" == "2" ]] || die "WSL default version is ${wsl_default:-unset}, expected 2"
assert_clean_product_state
assert_no_pending_reboot
}
prepare() {
ensure_vm_running
wait_for_guest
inventory
set_guest_paths
if snapshot_exists "$BASELINE_SNAPSHOT"; then
say "Restoring and verifying existing reusable baseline: $BASELINE_SNAPSHOT"
restore_snapshot "$BASELINE_SNAPSHOT"
verify_baseline
say "Reusable baseline verified: $BASELINE_SNAPSHOT"
return
fi
assert_clean_product_state
create_clean_snapshot_if_raw
ensure_wsl_features
ensure_wsl_package
ensure_git
ensure_node
cleanup_installers
restart_guest
guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null
verify_baseline
create_snapshot "$BASELINE_SNAPSHOT" "E2E-ready OpenClaw Windows baseline with WSL 2, Git, Node/npm, and no OpenClaw product state."
say "Baseline ready: $BASELINE_SNAPSHOT"
}
if [[ "${OPENCLAW_PARALLELS_WINDOWS_LIBRARY_ONLY:-0}" == "1" ]]; then
return 0 2>/dev/null || exit 0
fi
case "$COMMAND" in
help|-h|--help)
usage
exit 0
;;
inventory|prepare|verify|restore)
;;
*)
usage >&2
die "unknown command: $COMMAND"
;;
esac
require_host_tools
vm_exists || die "Parallels VM not found: $VM_NAME"
case "$COMMAND" in
inventory)
inventory
;;
prepare)
prepare
;;
verify)
ensure_vm_running
wait_for_guest
verify_baseline
;;
restore)
[[ -n "$SNAPSHOT" ]] || die "restore requires --snapshot <name-or-id>"
restore_snapshot "$SNAPSHOT"
;;
esac
+29 -1
View File
@@ -1,5 +1,5 @@
// Parallels Smoke Model tests cover parallels smoke model script behavior.
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { EventEmitter } from "node:events";
import {
chmodSync,
@@ -73,6 +73,7 @@ const WRAPPERS = {
npmUpdate: "scripts/e2e/parallels-npm-update-smoke.sh",
windows: "scripts/e2e/parallels-windows-smoke.sh",
};
const WINDOWS_PREPARE_WRAPPER = "scripts/e2e/parallels-windows-prepare.sh";
const TS_PATHS = {
agentWorkspace: "scripts/e2e/parallels/agent-workspace.ts",
@@ -272,6 +273,33 @@ describe("Parallels smoke model selection", () => {
}
});
it("owns the reusable Windows VM and OpenClaw baseline lifecycle", () => {
const controller = readFileSync(WINDOWS_PREPARE_WRAPPER, "utf8");
expect(controller).toContain("ensure_wsl_features");
expect(controller).toContain("resolve_winget_manifest");
expect(controller).toContain("pre-openclaw-native-e2e-");
expect(controller).toContain('prlctl stop "$VM_NAME" --acpi');
expect(controller).toContain("HypervisorPresent");
expect(controller).toContain("git --version && node --version && npm --version");
expect(controller).toContain("OPENCLAW_PARALLELS_WINDOWS_LIBRARY_ONLY");
expect(controller).not.toContain("openclaw-windows-node");
});
it("preserves caller arguments when loaded as the Windows controller library", () => {
const result = spawnSync(
"bash",
[
"-c",
'set -- run-tests --app-option; OPENCLAW_PARALLELS_WINDOWS_LIBRARY_ONLY=1 source "$1"; printf "%s\\n" "$*"',
"bash",
WINDOWS_PREPARE_WRAPPER,
],
{ encoding: "utf8" },
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toBe("run-tests --app-option");
});
it("accepts leading package-manager separators and still honors later terminators", () => {
expect(parseLinuxSmokeArgs(["--", "--mode", "upgrade"]).mode).toBe("upgrade");
expect(parseLinuxSmokeArgs(["--mode", "fresh", "--", "--mode", "upgrade"]).mode).toBe("fresh");