mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:30:45 +00:00
2026 lines
65 KiB
Bash
Executable File
2026 lines
65 KiB
Bash
Executable File
#!/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"
|
|
source "$ROOT_DIR/scripts/e2e/lib/parallels-package-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=""
|
|
RUN_PLATFORMS="all"
|
|
JSON_OUTPUT=0
|
|
RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-npm-update.XXXXXX)"
|
|
MAIN_TGZ_DIR="$(mktemp -d)"
|
|
MAIN_TGZ_PATH=""
|
|
BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock"
|
|
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:-1200}"
|
|
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 <npm-spec> Baseline npm package spec. Default: openclaw@latest
|
|
--update-target <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
|
|
--platform <list> Comma-separated platforms to run: all, macos, windows, linux.
|
|
Default: all
|
|
--provider <openai|anthropic|minimax>
|
|
Provider auth/model lane. Default: openai
|
|
--api-key-env <var> Host env var name for provider API key.
|
|
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
|
|
--openai-api-key-env <var> 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
|
|
;;
|
|
--platform|--only)
|
|
RUN_PLATFORMS="$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
|
|
|
|
platform_enabled() {
|
|
local platform="$1"
|
|
[[ "$RUN_PLATFORMS" == "all" ]] && return 0
|
|
case ",$RUN_PLATFORMS," in
|
|
*,"$platform",*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
validate_platforms() {
|
|
local normalized entry valid_any
|
|
local -a entries
|
|
normalized="${RUN_PLATFORMS// /}"
|
|
[[ -n "$normalized" ]] || die "--platform must not be empty"
|
|
RUN_PLATFORMS="$normalized"
|
|
if [[ "$RUN_PLATFORMS" == "all" ]]; then
|
|
return
|
|
fi
|
|
valid_any=0
|
|
IFS=',' read -ra entries <<<"$RUN_PLATFORMS"
|
|
for entry in "${entries[@]}"; do
|
|
case "$entry" in
|
|
macos|windows|linux)
|
|
valid_any=1
|
|
;;
|
|
*)
|
|
die "invalid --platform entry: $entry"
|
|
;;
|
|
esac
|
|
done
|
|
[[ "$valid_any" -eq 1 ]] || die "--platform must include at least one platform"
|
|
}
|
|
|
|
validate_platforms
|
|
|
|
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
|
|
from typing import Optional
|
|
|
|
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) -> Optional[tuple[int, ...]]:
|
|
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
|
|
}
|
|
|
|
current_build_commit() {
|
|
parallels_package_current_build_commit
|
|
}
|
|
|
|
source_tree_dirty_for_build() {
|
|
[[ -n "$(git status --porcelain -- src ui packages extensions package.json pnpm-lock.yaml 'tsconfig*.json' 2>/dev/null)" ]]
|
|
}
|
|
|
|
current_build_has_control_ui() {
|
|
[[ -f dist/control-ui/index.html ]] || return 1
|
|
compgen -G "dist/control-ui/assets/*" >/dev/null
|
|
}
|
|
|
|
ensure_current_build() {
|
|
local build_commit head rc
|
|
head="$(git rev-parse HEAD)"
|
|
build_commit="$(current_build_commit)"
|
|
if [[ "$build_commit" == "$head" ]] && ! source_tree_dirty_for_build && current_build_has_control_ui; then
|
|
return 0
|
|
fi
|
|
say "Build dist for current head"
|
|
pnpm build
|
|
rc=$?
|
|
if [[ $rc -eq 0 ]]; then
|
|
pnpm ui:build
|
|
rc=$?
|
|
fi
|
|
if [[ $rc -eq 0 ]]; then
|
|
parallels_package_assert_no_generated_drift
|
|
rc=$?
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
write_package_dist_inventory() {
|
|
parallels_package_write_dist_inventory
|
|
}
|
|
|
|
pack_main_tgz() {
|
|
local pkg rc
|
|
CURRENT_HEAD="$(git rev-parse HEAD)"
|
|
CURRENT_HEAD_SHORT="$(git rev-parse --short=7 HEAD)"
|
|
parallels_package_acquire_build_lock "$BUILD_LOCK_DIR"
|
|
set +e
|
|
{
|
|
ensure_current_build &&
|
|
write_package_dist_inventory &&
|
|
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"])'
|
|
)"
|
|
}
|
|
rc=$?
|
|
set -e
|
|
parallels_package_release_build_lock "$BUILD_LOCK_DIR"
|
|
[[ $rc -eq 0 ]] || return "$rc"
|
|
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 Test-GatewayListenerReady {
|
|
$listeners = Get-NetTCPConnection -LocalPort 18789 -State Listen -ErrorAction SilentlyContinue
|
|
return [bool]$listeners
|
|
}
|
|
|
|
function Test-GatewayLogReady {
|
|
$logDir = Join-Path $env:LOCALAPPDATA 'Temp\openclaw'
|
|
if (-not (Test-Path $logDir)) {
|
|
return $false
|
|
}
|
|
$logFile = Get-ChildItem -Path $logDir -Filter 'openclaw-*.log' -File -ErrorAction SilentlyContinue |
|
|
Sort-Object LastWriteTime -Descending |
|
|
Select-Object -First 1
|
|
if (-not $logFile) {
|
|
return $false
|
|
}
|
|
try {
|
|
$tail = Get-Content -Path $logFile.FullName -Tail 120 -ErrorAction Stop | Out-String
|
|
} catch {
|
|
return $false
|
|
}
|
|
return $tail -match '"ready \('
|
|
}
|
|
|
|
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"
|
|
if ((Test-GatewayListenerReady) -and (Test-GatewayLogReady)) {
|
|
Write-ProgressLog "update.gateway-status.ready-log-$attempt"
|
|
return $true
|
|
}
|
|
try {
|
|
$probeOutput = Invoke-CaptureLogged 'openclaw gateway probe' { & $OpenClawPath gateway probe --url ws://127.0.0.1:18789 --timeout 5000 --json }
|
|
$probe = $probeOutput | ConvertFrom-Json
|
|
if (-not $probe.ok) {
|
|
throw 'gateway probe returned without RPC readiness'
|
|
}
|
|
Invoke-CaptureLogged 'openclaw gateway status' { & $OpenClawPath gateway status --deep --require-rpc } | Out-Null
|
|
return $true
|
|
} catch {
|
|
if ($attempt -ge $Attempts) {
|
|
return $false
|
|
}
|
|
Write-ProgressLog "update.gateway-status.retry-$attempt"
|
|
Start-Sleep -Seconds $SleepSeconds
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Stop-GatewayScheduledTaskIfPresent {
|
|
$previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference
|
|
try {
|
|
$PSNativeCommandUseErrorActionPreference = $false
|
|
schtasks /End /TN 'OpenClaw Gateway' 2>$null | Out-Null
|
|
} catch {
|
|
} finally {
|
|
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
|
|
}
|
|
}
|
|
|
|
function Stop-OpenClawGatewayProcesses {
|
|
Write-ProgressLog 'update.stop-old-gateway'
|
|
Stop-GatewayScheduledTaskIfPresent
|
|
$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
|
|
}
|
|
for ($attempt = 1; $attempt -le 20; $attempt++) {
|
|
$listeners = Get-NetTCPConnection -LocalPort 18789 -State Listen -ErrorAction SilentlyContinue
|
|
if (-not $listeners) {
|
|
return
|
|
}
|
|
$listeners |
|
|
ForEach-Object {
|
|
Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Start-Sleep -Seconds 1
|
|
}
|
|
$remaining = Get-NetTCPConnection -LocalPort 18789 -State Listen -ErrorAction SilentlyContinue
|
|
if ($remaining) {
|
|
$pids = ($remaining | Select-Object -ExpandProperty OwningProcess -Unique) -join ', '
|
|
throw "gateway listener still active on port 18789 after stop attempts: $pids"
|
|
}
|
|
}
|
|
|
|
function Stop-OpenClawUpdateProcesses {
|
|
Write-ProgressLog 'update.stop-stale-update'
|
|
$patterns = @(
|
|
'openclaw.* update --tag ',
|
|
'openclaw.* completion --write-state'
|
|
)
|
|
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
|
|
}
|
|
} |
|
|
Sort-Object ParentProcessId -Descending |
|
|
ForEach-Object {
|
|
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
function Remove-FuturePluginEntries {
|
|
$configPath = Join-Path $env:USERPROFILE '.openclaw\openclaw.json'
|
|
if (-not (Test-Path $configPath)) {
|
|
return
|
|
}
|
|
try {
|
|
$config = Get-Content $configPath -Raw | ConvertFrom-Json -AsHashtable
|
|
} catch {
|
|
return
|
|
}
|
|
$plugins = $config['plugins']
|
|
if (-not ($plugins -is [hashtable])) {
|
|
return
|
|
}
|
|
$entries = $plugins['entries']
|
|
if ($entries -is [hashtable]) {
|
|
foreach ($pluginId in @('feishu', 'whatsapp')) {
|
|
if ($entries.ContainsKey($pluginId)) {
|
|
$entries.Remove($pluginId)
|
|
}
|
|
}
|
|
}
|
|
$allow = $plugins['allow']
|
|
if ($allow -is [array]) {
|
|
$plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') })
|
|
}
|
|
$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8
|
|
}
|
|
|
|
function Invoke-OpenClawUpdateWithTimeout {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$OpenClawPath,
|
|
[Parameter(Mandatory = $true)][string]$UpdateTarget,
|
|
[int]$TimeoutSeconds = 1200
|
|
)
|
|
|
|
$updateJob = Start-Job -ScriptBlock {
|
|
param([string]$Path, [string]$Target)
|
|
$output = & $Path update --tag $Target --yes --json *>&1
|
|
[pscustomobject]@{
|
|
ExitCode = $LASTEXITCODE
|
|
Output = ($output | Out-String).Trim()
|
|
}
|
|
} -ArgumentList $OpenClawPath, $UpdateTarget
|
|
|
|
$completed = Wait-Job $updateJob -Timeout $TimeoutSeconds
|
|
if ($null -ne $completed) {
|
|
$result = Receive-Job $updateJob
|
|
if ($null -ne $result.Output -and $result.Output.Length -gt 0) {
|
|
$result.Output | Tee-Object -FilePath $LogPath -Append | Out-Null
|
|
}
|
|
Remove-Job $updateJob -Force -ErrorAction SilentlyContinue
|
|
if ($result.ExitCode -ne 0) {
|
|
throw "openclaw update failed with exit code $($result.ExitCode)"
|
|
}
|
|
return
|
|
}
|
|
|
|
Stop-Job $updateJob -ErrorAction SilentlyContinue
|
|
Remove-Job $updateJob -Force -ErrorAction SilentlyContinue
|
|
Write-ProgressLog 'update.openclaw-update.timeout'
|
|
'openclaw update timed out after package install window; killing stale update/completion processes and verifying installed version' | Tee-Object -FilePath $LogPath -Append | Out-Null
|
|
Stop-OpenClawUpdateProcesses
|
|
}
|
|
|
|
function Invoke-OpenClawAgentWithTimeout {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$OpenClawPath,
|
|
[Parameter(Mandatory = $true)][string]$SessionId,
|
|
[int]$TimeoutSeconds = 600
|
|
)
|
|
|
|
$message = 'Reply with exact ASCII text OK only.'
|
|
$stdout = Join-Path $env:TEMP ("openclaw-parallels-agent-{0}.out.log" -f ([guid]::NewGuid().ToString('N')))
|
|
$stderr = Join-Path $env:TEMP ("openclaw-parallels-agent-{0}.err.log" -f ([guid]::NewGuid().ToString('N')))
|
|
$agentJob = Start-Job -ScriptBlock {
|
|
param([string]$Path, [string]$AgentSessionId, [string]$AgentMessage, [string]$StdoutPath, [string]$StderrPath)
|
|
& $Path agent --local --agent main --session-id $AgentSessionId --message $AgentMessage --json > $StdoutPath 2> $StderrPath
|
|
exit $LASTEXITCODE
|
|
} -ArgumentList $OpenClawPath, $SessionId, $message, $stdout, $stderr
|
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
|
$combined = ''
|
|
while ((Get-Date) -lt $deadline) {
|
|
Start-Sleep -Seconds 2
|
|
$out = ''
|
|
$err = ''
|
|
if (Test-Path $stdout) {
|
|
$out = Get-Content -Path $stdout -Raw -ErrorAction SilentlyContinue
|
|
}
|
|
if (Test-Path $stderr) {
|
|
$err = Get-Content -Path $stderr -Raw -ErrorAction SilentlyContinue
|
|
}
|
|
$combined = "$out`n$err"
|
|
if ($combined -match '"finalAssistantRawText":\s*"OK"' -or $combined -match '"finalAssistantVisibleText":\s*"OK"') {
|
|
if ($combined.Trim().Length -gt 0) {
|
|
$combined.Trim() | Tee-Object -FilePath $LogPath -Append | Out-Null
|
|
}
|
|
Stop-Job $agentJob -ErrorAction SilentlyContinue
|
|
Remove-Job $agentJob -Force -ErrorAction SilentlyContinue
|
|
return 0
|
|
}
|
|
if ($agentJob.State -in @('Completed', 'Failed', 'Stopped')) {
|
|
if ($combined.Trim().Length -gt 0) {
|
|
$combined.Trim() | Tee-Object -FilePath $LogPath -Append | Out-Null
|
|
}
|
|
Receive-Job $agentJob -ErrorAction SilentlyContinue | Out-Null
|
|
$jobState = $agentJob.State
|
|
Remove-Job $agentJob -Force -ErrorAction SilentlyContinue
|
|
if ($jobState -ne 'Completed') {
|
|
throw "openclaw agent failed with job state $jobState"
|
|
}
|
|
throw 'openclaw agent finished without OK response'
|
|
}
|
|
}
|
|
|
|
Stop-Job $agentJob -ErrorAction SilentlyContinue
|
|
Remove-Job $agentJob -Force -ErrorAction SilentlyContinue
|
|
Write-ProgressLog 'update.agent-turn.timeout'
|
|
if ($combined.Trim().Length -gt 0) {
|
|
$combined.Trim() | Tee-Object -FilePath $LogPath -Append | Out-Null
|
|
}
|
|
throw "openclaw agent timed out after ${TimeoutSeconds}s"
|
|
}
|
|
|
|
function Start-GatewayRunFallback {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$OpenClawPath
|
|
)
|
|
|
|
Write-ProgressLog 'update.gateway-run-fallback'
|
|
Stop-OpenClawGatewayProcesses
|
|
$entry = Join-Path $env:APPDATA 'npm\node_modules\openclaw\dist\index.js'
|
|
if (-not (Test-Path $entry)) {
|
|
throw "openclaw dist entry missing: $entry"
|
|
}
|
|
$node = (Get-Command node.exe -ErrorAction Stop).Source
|
|
$stdout = Join-Path $env:TEMP 'openclaw-parallels-npm-update-gateway.log'
|
|
$stderr = Join-Path $env:TEMP 'openclaw-parallels-npm-update-gateway.err.log'
|
|
Start-Process -FilePath $node -ArgumentList @($entry, 'gateway', 'run', '--bind', 'loopback', '--port', '18789', '--force') -WindowStyle Hidden -RedirectStandardOutput $stdout -RedirectStandardError $stderr | Out-Null
|
|
if (-not (Wait-GatewayRpcReady -OpenClawPath $OpenClawPath -Attempts 20 -SleepSeconds 3)) {
|
|
if (Test-Path $stdout) {
|
|
Get-Content $stdout -Tail 80 | Tee-Object -FilePath $LogPath -Append | Out-Null
|
|
}
|
|
if (Test-Path $stderr) {
|
|
Get-Content $stderr -Tail 80 | Tee-Object -FilePath $LogPath -Append | Out-Null
|
|
}
|
|
throw 'gateway did not become RPC-ready after run fallback'
|
|
}
|
|
}
|
|
|
|
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'
|
|
if (Wait-GatewayRpcReady -OpenClawPath $OpenClawPath) {
|
|
return
|
|
}
|
|
Write-ProgressLog 'update.gateway-start-recover'
|
|
Stop-OpenClawGatewayProcesses
|
|
Invoke-Logged 'openclaw gateway start' { & $OpenClawPath gateway start }
|
|
Write-ProgressLog 'update.gateway-status-recover'
|
|
if (-not (Wait-GatewayRpcReady -OpenClawPath $OpenClawPath)) {
|
|
Start-GatewayRunFallback -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'
|
|
Remove-FuturePluginEntries
|
|
Stop-OpenClawGatewayProcesses
|
|
Write-ProgressLog 'update.openclaw-update'
|
|
Invoke-OpenClawUpdateWithTimeout -OpenClawPath $openclaw -UpdateTarget $UpdateTarget
|
|
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
|
|
Stop-OpenClawGatewayProcesses
|
|
Complete-WorkspaceSetup
|
|
Write-ProgressLog 'update.agent-turn'
|
|
$exitCode = Invoke-OpenClawAgentWithTimeout -OpenClawPath $openclaw -SessionId $SessionId
|
|
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 <<EOF | prlctl exec "$MACOS_VM" /usr/bin/tee "$script_path" >/dev/null
|
|
set -euo pipefail
|
|
export PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin
|
|
export OPENCLAW_PLUGIN_STAGE_DIR="\$HOME/.openclaw/plugin-runtime-deps-parallels"
|
|
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)"
|
|
gateway_listener_ready() {
|
|
/usr/sbin/lsof -tiTCP:18789 -sTCP:LISTEN >/dev/null 2>&1
|
|
}
|
|
gateway_log_ready() {
|
|
latest="\$(/bin/ls -t /tmp/openclaw/openclaw-*.log 2>/dev/null | /usr/bin/head -n 1 || true)"
|
|
[ -n "\$latest" ] || return 1
|
|
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -q 'ready ('
|
|
}
|
|
gateway_smoke_ready() {
|
|
gateway_listener_ready && gateway_log_ready
|
|
}
|
|
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
|
|
gateway_smoke_ready || /opt/homebrew/bin/openclaw gateway restart || true
|
|
gateway_ready=0
|
|
for _ in 1 2 3 4 5 6; do
|
|
if gateway_smoke_ready; 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 gateway_smoke_ready; then
|
|
gateway_ready=1
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
fi
|
|
if [ "\$gateway_ready" != "1" ]; then
|
|
echo "gateway did not become 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 720 "$(cat <<EOF
|
|
\$ErrorActionPreference = 'Stop'
|
|
\$openclaw = Join-Path \$env:APPDATA 'npm\\openclaw.cmd'
|
|
if (-not (Test-Path \$openclaw)) {
|
|
throw "openclaw shim missing: \$openclaw"
|
|
}
|
|
\$busy = Get-CimInstance Win32_Process |
|
|
Where-Object {
|
|
\$_.CommandLine -and
|
|
(\$_.CommandLine -match 'openclaw update|npm install|pnpm install|pnpm run build')
|
|
}
|
|
if (\$busy) {
|
|
throw 'update still has active npm/pnpm/openclaw processes'
|
|
}
|
|
\$version = & \$openclaw --version
|
|
Write-Output \$version
|
|
if ('$expected_needle' -and \$version -notmatch [regex]::Escape('$expected_needle')) {
|
|
throw "version mismatch after transport loss: expected substring $expected_needle"
|
|
}
|
|
function Test-GatewayWritable {
|
|
param([string]\$Path)
|
|
\$statusOutput = & \$Path gateway status --deep --require-rpc *>&1
|
|
if (\$null -ne \$statusOutput) {
|
|
\$statusOutput | Write-Output
|
|
}
|
|
if (\$LASTEXITCODE -ne 0) {
|
|
return \$false
|
|
}
|
|
\$statusText = (\$statusOutput | Out-String)
|
|
return (\$statusText -notmatch 'Read probe:\s*failed')
|
|
}
|
|
function Stop-GatewayListeners {
|
|
\$previousNativeErrorPreference = \$PSNativeCommandUseErrorActionPreference
|
|
try {
|
|
\$PSNativeCommandUseErrorActionPreference = \$false
|
|
schtasks /End /TN 'OpenClaw Gateway' 2>\$null | Out-Null
|
|
} catch {
|
|
} finally {
|
|
\$PSNativeCommandUseErrorActionPreference = \$previousNativeErrorPreference
|
|
}
|
|
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
|
Where-Object {
|
|
\$_.CommandLine -and (
|
|
\$_.CommandLine -match 'openclaw.*gateway --port 18789' -or
|
|
\$_.CommandLine -match 'openclaw.*gateway run' -or
|
|
\$_.CommandLine -match 'dist\\\\index\\.js gateway --port 18789'
|
|
)
|
|
} |
|
|
ForEach-Object {
|
|
Stop-Process -Id \$_.ProcessId -Force -ErrorAction SilentlyContinue
|
|
}
|
|
for (\$i = 0; \$i -lt 20; \$i++) {
|
|
\$listeners = Get-NetTCPConnection -LocalPort 18789 -State Listen -ErrorAction SilentlyContinue
|
|
if (-not \$listeners) {
|
|
return
|
|
}
|
|
\$listeners | ForEach-Object {
|
|
Stop-Process -Id \$_.OwningProcess -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Start-Sleep -Seconds 1
|
|
}
|
|
}
|
|
\$gatewayReady = \$false
|
|
for (\$i = 0; \$i -lt 6; \$i++) {
|
|
if (Test-GatewayWritable \$openclaw) {
|
|
\$gatewayReady = \$true
|
|
break
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
if (-not \$gatewayReady) {
|
|
Stop-GatewayListeners
|
|
& \$openclaw gateway restart
|
|
for (\$i = 0; \$i -lt 6; \$i++) {
|
|
if (Test-GatewayWritable \$openclaw) {
|
|
\$gatewayReady = \$true
|
|
break
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
}
|
|
if (-not \$gatewayReady) {
|
|
Stop-GatewayListeners
|
|
& \$openclaw gateway start
|
|
for (\$i = 0; \$i -lt 6; \$i++) {
|
|
if (Test-GatewayWritable \$openclaw) {
|
|
\$gatewayReady = \$true
|
|
break
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
}
|
|
if (-not \$gatewayReady) {
|
|
Stop-GatewayListeners
|
|
\$entry = Join-Path \$env:APPDATA 'npm\\node_modules\\openclaw\\dist\\index.js'
|
|
\$node = (Get-Command node.exe -ErrorAction Stop).Source
|
|
\$stdout = Join-Path \$env:TEMP 'openclaw-parallels-npm-update-recover-gateway.log'
|
|
\$stderr = Join-Path \$env:TEMP 'openclaw-parallels-npm-update-recover-gateway.err.log'
|
|
Start-Process -FilePath \$node -ArgumentList @(\$entry, 'gateway', 'run', '--bind', 'loopback', '--port', '18789', '--force') -WindowStyle Hidden -RedirectStandardOutput \$stdout -RedirectStandardError \$stderr | Out-Null
|
|
for (\$i = 0; \$i -lt 20; \$i++) {
|
|
if (Test-GatewayWritable \$openclaw) {
|
|
\$gatewayReady = \$true
|
|
break
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
}
|
|
if (-not \$gatewayReady) {
|
|
throw 'gateway did not become RPC-ready after transport recovery'
|
|
}
|
|
\$providerBytes = [Convert]::FromBase64String('$provider_key_b64')
|
|
\$providerValue = [Text.Encoding]::UTF8.GetString(\$providerBytes)
|
|
Set-Item -Path ('Env:' + '$API_KEY_ENV') -Value \$providerValue
|
|
& \$openclaw models set '$MODEL_ID'
|
|
\$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
|
|
Stop-GatewayListeners
|
|
\$agentStdout = Join-Path \$env:TEMP ("openclaw-parallels-agent-{0}.out.log" -f ([guid]::NewGuid().ToString('N')))
|
|
\$agentStderr = Join-Path \$env:TEMP ("openclaw-parallels-agent-{0}.err.log" -f ([guid]::NewGuid().ToString('N')))
|
|
\$agentJob = Start-Job -ScriptBlock {
|
|
param([string]\$Path, [string]\$StdoutPath, [string]\$StderrPath)
|
|
& \$Path agent --local --agent main --session-id 'parallels-npm-update-windows-transport-recovery-$expected_needle' --message 'Reply with exact ASCII text OK only.' --json > \$StdoutPath 2> \$StderrPath
|
|
exit \$LASTEXITCODE
|
|
} -ArgumentList \$openclaw, \$agentStdout, \$agentStderr
|
|
\$agentDeadline = (Get-Date).AddSeconds(600)
|
|
\$agentCombined = ''
|
|
while ((Get-Date) -lt \$agentDeadline) {
|
|
Start-Sleep -Seconds 2
|
|
\$agentOut = ''
|
|
\$agentErr = ''
|
|
if (Test-Path \$agentStdout) {
|
|
\$agentOut = Get-Content -Path \$agentStdout -Raw -ErrorAction SilentlyContinue
|
|
}
|
|
if (Test-Path \$agentStderr) {
|
|
\$agentErr = Get-Content -Path \$agentStderr -Raw -ErrorAction SilentlyContinue
|
|
}
|
|
\$agentCombined = \$agentOut + [Environment]::NewLine + \$agentErr
|
|
if (\$agentCombined -match '"finalAssistantRawText":\s*"OK"' -or \$agentCombined -match '"finalAssistantVisibleText":\s*"OK"') {
|
|
if (\$agentCombined.Trim().Length -gt 0) {
|
|
\$agentCombined.Trim() | Write-Output
|
|
}
|
|
Stop-Job \$agentJob -ErrorAction SilentlyContinue
|
|
Remove-Job \$agentJob -Force -ErrorAction SilentlyContinue
|
|
\$agentJob = \$null
|
|
break
|
|
}
|
|
if (\$agentJob.State -in @('Completed', 'Failed', 'Stopped')) {
|
|
if (\$agentCombined.Trim().Length -gt 0) {
|
|
\$agentCombined.Trim() | Write-Output
|
|
}
|
|
Receive-Job \$agentJob -ErrorAction SilentlyContinue | Out-Null
|
|
\$agentJobState = \$agentJob.State
|
|
Remove-Job \$agentJob -Force -ErrorAction SilentlyContinue
|
|
\$agentJob = \$null
|
|
if (\$agentJobState -ne 'Completed') {
|
|
throw "openclaw agent failed with job state \$agentJobState"
|
|
}
|
|
throw 'openclaw agent finished without OK response'
|
|
break
|
|
}
|
|
}
|
|
if (\$null -ne \$agentJob) {
|
|
Stop-Job \$agentJob -ErrorAction SilentlyContinue
|
|
Remove-Job \$agentJob -Force -ErrorAction SilentlyContinue
|
|
if (\$agentCombined.Trim().Length -gt 0) {
|
|
\$agentCombined.Trim() | Write-Output
|
|
}
|
|
throw 'openclaw agent timed out after 600s'
|
|
}
|
|
EOF
|
|
)"
|
|
local rc=$?
|
|
set -e
|
|
return "$rc"
|
|
}
|
|
|
|
start_timeout_guard() {
|
|
local label="$1"
|
|
local timeout_s="$2"
|
|
local pid="$3"
|
|
local log_path="${4:-}"
|
|
(
|
|
sleep "$timeout_s"
|
|
if kill -0 "$pid" >/dev/null 2>&1; then
|
|
warn "$label exceeded ${timeout_s}s; stopping"
|
|
if [[ -n "$log_path" ]]; then
|
|
dump_log_tail "$label" "$log_path"
|
|
fi
|
|
terminate_process_tree "$pid" TERM
|
|
sleep 2
|
|
terminate_process_tree "$pid" KILL
|
|
fi
|
|
) >&2 &
|
|
printf '%s\n' "$!"
|
|
}
|
|
|
|
terminate_process_tree() {
|
|
local pid="$1"
|
|
local signal_name="${2:-TERM}"
|
|
local child
|
|
pgrep -P "$pid" 2>/dev/null | while read -r child; do
|
|
terminate_process_tree "$child" "$signal_name"
|
|
done
|
|
kill "-$signal_name" "$pid" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
stop_timeout_guard() {
|
|
local pid="${1:-}"
|
|
[[ -n "$pid" ]] || return 0
|
|
kill "$pid" >/dev/null 2>&1 || true
|
|
wait "$pid" 2>/dev/null || true
|
|
}
|
|
|
|
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
|
|
parallels_monitor_jobs_progress "$group" "$PROGRESS_INTERVAL_S" "$PROGRESS_STALE_S" "$PYTHON_BIN" "$$" "$@"
|
|
}
|
|
|
|
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 signal
|
|
import subprocess
|
|
import sys
|
|
|
|
timeout = int(os.environ["HOST_TIMEOUT_S"])
|
|
args = sys.argv[1:]
|
|
|
|
process = subprocess.Popen(
|
|
args,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
stdout, stderr = process.communicate(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
except PermissionError:
|
|
pass
|
|
try:
|
|
stdout, stderr = process.communicate(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
except PermissionError:
|
|
pass
|
|
stdout, stderr = process.communicate()
|
|
if stdout:
|
|
sys.stdout.buffer.write(stdout)
|
|
if stderr:
|
|
sys.stderr.buffer.write(stderr)
|
|
sys.stderr.write(f"host timeout after {timeout}s\n")
|
|
raise SystemExit(124)
|
|
|
|
if stdout:
|
|
sys.stdout.buffer.write(stdout)
|
|
if stderr:
|
|
sys.stderr.buffer.write(stderr)
|
|
raise SystemExit(process.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 <<EOF
|
|
\$runner = Join-Path \$env:TEMP '$runner_name'
|
|
\$log = Join-Path \$env:TEMP '$log_name'
|
|
\$done = Join-Path \$env:TEMP '$done_name'
|
|
\$providerKeyFile = Join-Path \$env:TEMP '$runner_name.key'
|
|
Remove-Item \$runner, \$log, \$done, \$providerKeyFile -Force -ErrorAction SilentlyContinue
|
|
\$providerBytes = [Convert]::FromBase64String('$provider_key_b64')
|
|
[IO.File]::WriteAllBytes(\$providerKeyFile, \$providerBytes)
|
|
curl.exe -fsSL '$script_url' -o \$runner
|
|
Start-Process powershell.exe -ArgumentList @(
|
|
'-NoProfile',
|
|
'-ExecutionPolicy', 'Bypass',
|
|
'-File', \$runner,
|
|
'-UpdateTarget', '$update_target',
|
|
'-ExpectedNeedle', '$expected_needle',
|
|
'-SessionId', '$session_id',
|
|
'-ModelId', '$model_id',
|
|
'-ProviderKeyEnv', '$provider_key_env',
|
|
'-ProviderKeyFile', \$providerKeyFile,
|
|
'-LogPath', \$log,
|
|
'-DonePath', \$done
|
|
) -WindowStyle Hidden | Out-Null
|
|
EOF
|
|
)"
|
|
|
|
stream_windows_update_log() {
|
|
set +e
|
|
guest_log="$(
|
|
guest_powershell_poll 60 "\$log = Join-Path \$env:TEMP '$log_name'; if (Test-Path \$log) { Get-Content \$log }"
|
|
)"
|
|
log_rc=$?
|
|
set -e
|
|
if [[ $log_rc -ne 0 ]] || [[ -z "$guest_log" ]]; then
|
|
return "$log_rc"
|
|
fi
|
|
GUEST_LOG="$guest_log" "$PYTHON_BIN" - "$log_state_path" <<'PY'
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
state_path = pathlib.Path(sys.argv[1])
|
|
previous = state_path.read_text(encoding="utf-8", errors="replace")
|
|
current = os.environ["GUEST_LOG"].replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
if current.startswith(previous):
|
|
sys.stdout.write(current[len(previous):])
|
|
else:
|
|
sys.stdout.write(current)
|
|
|
|
state_path.write_text(current, encoding="utf-8")
|
|
PY
|
|
}
|
|
|
|
while :; do
|
|
set +e
|
|
done_status="$(
|
|
guest_powershell_poll 60 "\$done = Join-Path \$env:TEMP '$done_name'; if (Test-Path \$done) { (Get-Content \$done -Raw).Trim() }"
|
|
)"
|
|
poll_rc=$?
|
|
set -e
|
|
done_status="${done_status//$'\r'/}"
|
|
if [[ $poll_rc -ne 0 ]]; then
|
|
warn "windows update helper poll failed; retrying"
|
|
if (( SECONDS >= poll_deadline )); then
|
|
warn "windows update helper timed out while polling done file"
|
|
return 1
|
|
fi
|
|
sleep 2
|
|
continue
|
|
fi
|
|
set +e
|
|
stream_windows_update_log
|
|
log_rc=$?
|
|
set -e
|
|
if [[ $log_rc -ne 0 ]]; then
|
|
warn "windows update helper live log poll failed; retrying"
|
|
fi
|
|
if [[ -n "$done_status" ]]; then
|
|
if ! stream_windows_update_log; then
|
|
warn "windows update helper log drain failed after completion"
|
|
fi
|
|
rm -f "$log_state_path"
|
|
[[ "$done_status" == "0" ]]
|
|
return $?
|
|
fi
|
|
if [[ "$startup_checked" -eq 0 && $((SECONDS - start_seconds)) -ge 20 ]]; then
|
|
set +e
|
|
launcher_state="$(
|
|
guest_powershell_poll 60 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$log = Join-Path \$env:TEMP '$log_name'; \$done = Join-Path \$env:TEMP '$done_name'; 'runner=' + (Test-Path \$runner) + ' log=' + (Test-Path \$log) + ' done=' + (Test-Path \$done)"
|
|
)"
|
|
state_rc=$?
|
|
set -e
|
|
launcher_state="${launcher_state//$'\r'/}"
|
|
startup_checked=1
|
|
if [[ $state_rc -eq 0 && "$launcher_state" == *"runner=False"* && "$launcher_state" == *"log=False"* && "$launcher_state" == *"done=False"* ]]; then
|
|
warn "windows update helper failed to materialize guest files"
|
|
return 1
|
|
fi
|
|
fi
|
|
if (( SECONDS >= poll_deadline )); then
|
|
if ! stream_windows_update_log; then
|
|
warn "windows update helper log drain failed after timeout"
|
|
fi
|
|
rm -f "$log_state_path"
|
|
warn "windows update helper timed out waiting for done file"
|
|
return 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
}
|
|
|
|
run_macos_update() {
|
|
local update_target="$1"
|
|
local expected_needle="$2"
|
|
cat <<EOF | prlctl exec "$MACOS_VM" /usr/bin/tee /tmp/openclaw-main-update.sh >/dev/null
|
|
set -euo pipefail
|
|
export PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin
|
|
if [ -z "\${HOME:-}" ]; then export HOME="/Users/\$(id -un)"; fi
|
|
export OPENCLAW_PLUGIN_STAGE_DIR="\$HOME/.openclaw/plugin-runtime-deps-parallels"
|
|
if [ -z "\${$API_KEY_ENV:-}" ]; then
|
|
echo "$API_KEY_ENV is required in the macOS update environment" >&2
|
|
exit 1
|
|
fi
|
|
cd "\$HOME"
|
|
gateway_listener_ready() {
|
|
/usr/sbin/lsof -tiTCP:18789 -sTCP:LISTEN >/dev/null 2>&1
|
|
}
|
|
gateway_log_ready() {
|
|
latest="\$(/bin/ls -t /tmp/openclaw/openclaw-*.log 2>/dev/null | /usr/bin/head -n 1 || true)"
|
|
[ -n "\$latest" ] || return 1
|
|
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -q 'ready ('
|
|
}
|
|
gateway_smoke_ready() {
|
|
gateway_listener_ready && gateway_log_ready
|
|
}
|
|
scrub_future_plugin_entries() {
|
|
node - <<'JS' || true
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
|
if (!fs.existsSync(configPath)) process.exit(0);
|
|
let config;
|
|
try {
|
|
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
} catch {
|
|
process.exit(0);
|
|
}
|
|
const plugins = config?.plugins;
|
|
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) process.exit(0);
|
|
const entries = plugins.entries;
|
|
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
|
delete entries.feishu;
|
|
delete entries.whatsapp;
|
|
}
|
|
if (Array.isArray(plugins.allow)) {
|
|
plugins.allow = plugins.allow.filter((pluginId) => pluginId !== "feishu" && pluginId !== "whatsapp");
|
|
}
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n");
|
|
JS
|
|
}
|
|
stop_openclaw_gateway_processes() {
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop >/dev/null 2>&1 || true
|
|
/usr/bin/pkill -9 -f openclaw-gateway || true
|
|
/usr/bin/pkill -9 -f 'openclaw gateway run' || true
|
|
/usr/bin/pkill -9 -f 'openclaw.mjs gateway' || true
|
|
for pid in \$(/usr/sbin/lsof -tiTCP:18789 -sTCP:LISTEN 2>/dev/null || true); do
|
|
/bin/kill -9 "\$pid" 2>/dev/null || true
|
|
done
|
|
}
|
|
# Stop the pre-update gateway before replacing the package. Otherwise the old
|
|
# host can observe new plugin metadata mid-update and abort config validation.
|
|
scrub_future_plugin_entries
|
|
stop_openclaw_gateway_processes
|
|
/opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json
|
|
# Same-guest npm upgrades can leave the old gateway process holding the old
|
|
# bundled plugin host version. Stop it before post-update config commands.
|
|
stop_openclaw_gateway_processes
|
|
version="\$(/opt/homebrew/bin/openclaw --version)"
|
|
printf '%s\n' "\$version"
|
|
if [ -n "$expected_needle" ]; then
|
|
case "\$version" in
|
|
*"$expected_needle"*) ;;
|
|
*)
|
|
echo "version mismatch: expected substring $expected_needle" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
/opt/homebrew/bin/openclaw update status --json
|
|
/opt/homebrew/bin/openclaw models set "$MODEL_ID"
|
|
# Same-guest npm upgrades can leave launchd holding the old gateway process or
|
|
# module graph briefly; wait for a fresh RPC-ready restart before the agent turn.
|
|
# Fresh npm installs may not have a launchd service yet, so fall back to the
|
|
# same manual gateway launch used by the fresh macOS lane.
|
|
/opt/homebrew/bin/openclaw gateway restart || true
|
|
gateway_ready=0
|
|
for _ in 1 2 3 4 5 6 7 8; do
|
|
if gateway_smoke_ready; then
|
|
gateway_ready=1
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ "\$gateway_ready" != "1" ]; then
|
|
stop_openclaw_gateway_processes
|
|
/opt/homebrew/bin/openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-npm-update-macos-gateway.log 2>&1 </dev/null &
|
|
for _ in 1 2 3 4 5 6 7 8; do
|
|
if gateway_smoke_ready; then
|
|
gateway_ready=1
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
fi
|
|
if [ "\$gateway_ready" != "1" ]; then
|
|
tail -n 120 /tmp/openclaw-parallels-npm-update-macos-gateway.log 2>/dev/null || true
|
|
fi
|
|
if [ "\$gateway_ready" != "1" ]; then
|
|
/opt/homebrew/bin/openclaw gateway status --deep --require-rpc
|
|
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 agent --agent main --session-id parallels-npm-update-macos-$expected_needle --message "Reply with exact ASCII text OK only." --json
|
|
EOF
|
|
macos_desktop_user_exec /bin/bash /tmp/openclaw-main-update.sh
|
|
}
|
|
|
|
run_windows_update() {
|
|
local update_target="$1"
|
|
local expected_needle="$2"
|
|
local script_url="$3"
|
|
run_windows_script_via_log \
|
|
"$script_url" \
|
|
"$update_target" \
|
|
"$expected_needle" \
|
|
"parallels-npm-update-windows-$expected_needle" \
|
|
"$MODEL_ID" \
|
|
"$API_KEY_ENV" \
|
|
"$API_KEY_VALUE"
|
|
}
|
|
|
|
run_linux_update() {
|
|
local update_target="$1"
|
|
local expected_needle="$2"
|
|
cat <<EOF | prlctl exec "$LINUX_VM" /usr/bin/tee /tmp/openclaw-main-update.sh >/dev/null
|
|
set -euo pipefail
|
|
export HOME=/root
|
|
cd "\$HOME"
|
|
scrub_future_plugin_entries() {
|
|
node - <<'JS' || true
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
|
if (!fs.existsSync(configPath)) process.exit(0);
|
|
let config;
|
|
try {
|
|
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
} catch {
|
|
process.exit(0);
|
|
}
|
|
const plugins = config?.plugins;
|
|
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) process.exit(0);
|
|
const entries = plugins.entries;
|
|
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
|
delete entries.feishu;
|
|
delete entries.whatsapp;
|
|
}
|
|
if (Array.isArray(plugins.allow)) {
|
|
plugins.allow = plugins.allow.filter((pluginId) => pluginId !== "feishu" && pluginId !== "whatsapp");
|
|
}
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n");
|
|
JS
|
|
}
|
|
stop_openclaw_gateway_processes() {
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop >/dev/null 2>&1 || true
|
|
pkill -9 -f openclaw-gateway || true
|
|
pkill -9 -f 'openclaw gateway run' || true
|
|
pkill -9 -f 'openclaw.mjs gateway' || true
|
|
if command -v fuser >/dev/null 2>&1; then
|
|
fuser -k 18789/tcp >/dev/null 2>&1 || true
|
|
fi
|
|
if command -v lsof >/dev/null 2>&1; then
|
|
for pid in \$(lsof -tiTCP:18789 -sTCP:LISTEN 2>/dev/null || true); do
|
|
kill -9 "\$pid" 2>/dev/null || true
|
|
done
|
|
fi
|
|
}
|
|
# Stop the pre-update manual gateway before replacing the package. Otherwise
|
|
# the old host can observe new plugin metadata mid-update and abort validation.
|
|
scrub_future_plugin_entries
|
|
stop_openclaw_gateway_processes
|
|
openclaw update --tag "$update_target" --yes --json
|
|
# The fresh Linux lane starts a manual gateway; stop the old process before
|
|
# post-update config validation sees mixed old-host/new-plugin metadata.
|
|
stop_openclaw_gateway_processes
|
|
version="\$(openclaw --version)"
|
|
printf '%s\n' "\$version"
|
|
if [ -n "$expected_needle" ]; then
|
|
case "\$version" in
|
|
*"$expected_needle"*) ;;
|
|
*)
|
|
echo "version mismatch: expected substring $expected_needle" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
fi
|
|
openclaw update status --json
|
|
openclaw models set "$MODEL_ID"
|
|
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"
|
|
openclaw agent --local --agent main --session-id parallels-npm-update-linux-$expected_needle --message "Reply with exact ASCII text OK only." --json
|
|
EOF
|
|
prlctl exec "$LINUX_VM" /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" /bin/bash /tmp/openclaw-main-update.sh
|
|
}
|
|
|
|
write_summary_json() {
|
|
local summary_path="$RUN_DIR/summary.json"
|
|
"$PYTHON_BIN" - "$summary_path" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
summary = {
|
|
"packageSpec": os.environ["SUMMARY_PACKAGE_SPEC"],
|
|
"updateTarget": os.environ["SUMMARY_UPDATE_TARGET"],
|
|
"updateExpected": os.environ["SUMMARY_UPDATE_EXPECTED"],
|
|
"provider": os.environ["SUMMARY_PROVIDER"],
|
|
"latestVersion": os.environ["SUMMARY_LATEST_VERSION"],
|
|
"currentHead": os.environ["SUMMARY_CURRENT_HEAD"],
|
|
"runDir": os.environ["SUMMARY_RUN_DIR"],
|
|
"fresh": {
|
|
"macos": {"status": os.environ["SUMMARY_MACOS_FRESH_STATUS"]},
|
|
"windows": {"status": os.environ["SUMMARY_WINDOWS_FRESH_STATUS"]},
|
|
"linux": {"status": os.environ["SUMMARY_LINUX_FRESH_STATUS"]},
|
|
},
|
|
"update": {
|
|
"macos": {
|
|
"status": os.environ["SUMMARY_MACOS_UPDATE_STATUS"],
|
|
"version": os.environ["SUMMARY_MACOS_UPDATE_VERSION"],
|
|
},
|
|
"windows": {
|
|
"status": os.environ["SUMMARY_WINDOWS_UPDATE_STATUS"],
|
|
"version": os.environ["SUMMARY_WINDOWS_UPDATE_VERSION"],
|
|
},
|
|
"linux": {
|
|
"status": os.environ["SUMMARY_LINUX_UPDATE_STATUS"],
|
|
"version": os.environ["SUMMARY_LINUX_UPDATE_VERSION"],
|
|
"mode": "local-with-provider-env",
|
|
},
|
|
},
|
|
}
|
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
json.dump(summary, handle, indent=2, sort_keys=True)
|
|
print(sys.argv[1])
|
|
PY
|
|
}
|
|
|
|
LATEST_VERSION="$(resolve_latest_version)"
|
|
if [[ -z "$PACKAGE_SPEC" ]]; then
|
|
PACKAGE_SPEC="openclaw@$LATEST_VERSION"
|
|
fi
|
|
resolve_current_head
|
|
|
|
if platform_enabled linux; then
|
|
RESOLVED_LINUX_VM="$(resolve_linux_vm_name)"
|
|
if [[ "$RESOLVED_LINUX_VM" != "$LINUX_VM" ]]; then
|
|
warn "requested VM $LINUX_VM not found; using $RESOLVED_LINUX_VM"
|
|
LINUX_VM="$RESOLVED_LINUX_VM"
|
|
fi
|
|
fi
|
|
|
|
say "Run fresh npm baseline: $PACKAGE_SPEC"
|
|
say "Platforms: $RUN_PLATFORMS"
|
|
say "Run dir: $RUN_DIR"
|
|
fresh_monitor_args=()
|
|
if platform_enabled macos; then
|
|
bash "$ROOT_DIR/scripts/e2e/parallels-macos-smoke.sh" \
|
|
--mode fresh \
|
|
--provider "$PROVIDER" \
|
|
--api-key-env "$API_KEY_ENV" \
|
|
--target-package-spec "$PACKAGE_SPEC" \
|
|
--json >"$RUN_DIR/macos-fresh.log" 2>&1 &
|
|
macos_fresh_pid=$!
|
|
fresh_monitor_args+=("macOS" "$macos_fresh_pid" "$RUN_DIR/macos-fresh.log")
|
|
fi
|
|
|
|
if platform_enabled windows; then
|
|
bash "$ROOT_DIR/scripts/e2e/parallels-windows-smoke.sh" \
|
|
--mode fresh \
|
|
--provider "$PROVIDER" \
|
|
--api-key-env "$API_KEY_ENV" \
|
|
--target-package-spec "$PACKAGE_SPEC" \
|
|
--json >"$RUN_DIR/windows-fresh.log" 2>&1 &
|
|
windows_fresh_pid=$!
|
|
fresh_monitor_args+=("Windows" "$windows_fresh_pid" "$RUN_DIR/windows-fresh.log")
|
|
fi
|
|
|
|
if platform_enabled linux; then
|
|
bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \
|
|
--mode fresh \
|
|
--provider "$PROVIDER" \
|
|
--api-key-env "$API_KEY_ENV" \
|
|
--target-package-spec "$PACKAGE_SPEC" \
|
|
--json >"$RUN_DIR/linux-fresh.log" 2>&1 &
|
|
linux_fresh_pid=$!
|
|
fresh_monitor_args+=("Linux" "$linux_fresh_pid" "$RUN_DIR/linux-fresh.log")
|
|
fi
|
|
|
|
monitor_jobs_progress "fresh" "${fresh_monitor_args[@]}"
|
|
|
|
if platform_enabled macos; then
|
|
wait_job "macOS fresh" "$macos_fresh_pid" "$RUN_DIR/macos-fresh.log" && MACOS_FRESH_STATUS="pass" || MACOS_FRESH_STATUS="fail"
|
|
[[ "$MACOS_FRESH_STATUS" == "pass" ]] || die "macOS fresh baseline failed"
|
|
fi
|
|
if platform_enabled windows; then
|
|
wait_job "Windows fresh" "$windows_fresh_pid" "$RUN_DIR/windows-fresh.log" && WINDOWS_FRESH_STATUS="pass" || WINDOWS_FRESH_STATUS="fail"
|
|
[[ "$WINDOWS_FRESH_STATUS" == "pass" ]] || die "Windows fresh baseline failed"
|
|
fi
|
|
if platform_enabled linux; then
|
|
wait_job "Linux fresh" "$linux_fresh_pid" "$RUN_DIR/linux-fresh.log" && LINUX_FRESH_STATUS="pass" || LINUX_FRESH_STATUS="fail"
|
|
[[ "$LINUX_FRESH_STATUS" == "pass" ]] || die "Linux fresh baseline failed"
|
|
fi
|
|
|
|
if [[ -z "$UPDATE_TARGET" || "$UPDATE_TARGET" == "local-main" ]]; then
|
|
pack_main_tgz
|
|
UPDATE_TARGET_EFFECTIVE="http://$HOST_IP:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")"
|
|
UPDATE_EXPECTED_NEEDLE="$CURRENT_HEAD_SHORT"
|
|
else
|
|
UPDATE_TARGET_EFFECTIVE="$UPDATE_TARGET"
|
|
if is_explicit_package_target "$UPDATE_TARGET_EFFECTIVE"; then
|
|
UPDATE_EXPECTED_NEEDLE=""
|
|
else
|
|
UPDATE_EXPECTED_NEEDLE="$(resolve_registry_target_version "$UPDATE_TARGET_EFFECTIVE")"
|
|
[[ -n "$UPDATE_EXPECTED_NEEDLE" ]] || UPDATE_EXPECTED_NEEDLE="$UPDATE_TARGET_EFFECTIVE"
|
|
fi
|
|
fi
|
|
if platform_enabled windows; then
|
|
write_windows_update_script
|
|
fi
|
|
if [[ -n "$MAIN_TGZ_PATH" ]] || platform_enabled windows; then
|
|
start_server
|
|
fi
|
|
|
|
if [[ -n "$MAIN_TGZ_PATH" ]]; then
|
|
UPDATE_TARGET_EFFECTIVE="http://$HOST_IP:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")"
|
|
fi
|
|
if platform_enabled windows; then
|
|
windows_update_script_url="http://$HOST_IP:$HOST_PORT/$(basename "$WINDOWS_UPDATE_SCRIPT_PATH")"
|
|
fi
|
|
|
|
say "Run same-guest openclaw update to $UPDATE_TARGET_EFFECTIVE"
|
|
update_monitor_args=()
|
|
if platform_enabled macos; then
|
|
ensure_vm_running_for_update "$MACOS_VM"
|
|
run_macos_update "$UPDATE_TARGET_EFFECTIVE" "$UPDATE_EXPECTED_NEEDLE" >"$RUN_DIR/macos-update.log" 2>&1 &
|
|
macos_update_pid=$!
|
|
macos_update_guard_pid="$(start_timeout_guard "macOS update" "$TIMEOUT_UPDATE_S" "$macos_update_pid" "$RUN_DIR/macos-update.log")"
|
|
update_monitor_args+=("macOS" "$macos_update_pid" "$RUN_DIR/macos-update.log")
|
|
fi
|
|
if platform_enabled windows; then
|
|
ensure_vm_running_for_update "$WINDOWS_VM"
|
|
run_windows_update "$UPDATE_TARGET_EFFECTIVE" "$UPDATE_EXPECTED_NEEDLE" "$windows_update_script_url" >"$RUN_DIR/windows-update.log" 2>&1 &
|
|
windows_update_pid=$!
|
|
windows_update_guard_pid="$(start_timeout_guard "Windows update" "$TIMEOUT_UPDATE_S" "$windows_update_pid" "$RUN_DIR/windows-update.log")"
|
|
update_monitor_args+=("Windows" "$windows_update_pid" "$RUN_DIR/windows-update.log")
|
|
fi
|
|
if platform_enabled linux; then
|
|
ensure_vm_running_for_update "$LINUX_VM"
|
|
run_linux_update "$UPDATE_TARGET_EFFECTIVE" "$UPDATE_EXPECTED_NEEDLE" >"$RUN_DIR/linux-update.log" 2>&1 &
|
|
linux_update_pid=$!
|
|
linux_update_guard_pid="$(start_timeout_guard "Linux update" "$TIMEOUT_UPDATE_S" "$linux_update_pid" "$RUN_DIR/linux-update.log")"
|
|
update_monitor_args+=("Linux" "$linux_update_pid" "$RUN_DIR/linux-update.log")
|
|
fi
|
|
|
|
monitor_jobs_progress "update" "${update_monitor_args[@]}"
|
|
|
|
if platform_enabled macos; then
|
|
stop_timeout_guard "$macos_update_guard_pid"
|
|
wait_job "macOS update" "$macos_update_pid" "$RUN_DIR/macos-update.log" && MACOS_UPDATE_STATUS="pass" || MACOS_UPDATE_STATUS="fail"
|
|
[[ "$MACOS_UPDATE_STATUS" == "pass" ]] || die "macOS update failed"
|
|
MACOS_UPDATE_VERSION="$(extract_last_version "$RUN_DIR/macos-update.log")"
|
|
fi
|
|
if platform_enabled windows; then
|
|
stop_timeout_guard "$windows_update_guard_pid"
|
|
wait_job "Windows update" "$windows_update_pid" "$RUN_DIR/windows-update.log" && WINDOWS_UPDATE_STATUS="pass" || WINDOWS_UPDATE_STATUS="fail"
|
|
[[ "$WINDOWS_UPDATE_STATUS" == "pass" ]] || die "Windows update failed"
|
|
WINDOWS_UPDATE_VERSION="$(extract_last_version "$RUN_DIR/windows-update.log")"
|
|
fi
|
|
if platform_enabled linux; then
|
|
stop_timeout_guard "$linux_update_guard_pid"
|
|
wait_job "Linux update" "$linux_update_pid" "$RUN_DIR/linux-update.log" && LINUX_UPDATE_STATUS="pass" || LINUX_UPDATE_STATUS="fail"
|
|
[[ "$LINUX_UPDATE_STATUS" == "pass" ]] || die "Linux update failed"
|
|
LINUX_UPDATE_VERSION="$(extract_last_version "$RUN_DIR/linux-update.log")"
|
|
fi
|
|
|
|
SUMMARY_PACKAGE_SPEC="$PACKAGE_SPEC" \
|
|
SUMMARY_UPDATE_TARGET="$UPDATE_TARGET_EFFECTIVE" \
|
|
SUMMARY_UPDATE_EXPECTED="$UPDATE_EXPECTED_NEEDLE" \
|
|
SUMMARY_PROVIDER="$PROVIDER" \
|
|
SUMMARY_LATEST_VERSION="$LATEST_VERSION" \
|
|
SUMMARY_CURRENT_HEAD="$CURRENT_HEAD_SHORT" \
|
|
SUMMARY_RUN_DIR="$RUN_DIR" \
|
|
SUMMARY_MACOS_FRESH_STATUS="$MACOS_FRESH_STATUS" \
|
|
SUMMARY_WINDOWS_FRESH_STATUS="$WINDOWS_FRESH_STATUS" \
|
|
SUMMARY_LINUX_FRESH_STATUS="$LINUX_FRESH_STATUS" \
|
|
SUMMARY_MACOS_UPDATE_STATUS="$MACOS_UPDATE_STATUS" \
|
|
SUMMARY_WINDOWS_UPDATE_STATUS="$WINDOWS_UPDATE_STATUS" \
|
|
SUMMARY_LINUX_UPDATE_STATUS="$LINUX_UPDATE_STATUS" \
|
|
SUMMARY_MACOS_UPDATE_VERSION="$MACOS_UPDATE_VERSION" \
|
|
SUMMARY_WINDOWS_UPDATE_VERSION="$WINDOWS_UPDATE_VERSION" \
|
|
SUMMARY_LINUX_UPDATE_VERSION="$LINUX_UPDATE_VERSION" \
|
|
write_summary_json >/dev/null
|
|
|
|
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
|
cat "$RUN_DIR/summary.json"
|
|
else
|
|
say "Run dir: $RUN_DIR"
|
|
cat "$RUN_DIR/summary.json"
|
|
fi
|