From bc5471d0dc99c6fa845eed88e787c0761494a3ac Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 14 Jul 2026 17:56:45 -0700 Subject: [PATCH 1/3] improve: automate Parallels Windows baselines --- .../openclaw-parallels-windows/SKILL.md | 166 ++++ .../agents/openai.yaml | 4 + AGENTS.md | 7 + scripts/parallels-run-validation.ps1 | 36 + scripts/parallels-windows-vm.sh | 755 ++++++++++++++++++ 5 files changed, 968 insertions(+) create mode 100644 .agents/skills/openclaw-parallels-windows/SKILL.md create mode 100644 .agents/skills/openclaw-parallels-windows/agents/openai.yaml create mode 100644 scripts/parallels-run-validation.ps1 create mode 100755 scripts/parallels-windows-vm.sh diff --git a/.agents/skills/openclaw-parallels-windows/SKILL.md b/.agents/skills/openclaw-parallels-windows/SKILL.md new file mode 100644 index 000000000..9fbad705c --- /dev/null +++ b/.agents/skills/openclaw-parallels-windows/SKILL.md @@ -0,0 +1,166 @@ +--- +name: openclaw-parallels-windows +description: Prepare, inspect, restore, and test the OpenClaw Windows companion and Windows node in a Parallels Windows 11 VM controlled from macOS. Use for first-time VM baseline provisioning, snapshot lifecycle, remote prlctl management, WSL/dev-tool setup, required Windows validation, or Parallels/WSL/winget transport troubleshooting. +--- + +# OpenClaw Parallels Windows + +Use `scripts/parallels-windows-vm.sh` as the canonical host-side controller. Assume Parallels +Desktop is installed and activated and the user has downloaded/created a Windows 11 VM. Do not +reimplement provisioning with ad hoc `prlctl` commands unless debugging the controller itself. + +## Quick start + +Run from the macOS checkout of `openclaw-windows-node`: + +```bash +./scripts/parallels-windows-vm.sh inventory +./scripts/parallels-windows-vm.sh prepare +./scripts/parallels-windows-vm.sh verify +``` + +`prepare` is idempotent. It: + +1. Inventories the named VM and existing snapshots. +2. Requires a logged-in desktop user available through `prlctl exec --current-user`. +3. Refuses a reusable baseline if OpenClaw CLI/app/process/tray state or any WSL distro exists. +4. Creates a dated clean-OS snapshot only when no reusable prerequisites are installed. +5. Enables the WSL and Virtual Machine Platform features and reboots when needed. +6. Installs the latest signed Microsoft WSL MSI for the guest architecture and sets WSL 2 as default. +7. Resolves the current package version and SHA-256 from Microsoft's official WinGet manifest + repository, downloads that exact version with `winget`, then verifies both the trusted manifest + hash and expected Authenticode publisher while staging into a freshly ACL-restricted directory. + Only then does it run system-context silent installers for Git, Node/npm, .NET 10, Windows SDK + 10.0.26100, and WebView2. This avoids hidden UAC prompts without trusting a user-writable file. +8. Clones this repository, runs `scripts/setup-dev.ps1 -CheckOnly`, clears installers, reboots, + verifies clean state/no pending reboot, and creates the reusable power-on snapshot. + +Override names or paths when needed: + +```bash +./scripts/parallels-windows-vm.sh prepare \ + --vm "Windows 11" \ + --clean-snapshot "windows-11-clean-os-" \ + --baseline-snapshot "pre-openclaw-native-e2e-" +``` + +## Snapshot lifecycle + +Always inventory before mutation: + +```bash +./scripts/parallels-windows-vm.sh inventory +prlctl snapshot-list "Windows 11" --json +``` + +Restore by exact name or id: + +```bash +./scripts/parallels-windows-vm.sh restore --snapshot e2e +# Exact names and ids are also accepted: +./scripts/parallels-windows-vm.sh restore --snapshot "pre-openclaw-native-e2e-" +``` + +`e2e` selects the newest `pre-openclaw-native-e2e-*` snapshot by snapshot date; `clean` selects the +newest `windows-11-clean-os-*`. Use an exact name or id when reproducing against an older baseline. + +Restoring discards all post-snapshot guest changes. Do not restore while another Windows lane or +developer session owns the VM. Preserve the power-on snapshot's logged-in session by switching +normally; do not pass `--skip-resume` at test entry. Stop the VM after ad hoc or credentialed runs +when no follow-up work needs the desktop session. + +Create additional snapshots only when no suitable baseline exists or the user explicitly asks. +Use a new dated name; never overwrite or silently delete a known-good baseline. + +## Run Windows app validation + +The default run restores the newest `e2e` baseline. Pass an exact snapshot to pin an older date: + +```bash +./scripts/parallels-windows-vm.sh run-tests +./scripts/parallels-windows-vm.sh run-tests \ + --snapshot "pre-openclaw-native-e2e-" +``` + +To validate a pushed branch or SHA, fetch it into the guest and detach at `FETCH_HEAD`: + +```bash +./scripts/parallels-windows-vm.sh run-tests \ + --snapshot "pre-openclaw-native-e2e-" \ + --ref "" +``` + +For an unpushed local change, restore the baseline first, copy or fetch the reviewed files into an +isolated guest checkout, then preserve that checkout for the run: + +```bash +./scripts/parallels-windows-vm.sh restore \ + --snapshot "pre-openclaw-native-e2e-" +# Sync the local ref into the guest checkout. +./scripts/parallels-windows-vm.sh run-tests --no-restore +``` + +`--no-restore` is an explicit escape hatch for a checkout the developer just synchronized. Never +use it for a clean-snapshot proof or when the guest's ownership/state is unknown. + +The controller copies `scripts/parallels-run-validation.ps1` from the host into the guest temp +directory, so it works even when the restored snapshot predates the helper. It launches the helper +in the desktop session, polls a done file through host-bounded `prlctl exec` calls, emits progress, +hard-caps the run at 90 minutes, stops the worker on timeout, and runs the repository-required +build, Shared tests, and Tray tests through `setup-dev.ps1 -RunValidation`. The guest log path is +printed on completion. + +Use `.agents/skills/openclaw-proof-validation/SKILL.md` after the required suites when the change +also needs UI screenshots, local MCP/`winnode`, Gateway, accessibility, or MXC proof. + +## Run OpenClaw core smoke + +From a sibling `openclaw` checkout, use its wrapper so the same baseline owner remains canonical: + +```bash +pnpm test:parallels:windows:prepare -- inventory +pnpm test:parallels:windows:prepare -- verify +gtimeout --foreground 90m pnpm test:parallels:windows -- \ + --snapshot-hint "pre-openclaw-native-e2e-" \ + --json +``` + +Install GNU coreutils on macOS when neither `timeout` nor `gtimeout` exists. Keep the host hard cap; +the lane also has phase timeouts, but they do not protect a stalled `prlctl` process. + +## Remote management rules + +- Put the VM name before `--current-user`: `prlctl exec "$VM" --current-user ...`. +- Use `--current-user` for OpenClaw, `winget`, Git, app launch, tests, and user state. Plain + `prlctl exec` runs as `NT AUTHORITY\\SYSTEM`; reserve it for DISM and machine installers. +- Wrap multi-argument Windows commands with `cmd.exe /d /s /c ''`; direct App Execution + Alias calls can lose arguments or detach without useful output. +- Use explicit `.cmd` shims for `npm`, `pnpm`, and `openclaw` when command resolution is ambiguous. +- Keep long guest work behind a background runner plus log/done files. One long-lived encoded + PowerShell transport can hang after the guest process already completed. +- Inspect `prlctl status`, then resume/start a suspended or stopped guest before retrying `rc=255`. +- Do not print environment dumps or credentials. Inject only the provider/channel secret required + for a live lane and restore the clean snapshot afterward. + +## Troubleshooting + +- **`wsl.exe` says WSL is not installed after features are enabled:** the Store/MSI package is + missing. Rerun `prepare`; it resolves the current Microsoft/WSL release, validates the + Authenticode signature, and installs the matching ARM64/x64 MSI. +- **WSL default returns to 1 after install/reboot:** run `wsl.exe --set-default-version 2`; `prepare` + and `verify` enforce this before snapshot creation. +- **WSL2 cannot start:** inspect Parallels VM CPU settings and Windows virtualization errors. On + supported hosts, enable nested virtualization, reboot, and rerun the smallest WSL preflight. +- **`winget` prints nothing or waits forever:** invoke it through `cmd.exe /d /s /c`. For packages + needing elevation, use the controller's download-as-user/install-as-system flow instead of UAC. +- **Snapshot restore reports incompatible saved CPU state:** create a power-off replacement + snapshot from the known-good disk state, then use that exact name. The test harness starts + restored power-off snapshots automatically. +- **Snapshot list is empty:** run `prepare`; do not bypass restore with + `OPENCLAW_PARALLELS_SKIP_SNAPSHOT_RESTORE=1` for a two-lane fresh+upgrade claim. +- **Baseline verification finds OpenClaw state:** restore the clean-OS snapshot or remove the + product state deliberately. Do not bless a dirty guest as the reusable baseline. +- **Windows installer appears idle:** inspect `tasklist` and the installer/MSI log in + `C:\Windows\Temp`; Windows SDK and npm installs can remain quiet while healthy. +- **`prlctl exec --current-user` cannot authenticate:** confirm the VM has a logged-in desktop user + and Parallels Tools is installed. A stopped login-screen VM is not a usable power-on baseline. diff --git a/.agents/skills/openclaw-parallels-windows/agents/openai.yaml b/.agents/skills/openclaw-parallels-windows/agents/openai.yaml new file mode 100644 index 000000000..a7146079f --- /dev/null +++ b/.agents/skills/openclaw-parallels-windows/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "OpenClaw Parallels Windows" + short_description: "Prepare and test OpenClaw Windows VMs" + default_prompt: "Use $openclaw-parallels-windows to prepare a Parallels Windows VM, manage snapshots, and run OpenClaw Windows validation." diff --git a/AGENTS.md b/AGENTS.md index 40a4d0a97..b856e4708 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,13 @@ Notes: Run the required validation above for every code change, then add the targeted path that matches the touched subsystem. +### Parallels Windows VM setup and proof + +Use `.agents/skills/openclaw-parallels-windows/SKILL.md` to prepare or restore the macOS-hosted +Parallels Windows baseline, manage snapshots, run the required suites inside the guest, or debug +`prlctl`, WSL, winget, and guest transport failures. Keep the provisioning implementation in +`scripts/parallels-windows-vm.sh`; do not duplicate it in ad hoc setup notes. + ### MXC / `system.run` / Windows node command execution When changing MXC sandboxing, `system.run`, exec approvals, Windows node command execution, gateway setup/connect E2E behavior, or files under `src\OpenClaw.Shared\Mxc`, run: diff --git a/scripts/parallels-run-validation.ps1 b/scripts/parallels-run-validation.ps1 new file mode 100644 index 000000000..f7ae37e4f --- /dev/null +++ b/scripts/parallels-run-validation.ps1 @@ -0,0 +1,36 @@ +param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [Parameter(Mandatory = $true)] + [string]$LogPath, + [Parameter(Mandatory = $true)] + [string]$DonePath, + [Parameter(Mandatory = $true)] + [string]$PidPath +) + +$ErrorActionPreference = "Stop" +$exitCode = 1 + +try { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null + Remove-Item -LiteralPath $LogPath, $DonePath, $PidPath -Force -ErrorAction SilentlyContinue + Set-Content -LiteralPath $PidPath -Value $PID -Encoding ASCII + Set-Location $RepoRoot + $env:OPENCLAW_REPO_ROOT = $RepoRoot + + & (Join-Path $RepoRoot "scripts\setup-dev.ps1") -RunValidation *> $LogPath + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Required validation failed with exit code $exitCode." + } +} catch { + $_ | Out-String | Add-Content -LiteralPath $LogPath -Encoding UTF8 + if ($exitCode -eq 0) { + $exitCode = 1 + } +} finally { + Set-Content -LiteralPath $DonePath -Value $exitCode -Encoding ASCII +} + +exit $exitCode diff --git a/scripts/parallels-windows-vm.sh b/scripts/parallels-windows-vm.sh new file mode 100755 index 000000000..4328e5206 --- /dev/null +++ b/scripts/parallels-windows-vm.sh @@ -0,0 +1,755 @@ +#!/usr/bin/env bash +set -euo pipefail + +VM_NAME="Windows 11" +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_REPO="" +GUEST_PROFILE_PS="" +GUEST_REPO_PS="" +GUEST_ARCH="" +WINGET_EXPECTED_HASH="" +REPO_URL="https://github.com/openclaw/openclaw-windows-node.git" +REPO_REF="" +SNAPSHOT="" +SKIP_RESTORE=0 +SECURE_STAGE_DIR="C:/ProgramData/OpenClawPrerequisiteInstallers" +COMMAND="${1:-help}" +if [[ $# -gt 0 ]]; then + shift +fi + +say() { + printf '[parallels-windows] %s\n' "$*" +} + +die() { + printf '[parallels-windows] error: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: scripts/parallels-windows-vm.sh [options] + +Commands: + inventory List the VM, hardware facts, and snapshots. + prepare Create a clean snapshot, provision the reusable baseline, and snapshot it. + verify Verify prerequisites and prove the guest contains no OpenClaw product state. + restore Restore a snapshot by exact name or id. + run-tests Restore the baseline, optionally check out a ref, and run required validation. + +Options: + --vm Parallels VM name. Default: Windows 11 + --clean-snapshot Clean-OS snapshot name. + --baseline-snapshot Reusable E2E snapshot name. + --snapshot Snapshot for restore/run-tests. + --guest-repo Guest checkout path; defaults under the detected user's profile. + --repo-url Windows app repository URL. + --ref Fetch and detach at this ref before run-tests. + --no-restore Run tests in the current guest checkout without restoring first. + -h, --help Show this help. + +prepare assumes Parallels Desktop is installed/activated and a Windows 11 VM already exists. +It installs only reusable prerequisites: WSL platform/package, Git, Node/npm, .NET 10, +Windows SDK 10.0.26100, WebView2, and a clean developer checkout. It refuses to create the +baseline when the guest contains an OpenClaw CLI, app package, process, tray state, or WSL distro. + +run-tests is destructive to post-snapshot guest changes because it restores the selected snapshot. +Use --no-restore only after deliberately syncing an unpushed local ref into the guest. +EOF +} + +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 + ;; + --guest-repo) + GUEST_REPO="${2:?missing value for --guest-repo}" + shift 2 + ;; + --repo-url) + REPO_URL="${2:?missing value for --repo-url}" + shift 2 + ;; + --ref) + REPO_REF="${2:?missing value for --ref}" + shift 2 + ;; + --no-restore) + SKIP_RESTORE=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac +done + +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 + "$@" || 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|105|194) return 0 ;; + *) return "$exit_code" ;; + esac +} + +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_REPO" ]]; then + GUEST_REPO="${GUEST_PROFILE}/github/openclaw-windows-node" + fi + 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")" + GUEST_REPO_PS="$(powershell_literal_content "$GUEST_REPO")" +} + +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 + say "Creating snapshot: $name" + run_bounded 900 prlctl snapshot "$VM_NAME" --name "$name" --description "$description" || die "snapshot creation exceeded 15 minutes" +} + +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: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 + 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 + prlctl exec "$VM_NAME" curl.exe -fL --connect-timeout 20 --max-time 600 "$url" -o "$wsl_msi" + 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' + 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 + 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 + wait_for_check Node.js 'where node.exe' +} + +ensure_dotnet() { + if guest_user_cmd 'dotnet.exe --list-sdks | findstr /B 10.' >/dev/null 2>&1; then + return + fi + winget_download Microsoft.DotNet.SDK.10 + local installer + installer="$(downloaded_installer 'Microsoft .NET SDK 10.0*.exe')" + [[ -n "$installer" ]] || die "winget did not download the .NET SDK installer" + installer="$(stage_installer "$installer" DotNetSDK 'Microsoft Corporation' "$WINGET_EXPECTED_HASH")" + say "Installing .NET 10 SDK" + run_windows_installer prlctl exec "$VM_NAME" "$installer" /install /quiet /norestart + wait_for_check '.NET 10 SDK' 'dotnet.exe --list-sdks | findstr /B 10.' +} + +ensure_windows_sdk() { + if guest_user_ps "Test-Path 'C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0/um'" | grep -q True; then + return + fi + winget_download Microsoft.WindowsSDK.10.0.26100 + local installer + installer="$(downloaded_installer 'Windows Software Development Kit*.exe')" + [[ -n "$installer" ]] || die "winget did not download the Windows SDK installer" + installer="$(stage_installer "$installer" WindowsSDK 'Microsoft Corporation' "$WINGET_EXPECTED_HASH")" + say "Installing Windows SDK 10.0.26100" + run_windows_installer prlctl exec "$VM_NAME" "$installer" /quiet /norestart /ceip off + wait_for_check 'Windows SDK' 'if exist "C:\Program Files (x86)\Windows Kits\10\Include\10.0.26100.0\um" exit /b 0 else exit /b 1' +} + +ensure_webview2() { + if guest_system_ps "Test-Path 'HKLM:/SOFTWARE/WOW6432Node/Microsoft/EdgeUpdate/Clients/{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'" | grep -q True; then + return + fi + winget_download Microsoft.EdgeWebView2Runtime + local installer + installer="$(downloaded_installer '*WebView2*.exe')" + [[ -n "$installer" ]] || die "winget did not download the WebView2 installer" + installer="$(stage_installer "$installer" WebView2 'Microsoft Corporation' "$WINGET_EXPECTED_HASH")" + say "Installing WebView2 Runtime" + run_windows_installer prlctl exec "$VM_NAME" "$installer" /silent /install + wait_for_check WebView2 'reg.exe query "HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" /v pv' +} + +cleanup_installers() { + guest_user_ps "Remove-Item -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs' -Recurse -Force -ErrorAction SilentlyContinue" + guest_system_ps "Remove-Item -LiteralPath '${SECURE_STAGE_DIR}' -Recurse -Force -ErrorAction SilentlyContinue" +} + +ensure_guest_checkout() { + if ! guest_user_ps "Test-Path '${GUEST_REPO_PS}/.git'" | grep -q True; then + say "Cloning Windows app repository" + guest_user_cmd "git.exe clone ${REPO_URL} \"${GUEST_REPO//\//\\}\"" + fi + say "Running repo-native prerequisite check" + guest_user_ps "Set-Location '${GUEST_REPO_PS}'; & './scripts/setup-dev.ps1' -CheckOnly" +} + +verify_baseline() { + set_guest_paths + guest_user_cmd 'git --version & node --version & npm --version & dotnet --list-sdks' + guest_user_cmd 'wsl.exe --version' || die "MSI-backed WSL is unavailable" + guest_user_cmd 'wsl.exe --status' || die "WSL status failed" + 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" + guest_user_ps "Get-ChildItem -Name 'C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0'" + assert_clean_product_state + assert_no_pending_reboot + if guest_user_ps "Test-Path '${GUEST_REPO_PS}/scripts/setup-dev.ps1'" | grep -q True; then + guest_user_ps "Set-Location '${GUEST_REPO_PS}'; & './scripts/setup-dev.ps1' -CheckOnly" + else + die "guest checkout missing: $GUEST_REPO" + fi +} + +prepare() { + ensure_vm_running + wait_for_guest + inventory + set_guest_paths + if snapshot_exists "$BASELINE_SNAPSHOT"; then + say "Reusable baseline already exists: $BASELINE_SNAPSHOT" + return + fi + assert_clean_product_state + create_clean_snapshot_if_raw + ensure_wsl_features + ensure_wsl_package + ensure_git + ensure_node + ensure_dotnet + ensure_windows_sdk + ensure_webview2 + cleanup_installers + ensure_guest_checkout + restart_guest + guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null + verify_baseline + create_snapshot "$BASELINE_SNAPSHOT" "E2E-ready Windows baseline with WSL platform, Git, Node/npm, .NET 10, Windows SDK, WebView2, and no OpenClaw product state." + say "Baseline ready: $BASELINE_SNAPSHOT" +} + +run_tests() { + local selector="${SNAPSHOT:-e2e}" + if [[ "$SKIP_RESTORE" == "0" ]]; then + restore_snapshot "$selector" + else + say "Skipping snapshot restore; validating the current guest checkout" + ensure_vm_running + wait_for_guest + fi + set_guest_paths + if [[ -n "$REPO_REF" ]]; then + local fetch_ref="$REPO_REF" + fetch_ref="${fetch_ref#origin/}" + say "Fetching guest ref: $REPO_REF" + guest_user_cmd_bounded 600 "cd /d \"${GUEST_REPO//\//\\}\" && git fetch --tags origin \"${fetch_ref}\" && git checkout --detach FETCH_HEAD" || die "guest ref fetch exceeded 10 minutes" + fi + local host_helper="${SCRIPT_DIR}/parallels-run-validation.ps1" + [[ -f "$host_helper" ]] || die "validation helper missing beside controller: $host_helper" + local helper_base64 + helper_base64="$(python3 -c 'import base64, pathlib, sys; print(base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode())' "$host_helper")" + local run_id helper log_path done_path pid_path helper_ps log_path_ps done_path_ps pid_path_ps start now last_line exit_code + run_id="$(date +%Y%m%d-%H%M%S)" + helper="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-parallels-validation-${run_id}.ps1" + log_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-parallels-validation-${run_id}.log" + done_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-parallels-validation-${run_id}.done" + pid_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-parallels-validation-${run_id}.pid" + helper_ps="$(powershell_literal_content "$helper")" + log_path_ps="$(powershell_literal_content "$log_path")" + done_path_ps="$(powershell_literal_content "$done_path")" + pid_path_ps="$(powershell_literal_content "$pid_path")" + guest_user_ps_bounded 30 "[IO.File]::WriteAllBytes('${helper_ps}', [Convert]::FromBase64String('${helper_base64}'))" || die "could not materialize the validation helper in the guest" + say "Launching required Windows validation" + guest_user_ps_bounded 30 "Remove-Item -LiteralPath '${log_path_ps}','${done_path_ps}','${pid_path_ps}' -Force -ErrorAction SilentlyContinue; Start-Process powershell.exe -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"${helper_ps}\" -RepoRoot \"${GUEST_REPO_PS}\" -LogPath \"${log_path_ps}\" -DonePath \"${done_path_ps}\" -PidPath \"${pid_path_ps}\"' -WindowStyle Hidden" || die "could not launch Windows validation" + start="$(date +%s)" + while true; do + if guest_user_ps_bounded 20 "Test-Path '${done_path_ps}'" 2>/dev/null | grep -q True; then + break + fi + now="$(date +%s)" + if (( now - start > 5400 )); then + guest_user_ps_bounded 20 "if (Test-Path '${pid_path_ps}') { \$workerPid = [int](Get-Content '${pid_path_ps}' -Raw); & taskkill.exe /PID \$workerPid /T /F 2>\$null | Out-Null }; if (Test-Path '${log_path_ps}') { Get-Content '${log_path_ps}' -Tail 120 }" || true + die "Windows validation exceeded 90 minutes" + fi + last_line="$(guest_user_ps_bounded 20 "if (Test-Path '${log_path_ps}') { Get-Content '${log_path_ps}' -Tail 1 } else { 'waiting for first log line' }" 2>/dev/null | tr -d '\r' | tail -n 1)" || last_line="guest transport unavailable; retrying" + say "validation running: ${last_line}" + sleep 10 + done + exit_code="$(guest_user_ps_bounded 20 "Get-Content '${done_path_ps}' -Raw" | tr -d '\r\n ')" || die "could not read Windows validation result" + guest_user_ps_bounded 20 "Get-Content '${log_path_ps}' -Tail 160" + [[ "$exit_code" == "0" ]] || die "Windows validation failed with exit code ${exit_code}; log: ${log_path}" + say "Windows validation passed; log: $log_path" +} + +case "$COMMAND" in + help|-h|--help) + usage + exit 0 + ;; + inventory|prepare|verify|restore|run-tests) + ;; + *) + 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" + ;; + run-tests) + run_tests + ;; +esac From f8c298971e90d8c0909e80ef2ba33e063a86a0e5 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 14 Jul 2026 19:30:45 -0700 Subject: [PATCH 2/3] refactor: layer Windows app Parallels setup --- .../openclaw-parallels-windows/SKILL.md | 191 ++--- AGENTS.md | 5 +- scripts/parallels-windows-vm.sh | 773 ++++-------------- 3 files changed, 216 insertions(+), 753 deletions(-) diff --git a/.agents/skills/openclaw-parallels-windows/SKILL.md b/.agents/skills/openclaw-parallels-windows/SKILL.md index 9fbad705c..a0aa874c1 100644 --- a/.agents/skills/openclaw-parallels-windows/SKILL.md +++ b/.agents/skills/openclaw-parallels-windows/SKILL.md @@ -1,17 +1,23 @@ --- name: openclaw-parallels-windows -description: Prepare, inspect, restore, and test the OpenClaw Windows companion and Windows node in a Parallels Windows 11 VM controlled from macOS. Use for first-time VM baseline provisioning, snapshot lifecycle, remote prlctl management, WSL/dev-tool setup, required Windows validation, or Parallels/WSL/winget transport troubleshooting. +description: Add OpenClaw Windows companion prerequisites, app-layer snapshots, native build/tests, and UI or node proof on top of the general Parallels Windows baseline owned by the sibling OpenClaw repo. --- -# OpenClaw Parallels Windows +# OpenClaw Parallels Windows Companion -Use `scripts/parallels-windows-vm.sh` as the canonical host-side controller. Assume Parallels -Desktop is installed and activated and the user has downloaded/created a Windows 11 VM. Do not -reimplement provisioning with ad hoc `prlctl` commands unless debugging the controller itself. +This is the native Windows supplement to +`../../../../openclaw/.agents/skills/openclaw-parallels-smoke/SKILL.md`. Read that skill first for VM +inventory, `prlctl` transport, WSL/Git/Node provisioning, clean/E2E snapshot lifecycle, OpenClaw +install/update smoke, and general troubleshooting. Do not duplicate those rules here. -## Quick start +Assume Parallels Desktop is installed and activated, a Windows 11 VM has been downloaded, and the +`openclaw` and `openclaw-windows-node` checkouts are siblings. Set `OPENCLAW_REPO` when they are not. +The wrapper intentionally fails closed when the sibling controller/API is absent; use an OpenClaw +revision containing `scripts/e2e/parallels-windows-prepare.sh` before running this app layer. -Run from the macOS checkout of `openclaw-windows-node`: +## Prepare the app layer + +Run from the macOS `openclaw-windows-node` checkout: ```bash ./scripts/parallels-windows-vm.sh inventory @@ -19,148 +25,79 @@ Run from the macOS checkout of `openclaw-windows-node`: ./scripts/parallels-windows-vm.sh verify ``` -`prepare` is idempotent. It: +The wrapper delegates the general baseline to +`../openclaw/scripts/e2e/parallels-windows-prepare.sh`, restores its newest `e2e` snapshot, then +adds only companion-owned prerequisites: -1. Inventories the named VM and existing snapshots. -2. Requires a logged-in desktop user available through `prlctl exec --current-user`. -3. Refuses a reusable baseline if OpenClaw CLI/app/process/tray state or any WSL distro exists. -4. Creates a dated clean-OS snapshot only when no reusable prerequisites are installed. -5. Enables the WSL and Virtual Machine Platform features and reboots when needed. -6. Installs the latest signed Microsoft WSL MSI for the guest architecture and sets WSL 2 as default. -7. Resolves the current package version and SHA-256 from Microsoft's official WinGet manifest - repository, downloads that exact version with `winget`, then verifies both the trusted manifest - hash and expected Authenticode publisher while staging into a freshly ACL-restricted directory. - Only then does it run system-context silent installers for Git, Node/npm, .NET 10, Windows SDK - 10.0.26100, and WebView2. This avoids hidden UAC prompts without trusting a user-writable file. -8. Clones this repository, runs `scripts/setup-dev.ps1 -CheckOnly`, clears installers, reboots, - verifies clean state/no pending reboot, and creates the reusable power-on snapshot. +1. .NET 10 SDK. +2. Windows SDK 10.0.26100. +3. WebView2 Runtime. +4. A clean `openclaw-windows-node` checkout and `scripts/setup-dev.ps1 -CheckOnly`. +5. A dated power-off `pre-openclaw-windows-app-e2e-*` snapshot. -Override names or paths when needed: +When today's app snapshot already exists, `prepare` restores and verifies it. Like any restore, this +discards post-snapshot guest changes. -```bash -./scripts/parallels-windows-vm.sh prepare \ - --vm "Windows 11" \ - --clean-snapshot "windows-11-clean-os-" \ - --baseline-snapshot "pre-openclaw-native-e2e-" -``` +Package installation reuses the OpenClaw controller's official WinGet manifest hash, +Authenticode publisher, ACL-restricted staging, reboot-code handling, and bounded transport +implementation. Keep generic package/security/transport changes in OpenClaw; keep these app package +choices and validation here. -## Snapshot lifecycle +## Snapshots -Always inventory before mutation: +The shared aliases are: -```bash -./scripts/parallels-windows-vm.sh inventory -prlctl snapshot-list "Windows 11" --json -``` +- `clean`: newest raw Windows snapshot. +- `e2e`: newest general OpenClaw Windows snapshot. +- `app`: newest native Windows companion snapshot. -Restore by exact name or id: +Restore deliberately because post-snapshot guest changes are discarded: ```bash -./scripts/parallels-windows-vm.sh restore --snapshot e2e -# Exact names and ids are also accepted: -./scripts/parallels-windows-vm.sh restore --snapshot "pre-openclaw-native-e2e-" +./scripts/parallels-windows-vm.sh restore --snapshot app +./scripts/parallels-windows-vm.sh restore --snapshot "pre-openclaw-windows-app-e2e-" ``` -`e2e` selects the newest `pre-openclaw-native-e2e-*` snapshot by snapshot date; `clean` selects the -newest `windows-11-clean-os-*`. Use an exact name or id when reproducing against an older baseline. - -Restoring discards all post-snapshot guest changes. Do not restore while another Windows lane or -developer session owns the VM. Preserve the power-on snapshot's logged-in session by switching -normally; do not pass `--skip-resume` at test entry. Stop the VM after ad hoc or credentialed runs -when no follow-up work needs the desktop session. - -Create additional snapshots only when no suitable baseline exists or the user explicitly asks. -Use a new dated name; never overwrite or silently delete a known-good baseline. - -## Run Windows app validation +Never restore while another developer or smoke lane owns the VM. Use exact names or ids for +historical reproduction; aliases select the newest matching snapshot by snapshot date. -The default run restores the newest `e2e` baseline. Pass an exact snapshot to pin an older date: +## Run required Windows validation ```bash ./scripts/parallels-windows-vm.sh run-tests -./scripts/parallels-windows-vm.sh run-tests \ - --snapshot "pre-openclaw-native-e2e-" +./scripts/parallels-windows-vm.sh run-tests --ref ``` -To validate a pushed branch or SHA, fetch it into the guest and detach at `FETCH_HEAD`: +The default restores `app`. The controller materializes `scripts/parallels-run-validation.ps1` +into guest temp independently of snapshot age, launches it in the desktop session, polls through +short host-bounded `prlctl exec` calls, stops the full process tree at 90 minutes, and runs: -```bash -./scripts/parallels-windows-vm.sh run-tests \ - --snapshot "pre-openclaw-native-e2e-" \ - --ref "" -``` - -For an unpushed local change, restore the baseline first, copy or fetch the reviewed files into an -isolated guest checkout, then preserve that checkout for the run: - -```bash -./scripts/parallels-windows-vm.sh restore \ - --snapshot "pre-openclaw-native-e2e-" -# Sync the local ref into the guest checkout. -./scripts/parallels-windows-vm.sh run-tests --no-restore -``` +- `./build.ps1` +- Shared tests +- Tray tests -`--no-restore` is an explicit escape hatch for a checkout the developer just synchronized. Never -use it for a clean-snapshot proof or when the guest's ownership/state is unknown. +For an unpushed reviewed change, restore `app`, sync an isolated guest checkout, then use +`run-tests --no-restore`. Never use `--no-restore` as clean-snapshot evidence. -The controller copies `scripts/parallels-run-validation.ps1` from the host into the guest temp -directory, so it works even when the restored snapshot predates the helper. It launches the helper -in the desktop session, polls a done file through host-bounded `prlctl exec` calls, emits progress, -hard-caps the run at 90 minutes, stops the worker on timeout, and runs the repository-required -build, Shared tests, and Tray tests through `setup-dev.ps1 -RunValidation`. The guest log path is -printed on completion. +## Native proof -Use `.agents/skills/openclaw-proof-validation/SKILL.md` after the required suites when the change -also needs UI screenshots, local MCP/`winnode`, Gateway, accessibility, or MXC proof. +After the required suites, use `.agents/skills/openclaw-proof-validation/SKILL.md` when the change +needs visible app screenshots/video, `winnode` or raw MCP proof, Gateway pairing/invocation, +accessibility, permissions, Command Center, chat/canvas, or MXC evidence. -## Run OpenClaw core smoke +Keep contexts straight: -From a sibling `openclaw` checkout, use its wrapper so the same baseline owner remains canonical: +- `--current-user`: checkout, app launch, tests, Git, and user state. +- SYSTEM: machine installers only, through the shared verified staging path. +- Explicit `.cmd` shims: `npm`, `pnpm`, and `openclaw` when command resolution is ambiguous. -```bash -pnpm test:parallels:windows:prepare -- inventory -pnpm test:parallels:windows:prepare -- verify -gtimeout --foreground 90m pnpm test:parallels:windows -- \ - --snapshot-hint "pre-openclaw-native-e2e-" \ - --json -``` +## App-specific troubleshooting -Install GNU coreutils on macOS when neither `timeout` nor `gtimeout` exists. Keep the host hard cap; -the lane also has phase timeouts, but they do not protect a stalled `prlctl` process. - -## Remote management rules - -- Put the VM name before `--current-user`: `prlctl exec "$VM" --current-user ...`. -- Use `--current-user` for OpenClaw, `winget`, Git, app launch, tests, and user state. Plain - `prlctl exec` runs as `NT AUTHORITY\\SYSTEM`; reserve it for DISM and machine installers. -- Wrap multi-argument Windows commands with `cmd.exe /d /s /c ''`; direct App Execution - Alias calls can lose arguments or detach without useful output. -- Use explicit `.cmd` shims for `npm`, `pnpm`, and `openclaw` when command resolution is ambiguous. -- Keep long guest work behind a background runner plus log/done files. One long-lived encoded - PowerShell transport can hang after the guest process already completed. -- Inspect `prlctl status`, then resume/start a suspended or stopped guest before retrying `rc=255`. -- Do not print environment dumps or credentials. Inject only the provider/channel secret required - for a live lane and restore the clean snapshot afterward. - -## Troubleshooting - -- **`wsl.exe` says WSL is not installed after features are enabled:** the Store/MSI package is - missing. Rerun `prepare`; it resolves the current Microsoft/WSL release, validates the - Authenticode signature, and installs the matching ARM64/x64 MSI. -- **WSL default returns to 1 after install/reboot:** run `wsl.exe --set-default-version 2`; `prepare` - and `verify` enforce this before snapshot creation. -- **WSL2 cannot start:** inspect Parallels VM CPU settings and Windows virtualization errors. On - supported hosts, enable nested virtualization, reboot, and rerun the smallest WSL preflight. -- **`winget` prints nothing or waits forever:** invoke it through `cmd.exe /d /s /c`. For packages - needing elevation, use the controller's download-as-user/install-as-system flow instead of UAC. -- **Snapshot restore reports incompatible saved CPU state:** create a power-off replacement - snapshot from the known-good disk state, then use that exact name. The test harness starts - restored power-off snapshots automatically. -- **Snapshot list is empty:** run `prepare`; do not bypass restore with - `OPENCLAW_PARALLELS_SKIP_SNAPSHOT_RESTORE=1` for a two-lane fresh+upgrade claim. -- **Baseline verification finds OpenClaw state:** restore the clean-OS snapshot or remove the - product state deliberately. Do not bless a dirty guest as the reusable baseline. -- **Windows installer appears idle:** inspect `tasklist` and the installer/MSI log in - `C:\Windows\Temp`; Windows SDK and npm installs can remain quiet while healthy. -- **`prlctl exec --current-user` cannot authenticate:** confirm the VM has a logged-in desktop user - and Parallels Tools is installed. A stopped login-screen VM is not a usable power-on baseline. +- `setup-dev.ps1 -CheckOnly` reports .NET/SDK/WebView2 missing: rerun `prepare`; do not modify the + general `e2e` snapshot to hide an app-layer prerequisite. +- `run-tests` cannot find an `app` snapshot: run `prepare`; existing older general E2E snapshots do + not implicitly contain the native app layer. +- Build output is locked: stop the companion/WinUI process, restore `app`, and rerun all required + suites. +- UI, MCP, Gateway, or MXC behavior needs proof beyond build/tests: route to + `openclaw-proof-validation`; this skill only establishes the reusable VM and native closeout lane. diff --git a/AGENTS.md b/AGENTS.md index b856e4708..b4a66cbe9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,9 @@ Run the required validation above for every code change, then add the targeted p Use `.agents/skills/openclaw-parallels-windows/SKILL.md` to prepare or restore the macOS-hosted Parallels Windows baseline, manage snapshots, run the required suites inside the guest, or debug -`prlctl`, WSL, winget, and guest transport failures. Keep the provisioning implementation in -`scripts/parallels-windows-vm.sh`; do not duplicate it in ad hoc setup notes. +the companion-specific app layer. General VM lifecycle, `prlctl`, WSL, Git/Node, snapshot, and +OpenClaw smoke policy is owned by sibling `openclaw`; this repo wrapper adds only .NET, Windows SDK, +WebView2, the app checkout, app-layer snapshots, required native suites, and native proof routing. ### MXC / `system.run` / Windows node command execution diff --git a/scripts/parallels-windows-vm.sh b/scripts/parallels-windows-vm.sh index 4328e5206..5a34a01b1 100755 --- a/scripts/parallels-windows-vm.sh +++ b/scripts/parallels-windows-vm.sh @@ -1,572 +1,141 @@ #!/usr/bin/env bash set -euo pipefail -VM_NAME="Windows 11" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WINDOWS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WINDOWS_ROOT="$(cd "$WINDOWS_SCRIPT_DIR/.." && pwd)" +OPENCLAW_REPO="${OPENCLAW_REPO:-$WINDOWS_ROOT/../openclaw}" +BASE_CONTROLLER="$OPENCLAW_REPO/scripts/e2e/parallels-windows-prepare.sh" + +COMMAND="${1:-help}" +[[ $# -gt 0 ]] && shift +VM="Windows 11" TODAY="$(date +%F)" -CLEAN_SNAPSHOT="windows-11-clean-os-${TODAY}" -BASELINE_SNAPSHOT="pre-openclaw-native-e2e-${TODAY}" -GUEST_PROFILE="" -GUEST_REPO="" -GUEST_PROFILE_PS="" -GUEST_REPO_PS="" -GUEST_ARCH="" -WINGET_EXPECTED_HASH="" +APP_SNAPSHOT="pre-openclaw-windows-app-e2e-${TODAY}" +APP_SNAPSHOT_EXPLICIT=0 +SELECTED_SNAPSHOT="" +GUEST_CHECKOUT="" REPO_URL="https://github.com/openclaw/openclaw-windows-node.git" REPO_REF="" -SNAPSHOT="" SKIP_RESTORE=0 -SECURE_STAGE_DIR="C:/ProgramData/OpenClawPrerequisiteInstallers" -COMMAND="${1:-help}" -if [[ $# -gt 0 ]]; then - shift -fi - -say() { - printf '[parallels-windows] %s\n' "$*" -} - -die() { - printf '[parallels-windows] error: %s\n' "$*" >&2 - exit 1 -} usage() { cat <<'EOF' Usage: scripts/parallels-windows-vm.sh [options] Commands: - inventory List the VM, hardware facts, and snapshots. - prepare Create a clean snapshot, provision the reusable baseline, and snapshot it. - verify Verify prerequisites and prove the guest contains no OpenClaw product state. - restore Restore a snapshot by exact name or id. - run-tests Restore the baseline, optionally check out a ref, and run required validation. + inventory Delegate base VM and snapshot inventory to the OpenClaw controller. + prepare Prepare the OpenClaw base, then add Windows app prerequisites and snapshot them. + verify Verify both the OpenClaw base and Windows app development prerequisites. + restore Restore `clean`, `e2e`, `app`, an exact snapshot name, or an id. + run-tests Restore the app snapshot and run the required Windows build and test suites. Options: --vm Parallels VM name. Default: Windows 11 - --clean-snapshot Clean-OS snapshot name. - --baseline-snapshot Reusable E2E snapshot name. + --app-snapshot Windows app E2E snapshot name. --snapshot Snapshot for restore/run-tests. - --guest-repo Guest checkout path; defaults under the detected user's profile. + --guest-repo Windows app checkout path in the guest. --repo-url Windows app repository URL. --ref Fetch and detach at this ref before run-tests. - --no-restore Run tests in the current guest checkout without restoring first. + --no-restore Test the current deliberately synchronized guest checkout. -h, --help Show this help. -prepare assumes Parallels Desktop is installed/activated and a Windows 11 VM already exists. -It installs only reusable prerequisites: WSL platform/package, Git, Node/npm, .NET 10, -Windows SDK 10.0.26100, WebView2, and a clean developer checkout. It refuses to create the -baseline when the guest contains an OpenClaw CLI, app package, process, tray state, or WSL distro. - -run-tests is destructive to post-snapshot guest changes because it restores the selected snapshot. -Use --no-restore only after deliberately syncing an unpushed local ref into the guest. +The general Parallels, WSL, Git/Node, snapshot, and transport implementation lives in the sibling +OpenClaw repo. Set OPENCLAW_REPO when the checkouts are not siblings. EOF } 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 - ;; - --guest-repo) - GUEST_REPO="${2:?missing value for --guest-repo}" - shift 2 - ;; - --repo-url) - REPO_URL="${2:?missing value for --repo-url}" - shift 2 - ;; - --ref) - REPO_REF="${2:?missing value for --ref}" - shift 2 - ;; - --no-restore) - SKIP_RESTORE=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - die "unknown option: $1" - ;; + --vm) VM="${2:?missing value for --vm}"; shift 2 ;; + --app-snapshot) APP_SNAPSHOT="${2:?missing value for --app-snapshot}"; APP_SNAPSHOT_EXPLICIT=1; shift 2 ;; + --snapshot) SELECTED_SNAPSHOT="${2:?missing value for --snapshot}"; shift 2 ;; + --guest-repo) GUEST_CHECKOUT="${2:?missing value for --guest-repo}"; shift 2 ;; + --repo-url) REPO_URL="${2:?missing value for --repo-url}"; shift 2 ;; + --ref) REPO_REF="${2:?missing value for --ref}"; shift 2 ;; + --no-restore) SKIP_RESTORE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) printf 'error: unknown option: %s\n' "$1" >&2; exit 1 ;; esac done -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 - "$@" || 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|105|194) return 0 ;; - *) return "$exit_code" ;; - esac -} - -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" -} +case "$COMMAND" in + help|-h|--help) usage; exit 0 ;; + inventory|prepare|verify|restore|run-tests) ;; + *) usage >&2; printf 'error: unknown command: %s\n' "$COMMAND" >&2; exit 1 ;; +esac -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" +[[ -f "$BASE_CONTROLLER" ]] || { + printf 'error: OpenClaw Parallels controller not found: %s\n' "$BASE_CONTROLLER" >&2 + printf 'clone https://github.com/openclaw/openclaw beside this repo or set OPENCLAW_REPO\n' >&2 + exit 1 } -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 -} +base_args=(--vm "$VM") -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_REPO" ]]; then - GUEST_REPO="${GUEST_PROFILE}/github/openclaw-windows-node" - fi - 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")" - GUEST_REPO_PS="$(powershell_literal_content "$GUEST_REPO")" -} +case "$COMMAND" in + inventory) + exec bash "$BASE_CONTROLLER" inventory "${base_args[@]}" + ;; +esac -snapshot_json() { - prlctl snapshot-list "$VM_NAME" --json 2>/dev/null || true +# Source only the shared implementation. This repo owns the app-specific functions below. +APP_COMMAND="$COMMAND" +APP_REPO_URL="$REPO_URL" +APP_REPO_REF="$REPO_REF" +APP_SKIP_RESTORE="$SKIP_RESTORE" +APP_GUEST_CHECKOUT="$GUEST_CHECKOUT" +APP_SNAPSHOT_NAME="$APP_SNAPSHOT" +APP_SNAPSHOT_WAS_EXPLICIT="$APP_SNAPSHOT_EXPLICIT" +set -- +OPENCLAW_PARALLELS_WINDOWS_LIBRARY_ONLY=1 source "$BASE_CONTROLLER" +[[ "${OPENCLAW_PARALLELS_WINDOWS_API:-0}" == "1" ]] || { + printf 'error: incompatible OpenClaw Parallels Windows controller API\n' >&2 + exit 1 } - -snapshot_id() { +COMMAND="$APP_COMMAND" +VM_NAME="$VM" +BASELINE_SNAPSHOT="pre-openclaw-native-e2e-${TODAY}" +SNAPSHOT="$SELECTED_SNAPSHOT" +REPO_URL="$APP_REPO_URL" +REPO_REF="$APP_REPO_REF" +SKIP_RESTORE="$APP_SKIP_RESTORE" +GUEST_CHECKOUT="$APP_GUEST_CHECKOUT" +APP_SNAPSHOT="$APP_SNAPSHOT_NAME" +APP_SNAPSHOT_EXPLICIT="$APP_SNAPSHOT_WAS_EXPLICIT" + +resolve_app_snapshot_selector() { local selector="$1" + if [[ "$selector" != "app" ]]; then + printf '%s\n' "$selector" + return + fi 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 - say "Creating snapshot: $name" - run_bounded 900 prlctl snapshot "$VM_NAME" --name "$name" --description "$description" || die "snapshot creation exceeded 15 minutes" -} - -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: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 - 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 - prlctl exec "$VM_NAME" curl.exe -fL --connect-timeout 20 --max-time 600 "$url" -o "$wsl_msi" - 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' - 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 - wait_for_check Git 'where git.exe' +matches = [ + (item.get("date", ""), snapshot_id) + for snapshot_id, item in data.items() + if item.get("name", "").startswith("pre-openclaw-windows-app-e2e-") +] +if not matches: + raise SystemExit("Windows app snapshot not found; run prepare first") +print(max(matches)[1]) +' } +require_host_tools +vm_exists || die "Parallels VM not found: $VM_NAME" -ensure_node() { - if guest_user_cmd 'where node.exe' >/dev/null 2>&1; then - return +set_app_paths() { + set_guest_paths + if [[ -z "$GUEST_CHECKOUT" ]]; then + GUEST_CHECKOUT="${GUEST_PROFILE}/github/openclaw-windows-node" 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 - wait_for_check Node.js 'where node.exe' + GUEST_REPO_PS="$(powershell_literal_content "$GUEST_CHECKOUT")" } ensure_dotnet() { - if guest_user_cmd 'dotnet.exe --list-sdks | findstr /B 10.' >/dev/null 2>&1; then - return - fi + guest_user_cmd 'dotnet.exe --list-sdks | findstr /B 10.' >/dev/null 2>&1 && return winget_download Microsoft.DotNet.SDK.10 local installer installer="$(downloaded_installer 'Microsoft .NET SDK 10.0*.exe')" @@ -574,13 +143,12 @@ ensure_dotnet() { installer="$(stage_installer "$installer" DotNetSDK 'Microsoft Corporation' "$WINGET_EXPECTED_HASH")" say "Installing .NET 10 SDK" run_windows_installer prlctl exec "$VM_NAME" "$installer" /install /quiet /norestart + finish_installer_reboot wait_for_check '.NET 10 SDK' 'dotnet.exe --list-sdks | findstr /B 10.' } ensure_windows_sdk() { - if guest_user_ps "Test-Path 'C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0/um'" | grep -q True; then - return - fi + guest_user_ps "Test-Path 'C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0/um'" | grep -q True && return winget_download Microsoft.WindowsSDK.10.0.26100 local installer installer="$(downloaded_installer 'Windows Software Development Kit*.exe')" @@ -588,13 +156,12 @@ ensure_windows_sdk() { installer="$(stage_installer "$installer" WindowsSDK 'Microsoft Corporation' "$WINGET_EXPECTED_HASH")" say "Installing Windows SDK 10.0.26100" run_windows_installer prlctl exec "$VM_NAME" "$installer" /quiet /norestart /ceip off + finish_installer_reboot wait_for_check 'Windows SDK' 'if exist "C:\Program Files (x86)\Windows Kits\10\Include\10.0.26100.0\um" exit /b 0 else exit /b 1' } ensure_webview2() { - if guest_system_ps "Test-Path 'HKLM:/SOFTWARE/WOW6432Node/Microsoft/EdgeUpdate/Clients/{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'" | grep -q True; then - return - fi + guest_system_ps "Test-Path 'HKLM:/SOFTWARE/WOW6432Node/Microsoft/EdgeUpdate/Clients/{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'" | grep -q True && return winget_download Microsoft.EdgeWebView2Runtime local installer installer="$(downloaded_installer '*WebView2*.exe')" @@ -602,154 +169,112 @@ ensure_webview2() { installer="$(stage_installer "$installer" WebView2 'Microsoft Corporation' "$WINGET_EXPECTED_HASH")" say "Installing WebView2 Runtime" run_windows_installer prlctl exec "$VM_NAME" "$installer" /silent /install + finish_installer_reboot wait_for_check WebView2 'reg.exe query "HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" /v pv' } -cleanup_installers() { - guest_user_ps "Remove-Item -LiteralPath '${GUEST_PROFILE_PS}/Downloads/OpenClawPrereqs' -Recurse -Force -ErrorAction SilentlyContinue" - guest_system_ps "Remove-Item -LiteralPath '${SECURE_STAGE_DIR}' -Recurse -Force -ErrorAction SilentlyContinue" -} - ensure_guest_checkout() { if ! guest_user_ps "Test-Path '${GUEST_REPO_PS}/.git'" | grep -q True; then say "Cloning Windows app repository" - guest_user_cmd "git.exe clone ${REPO_URL} \"${GUEST_REPO//\//\\}\"" + guest_user_cmd "git.exe clone ${REPO_URL} \"${GUEST_CHECKOUT//\//\\}\"" fi - say "Running repo-native prerequisite check" - guest_user_ps "Set-Location '${GUEST_REPO_PS}'; & './scripts/setup-dev.ps1' -CheckOnly" } -verify_baseline() { - set_guest_paths - guest_user_cmd 'git --version & node --version & npm --version & dotnet --list-sdks' - guest_user_cmd 'wsl.exe --version' || die "MSI-backed WSL is unavailable" - guest_user_cmd 'wsl.exe --status' || die "WSL status failed" - 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" - guest_user_ps "Get-ChildItem -Name 'C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0'" - assert_clean_product_state - assert_no_pending_reboot - if guest_user_ps "Test-Path '${GUEST_REPO_PS}/scripts/setup-dev.ps1'" | grep -q True; then - guest_user_ps "Set-Location '${GUEST_REPO_PS}'; & './scripts/setup-dev.ps1' -CheckOnly" - else - die "guest checkout missing: $GUEST_REPO" - fi +verify_app() { + set_app_paths + guest_user_cmd 'dotnet.exe --list-sdks | findstr /B 10.' >/dev/null || die ".NET 10 SDK is unavailable" + guest_user_ps "Test-Path 'C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0/um'" | grep -q True || die "Windows SDK 10.0.26100 is unavailable" + guest_system_ps "Test-Path 'HKLM:/SOFTWARE/WOW6432Node/Microsoft/EdgeUpdate/Clients/{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'" | grep -q True || die "WebView2 is unavailable" + guest_user_ps "Test-Path '${GUEST_REPO_PS}/scripts/setup-dev.ps1'" | grep -q True || die "guest checkout missing: $GUEST_CHECKOUT" + guest_user_ps "Set-Location '${GUEST_REPO_PS}'; & './scripts/setup-dev.ps1' -CheckOnly" } -prepare() { - ensure_vm_running - wait_for_guest - inventory - set_guest_paths - if snapshot_exists "$BASELINE_SNAPSHOT"; then - say "Reusable baseline already exists: $BASELINE_SNAPSHOT" +prepare_app() { + bash "$BASE_CONTROLLER" prepare "${base_args[@]}" + if snapshot_exists "$APP_SNAPSHOT"; then + say "Restoring and verifying existing Windows app baseline: $APP_SNAPSHOT" + restore_snapshot "$APP_SNAPSHOT" + verify_baseline + verify_app + say "Windows app baseline verified: $APP_SNAPSHOT" return fi + bash "$BASE_CONTROLLER" restore "${base_args[@]}" --snapshot e2e + set_app_paths assert_clean_product_state - create_clean_snapshot_if_raw - ensure_wsl_features - ensure_wsl_package - ensure_git - ensure_node ensure_dotnet ensure_windows_sdk ensure_webview2 cleanup_installers ensure_guest_checkout restart_guest - guest_user_cmd 'wsl.exe --set-default-version 2' >/dev/null verify_baseline - create_snapshot "$BASELINE_SNAPSHOT" "E2E-ready Windows baseline with WSL platform, Git, Node/npm, .NET 10, Windows SDK, WebView2, and no OpenClaw product state." - say "Baseline ready: $BASELINE_SNAPSHOT" + verify_app + create_snapshot "$APP_SNAPSHOT" "Windows app E2E layer with .NET 10, Windows SDK 10.0.26100, WebView2, and a clean companion checkout." + say "Windows app baseline ready: $APP_SNAPSHOT" } -run_tests() { - local selector="${SNAPSHOT:-e2e}" +run_app_tests() { if [[ "$SKIP_RESTORE" == "0" ]]; then - restore_snapshot "$selector" + local selector + if [[ -n "$SELECTED_SNAPSHOT" ]]; then + selector="$SELECTED_SNAPSHOT" + elif [[ "$APP_SNAPSHOT_EXPLICIT" == "1" ]]; then + selector="$APP_SNAPSHOT" + else + selector="app" + fi + restore_snapshot "$(resolve_app_snapshot_selector "$selector")" else - say "Skipping snapshot restore; validating the current guest checkout" ensure_vm_running wait_for_guest fi - set_guest_paths + set_app_paths if [[ -n "$REPO_REF" ]]; then - local fetch_ref="$REPO_REF" - fetch_ref="${fetch_ref#origin/}" - say "Fetching guest ref: $REPO_REF" - guest_user_cmd_bounded 600 "cd /d \"${GUEST_REPO//\//\\}\" && git fetch --tags origin \"${fetch_ref}\" && git checkout --detach FETCH_HEAD" || die "guest ref fetch exceeded 10 minutes" + local fetch_ref="${REPO_REF#origin/}" + guest_user_cmd_bounded 600 "cd /d \"${GUEST_CHECKOUT//\//\\}\" && git fetch --tags origin \"${fetch_ref}\" && git checkout --detach FETCH_HEAD" || die "guest ref fetch exceeded 10 minutes" fi - local host_helper="${SCRIPT_DIR}/parallels-run-validation.ps1" - [[ -f "$host_helper" ]] || die "validation helper missing beside controller: $host_helper" - local helper_base64 - helper_base64="$(python3 -c 'import base64, pathlib, sys; print(base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode())' "$host_helper")" - local run_id helper log_path done_path pid_path helper_ps log_path_ps done_path_ps pid_path_ps start now last_line exit_code + local helper_base64 run_id helper log_path done_path pid_path helper_ps log_path_ps done_path_ps pid_path_ps start now last_line exit_code + helper_base64="$(python3 -c 'import base64, pathlib, sys; print(base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode())' "$WINDOWS_SCRIPT_DIR/parallels-run-validation.ps1")" run_id="$(date +%Y%m%d-%H%M%S)" - helper="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-parallels-validation-${run_id}.ps1" - log_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-parallels-validation-${run_id}.log" - done_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-parallels-validation-${run_id}.done" - pid_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-parallels-validation-${run_id}.pid" + helper="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-windows-validation-${run_id}.ps1" + log_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-windows-validation-${run_id}.log" + done_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-windows-validation-${run_id}.done" + pid_path="${GUEST_PROFILE}/AppData/Local/Temp/openclaw-windows-validation-${run_id}.pid" helper_ps="$(powershell_literal_content "$helper")" log_path_ps="$(powershell_literal_content "$log_path")" done_path_ps="$(powershell_literal_content "$done_path")" pid_path_ps="$(powershell_literal_content "$pid_path")" - guest_user_ps_bounded 30 "[IO.File]::WriteAllBytes('${helper_ps}', [Convert]::FromBase64String('${helper_base64}'))" || die "could not materialize the validation helper in the guest" - say "Launching required Windows validation" - guest_user_ps_bounded 30 "Remove-Item -LiteralPath '${log_path_ps}','${done_path_ps}','${pid_path_ps}' -Force -ErrorAction SilentlyContinue; Start-Process powershell.exe -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"${helper_ps}\" -RepoRoot \"${GUEST_REPO_PS}\" -LogPath \"${log_path_ps}\" -DonePath \"${done_path_ps}\" -PidPath \"${pid_path_ps}\"' -WindowStyle Hidden" || die "could not launch Windows validation" + guest_user_ps_bounded 30 "[IO.File]::WriteAllBytes('${helper_ps}', [Convert]::FromBase64String('${helper_base64}'))" || die "could not materialize validation helper" + guest_user_ps_bounded 30 "Start-Process powershell.exe -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"${helper_ps}\" -RepoRoot \"${GUEST_REPO_PS}\" -LogPath \"${log_path_ps}\" -DonePath \"${done_path_ps}\" -PidPath \"${pid_path_ps}\"' -WindowStyle Hidden" || die "could not launch validation" start="$(date +%s)" - while true; do - if guest_user_ps_bounded 20 "Test-Path '${done_path_ps}'" 2>/dev/null | grep -q True; then - break - fi + while ! guest_user_ps_bounded 20 "Test-Path '${done_path_ps}'" 2>/dev/null | grep -q True; do now="$(date +%s)" if (( now - start > 5400 )); then - guest_user_ps_bounded 20 "if (Test-Path '${pid_path_ps}') { \$workerPid = [int](Get-Content '${pid_path_ps}' -Raw); & taskkill.exe /PID \$workerPid /T /F 2>\$null | Out-Null }; if (Test-Path '${log_path_ps}') { Get-Content '${log_path_ps}' -Tail 120 }" || true + guest_user_ps_bounded 20 "if (Test-Path '${pid_path_ps}') { \$workerPid = [int](Get-Content '${pid_path_ps}' -Raw); & taskkill.exe /PID \$workerPid /T /F 2>\$null | Out-Null }" || true die "Windows validation exceeded 90 minutes" fi last_line="$(guest_user_ps_bounded 20 "if (Test-Path '${log_path_ps}') { Get-Content '${log_path_ps}' -Tail 1 } else { 'waiting for first log line' }" 2>/dev/null | tr -d '\r' | tail -n 1)" || last_line="guest transport unavailable; retrying" - say "validation running: ${last_line}" + say "validation running: $last_line" sleep 10 done - exit_code="$(guest_user_ps_bounded 20 "Get-Content '${done_path_ps}' -Raw" | tr -d '\r\n ')" || die "could not read Windows validation result" + exit_code="$(guest_user_ps_bounded 20 "Get-Content '${done_path_ps}' -Raw" | tr -d '\r\n ')" guest_user_ps_bounded 20 "Get-Content '${log_path_ps}' -Tail 160" - [[ "$exit_code" == "0" ]] || die "Windows validation failed with exit code ${exit_code}; log: ${log_path}" + [[ "$exit_code" == "0" ]] || die "Windows validation failed with exit code $exit_code; log: $log_path" say "Windows validation passed; log: $log_path" } case "$COMMAND" in - help|-h|--help) - usage - exit 0 - ;; - inventory|prepare|verify|restore|run-tests) - ;; - *) - 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 - ;; + prepare) prepare_app ;; verify) ensure_vm_running wait_for_guest verify_baseline + verify_app ;; restore) - [[ -n "$SNAPSHOT" ]] || die "restore requires --snapshot " - restore_snapshot "$SNAPSHOT" - ;; - run-tests) - run_tests + [[ -n "$SELECTED_SNAPSHOT" ]] || die "restore requires --snapshot" + restore_snapshot "$(resolve_app_snapshot_selector "$SELECTED_SNAPSHOT")" ;; + run-tests) run_app_tests ;; esac From e39ad174c721305cfc7ed6378b8c033747bd66a8 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 14 Jul 2026 21:39:08 -0700 Subject: [PATCH 3/3] docs: make Parallels an optional proof backend --- .../openclaw-parallels-windows/SKILL.md | 103 ------------------ .../agents/openai.yaml | 4 - .../openclaw-proof-validation/PARALLELS.md | 82 ++++++++++++++ .../skills/openclaw-proof-validation/SKILL.md | 2 + AGENTS.md | 8 -- 5 files changed, 84 insertions(+), 115 deletions(-) delete mode 100644 .agents/skills/openclaw-parallels-windows/SKILL.md delete mode 100644 .agents/skills/openclaw-parallels-windows/agents/openai.yaml create mode 100644 .agents/skills/openclaw-proof-validation/PARALLELS.md diff --git a/.agents/skills/openclaw-parallels-windows/SKILL.md b/.agents/skills/openclaw-parallels-windows/SKILL.md deleted file mode 100644 index a0aa874c1..000000000 --- a/.agents/skills/openclaw-parallels-windows/SKILL.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -name: openclaw-parallels-windows -description: Add OpenClaw Windows companion prerequisites, app-layer snapshots, native build/tests, and UI or node proof on top of the general Parallels Windows baseline owned by the sibling OpenClaw repo. ---- - -# OpenClaw Parallels Windows Companion - -This is the native Windows supplement to -`../../../../openclaw/.agents/skills/openclaw-parallels-smoke/SKILL.md`. Read that skill first for VM -inventory, `prlctl` transport, WSL/Git/Node provisioning, clean/E2E snapshot lifecycle, OpenClaw -install/update smoke, and general troubleshooting. Do not duplicate those rules here. - -Assume Parallels Desktop is installed and activated, a Windows 11 VM has been downloaded, and the -`openclaw` and `openclaw-windows-node` checkouts are siblings. Set `OPENCLAW_REPO` when they are not. -The wrapper intentionally fails closed when the sibling controller/API is absent; use an OpenClaw -revision containing `scripts/e2e/parallels-windows-prepare.sh` before running this app layer. - -## Prepare the app layer - -Run from the macOS `openclaw-windows-node` checkout: - -```bash -./scripts/parallels-windows-vm.sh inventory -./scripts/parallels-windows-vm.sh prepare -./scripts/parallels-windows-vm.sh verify -``` - -The wrapper delegates the general baseline to -`../openclaw/scripts/e2e/parallels-windows-prepare.sh`, restores its newest `e2e` snapshot, then -adds only companion-owned prerequisites: - -1. .NET 10 SDK. -2. Windows SDK 10.0.26100. -3. WebView2 Runtime. -4. A clean `openclaw-windows-node` checkout and `scripts/setup-dev.ps1 -CheckOnly`. -5. A dated power-off `pre-openclaw-windows-app-e2e-*` snapshot. - -When today's app snapshot already exists, `prepare` restores and verifies it. Like any restore, this -discards post-snapshot guest changes. - -Package installation reuses the OpenClaw controller's official WinGet manifest hash, -Authenticode publisher, ACL-restricted staging, reboot-code handling, and bounded transport -implementation. Keep generic package/security/transport changes in OpenClaw; keep these app package -choices and validation here. - -## Snapshots - -The shared aliases are: - -- `clean`: newest raw Windows snapshot. -- `e2e`: newest general OpenClaw Windows snapshot. -- `app`: newest native Windows companion snapshot. - -Restore deliberately because post-snapshot guest changes are discarded: - -```bash -./scripts/parallels-windows-vm.sh restore --snapshot app -./scripts/parallels-windows-vm.sh restore --snapshot "pre-openclaw-windows-app-e2e-" -``` - -Never restore while another developer or smoke lane owns the VM. Use exact names or ids for -historical reproduction; aliases select the newest matching snapshot by snapshot date. - -## Run required Windows validation - -```bash -./scripts/parallels-windows-vm.sh run-tests -./scripts/parallels-windows-vm.sh run-tests --ref -``` - -The default restores `app`. The controller materializes `scripts/parallels-run-validation.ps1` -into guest temp independently of snapshot age, launches it in the desktop session, polls through -short host-bounded `prlctl exec` calls, stops the full process tree at 90 minutes, and runs: - -- `./build.ps1` -- Shared tests -- Tray tests - -For an unpushed reviewed change, restore `app`, sync an isolated guest checkout, then use -`run-tests --no-restore`. Never use `--no-restore` as clean-snapshot evidence. - -## Native proof - -After the required suites, use `.agents/skills/openclaw-proof-validation/SKILL.md` when the change -needs visible app screenshots/video, `winnode` or raw MCP proof, Gateway pairing/invocation, -accessibility, permissions, Command Center, chat/canvas, or MXC evidence. - -Keep contexts straight: - -- `--current-user`: checkout, app launch, tests, Git, and user state. -- SYSTEM: machine installers only, through the shared verified staging path. -- Explicit `.cmd` shims: `npm`, `pnpm`, and `openclaw` when command resolution is ambiguous. - -## App-specific troubleshooting - -- `setup-dev.ps1 -CheckOnly` reports .NET/SDK/WebView2 missing: rerun `prepare`; do not modify the - general `e2e` snapshot to hide an app-layer prerequisite. -- `run-tests` cannot find an `app` snapshot: run `prepare`; existing older general E2E snapshots do - not implicitly contain the native app layer. -- Build output is locked: stop the companion/WinUI process, restore `app`, and rerun all required - suites. -- UI, MCP, Gateway, or MXC behavior needs proof beyond build/tests: route to - `openclaw-proof-validation`; this skill only establishes the reusable VM and native closeout lane. diff --git a/.agents/skills/openclaw-parallels-windows/agents/openai.yaml b/.agents/skills/openclaw-parallels-windows/agents/openai.yaml deleted file mode 100644 index a7146079f..000000000 --- a/.agents/skills/openclaw-parallels-windows/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "OpenClaw Parallels Windows" - short_description: "Prepare and test OpenClaw Windows VMs" - default_prompt: "Use $openclaw-parallels-windows to prepare a Parallels Windows VM, manage snapshots, and run OpenClaw Windows validation." diff --git a/.agents/skills/openclaw-proof-validation/PARALLELS.md b/.agents/skills/openclaw-proof-validation/PARALLELS.md new file mode 100644 index 000000000..84e1a745f --- /dev/null +++ b/.agents/skills/openclaw-proof-validation/PARALLELS.md @@ -0,0 +1,82 @@ +# Parallels Windows proof backend + +Use this optional backend only from macOS when Parallels Desktop is installed and activated and a +Windows 11 VM has already been downloaded. Run commands from the `openclaw-windows-node` repo root. + +The sibling OpenClaw repo owns the general Windows VM lifecycle. Read +`../../../../openclaw/.agents/skills/openclaw-parallels-smoke/SKILL.md` for `prlctl` transport, +WSL/Git/Node provisioning, clean/E2E snapshots, OpenClaw smoke, and general troubleshooting. Keep +the `openclaw` and `openclaw-windows-node` checkouts beside each other, or set `OPENCLAW_REPO`. + +## Prepare the app layer + +```bash +./scripts/parallels-windows-vm.sh inventory +./scripts/parallels-windows-vm.sh prepare +./scripts/parallels-windows-vm.sh verify +``` + +The wrapper restores the newest general `e2e` snapshot, then adds only the native app prerequisites: + +1. .NET 10 SDK. +2. Windows SDK 10.0.26100. +3. WebView2 Runtime. +4. A clean `openclaw-windows-node` checkout and `scripts/setup-dev.ps1 -CheckOnly`. +5. A dated power-off `pre-openclaw-windows-app-e2e-*` snapshot. + +When today's app snapshot exists, `prepare` restores and verifies it. This discards post-snapshot +guest changes. Package installation reuses the OpenClaw controller's official WinGet manifest hash, +Authenticode publisher, ACL-restricted staging, reboot handling, and bounded transport. + +## Restore snapshots + +- `clean`: newest raw Windows snapshot. +- `e2e`: newest general OpenClaw Windows snapshot. +- `app`: newest native Windows app snapshot. + +```bash +./scripts/parallels-windows-vm.sh restore --snapshot app +./scripts/parallels-windows-vm.sh restore --snapshot "pre-openclaw-windows-app-e2e-" +``` + +Never restore while another developer or smoke lane owns the VM. Use an exact name or id for +historical reproduction; aliases select the newest matching snapshot. + +## Run required validation + +```bash +./scripts/parallels-windows-vm.sh run-tests +./scripts/parallels-windows-vm.sh run-tests --ref +``` + +The default restores `app`. The controller copies the current +`scripts/parallels-run-validation.ps1` into guest temp, launches it in the desktop session, polls +with short host-bounded `prlctl exec` calls, stops the process tree at 90 minutes, and runs: + +- `./build.ps1` +- Shared tests +- Tray tests + +For an unpushed reviewed change, restore `app`, sync an isolated guest checkout, then use +`run-tests --no-restore`. Never use `--no-restore` as clean-snapshot evidence. + +Continue with the proof checklist in [SKILL.md](SKILL.md) when the change needs screenshots/video, +`winnode` or raw MCP output, Gateway invocation, accessibility, permissions, Command Center, +chat/canvas, or MXC evidence. + +## Remote execution rules + +- Use `--current-user` for checkout, app launch, tests, Git, and user state. +- Use SYSTEM only for machine installers through the shared verified staging path. +- Use explicit `.cmd` shims for `npm`, `pnpm`, and `openclaw` when resolution is ambiguous. + +## Troubleshooting + +- Missing .NET, Windows SDK, or WebView2: rerun `prepare`; do not add app prerequisites to the + general `e2e` snapshot. +- Missing `app` snapshot: run `prepare`; an older general E2E snapshot does not contain the native + app layer. +- Locked build output: stop the companion/WinUI process, restore `app`, and rerun all required + suites. +- Missing sibling controller/API: use an OpenClaw revision containing + `scripts/e2e/parallels-windows-prepare.sh`. diff --git a/.agents/skills/openclaw-proof-validation/SKILL.md b/.agents/skills/openclaw-proof-validation/SKILL.md index 098aeb71e..c0015a968 100644 --- a/.agents/skills/openclaw-proof-validation/SKILL.md +++ b/.agents/skills/openclaw-proof-validation/SKILL.md @@ -7,6 +7,8 @@ description: "Plan and collect OpenClaw Windows validation/proof: tests, rubber- Use for changes that affect tray UX, Settings, onboarding, chat/canvas, Command Center, Windows node capabilities, local MCP, gateway connection/pairing, permissions, diagnostics, or agent-facing instructions. +If the validation host is macOS, read [PARALLELS.md](PARALLELS.md) for the optional local Parallels Windows VM workflow. + ## Rules - Required automated/focused tests are mandatory. Do not ask to skip them. diff --git a/AGENTS.md b/AGENTS.md index b4a66cbe9..40a4d0a97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,14 +36,6 @@ Notes: Run the required validation above for every code change, then add the targeted path that matches the touched subsystem. -### Parallels Windows VM setup and proof - -Use `.agents/skills/openclaw-parallels-windows/SKILL.md` to prepare or restore the macOS-hosted -Parallels Windows baseline, manage snapshots, run the required suites inside the guest, or debug -the companion-specific app layer. General VM lifecycle, `prlctl`, WSL, Git/Node, snapshot, and -OpenClaw smoke policy is owned by sibling `openclaw`; this repo wrapper adds only .NET, Windows SDK, -WebView2, the app checkout, app-layer snapshots, required native suites, and native proof routing. - ### MXC / `system.run` / Windows node command execution When changing MXC sandboxing, `system.run`, exec approvals, Windows node command execution, gateway setup/connect E2E behavior, or files under `src\OpenClaw.Shared\Mxc`, run: