#!/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 [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 Parallels VM name. Default: Windows 11 --clean-snapshot Clean-OS snapshot name. --baseline-snapshot Reusable E2E snapshot name. --snapshot 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 } fetch_host_metadata() { # Keep host metadata lookups bounded like the guest download paths below. local attempt output for attempt in 1 2 3; do if output="$(curl -fsSL --connect-timeout 10 --max-time 120 "$@")"; then printf '%s' "$output" return 0 fi output="" if [[ "$attempt" != "3" ]]; then sleep "$attempt" fi done return 1 } 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 argv_base64 argv_base64="$( python3 -c 'import base64, json, subprocess, sys; print(base64.b64encode(json.dumps({"executable": sys.argv[1], "argumentLine": subprocess.list2cmdline(sys.argv[2:])}).encode()).decode())' "$@" )" local guest_script guest_script=" \$invocation = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${argv_base64}')) | ConvertFrom-Json \$executable = [string]\$invocation.executable \$process = Start-Process -FilePath \$executable -ArgumentList ([string]\$invocation.argumentLine) -NoNewWindow -Wait -PassThru \$installerExitCode = \$process.ExitCode switch (\$installerExitCode) { 0 { exit 0 } 1641 { exit 105 } 3010 { exit 194 } default { Write-Error ('Windows installer failed with exit code ' + \$installerExitCode + ': ' + \$executable) exit 1 } } " local exit_code=0 run_bounded 1800 prlctl exec "$VM_NAME" powershell.exe \ -NoProfile -ExecutionPolicy Bypass -Command "$guest_script" || exit_code=$? # Parallels collapses native Windows exit codes above 255 to 255. Normalize the two # installer success-with-reboot codes in the guest so every other failure stays fatal. 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 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 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" fetch_host_metadata 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" } get_wsl_default_version() { guest_user_ps "Get-ItemPropertyValue 'HKCU:/Software/Microsoft/Windows/CurrentVersion/Lxss' -Name DefaultVersion -ErrorAction SilentlyContinue" | tr -d '\r' | tail -n 1 } ensure_wsl_default_version() { local attempt for attempt in $(seq 1 40); do guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null 2>&1 || true if [[ "$(get_wsl_default_version)" == "2" ]]; then say "WSL default version ready" return fi sleep 3 done die "WSL default version did not become 2 within 120 seconds" } ensure_wsl_package() { if guest_user_cmd 'wsl.exe --version' >/dev/null 2>&1; then ensure_wsl_default_version 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 msiexec.exe /i 'C:\ProgramData\OpenClawPrerequisiteInstallers\WSL.msi' /qn /norestart '/L*v' 'C:\Windows\Temp\openclaw-wsl-install.log' finish_installer_reboot # Parallels can return from the MSI client before the Windows Installer service # finishes publishing wsl.exe. Match the bounded readiness check used by Git and Node. wait_for_check WSL 'wsl.exe --version' ensure_wsl_default_version 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="$(fetch_host_metadata "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="$(fetch_host_metadata "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")" fetch_host_metadata "$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 "if (Test-Path -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs') { Remove-Item -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs' -Recurse -Force }" guest_user_cmd "if not exist \"${download_dir}\" mkdir \"${download_dir}\" & winget.exe download --source winget --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 "$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 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="$(get_wsl_default_version)" [[ "$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 ensure_wsl_default_version 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 " restore_snapshot "$SNAPSHOT" ;; esac