#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$ROOT_DIR/scripts/e2e/lib/parallels-macos-common.sh" MACOS_VM="macOS Tahoe" WINDOWS_VM="Windows 11" LINUX_VM="Ubuntu 24.04.3 ARM64" PROVIDER="openai" API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" PYTHON_BIN="${PYTHON_BIN:-}" PACKAGE_SPEC="" UPDATE_TARGET="" JSON_OUTPUT=0 RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-npm-update.XXXXXX)" MAIN_TGZ_DIR="$(mktemp -d)" MAIN_TGZ_PATH="" WINDOWS_UPDATE_SCRIPT_PATH="" SERVER_PID="" HOST_IP="" HOST_PORT="" LATEST_VERSION="" CURRENT_HEAD="" CURRENT_HEAD_SHORT="" UPDATE_TARGET_EFFECTIVE="" UPDATE_EXPECTED_NEEDLE="" API_KEY_VALUE="" PROGRESS_INTERVAL_S=15 PROGRESS_STALE_S=60 TIMEOUT_UPDATE_S="${OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S:-900}" TIMEOUT_UPDATE_POLL_GRACE_S=60 child_job_running() { local target="$1" local ppid kill -0 "$target" >/dev/null 2>&1 || return 1 ppid="$(ps -o ppid= -p "$target" 2>/dev/null | tr -d '[:space:]')" [[ "$ppid" == "$$" ]] } MACOS_FRESH_STATUS="skip" WINDOWS_FRESH_STATUS="skip" LINUX_FRESH_STATUS="skip" MACOS_UPDATE_STATUS="skip" WINDOWS_UPDATE_STATUS="skip" LINUX_UPDATE_STATUS="skip" MACOS_UPDATE_VERSION="skip" WINDOWS_UPDATE_VERSION="skip" LINUX_UPDATE_VERSION="skip" say() { printf '==> %s\n' "$*" } warn() { printf 'warn: %s\n' "$*" >&2 } die() { printf 'error: %s\n' "$*" >&2 exit 1 } cleanup() { if [[ -n "${SERVER_PID:-}" ]]; then kill "$SERVER_PID" >/dev/null 2>&1 || true wait "$SERVER_PID" 2>/dev/null || true fi rm -rf "$MAIN_TGZ_DIR" } trap cleanup EXIT resolve_python_bin() { local candidate python_bin_usable() { "$1" - <<'PY' >/dev/null 2>&1 import sys if sys.version_info < (3, 10): raise SystemExit(1) _value: tuple[int, ...] | None = None PY } if [[ -n "$PYTHON_BIN" ]]; then [[ -x "$PYTHON_BIN" ]] || die "PYTHON_BIN is not executable: $PYTHON_BIN" python_bin_usable "$PYTHON_BIN" || die "PYTHON_BIN must be Python 3.10+: $PYTHON_BIN" return fi for candidate in "$(command -v python3 || true)" /opt/homebrew/bin/python3 /usr/local/bin/python3 /usr/bin/python3; do [[ -n "$candidate" && -x "$candidate" ]] || continue if python_bin_usable "$candidate"; then PYTHON_BIN="$candidate" return fi done die "Python 3.10+ is required" } usage() { cat <<'EOF' Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options] Options: --package-spec Baseline npm package spec. Default: openclaw@latest --update-target Target passed to guest 'openclaw update --tag'. Default: host-served tgz packed from current checkout. Examples: latest, beta, 2026.4.10, http://host/openclaw.tgz --provider Provider auth/model lane. Default: openai --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) --json Print machine-readable JSON summary. -h, --help Show help. EOF } while [[ $# -gt 0 ]]; do case "$1" in --) shift ;; --package-spec) PACKAGE_SPEC="$2" shift 2 ;; --update-target) UPDATE_TARGET="$2" shift 2 ;; --provider) PROVIDER="$2" shift 2 ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 ;; --json) JSON_OUTPUT=1 shift ;; -h|--help) usage exit 0 ;; *) die "unknown arg: $1" ;; esac done case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" MODEL_ID="openai/gpt-5.4" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" MODEL_ID="anthropic/claude-sonnet-4-6" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" MODEL_ID="minimax/MiniMax-M2.7" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) die "invalid --provider: $PROVIDER" ;; esac API_KEY_VALUE="${!API_KEY_ENV:-}" [[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required" resolve_python_bin resolve_linux_vm_name() { local json requested json="$(prlctl list --all --json)" requested="$LINUX_VM" PRL_VM_JSON="$json" REQUESTED_VM_NAME="$requested" "$PYTHON_BIN" - <<'PY' import difflib import json import os import re import sys payload = json.loads(os.environ["PRL_VM_JSON"]) requested = os.environ["REQUESTED_VM_NAME"].strip() requested_lower = requested.lower() names = [str(item.get("name", "")).strip() for item in payload if str(item.get("name", "")).strip()] def parse_ubuntu_version(name: str) -> tuple[int, ...] | None: match = re.search(r"ubuntu\s+(\d+(?:\.\d+)*)", name, re.IGNORECASE) if not match: return None return tuple(int(part) for part in match.group(1).split(".")) def version_distance(version: tuple[int, ...], target: tuple[int, ...]) -> tuple[int, ...]: width = max(len(version), len(target)) padded_version = version + (0,) * (width - len(version)) padded_target = target + (0,) * (width - len(target)) return tuple(abs(a - b) for a, b in zip(padded_version, padded_target)) if requested in names: print(requested) raise SystemExit(0) ubuntu_names = [name for name in names if "ubuntu" in name.lower()] if not ubuntu_names: sys.exit(f"default vm not found and no Ubuntu fallback available: {requested}") requested_version = parse_ubuntu_version(requested) or (24,) ubuntu_with_versions = [ (name, parse_ubuntu_version(name)) for name in ubuntu_names ] ubuntu_ge_24 = [ (name, version) for name, version in ubuntu_with_versions if version and version[0] >= 24 ] if ubuntu_ge_24: best_name = min( ubuntu_ge_24, key=lambda item: ( version_distance(item[1], requested_version), -len(item[1]), item[0].lower(), ), )[0] print(best_name) raise SystemExit(0) best_name = max( ubuntu_names, key=lambda name: difflib.SequenceMatcher(None, requested_lower, name.lower()).ratio(), ) print(best_name) PY } resolve_latest_version() { npm view openclaw version --userconfig "$(mktemp)" } vm_status() { local json vm_name vm_name="$1" json="$(prlctl list --all --json)" PRL_VM_JSON="$json" VM_NAME="$vm_name" "$PYTHON_BIN" - <<'PY' import json import os name = os.environ["VM_NAME"] for vm in json.loads(os.environ["PRL_VM_JSON"]): if vm.get("name") == name: print(vm.get("status", "unknown")) break else: print("missing") PY } ensure_vm_running_for_update() { local vm_name status deadline vm_name="$1" deadline=$((SECONDS + 180)) while :; do status="$(vm_status "$vm_name")" case "$status" in running) return 0 ;; stopped) say "Start $vm_name before update phase" prlctl start "$vm_name" >/dev/null ;; suspended|paused) say "Resume $vm_name before update phase" prlctl resume "$vm_name" >/dev/null ;; restoring|stopping|starting|pausing|suspending|resuming) ;; missing) die "VM not found before update phase: $vm_name" ;; *) warn "unexpected VM state for $vm_name before update phase: $status" ;; esac if (( SECONDS >= deadline )); then die "VM did not become running before update phase: $vm_name ($status)" fi sleep 5 done } resolve_host_ip() { local detected detected="$(ifconfig | awk '/inet 10\.211\./ { print $2; exit }')" [[ -n "$detected" ]] || die "failed to detect Parallels host IP" printf '%s\n' "$detected" } allocate_host_port() { "$PYTHON_BIN" - <<'PY' import socket sock = socket.socket() sock.bind(("0.0.0.0", 0)) print(sock.getsockname()[1]) sock.close() PY } ensure_current_build() { say "Build dist for current head" pnpm build } pack_main_tgz() { local pkg CURRENT_HEAD="$(git rev-parse HEAD)" CURRENT_HEAD_SHORT="$(git rev-parse --short=7 HEAD)" ensure_current_build pkg="$( npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \ | "$PYTHON_BIN" -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])' )" MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$CURRENT_HEAD_SHORT.tgz" cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH" } resolve_current_head() { CURRENT_HEAD="$(git rev-parse HEAD)" CURRENT_HEAD_SHORT="$(git rev-parse --short=7 HEAD)" } resolve_registry_target_version() { local target="$1" local spec="$target" if [[ "$spec" != openclaw@* ]]; then spec="openclaw@$spec" fi npm view "$spec" version 2>/dev/null || true } is_explicit_package_target() { local target="$1" [[ "$target" == *"://"* || "$target" == *"#"* || "$target" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]] } write_windows_update_script() { WINDOWS_UPDATE_SCRIPT_PATH="$MAIN_TGZ_DIR/openclaw-main-update.ps1" cat >"$WINDOWS_UPDATE_SCRIPT_PATH" <<'EOF' param( [Parameter(Mandatory = $true)][string]$UpdateTarget, [Parameter(Mandatory = $true)][string]$ExpectedNeedle, [Parameter(Mandatory = $true)][string]$SessionId, [Parameter(Mandatory = $true)][string]$ModelId, [Parameter(Mandatory = $true)][string]$ProviderKeyEnv, [Parameter(Mandatory = $false)][string]$ProviderKey, [Parameter(Mandatory = $false)][string]$ProviderKeyFile, [Parameter(Mandatory = $true)][string]$LogPath, [Parameter(Mandatory = $true)][string]$DonePath ) $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false function Write-ProgressLog { param([Parameter(Mandatory = $true)][string]$Stage) "==> $Stage" | Tee-Object -FilePath $LogPath -Append | Out-Null } function Invoke-Logged { param( [Parameter(Mandatory = $true)][string]$Label, [Parameter(Mandatory = $true)][scriptblock]$Command ) $output = $null $previousErrorActionPreference = $ErrorActionPreference $previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference try { $ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false # Merge native stderr into stdout before logging so npm/openclaw warnings do not # surface as PowerShell error records and abort a healthy in-place update. $output = & $Command *>&1 $exitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorActionPreference $PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference } if ($null -ne $output) { $output | Tee-Object -FilePath $LogPath -Append | Out-Null } if ($exitCode -ne 0) { throw "$Label failed with exit code $exitCode" } } function Invoke-CaptureLogged { param( [Parameter(Mandatory = $true)][string]$Label, [Parameter(Mandatory = $true)][scriptblock]$Command ) $previousErrorActionPreference = $ErrorActionPreference $previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference try { $ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false $output = & $Command *>&1 $exitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorActionPreference $PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference } if ($null -ne $output) { $output | Tee-Object -FilePath $LogPath -Append | Out-Null } if ($exitCode -ne 0) { throw "$Label failed with exit code $exitCode" } return ($output | Out-String).Trim() } function Wait-GatewayRpcReady { param( [Parameter(Mandatory = $true)][string]$OpenClawPath, [int]$Attempts = 20, [int]$SleepSeconds = 3 ) for ($attempt = 1; $attempt -le $Attempts; $attempt++) { Write-ProgressLog "update.gateway-status.attempt-$attempt" try { Invoke-Logged 'openclaw gateway status' { & $OpenClawPath gateway status --deep --require-rpc } return } catch { if ($attempt -ge $Attempts) { throw } Write-ProgressLog "update.gateway-status.retry-$attempt" Start-Sleep -Seconds $SleepSeconds } } } function Stop-OpenClawGatewayProcesses { Write-ProgressLog 'update.stop-old-gateway' $patterns = @( 'openclaw-gateway', 'openclaw.*gateway --port 18789', 'openclaw.*gateway run', 'openclaw\.mjs gateway', 'dist\\index\.js gateway --port 18789' ) Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $commandLine = $_.CommandLine if (-not $commandLine) { $false } else { $matched = $false foreach ($pattern in $patterns) { if ($commandLine -match $pattern) { $matched = $true break } } $matched } } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } Get-NetTCPConnection -LocalPort 18789 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } Start-Sleep -Seconds 2 } function Complete-WorkspaceSetup { $workspace = $env:OPENCLAW_WORKSPACE_DIR if (-not $workspace) { $workspace = Join-Path $env:USERPROFILE '.openclaw\workspace' } $stateDir = Join-Path $workspace '.openclaw' New-Item -ItemType Directory -Path $stateDir -Force | Out-Null @' # Identity - Name: OpenClaw - Purpose: Parallels npm update smoke test assistant. '@ | Set-Content -Path (Join-Path $workspace 'IDENTITY.md') -Encoding UTF8 @' { "version": 1, "setupCompletedAt": "2026-01-01T00:00:00.000Z" } '@ | Set-Content -Path (Join-Path $stateDir 'workspace-state.json') -Encoding UTF8 Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue } function Restart-GatewayWithRecovery { param( [Parameter(Mandatory = $true)][string]$OpenClawPath ) $restartFailed = $false $restartJob = Start-Job -ScriptBlock { param([string]$Path) $output = & $Path gateway restart *>&1 [pscustomobject]@{ ExitCode = $LASTEXITCODE Output = ($output | Out-String).Trim() } } -ArgumentList $OpenClawPath $restartCompleted = Wait-Job $restartJob -Timeout 20 if ($null -ne $restartCompleted) { $restartResult = Receive-Job $restartJob if ($null -ne $restartResult.Output -and $restartResult.Output.Length -gt 0) { $restartResult.Output | Tee-Object -FilePath $LogPath -Append | Out-Null } if ($restartResult.ExitCode -ne 0) { $restartFailed = $true Write-ProgressLog 'update.restart-gateway.soft-fail' "openclaw gateway restart failed with exit code $($restartResult.ExitCode)" | Tee-Object -FilePath $LogPath -Append | Out-Null } } else { $restartFailed = $true Stop-Job $restartJob -ErrorAction SilentlyContinue Write-ProgressLog 'update.restart-gateway.timeout' 'openclaw gateway restart timed out after 20s; continuing to RPC readiness checks' | Tee-Object -FilePath $LogPath -Append | Out-Null } Remove-Job $restartJob -Force -ErrorAction SilentlyContinue Write-ProgressLog 'update.gateway-status' try { Wait-GatewayRpcReady -OpenClawPath $OpenClawPath return } catch { if (-not $restartFailed) { throw } Write-ProgressLog 'update.gateway-start-recover' Invoke-Logged 'openclaw gateway start' { & $OpenClawPath gateway start } Write-ProgressLog 'update.gateway-status-recover' Wait-GatewayRpcReady -OpenClawPath $OpenClawPath } } try { $env:PATH = "$env:LOCALAPPDATA\OpenClaw\deps\portable-git\cmd;$env:LOCALAPPDATA\OpenClaw\deps\portable-git\mingw64\bin;$env:LOCALAPPDATA\OpenClaw\deps\portable-git\usr\bin;$env:PATH" Remove-Item $LogPath, $DonePath -Force -ErrorAction SilentlyContinue Write-ProgressLog 'update.start' if ($ProviderKeyFile) { $ProviderKey = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($ProviderKeyFile)) Remove-Item $ProviderKeyFile -Force -ErrorAction SilentlyContinue } if (-not $ProviderKey) { throw "$ProviderKeyEnv is required" } Set-Item -Path ('Env:' + $ProviderKeyEnv) -Value $ProviderKey $openclaw = Join-Path $env:APPDATA 'npm\openclaw.cmd' Stop-OpenClawGatewayProcesses Write-ProgressLog 'update.openclaw-update' Invoke-Logged 'openclaw update' { & $openclaw update --tag $UpdateTarget --yes --json } Write-ProgressLog 'update.verify-version' $version = Invoke-CaptureLogged 'openclaw --version' { & $openclaw --version } if ($ExpectedNeedle -and $version -notmatch [regex]::Escape($ExpectedNeedle)) { throw "version mismatch: expected substring $ExpectedNeedle" } Write-ProgressLog $version Write-ProgressLog 'update.status' Invoke-Logged 'openclaw update status' { & $openclaw update status --json } Write-ProgressLog 'update.set-model' Invoke-Logged 'openclaw models set' { & $openclaw models set $ModelId } # Windows can keep the old hashed dist modules alive across in-place global npm upgrades. # Restart the gateway/service before verifying status or the next agent turn. # Current login-item restarts can report failure before the background service # is fully observable again, so verify readiness separately and fall back to # an explicit start only if the RPC endpoint never returns. Write-ProgressLog 'update.restart-gateway' Restart-GatewayWithRecovery -OpenClawPath $openclaw Complete-WorkspaceSetup Write-ProgressLog 'update.agent-turn' Invoke-CaptureLogged 'openclaw agent' { & $openclaw agent --agent main --session-id $SessionId --message 'Reply with exact ASCII text OK only.' --json } | Out-Null $exitCode = $LASTEXITCODE if ($null -eq $exitCode) { $exitCode = 0 } Write-ProgressLog 'update.done' Set-Content -Path $DonePath -Value ([string]$exitCode) exit $exitCode } catch { if (Test-Path $LogPath) { Add-Content -Path $LogPath -Value ($_ | Out-String) } else { ($_ | Out-String) | Set-Content -Path $LogPath } Set-Content -Path $DonePath -Value '1' exit 1 } EOF } start_server() { HOST_IP="$(resolve_host_ip)" HOST_PORT="$(allocate_host_port)" say "Serve update helper artifacts on $HOST_IP:$HOST_PORT" ( cd "$MAIN_TGZ_DIR" exec "$PYTHON_BIN" -m http.server "$HOST_PORT" --bind 0.0.0.0 ) >/tmp/openclaw-parallels-npm-update-http.log 2>&1 & SERVER_PID=$! sleep 1 kill -0 "$SERVER_PID" >/dev/null 2>&1 || die "failed to start host HTTP server" } wait_job() { local label="$1" local pid="$2" local log_path="${3:-}" if wait "$pid"; then return 0 fi if [[ -n "$log_path" && "$label" == *"update"* ]] && update_log_completed "$log_path"; then warn "$label exited nonzero after completion markers; treating as pass" return 0 fi if [[ "$label" == "macOS update" ]] && verify_macos_update_after_transport_loss "$UPDATE_EXPECTED_NEEDLE"; then warn "$label transport failed after product verification passed; treating as pass" return 0 fi if [[ "$label" == "Windows update" ]] && verify_windows_update_after_transport_loss "$UPDATE_EXPECTED_NEEDLE"; then warn "$label transport failed after product verification passed; treating as pass" return 0 fi warn "$label failed" if [[ -n "$log_path" ]]; then dump_log_tail "$label" "$log_path" fi return 1 } update_log_completed() { local log_path="$1" [[ -f "$log_path" ]] || return 1 "$PYTHON_BIN" - "$log_path" <<'PY' import pathlib import sys text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace") if "==> update.done" in text: raise SystemExit(0) if '"finalAssistantRawText": "OK"' in text: raise SystemExit(0) if '"finalAssistantVisibleText": "OK"' in text: raise SystemExit(0) raise SystemExit(1) PY } verify_macos_update_after_transport_loss() { local expected_needle="$1" local script_path="/tmp/openclaw-npm-update-macos-recover.sh" cat </dev/null set -euo pipefail export PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin busy="\$(/bin/ps -axo command | /usr/bin/egrep 'openclaw update|npm install|pnpm install|pnpm run build' | /usr/bin/egrep -v 'egrep|openclaw-npm-update-macos-recover' || true)" if [ -n "\$busy" ]; then printf 'update still has active npm/pnpm/openclaw processes\n%s\n' "\$busy" >&2 exit 1 fi version="\$(/opt/homebrew/bin/openclaw --version)" printf '%s\n' "\$version" if [ -n "$expected_needle" ]; then case "\$version" in *"$expected_needle"*) ;; *) echo "version mismatch after transport loss: expected substring $expected_needle" >&2 exit 1 ;; esac fi /opt/homebrew/bin/openclaw gateway status --deep --require-rpc >/dev/null 2>&1 || /opt/homebrew/bin/openclaw gateway restart || true gateway_ready=0 for _ in 1 2 3 4 5 6; do if /opt/homebrew/bin/openclaw gateway status --deep --require-rpc; then gateway_ready=1 break fi sleep 2 done if [ "\$gateway_ready" != "1" ]; then /opt/homebrew/bin/openclaw gateway start || true for _ in 1 2 3 4 5 6; do if /opt/homebrew/bin/openclaw gateway status --deep --require-rpc; then gateway_ready=1 break fi sleep 2 done fi if [ "\$gateway_ready" != "1" ]; then echo "gateway did not become RPC-ready after transport recovery" >&2 exit 1 fi workspace="\${OPENCLAW_WORKSPACE_DIR:-\$HOME/.openclaw/workspace}" mkdir -p "\$workspace/.openclaw" cat > "\$workspace/IDENTITY.md" <<'IDENTITY_EOF' # Identity - Name: OpenClaw - Purpose: Parallels npm update smoke test assistant. IDENTITY_EOF cat > "\$workspace/.openclaw/workspace-state.json" <<'STATE_EOF' { "version": 1, "setupCompletedAt": "2026-01-01T00:00:00.000Z" } STATE_EOF rm -f "\$workspace/BOOTSTRAP.md" /opt/homebrew/bin/openclaw models set "$MODEL_ID" /opt/homebrew/bin/openclaw agent --agent main --session-id "parallels-npm-update-macos-transport-recovery-$expected_needle" --message "Reply with exact ASCII text OK only." --json EOF macos_desktop_user_exec /bin/bash "$script_path" } verify_windows_update_after_transport_loss() { local expected_needle="$1" local provider_key_b64 provider_key_b64="$( PROVIDER_KEY="$API_KEY_VALUE" "$PYTHON_BIN" - <<'PY' import base64 import os print(base64.b64encode(os.environ["PROVIDER_KEY"].encode("utf-8")).decode("ascii")) PY )" set +e guest_powershell_poll 120 "$(cat </dev/null 2>&1; then warn "$label exceeded ${timeout_s}s; stopping" if [[ -n "$log_path" ]]; then dump_log_tail "$label" "$log_path" fi kill "$pid" >/dev/null 2>&1 || true sleep 2 kill -9 "$pid" >/dev/null 2>&1 || true fi ) >&2 & printf '%s\n' "$!" } stop_timeout_guard() { local pid="${1:-}" [[ -n "$pid" ]] || return 0 kill "$pid" >/dev/null 2>&1 || true wait "$pid" 2>/dev/null || true } extract_log_progress() { local log_path="$1" "$PYTHON_BIN" - "$log_path" <<'PY' import pathlib import sys path = pathlib.Path(sys.argv[1]) if not path.exists(): print("") raise SystemExit(0) text = path.read_text(encoding="utf-8", errors="replace") lines = [line.strip() for line in text.splitlines() if line.strip()] for line in reversed(lines): if line.startswith("==> "): print(line[4:].strip()) raise SystemExit(0) for line in reversed(lines): if line.startswith("warn:") or line.startswith("error:"): print(line) raise SystemExit(0) if lines: print(lines[-1][:240]) else: print("") PY } dump_log_tail() { local label="$1" local log_path="$2" [[ -f "$log_path" ]] || return 0 warn "$label log tail ($log_path)" tail -n 40 "$log_path" >&2 || true } monitor_jobs_progress() { local group="$1" shift local labels=() local pids=() local logs=() local last_progress=() local last_print=() local i summary now running while [[ $# -gt 0 ]]; do labels+=("$1") pids+=("$2") logs+=("$3") last_progress+=("") last_print+=(0) shift 3 done say "$group progress; run dir: $RUN_DIR" while :; do running=0 now=$SECONDS for ((i = 0; i < ${#pids[@]}; i++)); do if ! child_job_running "${pids[$i]}"; then continue fi running=1 summary="$(extract_log_progress "${logs[$i]}")" [[ -n "$summary" ]] || summary="waiting for first log line" if [[ "${last_progress[i]}" != "$summary" ]] || (( now - last_print[i] >= PROGRESS_STALE_S )); then say "$group ${labels[$i]}: $summary" last_progress[i]="$summary" last_print[i]=$now fi done (( running )) || break sleep "$PROGRESS_INTERVAL_S" done } extract_last_version() { local log_path="$1" "$PYTHON_BIN" - "$log_path" <<'PY' import pathlib import re import sys text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace") matches = re.findall(r"OpenClaw [^\r\n]+", text) matches = [match for match in matches if re.search(r"OpenClaw \d", match)] print(matches[-1] if matches else "") PY } guest_powershell() { local script="$1" local encoded encoded="$( SCRIPT_CONTENT="$script" "$PYTHON_BIN" - <<'PY' import base64 import os script = "$ProgressPreference = 'SilentlyContinue'\n" + os.environ["SCRIPT_CONTENT"] payload = script.encode("utf-16le") print(base64.b64encode(payload).decode("ascii")) PY )" prlctl exec "$WINDOWS_VM" --current-user powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$encoded" } host_timeout_exec() { local timeout_s="$1" shift HOST_TIMEOUT_S="$timeout_s" "$PYTHON_BIN" - "$@" <<'PY' import os import subprocess import sys timeout = int(os.environ["HOST_TIMEOUT_S"]) args = sys.argv[1:] try: completed = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) except subprocess.TimeoutExpired as exc: if exc.stdout: sys.stdout.buffer.write(exc.stdout) if exc.stderr: sys.stderr.buffer.write(exc.stderr) sys.stderr.write(f"host timeout after {timeout}s\n") raise SystemExit(124) if completed.stdout: sys.stdout.buffer.write(completed.stdout) if completed.stderr: sys.stderr.buffer.write(completed.stderr) raise SystemExit(completed.returncode) PY } macos_desktop_user_exec() { parallels_macos_desktop_user_exec "$MACOS_VM" "$API_KEY_ENV" "$API_KEY_VALUE" "$@" } guest_powershell_poll() { local timeout_s="$1" local script="$2" local encoded encoded="$( SCRIPT_CONTENT="$script" "$PYTHON_BIN" - <<'PY' import base64 import os script = "$ProgressPreference = 'SilentlyContinue'\n" + os.environ["SCRIPT_CONTENT"] payload = script.encode("utf-16le") print(base64.b64encode(payload).decode("ascii")) PY )" host_timeout_exec "$timeout_s" prlctl exec "$WINDOWS_VM" --current-user powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$encoded" } run_windows_script_via_log() { local script_url="$1" local update_target="$2" local expected_needle="$3" local session_id="$4" local model_id="$5" local provider_key_env="$6" local provider_key="$7" local runner_name log_name done_name done_status launcher_state guest_log local start_seconds poll_deadline startup_checked poll_rc state_rc log_rc local log_state_path provider_key_b64 runner_name="openclaw-update-$RANDOM-$RANDOM.ps1" log_name="openclaw-update-$RANDOM-$RANDOM.log" done_name="openclaw-update-$RANDOM-$RANDOM.done" log_state_path="$(mktemp "${TMPDIR:-/tmp}/openclaw-update-log-state.XXXXXX")" : >"$log_state_path" provider_key_b64="$( PROVIDER_KEY="$provider_key" "$PYTHON_BIN" - <<'PY' import base64 import os print(base64.b64encode(os.environ["PROVIDER_KEY"].encode("utf-8")).decode("ascii")) PY )" start_seconds="$SECONDS" poll_deadline=$((SECONDS + TIMEOUT_UPDATE_S + TIMEOUT_UPDATE_POLL_GRACE_S)) startup_checked=0 guest_powershell "$(cat <