#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" 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="" PACKAGE_SPEC="" 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="" API_KEY_VALUE="" PROGRESS_INTERVAL_S=15 PROGRESS_STALE_S=60 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 fi rm -rf "$MAIN_TGZ_DIR" } trap cleanup EXIT usage() { cat <<'EOF' Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options] Options: --package-spec Baseline npm package spec. Default: openclaw@latest --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 ;; --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_linux_vm_name() { local json requested json="$(prlctl list --all --json)" requested="$LINUX_VM" PRL_VM_JSON="$json" REQUESTED_VM_NAME="$requested" python3 - <<'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)" } 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() { python3 - <<'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" \ | python3 -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" } 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]$TgzUrl, [Parameter(Mandatory = $true)][string]$HeadShort, [Parameter(Mandatory = $true)][string]$SessionId, [Parameter(Mandatory = $true)][string]$ModelId, [Parameter(Mandatory = $true)][string]$ProviderKeyEnv, [Parameter(Mandatory = $true)][string]$ProviderKey, [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() } 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" $tgz = Join-Path $env:TEMP 'openclaw-main-update.tgz' Remove-Item $tgz, $LogPath, $DonePath -Force -ErrorAction SilentlyContinue Write-ProgressLog 'update.start' Set-Item -Path ('Env:' + $ProviderKeyEnv) -Value $ProviderKey Write-ProgressLog 'update.download-tgz' Invoke-Logged 'download current tgz' { curl.exe -fsSL $TgzUrl -o $tgz } Write-ProgressLog 'update.install-tgz' Invoke-Logged 'npm install current tgz' { npm.cmd install -g $tgz --no-fund --no-audit } $openclaw = Join-Path $env:APPDATA 'npm\openclaw.cmd' Write-ProgressLog 'update.verify-version' $version = Invoke-CaptureLogged 'openclaw --version' { & $openclaw --version } if ($version -notmatch [regex]::Escape($HeadShort)) { throw "version mismatch: expected substring $HeadShort" } Write-ProgressLog $version 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. Write-ProgressLog 'update.restart-gateway' Invoke-Logged 'openclaw gateway restart' { & $openclaw gateway restart } Start-Sleep -Seconds 5 Write-ProgressLog 'update.gateway-status' Invoke-Logged 'openclaw gateway status' { & $openclaw gateway status --deep --require-rpc } 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 current main tgz on $HOST_IP:$HOST_PORT" ( cd "$MAIN_TGZ_DIR" exec python3 -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 warn "$label failed" if [[ -n "$log_path" ]]; then dump_log_tail "$label" "$log_path" fi return 1 } extract_log_progress() { local log_path="$1" python3 - "$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 ! kill -0 "${pids[$i]}" >/dev/null 2>&1; 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" python3 - "$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" python3 - <<'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" python3 - "$@" <<'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 } guest_powershell_poll() { local timeout_s="$1" local script="$2" local encoded encoded="$( SCRIPT_CONTENT="$script" python3 - <<'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 tgz_url="$2" local head_short="$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 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" start_seconds="$SECONDS" poll_deadline=$((SECONDS + 900)) startup_checked=0 guest_powershell "$(cat <