mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+660
@@ -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
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user