From 39f810911c382f92ea80b2fb406fdf7dbd405721 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 12:52:42 +0100 Subject: [PATCH] refactor: convert parallels smoke scripts to typescript --- scripts/e2e/parallels-linux-smoke.sh | 1065 +------ scripts/e2e/parallels-macos-smoke.sh | 2126 +------------ scripts/e2e/parallels-npm-update-smoke.sh | 2023 +----------- scripts/e2e/parallels-windows-smoke.sh | 2782 +---------------- scripts/e2e/parallels/agent-workspace.ts | 38 + scripts/e2e/parallels/common.ts | 7 + scripts/e2e/parallels/filesystem.ts | 39 + scripts/e2e/parallels/guest-transports.ts | 116 + scripts/e2e/parallels/host-command.ts | 122 + scripts/e2e/parallels/host-server.ts | 107 + scripts/e2e/parallels/linux-smoke.ts | 789 +++++ scripts/e2e/parallels/macos-smoke.ts | 1252 ++++++++ scripts/e2e/parallels/npm-update-smoke.ts | 719 +++++ scripts/e2e/parallels/package-artifact.ts | 145 + scripts/e2e/parallels/phase-runner.ts | 74 + scripts/e2e/parallels/powershell.ts | 13 + scripts/e2e/parallels/provider-auth.ts | 106 + scripts/e2e/parallels/snapshots.ts | 73 + scripts/e2e/parallels/types.ts | 46 + scripts/e2e/parallels/windows-git.ts | 124 + scripts/e2e/parallels/windows-smoke.ts | 782 +++++ .../parallels-npm-update-smoke.test.ts | 18 +- test/scripts/parallels-smoke-model.test.ts | 301 +- 23 files changed, 4829 insertions(+), 8038 deletions(-) mode change 100644 => 100755 scripts/e2e/parallels-linux-smoke.sh mode change 100644 => 100755 scripts/e2e/parallels-macos-smoke.sh mode change 100644 => 100755 scripts/e2e/parallels-windows-smoke.sh create mode 100644 scripts/e2e/parallels/agent-workspace.ts create mode 100644 scripts/e2e/parallels/common.ts create mode 100644 scripts/e2e/parallels/filesystem.ts create mode 100644 scripts/e2e/parallels/guest-transports.ts create mode 100644 scripts/e2e/parallels/host-command.ts create mode 100644 scripts/e2e/parallels/host-server.ts create mode 100755 scripts/e2e/parallels/linux-smoke.ts create mode 100755 scripts/e2e/parallels/macos-smoke.ts create mode 100755 scripts/e2e/parallels/npm-update-smoke.ts create mode 100644 scripts/e2e/parallels/package-artifact.ts create mode 100644 scripts/e2e/parallels/phase-runner.ts create mode 100644 scripts/e2e/parallels/powershell.ts create mode 100644 scripts/e2e/parallels/provider-auth.ts create mode 100644 scripts/e2e/parallels/snapshots.ts create mode 100644 scripts/e2e/parallels/types.ts create mode 100644 scripts/e2e/parallels/windows-git.ts create mode 100755 scripts/e2e/parallels/windows-smoke.ts diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh old mode 100644 new mode 100755 index b023fc38ad2..a1b636f2fdc --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -2,1067 +2,4 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -source "$ROOT_DIR/scripts/e2e/lib/parallels-package-common.sh" - -VM_NAME="Ubuntu 24.04.3 ARM64" -VM_NAME_EXPLICIT=0 -SNAPSHOT_HINT="fresh" -MODE="both" -PROVIDER="openai" -API_KEY_ENV="" -AUTH_CHOICE="" -AUTH_KEY_FLAG="" -MODEL_ID="" -MODEL_ID_EXPLICIT=0 -INSTALL_URL="https://openclaw.ai/install.sh" -HOST_PORT="18427" -HOST_PORT_EXPLICIT=0 -HOST_IP="" -LATEST_VERSION="" -INSTALL_VERSION="" -TARGET_PACKAGE_SPEC="" -JSON_OUTPUT=0 -KEEP_SERVER=0 -SNAPSHOT_ID="" -SNAPSHOT_STATE="" -SNAPSHOT_NAME="" -PACKED_MAIN_COMMIT_SHORT="" - -MAIN_TGZ_DIR="$(mktemp -d)" -MAIN_TGZ_PATH="" -SERVER_PID="" -RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-linux.XXXXXX)" -BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock" - -TIMEOUT_SNAPSHOT_S=180 -TIMEOUT_BOOTSTRAP_S=600 -TIMEOUT_INSTALL_S=420 -TIMEOUT_VERIFY_S=90 -TIMEOUT_ONBOARD_S=180 -TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S:-300}" -TIMEOUT_GATEWAY_S=240 -PHASE_STALE_WARN_S=60 -DISABLE_BONJOUR_FOR_GATEWAY=0 - -FRESH_MAIN_STATUS="skip" -FRESH_MAIN_VERSION="skip" -FRESH_GATEWAY_STATUS="skip" -FRESH_AGENT_STATUS="skip" -UPGRADE_STATUS="skip" -LATEST_INSTALLED_VERSION="skip" -UPGRADE_MAIN_VERSION="skip" -UPGRADE_GATEWAY_STATUS="skip" -UPGRADE_AGENT_STATUS="skip" -DAEMON_STATUS="systemd-user-unavailable" - -say() { - printf '==> %s\n' "$*" -} - -artifact_label() { - if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then - printf 'target package tgz' - return - fi - printf 'current main tgz' -} - -extract_package_build_commit_from_tgz() { - tar -xOf "$1" package/dist/build-info.json | python3 -c 'import json, sys; print(json.load(sys.stdin).get("commit", ""))' -} - -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 - -shell_quote() { - local value="$1" - printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\"'\"'/g")" -} - -usage() { - cat <<'EOF' -Usage: bash scripts/e2e/parallels-linux-smoke.sh [options] - -Options: - --vm Parallels VM name. Default: "Ubuntu 24.04.3 ARM64" - Falls back to the closest Ubuntu VM when omitted and unavailable. - --snapshot-hint Snapshot name substring/fuzzy match. Default: "fresh" - --mode - --provider - Provider auth/model lane. Default: openai - --model Override the model used for the agent-turn smoke. - Default: openai/gpt-5.5 for the OpenAI lane - --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) - --install-url Installer URL for latest release. Default: https://openclaw.ai/install.sh - --host-port Host HTTP port for current-main tgz. Default: 18427 - --host-ip Override Parallels host IP. - --latest-version Override npm latest version lookup. - --install-version Pin site-installer version/dist-tag for the baseline lane. - --target-package-spec - Install this npm package tarball instead of packing current main. - Example: openclaw@2026.3.13-beta.1 - --keep-server Leave temp host HTTP server running. - --json Print machine-readable JSON summary. - -h, --help Show help. -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --) - shift - ;; - --vm) - VM_NAME="$2" - VM_NAME_EXPLICIT=1 - shift 2 - ;; - --snapshot-hint) - SNAPSHOT_HINT="$2" - shift 2 - ;; - --mode) - MODE="$2" - shift 2 - ;; - --provider) - PROVIDER="$2" - shift 2 - ;; - --model) - MODEL_ID="$2" - MODEL_ID_EXPLICIT=1 - shift 2 - ;; - --api-key-env|--openai-api-key-env) - API_KEY_ENV="$2" - shift 2 - ;; - --install-url) - INSTALL_URL="$2" - shift 2 - ;; - --host-port) - HOST_PORT="$2" - HOST_PORT_EXPLICIT=1 - shift 2 - ;; - --host-ip) - HOST_IP="$2" - shift 2 - ;; - --latest-version) - LATEST_VERSION="$2" - shift 2 - ;; - --install-version) - INSTALL_VERSION="$2" - shift 2 - ;; - --target-package-spec) - TARGET_PACKAGE_SPEC="$2" - shift 2 - ;; - --keep-server) - KEEP_SERVER=1 - shift - ;; - --json) - JSON_OUTPUT=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - die "unknown arg: $1" - ;; - esac -done - -case "$MODE" in - fresh|upgrade|both) ;; - *) - die "invalid --mode: $MODE" - ;; -esac - -case "$PROVIDER" in - openai) - AUTH_CHOICE="openai-api-key" - AUTH_KEY_FLAG="openai-api-key" - [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" - [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" - ;; - anthropic) - AUTH_CHOICE="apiKey" - AUTH_KEY_FLAG="anthropic-api-key" - [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-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_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-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" -case "${OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR:-}" in - 1|true|TRUE|yes|YES|on|ON) - DISABLE_BONJOUR_FOR_GATEWAY=1 - ;; -esac - -resolve_vm_name() { - local json requested explicit - json="$(prlctl list --all --json)" - requested="$VM_NAME" - explicit="$VM_NAME_EXPLICIT" - PRL_VM_JSON="$json" REQUESTED_VM_NAME="$requested" VM_NAME_EXPLICIT="$explicit" python3 - <<'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() -explicit = os.environ["VM_NAME_EXPLICIT"] == "1" -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) - -if explicit: - sys.exit(f"vm not found: {requested}") - -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_snapshot_info() { - local json hint - json="$(prlctl snapshot-list "$VM_NAME" --json)" - hint="$SNAPSHOT_HINT" - SNAPSHOT_JSON="$json" SNAPSHOT_HINT="$hint" python3 - <<'PY' -import difflib -import json -import os -import re -import sys - -payload = json.loads(os.environ["SNAPSHOT_JSON"]) -hint = os.environ["SNAPSHOT_HINT"].strip().lower() -best_id = None -best_meta = None -best_score = -1.0 - -def aliases(name: str) -> list[str]: - values = [name] - for pattern in ( - r"^(.*)-poweroff$", - r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$", - ): - match = re.match(pattern, name) - if match: - values.append(match.group(1)) - return values - -for snapshot_id, meta in payload.items(): - name = str(meta.get("name", "")).strip() - lowered = name.lower() - score = 0.0 - for alias in aliases(lowered): - if alias == hint: - score = max(score, 10.0) - elif hint and hint in alias: - score = max(score, 5.0 + len(hint) / max(len(alias), 1)) - else: - score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio()) - if str(meta.get("state", "")).lower() == "poweroff": - score += 0.5 - if score > best_score: - best_score = score - best_id = snapshot_id - best_meta = meta -if not best_id: - sys.exit("no snapshot matched") -print( - "\t".join( - [ - best_id, - str(best_meta.get("state", "")).strip(), - str(best_meta.get("name", "")).strip(), - ] - ) -) -PY -} - -resolve_host_ip() { - if [[ -n "$HOST_IP" ]]; then - printf '%s\n' "$HOST_IP" - return - fi - local detected - detected="$(ifconfig | awk '/inet 10\.211\./ { print $2; exit }')" - [[ -n "$detected" ]] || die "failed to detect Parallels host IP; pass --host-ip" - printf '%s\n' "$detected" -} - -is_host_port_free() { - local port="$1" - python3 - "$port" <<'PY' -import socket -import sys - -sock = socket.socket() -try: - sock.bind(("0.0.0.0", int(sys.argv[1]))) -except OSError: - raise SystemExit(1) -finally: - sock.close() -PY -} - -allocate_host_port() { - python3 - <<'PY' -import socket - -sock = socket.socket() -sock.bind(("0.0.0.0", 0)) -print(sock.getsockname()[1]) -sock.close() -PY -} - -resolve_host_port() { - if is_host_port_free "$HOST_PORT"; then - printf '%s\n' "$HOST_PORT" - return - fi - if [[ "$HOST_PORT_EXPLICIT" -eq 1 ]]; then - die "host port $HOST_PORT already in use" - fi - HOST_PORT="$(allocate_host_port)" - warn "host port 18427 busy; using $HOST_PORT" - printf '%s\n' "$HOST_PORT" -} - -guest_exec() { - prlctl exec "$VM_NAME" /usr/bin/env HOME=/root "$@" -} - -guest_bash_script() { - local encoded - encoded="$(base64 | tr -d '\n')" - guest_exec bash -lc "printf '%s' '$encoded' | base64 -d | bash" -} - -wait_for_vm_status() { - local expected="$1" - local deadline status - deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) - while (( SECONDS < deadline )); do - status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" - if [[ "$status" == *" $expected" ]]; then - return 0 - fi - sleep 1 - done - return 1 -} - -wait_for_guest_ready() { - local deadline - deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) - while (( SECONDS < deadline )); do - if guest_exec /bin/true >/dev/null 2>&1; then - return 0 - fi - sleep 2 - done - return 1 -} - -restore_snapshot() { - local snapshot_id="$1" - say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" - prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null - if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then - wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME" - say "Start restored poweroff snapshot $SNAPSHOT_NAME" - prlctl start "$VM_NAME" >/dev/null - fi - wait_for_guest_ready || die "guest did not become ready in $VM_NAME" -} - -sync_guest_clock() { - local host_now - host_now="@$(date -u '+%s')" - guest_exec date -u -s "$host_now" >/dev/null - guest_exec hwclock --systohc >/dev/null 2>&1 || true - guest_exec timedatectl set-ntp true >/dev/null 2>&1 || true - guest_exec systemctl restart systemd-timesyncd >/dev/null 2>&1 || true - guest_exec date -u -} - -bootstrap_guest() { - sync_guest_clock - guest_exec apt-get -o Acquire::Check-Date=false update - guest_exec apt-get install -y curl ca-certificates -} - -resolve_latest_version() { - if [[ -n "$LATEST_VERSION" ]]; then - printf '%s\n' "$LATEST_VERSION" - return - fi - npm view openclaw version --userconfig "$(mktemp)" -} - -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)" ]] -} - -acquire_build_lock() { - parallels_package_acquire_build_lock "$BUILD_LOCK_DIR" -} - -release_build_lock() { - parallels_package_release_build_lock "$BUILD_LOCK_DIR" -} - -ensure_current_build() { - local head build_commit rc lock_owned - lock_owned=0 - if [[ "${OPENCLAW_PARALLELS_BUILD_LOCK_HELD:-0}" != "1" ]]; then - acquire_build_lock - lock_owned=1 - fi - head="$(git rev-parse HEAD)" - build_commit="$(current_build_commit)" - if [[ "$build_commit" == "$head" ]] && ! source_tree_dirty_for_build; then - if [[ "$lock_owned" -eq 1 ]]; then - release_build_lock - fi - return - fi - say "Build dist for current head" - set +e - pnpm build - rc=$? - if [[ $rc -eq 0 ]]; then - parallels_package_assert_no_generated_drift - rc=$? - fi - build_commit="$(current_build_commit)" - set -e - if [[ "$lock_owned" -eq 1 ]]; then - release_build_lock - fi - [[ $rc -eq 0 ]] || return "$rc" - if [[ "$build_commit" != "$head" ]]; then - warn "dist/build-info.json still does not match HEAD after build" - return 1 - fi -} - -write_package_dist_inventory() { - parallels_package_write_dist_inventory -} - -extract_package_version_from_tgz() { - tar -xOf "$1" package/package.json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])' -} - -pack_main_tgz() { - local short_head pkg packed_commit rc - if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then - say "Pack target package tgz: $TARGET_PACKAGE_SPEC" - pkg="$( - npm pack "$TARGET_PACKAGE_SPEC" --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/$(basename "$pkg")" - TARGET_EXPECT_VERSION="$(extract_package_version_from_tgz "$MAIN_TGZ_PATH")" - say "Packed $MAIN_TGZ_PATH" - say "Target package version: $TARGET_EXPECT_VERSION" - return - fi - say "Pack current main tgz" - acquire_build_lock - set +e - { - OPENCLAW_PARALLELS_BUILD_LOCK_HELD=1 ensure_current_build && - write_package_dist_inventory && - short_head="$(git rev-parse --short HEAD)" && - 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"])' - )" - } - rc=$? - set -e - release_build_lock - [[ $rc -eq 0 ]] || return "$rc" - MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$short_head.tgz" - cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH" - packed_commit="$(extract_package_build_commit_from_tgz "$MAIN_TGZ_PATH")" - [[ -n "$packed_commit" ]] || die "failed to read packed build commit from $MAIN_TGZ_PATH" - PACKED_MAIN_COMMIT_SHORT="${packed_commit:0:7}" - say "Packed $MAIN_TGZ_PATH" - tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json -} - -verify_target_version() { - if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then - verify_version_contains "$TARGET_EXPECT_VERSION" - return - fi - [[ -n "$PACKED_MAIN_COMMIT_SHORT" ]] || die "packed main commit not captured" - verify_version_contains "$PACKED_MAIN_COMMIT_SHORT" -} - -start_server() { - local host_ip="$1" - local artifact probe_url attempt - artifact="$(basename "$MAIN_TGZ_PATH")" - attempt=0 - while :; do - attempt=$((attempt + 1)) - say "Serve $(artifact_label) on $host_ip:$HOST_PORT" - ( - cd "$MAIN_TGZ_DIR" - exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 - ) >/tmp/openclaw-parallels-linux-http.log 2>&1 & - SERVER_PID=$! - sleep 1 - probe_url="http://127.0.0.1:$HOST_PORT/$artifact" - if kill -0 "$SERVER_PID" >/dev/null 2>&1 && curl -fsSI "$probe_url" >/dev/null 2>&1; then - return 0 - fi - kill "$SERVER_PID" >/dev/null 2>&1 || true - wait "$SERVER_PID" >/dev/null 2>&1 || true - SERVER_PID="" - if [[ "$HOST_PORT_EXPLICIT" -eq 1 || $attempt -ge 3 ]]; then - die "failed to start reachable host HTTP server on port $HOST_PORT" - fi - HOST_PORT="$(allocate_host_port)" - warn "retrying host HTTP server on port $HOST_PORT" - done -} - -install_latest_release() { - guest_exec curl -fsSL "$INSTALL_URL" -o /tmp/openclaw-install.sh - if [[ -n "$INSTALL_VERSION" ]]; then - guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh --version "$INSTALL_VERSION" --no-onboard - else - guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh --no-onboard - fi - guest_exec openclaw --version -} - -install_main_tgz() { - local host_ip="$1" - local temp_name="$2" - local tgz_url="http://$host_ip:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")" - guest_exec curl -fsSL "$tgz_url" -o "/tmp/$temp_name" - guest_exec npm install -g "/tmp/$temp_name" --no-fund --no-audit - guest_exec openclaw --version -} - -verify_version_contains() { - local needle="$1" - local version - version="$(guest_exec openclaw --version)" - printf '%s\n' "$version" - case "$version" in - *"$needle"*) ;; - *) - echo "version mismatch: expected substring $needle" >&2 - return 1 - ;; - esac -} - -run_ref_onboard() { - guest_exec /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" openclaw onboard \ - --non-interactive \ - --mode local \ - --auth-choice "$AUTH_CHOICE" \ - --secret-input-mode ref \ - --gateway-port 18789 \ - --gateway-bind loopback \ - --skip-skills \ - --skip-health \ - --accept-risk \ - --json -} - -inject_bad_plugin_fixture() { - guest_bash_script <<'EOF' -set -euo pipefail -plugin_dir=/root/.openclaw/test-bad-plugin -mkdir -p "$plugin_dir" -cat >"$plugin_dir/package.json" <<'JSON' -{ - "name": "@openclaw/test-bad-plugin", - "version": "1.0.0", - "openclaw": { - "extensions": ["./index.cjs"], - "setupEntry": "./setup-entry.cjs" - } -} -JSON -cat >"$plugin_dir/openclaw.plugin.json" <<'JSON' -{ - "id": "test-bad-plugin", - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - }, - "channels": ["test-bad-plugin"] -} -JSON -cat >"$plugin_dir/index.cjs" <<'JS' -module.exports = { id: "test-bad-plugin", register() {} }; -JS -cat >"$plugin_dir/setup-entry.cjs" <<'JS' -module.exports = { - kind: "bundled-channel-setup-entry", - loadSetupPlugin() { - throw new Error("boom: bad plugin smoke fixture"); - }, -}; -JS -python3 - <<'PY' -import json -from pathlib import Path - -config_path = Path("/root/.openclaw/openclaw.json") -config = {} -if config_path.exists(): - config = json.loads(config_path.read_text()) - -plugins = config.setdefault("plugins", {}) -load = plugins.setdefault("load", {}) -paths = load.setdefault("paths", []) -plugin_dir = "/root/.openclaw/test-bad-plugin" -if plugin_dir not in paths: - paths.append(plugin_dir) - -allow = plugins.get("allow") -if isinstance(allow, list) and "test-bad-plugin" not in allow: - allow.append("test-bad-plugin") - -config_path.write_text(json.dumps(config, indent=2) + "\n") -PY -EOF -} - -verify_bad_plugin_diagnostic() { - guest_bash_script <<'EOF' -grep -F "failed to load setup entry" /tmp/openclaw-parallels-linux-gateway.log -EOF -} - -start_gateway_background() { - local cmd api_key_value_q bonjour_env - api_key_value_q="$(shell_quote "$API_KEY_VALUE")" - bonjour_env="" - if [[ "$DISABLE_BONJOUR_FOR_GATEWAY" -eq 1 ]]; then - bonjour_env=" OPENCLAW_DISABLE_BONJOUR=1" - fi - cmd="$(cat </dev/null 2>&1 || true -rm -f /tmp/openclaw-parallels-linux-gateway.log -setsid sh -lc 'exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json${bonjour_env} ${API_KEY_ENV}=${api_key_value_q} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1' >/dev/null 2>&1 < /dev/null & -EOF -)" - guest_exec bash -lc "$cmd" - - # On the Ubuntu guest the backgrounded process can bind a few seconds after - # the launch command returns. Keep the race inside gateway-start instead of - # failing the next phase with a false-negative RPC probe. - local deadline - deadline=$((SECONDS + TIMEOUT_GATEWAY_S)) - while (( SECONDS < deadline )); do - if show_gateway_status_compat >/dev/null 2>&1; then - return 0 - fi - sleep 2 - done - - return 1 -} - -show_gateway_status_compat() { - if guest_exec openclaw gateway status --help | grep -Fq -- "--require-rpc"; then - guest_exec openclaw gateway status --deep --require-rpc - return - fi - guest_exec openclaw gateway status --deep -} - -verify_gateway_status() { - local attempt - for attempt in 1 2 3 4 5 6 7 8; do - if guest_exec openclaw gateway status --deep --require-rpc --timeout 15000; then - return 0 - fi - if (( attempt < 8 )); then - printf 'gateway-status retry %s\n' "$attempt" >&2 - sleep 5 - fi - done - return 1 -} - -prepare_agent_workspace() { - guest_exec /bin/sh -lc "set -eu -$(parallels_bash_seed_workspace_snippet "Parallels Linux smoke test assistant.")" -} - -verify_local_turn() { - guest_exec openclaw models set "$MODEL_ID" - guest_exec openclaw config set agents.defaults.skipBootstrap true --strict-json - prepare_agent_workspace - guest_exec /bin/sh -lc "$(cat <&2 || true -} - -phase_run() { - local phase_id="$1" - local timeout_s="$2" - shift 2 - - local log_path pid start rc timed_out next_warn summary - log_path="$(phase_log_path "$phase_id")" - say "$phase_id" - start=$SECONDS - next_warn=$((start + PHASE_STALE_WARN_S)) - timed_out=0 - - ( - "$@" - ) >"$log_path" 2>&1 & - pid=$! - - while kill -0 "$pid" >/dev/null 2>&1; do - if (( SECONDS >= next_warn )); then - summary="$(parallels_log_progress_extract python3 "$log_path")" - [[ -n "$summary" ]] || summary="waiting for first log line" - warn "$phase_id still running after $((SECONDS - start))s: $summary" - next_warn=$((SECONDS + PHASE_STALE_WARN_S)) - fi - if (( SECONDS - start >= timeout_s )); then - timed_out=1 - kill "$pid" >/dev/null 2>&1 || true - sleep 2 - kill -9 "$pid" >/dev/null 2>&1 || true - break - fi - sleep 1 - done - - set +e - wait "$pid" - rc=$? - set -e - - if (( timed_out )); then - warn "$phase_id timed out after ${timeout_s}s" - printf 'timeout after %ss\n' "$timeout_s" >>"$log_path" - show_log_excerpt "$log_path" - return 124 - fi - - if [[ $rc -ne 0 ]]; then - warn "$phase_id failed (rc=$rc)" - show_log_excerpt "$log_path" - return "$rc" - fi - - return 0 -} - -write_summary_json() { - local summary_path="$RUN_DIR/summary.json" - python3 - "$summary_path" <<'PY' -import json -import os -import sys - -summary = { - "vm": os.environ["SUMMARY_VM"], - "snapshotHint": os.environ["SUMMARY_SNAPSHOT_HINT"], - "snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"], - "mode": os.environ["SUMMARY_MODE"], - "provider": os.environ["SUMMARY_PROVIDER"], - "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], - "installVersion": os.environ["SUMMARY_INSTALL_VERSION"], - "targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"], - "currentHead": os.environ["SUMMARY_CURRENT_HEAD"], - "runDir": os.environ["SUMMARY_RUN_DIR"], - "daemon": os.environ["SUMMARY_DAEMON_STATUS"], - "freshMain": { - "status": os.environ["SUMMARY_FRESH_MAIN_STATUS"], - "version": os.environ["SUMMARY_FRESH_MAIN_VERSION"], - "gateway": os.environ["SUMMARY_FRESH_GATEWAY_STATUS"], - "agent": os.environ["SUMMARY_FRESH_AGENT_STATUS"], - }, - "upgrade": { - "status": os.environ["SUMMARY_UPGRADE_STATUS"], - "latestVersionInstalled": os.environ["SUMMARY_LATEST_INSTALLED_VERSION"], - "mainVersion": os.environ["SUMMARY_UPGRADE_MAIN_VERSION"], - "gateway": os.environ["SUMMARY_UPGRADE_GATEWAY_STATUS"], - "agent": os.environ["SUMMARY_UPGRADE_AGENT_STATUS"], - }, -} -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 -} - -run_fresh_main_lane() { - local snapshot_id="$1" - local host_ip="$2" - phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" - phase_run "fresh.bootstrap-guest" "$TIMEOUT_BOOTSTRAP_S" bootstrap_guest - phase_run "fresh.install-latest-bootstrap" "$TIMEOUT_INSTALL_S" install_latest_release - phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz" - FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")" - phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version - phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard - phase_run "fresh.inject-bad-plugin" "$TIMEOUT_VERIFY_S" inject_bad_plugin_fixture - phase_run "fresh.gateway-start" "$TIMEOUT_GATEWAY_S" start_gateway_background - phase_run "fresh.bad-plugin-diagnostic" "$TIMEOUT_VERIFY_S" verify_bad_plugin_diagnostic - phase_run "fresh.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway_status - FRESH_GATEWAY_STATUS="pass" - phase_run "fresh.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn - FRESH_AGENT_STATUS="pass" -} - -run_upgrade_lane() { - local snapshot_id="$1" - local host_ip="$2" - phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" - phase_run "upgrade.bootstrap-guest" "$TIMEOUT_BOOTSTRAP_S" bootstrap_guest - phase_run "upgrade.install-latest" "$TIMEOUT_INSTALL_S" install_latest_release - LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-latest)")" - phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$LATEST_VERSION" - phase_run "upgrade.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz" - UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")" - phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version - phase_run "upgrade.inject-bad-plugin" "$TIMEOUT_VERIFY_S" inject_bad_plugin_fixture - phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard - phase_run "upgrade.gateway-start" "$TIMEOUT_GATEWAY_S" start_gateway_background - phase_run "upgrade.bad-plugin-diagnostic" "$TIMEOUT_VERIFY_S" verify_bad_plugin_diagnostic - phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway_status - UPGRADE_GATEWAY_STATUS="pass" - phase_run "upgrade.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn - UPGRADE_AGENT_STATUS="pass" -} - -RESOLVED_VM_NAME="$(resolve_vm_name)" -if [[ "$RESOLVED_VM_NAME" != "$VM_NAME" ]]; then - warn "requested VM $VM_NAME not found; using $RESOLVED_VM_NAME" - VM_NAME="$RESOLVED_VM_NAME" -fi - -IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)" -[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id" -[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT" -LATEST_VERSION="$(resolve_latest_version)" -HOST_IP="$(resolve_host_ip)" -HOST_PORT="$(resolve_host_port)" - -say "VM: $VM_NAME" -say "Snapshot hint: $SNAPSHOT_HINT" -say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]" -say "Latest npm version: $LATEST_VERSION" -say "Current head: $(git rev-parse --short HEAD)" -say "Run logs: $RUN_DIR" - -pack_main_tgz -start_server "$HOST_IP" - -if [[ "$MODE" == "fresh" || "$MODE" == "both" ]]; then - set +e - run_fresh_main_lane "$SNAPSHOT_ID" "$HOST_IP" - fresh_rc=$? - set -e - if [[ $fresh_rc -eq 0 ]]; then - FRESH_MAIN_STATUS="pass" - else - FRESH_MAIN_STATUS="fail" - fi -fi - -if [[ "$MODE" == "upgrade" || "$MODE" == "both" ]]; then - set +e - run_upgrade_lane "$SNAPSHOT_ID" "$HOST_IP" - upgrade_rc=$? - set -e - if [[ $upgrade_rc -eq 0 ]]; then - UPGRADE_STATUS="pass" - else - UPGRADE_STATUS="fail" - fi -fi - -if [[ "$KEEP_SERVER" -eq 0 && -n "${SERVER_PID:-}" ]]; then - kill "$SERVER_PID" >/dev/null 2>&1 || true - SERVER_PID="" -fi - -SUMMARY_JSON_PATH="$( - SUMMARY_VM="$VM_NAME" \ - SUMMARY_SNAPSHOT_HINT="$SNAPSHOT_HINT" \ - SUMMARY_SNAPSHOT_ID="$SNAPSHOT_ID" \ - SUMMARY_MODE="$MODE" \ - SUMMARY_PROVIDER="$PROVIDER" \ - SUMMARY_LATEST_VERSION="$LATEST_VERSION" \ - SUMMARY_INSTALL_VERSION="$INSTALL_VERSION" \ - SUMMARY_TARGET_PACKAGE_SPEC="$TARGET_PACKAGE_SPEC" \ - SUMMARY_CURRENT_HEAD="${PACKED_MAIN_COMMIT_SHORT:-$(git rev-parse --short HEAD)}" \ - SUMMARY_RUN_DIR="$RUN_DIR" \ - SUMMARY_DAEMON_STATUS="$DAEMON_STATUS" \ - SUMMARY_FRESH_MAIN_STATUS="$FRESH_MAIN_STATUS" \ - SUMMARY_FRESH_MAIN_VERSION="$FRESH_MAIN_VERSION" \ - SUMMARY_FRESH_GATEWAY_STATUS="$FRESH_GATEWAY_STATUS" \ - SUMMARY_FRESH_AGENT_STATUS="$FRESH_AGENT_STATUS" \ - SUMMARY_UPGRADE_STATUS="$UPGRADE_STATUS" \ - SUMMARY_LATEST_INSTALLED_VERSION="$LATEST_INSTALLED_VERSION" \ - SUMMARY_UPGRADE_MAIN_VERSION="$UPGRADE_MAIN_VERSION" \ - SUMMARY_UPGRADE_GATEWAY_STATUS="$UPGRADE_GATEWAY_STATUS" \ - SUMMARY_UPGRADE_AGENT_STATUS="$UPGRADE_AGENT_STATUS" \ - write_summary_json -)" - -if [[ "$JSON_OUTPUT" -eq 1 ]]; then - cat "$SUMMARY_JSON_PATH" -else - printf '\nSummary:\n' - if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then - printf ' target-package: %s\n' "$TARGET_PACKAGE_SPEC" - fi - if [[ -n "$INSTALL_VERSION" ]]; then - printf ' baseline-install-version: %s\n' "$INSTALL_VERSION" - fi - printf ' daemon: %s\n' "$DAEMON_STATUS" - printf ' fresh-main: %s (%s)\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION" - printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" - printf ' logs: %s\n' "$RUN_DIR" - printf ' summary: %s\n' "$SUMMARY_JSON_PATH" -fi - -if [[ "$FRESH_MAIN_STATUS" == "fail" || "$UPGRADE_STATUS" == "fail" ]]; then - exit 1 -fi +exec pnpm --dir "$ROOT_DIR" exec tsx scripts/e2e/parallels/linux-smoke.ts "$@" diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh old mode 100644 new mode 100755 index b0455ba805b..05089c86b88 --- a/scripts/e2e/parallels-macos-smoke.sh +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -2,2128 +2,4 @@ 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" - -VM_NAME="macOS Tahoe" -SNAPSHOT_HINT="macOS 26.3.1 latest" -MODE="both" -PROVIDER="openai" -API_KEY_ENV="" -AUTH_CHOICE="" -AUTH_KEY_FLAG="" -MODEL_ID="" -MODEL_ID_EXPLICIT=0 -INSTALL_URL="https://openclaw.ai/install.sh" -HOST_PORT="18425" -HOST_PORT_EXPLICIT=0 -HOST_IP="" -LATEST_VERSION="" -INSTALL_VERSION="" -TARGET_PACKAGE_SPEC="" -KEEP_SERVER=0 -CHECK_LATEST_REF=1 -JSON_OUTPUT=0 -DISCORD_TOKEN_ENV="" -DISCORD_TOKEN_VALUE="" -DISCORD_GUILD_ID="" -DISCORD_CHANNEL_ID="" -SNAPSHOT_ID="" -SNAPSHOT_STATE="" -SNAPSHOT_NAME="" -GUEST_OPENCLAW_BIN="/opt/homebrew/bin/openclaw" -GUEST_OPENCLAW_ENTRY="/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs" -GUEST_NODE_BIN="/opt/homebrew/bin/node" -GUEST_NPM_BIN="/opt/homebrew/bin/npm" -GUEST_CURRENT_USER="" -GUEST_CURRENT_USER_TRANSPORT="prlctl" - -MAIN_TGZ_DIR="$(mktemp -d)" -MAIN_TGZ_PATH="" -PACKED_MAIN_COMMIT_SHORT="" -TARGET_EXPECT_VERSION="" -SERVER_PID="" -RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-smoke.XXXXXX)" -BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock" - -TIMEOUT_INSTALL_SITE_S=420 -TIMEOUT_INSTALL_TGZ_S=420 -TIMEOUT_INSTALL_REGISTRY_S=420 -TIMEOUT_UPDATE_DEV_S="${OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S:-1800}" -TIMEOUT_VERIFY_S=60 -TIMEOUT_ONBOARD_S=180 -TIMEOUT_GATEWAY_S=180 -TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S:-240}" -TIMEOUT_PERMISSION_S=60 -TIMEOUT_DASHBOARD_S=180 -TIMEOUT_SNAPSHOT_S=360 -TIMEOUT_CURRENT_USER_PRLCTL_S=45 -TIMEOUT_DISCORD_S=180 -PHASE_STALE_WARN_S=60 - -FRESH_MAIN_VERSION="skip" -LATEST_INSTALLED_VERSION="skip" -UPGRADE_MAIN_VERSION="skip" -FRESH_GATEWAY_STATUS="skip" -UPGRADE_GATEWAY_STATUS="skip" -FRESH_AGENT_STATUS="skip" -UPGRADE_AGENT_STATUS="skip" -FRESH_DASHBOARD_STATUS="skip" -UPGRADE_DASHBOARD_STATUS="skip" -FRESH_DISCORD_STATUS="skip" -UPGRADE_DISCORD_STATUS="skip" - -say() { - printf '==> %s\n' "$*" -} - -artifact_label() { - if target_package_installs_directly; then - printf 'target package spec' - return - fi - if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then - printf 'target package tgz' - return - fi - printf 'current main tgz' -} - -target_package_installs_directly() { - [[ -n "$TARGET_PACKAGE_SPEC" ]] || return 1 - case "$TARGET_PACKAGE_SPEC" in - http://*|https://*|file:*|/*|./*|../*|*.tgz) - return 1 - ;; - esac - return 0 -} - -warn() { - printf 'warn: %s\n' "$*" >&2 -} - -die() { - printf 'error: %s\n' "$*" >&2 - exit 1 -} - -cleanup() { - if command -v cleanup_discord_smoke_messages >/dev/null 2>&1; then - cleanup_discord_smoke_messages - fi - if [[ -n "${SERVER_PID:-}" ]]; then - kill "$SERVER_PID" >/dev/null 2>&1 || true - fi - rm -rf "$MAIN_TGZ_DIR" - if [[ "${KEEP_SERVER:-0}" -eq 0 ]]; then - : - fi -} - -trap cleanup EXIT - -shell_quote() { - local value="$1" - printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\"'\"'/g")" -} - -usage() { - cat <<'EOF' -Usage: bash scripts/e2e/parallels-macos-smoke.sh [options] - -Options: - --vm Parallels VM name. Default: "macOS Tahoe" - --snapshot-hint Snapshot name substring/fuzzy match. - Default: "macOS 26.3.1 latest" - --mode - fresh = fresh snapshot -> target package/current main install artifact -> onboard smoke - upgrade = fresh snapshot -> pinned latest stable -> dev channel update -> onboard smoke - (or latest stable -> target package install when --target-package-spec is set) - both = run both lanes - --provider - Provider auth/model lane. Default: openai - --model Override the model used for the agent-turn smoke. - Default: openai/gpt-5.5 for the OpenAI lane - --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) - --install-url Installer URL for latest release. Default: https://openclaw.ai/install.sh - --host-port Host HTTP port for current-main tgz. Default: 18425 - --host-ip Override Parallels host IP. - --latest-version Override npm latest version lookup. - --install-version Pin site-installer version/dist-tag for the baseline lane. - --target-package-spec - Install this npm package tarball instead of packing current main. - Example: openclaw@2026.3.13-beta.1 - --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. - --keep-server Leave temp host HTTP server running. - --discord-token-env Host env var name for Discord bot token. - --discord-guild-id Discord guild ID for smoke roundtrip. - --discord-channel-id Discord channel ID for smoke roundtrip. - --json Print machine-readable JSON summary. - -h, --help Show help. -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --) - shift - ;; - --vm) - VM_NAME="$2" - shift 2 - ;; - --snapshot-hint) - SNAPSHOT_HINT="$2" - shift 2 - ;; - --mode) - MODE="$2" - shift 2 - ;; - --provider) - PROVIDER="$2" - shift 2 - ;; - --model) - MODEL_ID="$2" - MODEL_ID_EXPLICIT=1 - shift 2 - ;; - --api-key-env|--openai-api-key-env) - API_KEY_ENV="$2" - shift 2 - ;; - --install-url) - INSTALL_URL="$2" - shift 2 - ;; - --host-port) - HOST_PORT="$2" - HOST_PORT_EXPLICIT=1 - shift 2 - ;; - --host-ip) - HOST_IP="$2" - shift 2 - ;; - --latest-version) - LATEST_VERSION="$2" - shift 2 - ;; - --install-version) - INSTALL_VERSION="$2" - shift 2 - ;; - --target-package-spec) - TARGET_PACKAGE_SPEC="$2" - shift 2 - ;; - --discord-token-env) - DISCORD_TOKEN_ENV="$2" - shift 2 - ;; - --discord-guild-id) - DISCORD_GUILD_ID="$2" - shift 2 - ;; - --discord-channel-id) - DISCORD_CHANNEL_ID="$2" - shift 2 - ;; - --skip-latest-ref-check) - CHECK_LATEST_REF=0 - shift - ;; - --keep-server) - KEEP_SERVER=1 - shift - ;; - --json) - JSON_OUTPUT=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - die "unknown arg: $1" - ;; - esac -done - -case "$MODE" in - fresh|upgrade|both) ;; - *) - die "invalid --mode: $MODE" - ;; -esac - -case "$PROVIDER" in - openai) - AUTH_CHOICE="openai-api-key" - AUTH_KEY_FLAG="openai-api-key" - [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" - [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" - ;; - anthropic) - AUTH_CHOICE="apiKey" - AUTH_KEY_FLAG="anthropic-api-key" - [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-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_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-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" - -if [[ -n "$DISCORD_TOKEN_ENV" || -n "$DISCORD_GUILD_ID" || -n "$DISCORD_CHANNEL_ID" ]]; then - [[ -n "$DISCORD_TOKEN_ENV" ]] || die "--discord-token-env is required when Discord smoke args are set" - [[ -n "$DISCORD_GUILD_ID" ]] || die "--discord-guild-id is required when Discord smoke args are set" - [[ -n "$DISCORD_CHANNEL_ID" ]] || die "--discord-channel-id is required when Discord smoke args are set" - DISCORD_TOKEN_VALUE="${!DISCORD_TOKEN_ENV:-}" - [[ -n "$DISCORD_TOKEN_VALUE" ]] || die "$DISCORD_TOKEN_ENV is required for Discord smoke" -fi - -discord_smoke_enabled() { - [[ -n "$DISCORD_TOKEN_VALUE" && -n "$DISCORD_GUILD_ID" && -n "$DISCORD_CHANNEL_ID" ]] -} - -successful_discord_smoke() { - discord_smoke_enabled || return 1 - [[ "$FRESH_DISCORD_STATUS" == "pass" || "$UPGRADE_DISCORD_STATUS" == "pass" ]] -} - -stop_vm_after_successful_discord_smoke() { - successful_discord_smoke || return 0 - - say "Stop $VM_NAME after successful Discord smoke" - set +e - if command -v gtimeout >/dev/null 2>&1; then - gtimeout --foreground 120s prlctl stop "$VM_NAME" - else - prlctl stop "$VM_NAME" - fi - local rc=$? - set -e - if (( rc != 0 )); then - warn "failed to stop $VM_NAME after successful Discord smoke (rc=$rc)" - fi -} - -fresh_uses_host_tgz() { - if [[ -z "$TARGET_PACKAGE_SPEC" ]]; then - return 0 - fi - ! target_package_installs_directly -} - -upgrade_uses_host_tgz() { - [[ -n "$TARGET_PACKAGE_SPEC" ]] && ! target_package_installs_directly -} - -needs_host_tgz() { - if [[ "$MODE" == "fresh" || "$MODE" == "both" ]]; then - fresh_uses_host_tgz && return 0 - fi - if [[ "$MODE" == "upgrade" || "$MODE" == "both" ]]; then - upgrade_uses_host_tgz && return 0 - fi - return 1 -} - -upgrade_summary_label() { - if upgrade_uses_host_tgz; then - printf 'latest->target-package' - return - fi - printf 'latest->dev' -} - -discord_api_request() { - local method="$1" - local path="$2" - local payload="${3:-}" - local url="https://discord.com/api/v10$path" - if [[ -n "$payload" ]]; then - curl -fsS -X "$method" \ - -H "Authorization: Bot $DISCORD_TOKEN_VALUE" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "$url" - return - fi - curl -fsS -X "$method" \ - -H "Authorization: Bot $DISCORD_TOKEN_VALUE" \ - "$url" -} - -json_contains_string() { - local needle="$1" - python3 - "$needle" <<'PY' -import json -import sys - -needle = sys.argv[1] -try: - payload = json.load(sys.stdin) -except Exception: - raise SystemExit(1) - -def contains(value): - if isinstance(value, str): - return needle in value - if isinstance(value, list): - return any(contains(item) for item in value) - if isinstance(value, dict): - return any(contains(item) for item in value.values()) - return False - -raise SystemExit(0 if contains(payload) else 1) -PY -} - -discord_delete_message_id_file() { - local path="$1" - [[ -f "$path" ]] || return 0 - [[ -s "$path" ]] || return 0 - discord_smoke_enabled || return 0 - - local message_id - message_id="$(tr -d '\r\n' <"$path")" - [[ -n "$message_id" ]] || return 0 - - set +e - discord_api_request DELETE "/channels/$DISCORD_CHANNEL_ID/messages/$message_id" >/dev/null - set -e -} - -cleanup_discord_smoke_messages() { - discord_smoke_enabled || return 0 - [[ -d "$RUN_DIR" ]] || return 0 - - discord_delete_message_id_file "$RUN_DIR/fresh.discord-sent-message-id" - discord_delete_message_id_file "$RUN_DIR/fresh.discord-host-message-id" - discord_delete_message_id_file "$RUN_DIR/upgrade.discord-sent-message-id" - discord_delete_message_id_file "$RUN_DIR/upgrade.discord-host-message-id" -} - -resolve_snapshot_info() { - local json hint - json="$(prlctl snapshot-list "$VM_NAME" --json)" - hint="$SNAPSHOT_HINT" - SNAPSHOT_JSON="$json" SNAPSHOT_HINT="$hint" python3 - <<'PY' -import difflib -import json -import os -import re -import sys - -payload = json.loads(os.environ["SNAPSHOT_JSON"]) -hint = os.environ["SNAPSHOT_HINT"].strip().lower() -best_id = None -best_meta = None -best_score = -1.0 - -def aliases(name: str) -> list[str]: - values = [name] - for pattern in ( - r"^(.*)-poweroff$", - r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$", - ): - match = re.match(pattern, name) - if match: - values.append(match.group(1)) - return values - -for snapshot_id, meta in payload.items(): - name = str(meta.get("name", "")).strip() - lowered = name.lower() - score = 0.0 - for alias in aliases(lowered): - if alias == hint: - score = max(score, 10.0) - elif hint and hint in alias: - score = max(score, 5.0 + len(hint) / max(len(alias), 1)) - else: - score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio()) - if str(meta.get("state", "")).lower() == "poweroff": - score += 0.5 - if score > best_score: - best_score = score - best_id = snapshot_id - best_meta = meta -if not best_id: - sys.exit("no snapshot matched") -print( - "\t".join( - [ - best_id, - str(best_meta.get("state", "")).strip(), - str(best_meta.get("name", "")).strip(), - ] - ) -) -PY -} - -resolve_host_ip() { - if [[ -n "$HOST_IP" ]]; then - printf '%s\n' "$HOST_IP" - return - fi - - local detected - detected="$(ifconfig | awk '/inet 10\.211\./ { print $2; exit }')" - [[ -n "$detected" ]] || die "failed to detect Parallels host IP; pass --host-ip" - printf '%s\n' "$detected" -} - -is_host_port_free() { - local port="$1" - python3 - "$port" <<'PY' -import socket -import sys - -port = int(sys.argv[1]) -sock = socket.socket() -try: - sock.bind(("0.0.0.0", port)) -except OSError: - raise SystemExit(1) -finally: - sock.close() -PY -} - -allocate_host_port() { - python3 - <<'PY' -import socket - -sock = socket.socket() -sock.bind(("0.0.0.0", 0)) -print(sock.getsockname()[1]) -sock.close() -PY -} - -resolve_host_port() { - if is_host_port_free "$HOST_PORT"; then - printf '%s\n' "$HOST_PORT" - return - fi - if [[ "$HOST_PORT_EXPLICIT" -eq 1 ]]; then - die "host port $HOST_PORT already in use" - fi - HOST_PORT="$(allocate_host_port)" - warn "host port 18425 busy; using $HOST_PORT" - printf '%s\n' "$HOST_PORT" -} - -wait_for_vm_status() { - local expected="$1" - local deadline status - deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) - while (( SECONDS < deadline )); do - status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" - if [[ "$status" == *" $expected" ]]; then - return 0 - fi - sleep 1 - done - return 1 -} - -resolve_headless_guest_user() { - parallels_macos_resolve_desktop_user "$VM_NAME" -} - -guest_current_user_transport_path() { - printf '%s/guest-current-user.tsv\n' "$RUN_DIR" -} - -save_guest_current_user_transport() { - printf '%s\t%s\n' "$GUEST_CURRENT_USER" "$GUEST_CURRENT_USER_TRANSPORT" >"$(guest_current_user_transport_path)" -} - -load_guest_current_user_transport() { - local transport_path - transport_path="$(guest_current_user_transport_path)" - if [[ -f "$transport_path" ]]; then - IFS=$'\t' read -r GUEST_CURRENT_USER GUEST_CURRENT_USER_TRANSPORT <"$transport_path" - fi -} - -wait_for_current_user() { - local deadline prlctl_deadline user_name - deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) - prlctl_deadline=$((SECONDS + TIMEOUT_CURRENT_USER_PRLCTL_S)) - while (( SECONDS < prlctl_deadline && SECONDS < deadline )); do - if user_name="$(prlctl exec "$VM_NAME" --current-user whoami 2>/dev/null | tr -d '\r' | tail -n 1)" \ - && [[ "$user_name" =~ ^[A-Za-z0-9._-]+$ ]]; then - GUEST_CURRENT_USER="$user_name" - GUEST_CURRENT_USER_TRANSPORT="prlctl" - save_guest_current_user_transport - return 0 - fi - sleep 2 - done - user_name="$(resolve_headless_guest_user || true)" - if [[ -n "$user_name" ]] && prlctl exec "$VM_NAME" /usr/bin/sudo -u "$user_name" /usr/bin/whoami >/dev/null 2>&1; then - GUEST_CURRENT_USER="$user_name" - GUEST_CURRENT_USER_TRANSPORT="sudo" - save_guest_current_user_transport - warn "desktop user unavailable via Parallels --current-user; using root sudo fallback for $user_name" - return 0 - fi - while (( SECONDS < deadline )); do - if user_name="$(prlctl exec "$VM_NAME" --current-user whoami 2>/dev/null | tr -d '\r' | tail -n 1)" \ - && [[ "$user_name" =~ ^[A-Za-z0-9._-]+$ ]]; then - GUEST_CURRENT_USER="$user_name" - GUEST_CURRENT_USER_TRANSPORT="prlctl" - save_guest_current_user_transport - return 0 - fi - sleep 2 - done - return 1 -} - -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 -} - -snapshot_switch_with_retry() { - local snapshot_id="$1" - local attempt rc status - rc=0 - for attempt in 1 2; do - set +e - host_timeout_exec "$TIMEOUT_SNAPSHOT_S" prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" --skip-resume >/dev/null - rc=$? - set -e - if [[ $rc -eq 0 ]]; then - return 0 - fi - # Tahoe occasionally gets stuck mid snapshot-switch and leaves the guest - # running or suspended. Reset that state and try once more before failing - # the whole lane. - warn "snapshot-switch attempt $attempt failed (rc=$rc)" - status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" - [[ -n "$status" ]] && warn "vm status after snapshot-switch failure: $status" - if [[ "$status" == *" running" || "$status" == *" suspended" ]]; then - prlctl stop "$VM_NAME" --kill >/dev/null 2>&1 || true - wait_for_vm_status "stopped" || true - fi - sleep 3 - done - return "$rc" -} - -GUEST_EXEC_PATH="/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin" - -headless_guest_fallback() { - load_guest_current_user_transport - [[ "$GUEST_CURRENT_USER_TRANSPORT" == "sudo" ]] -} - -guest_current_user_exec_path() { - local path_value="$1" - shift - if headless_guest_fallback; then - local guest_home - guest_home="$(parallels_macos_resolve_desktop_home "$VM_NAME" "$GUEST_CURRENT_USER")" - prlctl exec "$VM_NAME" /usr/bin/sudo -H -u "$GUEST_CURRENT_USER" /usr/bin/env \ - "HOME=$guest_home" \ - "USER=$GUEST_CURRENT_USER" \ - "LOGNAME=$GUEST_CURRENT_USER" \ - "PATH=$path_value" \ - "$@" - return - fi - local output rc user_name - set +e - output="$( - prlctl exec "$VM_NAME" --current-user /usr/bin/env \ - "PATH=$path_value" \ - "$@" 2>&1 - )" - rc=$? - set -e - if [[ $rc -eq 0 ]]; then - printf '%s' "$output" - [[ -z "$output" || "$output" == *$'\n' ]] || printf '\n' - return 0 - fi - if [[ "$output" == *"Unable to authenticate the user"* ]]; then - user_name="$(resolve_headless_guest_user || true)" - if [[ -n "$user_name" ]]; then - GUEST_CURRENT_USER="$user_name" - GUEST_CURRENT_USER_TRANSPORT="sudo" - save_guest_current_user_transport - warn "macOS --current-user became unavailable; switching to root sudo fallback for $user_name" - guest_current_user_exec_path "$path_value" "$@" - return - fi - fi - printf '%s\n' "$output" >&2 - return "$rc" -} - -guest_current_user_exec() { - guest_current_user_exec_path "$GUEST_EXEC_PATH" "$@" -} - -guest_current_user_node_cli() { - guest_current_user_exec "$GUEST_NODE_BIN" "$@" -} - -resolve_guest_current_user_home() { - local user_name - user_name="$(guest_current_user_exec /usr/bin/id -un | tr -d '\r')" - parallels_macos_resolve_desktop_home "$VM_NAME" "$user_name" -} - -resolve_guest_git_openclaw_entry() { - local guest_home - guest_home="$(resolve_guest_current_user_home)" - printf '%s/openclaw/openclaw.mjs\n' "$guest_home" -} - -guest_current_user_cli() { - local parts=() arg joined="" - for arg in "$@"; do - parts+=("$(shell_quote "$arg")") - done - joined="${parts[*]}" - guest_current_user_sh "$joined" -} - -guest_script() { - local mode script - mode="$1" - script="$2" - PRL_GUEST_VM_NAME="$VM_NAME" PRL_GUEST_MODE="$mode" PRL_GUEST_SCRIPT="$script" /opt/homebrew/bin/expect <<'EOF' -log_user 1 -set timeout -1 -match_max 1048576 - -set vm $env(PRL_GUEST_VM_NAME) -set mode $env(PRL_GUEST_MODE) -set script $env(PRL_GUEST_SCRIPT) -set cmd [list prlctl enter $vm] -if {$mode eq "current-user"} { - lappend cmd --current-user -} - -spawn {*}$cmd -send -- "printf '__OPENCLAW_READY__\\n'\r" -expect "__OPENCLAW_READY__" -log_user 0 -send -- "export PS1='' PROMPT='' PROMPT2='' RPROMPT=''\r" -send -- "stty -echo\r" - -send -- "cat >/tmp/openclaw-prl.sh <<'__OPENCLAW_SCRIPT__'\r" -send -- $script -if {![string match "*\n" $script]} { - send -- "\r" -} -send -- "__OPENCLAW_SCRIPT__\r" -send -- "/bin/bash /tmp/openclaw-prl.sh; rc=\$?; rm -f /tmp/openclaw-prl.sh; printf '__OPENCLAW_RC__:%s\\n' \"\$rc\"; exit \"\$rc\"\r" -log_user 1 - -set rc 1 -set saw_rc 0 -expect { - -re {__OPENCLAW_RC__:(-?[0-9]+)} { - set rc $expect_out(1,string) - set saw_rc 1 - } - eof {} -} -if {$saw_rc} { - # Tahoe can leave `prlctl enter` attached even after the guest command has - # printed its explicit rc marker. Close the transport once the marker lands so - # consecutive guest_current_user_cli calls in the same phase do not block. - catch close - exit $rc -} -catch wait result -exit $rc -EOF -} - -guest_current_user_sh() { - local script script_path rc - script=$'set -eu\n' - script+=$'set -o pipefail\n' - script+=$'trap "" PIPE\n' - script+=$'umask 022\n' - script+=$'export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"\n' - script+=$'if [ -z "${HOME:-}" ]; then export HOME="/Users/$(id -un)"; fi\n' - script+=$'cd "$HOME"\n' - script+="$1" - if headless_guest_fallback; then - script_path="/tmp/openclaw-prl-${BASHPID:-$$}-$RANDOM.sh" - local guest_home - guest_home="$(parallels_macos_resolve_desktop_home "$VM_NAME" "$GUEST_CURRENT_USER")" - printf '%s' "$script" | /usr/bin/base64 | prlctl exec "$VM_NAME" \ - /usr/bin/sudo -H -u "$GUEST_CURRENT_USER" /usr/bin/env \ - "HOME=$guest_home" \ - "USER=$GUEST_CURRENT_USER" \ - "LOGNAME=$GUEST_CURRENT_USER" \ - /usr/bin/base64 -D -o "$script_path" - set +e - guest_current_user_exec_path "$GUEST_EXEC_PATH" /bin/bash "$script_path" - rc=$? - set -e - guest_current_user_exec /bin/rm -f "$script_path" >/dev/null 2>&1 || true - return "$rc" - fi - guest_script current-user "$script" -} - -guest_current_user_tail_file() { - local file_path="$1" - local lines="${2:-80}" - guest_current_user_exec /usr/bin/tail -n "$lines" "$file_path" -} - -guest_current_user_kill_process_tree() { - local pid="$1" - [[ "$pid" =~ ^[0-9]+$ ]] || return 0 - guest_current_user_sh "$(cat </dev/null || true); do - kill_tree "\$child" - done - /bin/kill -TERM "\$target" 2>/dev/null || true -} -kill_tree $(shell_quote "$pid") -/bin/sleep 2 -kill_tree_force() { - local target="\$1" child - for child in \$(/usr/bin/pgrep -P "\$target" 2>/dev/null || true); do - kill_tree_force "\$child" - done - /bin/kill -KILL "\$target" 2>/dev/null || true -} -kill_tree_force $(shell_quote "$pid") -EOF -)" >/dev/null 2>&1 || true -} - -latest_guest_npm_debug_log_path() { - local guest_home="$1" - guest_current_user_sh "$(cat </dev/null)" - rc=$? - set -e - [[ $rc -eq 0 ]] || return 0 - CONTENT="$content" PREFIX="$prefix" python3 - "$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") if state_path.exists() else "" -current = os.environ["CONTENT"].replace("\r\n", "\n").replace("\r", "\n") -prefix = os.environ["PREFIX"] - -if current.startswith(previous): - delta = current[len(previous):] -else: - delta = current - -if delta: - for line in delta.splitlines(): - print(f"{prefix}{line}") - -state_path.write_text(current, encoding="utf-8") -PY -} - -run_logged_guest_current_user_sh() { - local script="$1" - local log_path="$2" - local done_path="$3" - local timeout_s="$4" - local runner_path="$5" - local deadline rc done_rc runner_body write_runner_cmd - local guest_home guest_log_state_path latest_npm_log_path latest_npm_log_state_path npm_state_path runner_pid_path runner_pid - rc="" - done_rc="" - latest_npm_log_path="" - runner_pid_path="$done_path.pid" - guest_current_user_exec /bin/rm -f "$log_path" "$done_path" "$runner_path" "$runner_pid_path" - runner_body="$(cat < "$done_path" -exit "\$status" -EOF -)" - write_runner_cmd="/bin/rm -f $(shell_quote "$runner_path")"$'\n' - write_runner_cmd+="cat > $(shell_quote "$runner_path") <<'__OPENCLAW_RUNNER__'"$'\n' - write_runner_cmd+="$runner_body"$'\n' - write_runner_cmd+="__OPENCLAW_RUNNER__"$'\n' - write_runner_cmd+="/bin/chmod +x $(shell_quote "$runner_path")"$'\n' - write_runner_cmd+="(/bin/bash $(shell_quote "$runner_path") > $(shell_quote "$log_path") 2>&1 < /dev/null & printf '%s\n' \"\$!\" > $(shell_quote "$runner_pid_path")) >/dev/null 2>&1" - guest_current_user_sh "$write_runner_cmd" - guest_home="$(resolve_guest_current_user_home)" - guest_log_state_path="$(mktemp "${TMPDIR:-/tmp}/openclaw-guest-log-state.XXXXXX")" - latest_npm_log_state_path="$(mktemp "${TMPDIR:-/tmp}/openclaw-guest-npm-log-state.XXXXXX")" - npm_state_path="$(mktemp "${TMPDIR:-/tmp}/openclaw-guest-npm-log-path.XXXXXX")" - : >"$guest_log_state_path" - : >"$latest_npm_log_state_path" - : >"$npm_state_path" - deadline=$((SECONDS + timeout_s)) - while (( SECONDS < deadline )); do - stream_guest_file_delta "$log_path" "$guest_log_state_path" "" - rc="$( - python3 - "$guest_log_state_path" <<'PY' -from pathlib import Path -import re -import sys - -path = Path(sys.argv[1]) -if not path.exists(): - raise SystemExit(1) - -text = path.read_text(encoding="utf-8", errors="replace") -matches = re.findall(r"^__OPENCLAW_RC__:(-?\d+)$", text, flags=re.MULTILINE) -if not matches: - raise SystemExit(1) -print(matches[-1]) -PY - )" || rc="" - if [[ "$rc" =~ ^-?[0-9]+$ ]]; then - guest_current_user_exec /bin/rm -f "$done_path" "$runner_path" "$runner_pid_path" >/dev/null 2>&1 || true - stream_guest_file_delta "$log_path" "$guest_log_state_path" "" - if [[ -n "$latest_npm_log_path" ]]; then - stream_guest_file_delta "$latest_npm_log_path" "$latest_npm_log_state_path" "npm-debug: " - fi - rm -f "$guest_log_state_path" "$latest_npm_log_state_path" "$npm_state_path" - [[ -n "$rc" ]] || rc=1 - return "$rc" - fi - latest_npm_log_path="$(latest_guest_npm_debug_log_path "$guest_home" || true)" - if [[ -n "$latest_npm_log_path" ]]; then - if [[ "$(cat "$npm_state_path" 2>/dev/null || true)" != "$latest_npm_log_path" ]]; then - printf '%s\n' "$latest_npm_log_path" >"$npm_state_path" - : >"$latest_npm_log_state_path" - printf 'npm-debug: %s\n' "$latest_npm_log_path" - fi - stream_guest_file_delta "$latest_npm_log_path" "$latest_npm_log_state_path" "npm-debug: " - fi - done_rc="$(guest_current_user_exec /bin/cat "$done_path" 2>/dev/null | tr -d '\r\n' || true)" - if [[ "$done_rc" =~ ^-?[0-9]+$ ]]; then - rc="$done_rc" - guest_current_user_exec /bin/rm -f "$done_path" "$runner_path" "$runner_pid_path" >/dev/null 2>&1 || true - stream_guest_file_delta "$log_path" "$guest_log_state_path" "" - if [[ -n "$latest_npm_log_path" ]]; then - stream_guest_file_delta "$latest_npm_log_path" "$latest_npm_log_state_path" "npm-debug: " - fi - rm -f "$guest_log_state_path" "$latest_npm_log_state_path" "$npm_state_path" - [[ -n "$rc" ]] || rc=1 - return "$rc" - fi - rc="$(guest_runner_rc_from_log "$log_path" 2>/dev/null || true)" - if [[ "$rc" =~ ^-?[0-9]+$ ]]; then - guest_current_user_exec /bin/rm -f "$done_path" "$runner_path" "$runner_pid_path" >/dev/null 2>&1 || true - stream_guest_file_delta "$log_path" "$guest_log_state_path" "" - if [[ -n "$latest_npm_log_path" ]]; then - stream_guest_file_delta "$latest_npm_log_path" "$latest_npm_log_state_path" "npm-debug: " - fi - rm -f "$guest_log_state_path" "$latest_npm_log_state_path" "$npm_state_path" - [[ -n "$rc" ]] || rc=1 - return "$rc" - fi - sleep 2 - done - runner_pid="$(guest_current_user_exec /bin/cat "$runner_pid_path" 2>/dev/null | tr -d '\r\n' || true)" - if [[ "$runner_pid" =~ ^[0-9]+$ ]]; then - warn "terminating timed-out guest runner pid $runner_pid" - guest_current_user_kill_process_tree "$runner_pid" - fi - guest_current_user_exec /bin/rm -f "$done_path" "$runner_path" "$runner_pid_path" >/dev/null 2>&1 || true - rm -f "$guest_log_state_path" "$latest_npm_log_state_path" "$npm_state_path" - warn "guest script timed out after ${timeout_s}s" - guest_current_user_tail_file "$log_path" 120 >&2 || true - return 124 -} - -restore_snapshot() { - local snapshot_id="$1" - local status - say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" - snapshot_switch_with_retry "$snapshot_id" || die "snapshot switch failed for $VM_NAME" - status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" - if [[ "$SNAPSHOT_STATE" == "poweroff" || "$status" == *" stopped" ]]; then - wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME" - say "Start restored snapshot $SNAPSHOT_NAME" - prlctl start "$VM_NAME" >/dev/null - elif [[ "$status" == *" suspended" ]]; then - say "Resume restored snapshot $SNAPSHOT_NAME" - prlctl resume "$VM_NAME" >/dev/null - fi - wait_for_current_user || die "desktop user did not become ready in $VM_NAME" -} - -resolve_latest_version() { - if [[ -n "$LATEST_VERSION" ]]; then - printf '%s\n' "$LATEST_VERSION" - return - fi - npm view openclaw version --userconfig "$(mktemp)" -} - -install_latest_release() { - local install_url_q version_arg_q version_to_install - install_url_q="$(shell_quote "$INSTALL_URL")" - version_to_install="${INSTALL_VERSION:-$LATEST_VERSION}" - version_arg_q=" --version $(shell_quote "$version_to_install")" - guest_current_user_sh "$(cat <&2 - guest_current_user_tail_file "$update_log" 120 >&2 || true - fi - repair_legacy_dev_source_checkout_if_needed - if (( update_rc != 0 && LEGACY_DEV_SOURCE_REPAIR_APPLIED == 0 )); then - return "$update_rc" - fi - printf 'update-dev: git-version\n' - guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" --version - printf 'update-dev: git-status\n' - guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" update status --json -} - -verify_dev_channel_update() { - local status_json - status_json="$(guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" update status --json)" - printf '%s\n' "$status_json" - printf '%s\n' "$status_json" | grep -F '"installKind": "git"' - printf '%s\n' "$status_json" | grep -F '"value": "dev"' - printf '%s\n' "$status_json" | grep -F '"branch": "main"' -} - -verify_version_contains() { - local needle="$1" - local version - version="$( - guest_current_user_exec "$GUEST_OPENCLAW_BIN" --version 2>&1 - )" - printf '%s\n' "$version" - case "$version" in - *"$needle"*) ;; - *) - echo "version mismatch: expected substring $needle" >&2 - return 1 - ;; - esac -} - -extract_package_version_from_tgz() { - tar -xOf "$1" package/package.json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])' -} - -extract_package_build_commit_from_tgz() { - tar -xOf "$1" package/dist/build-info.json | python3 -c 'import json, sys; print(json.load(sys.stdin).get("commit", ""))' -} - -pack_main_tgz() { - local short_head pkg packed_commit rc - if target_package_installs_directly; then - say "Use direct guest install for target package spec: $TARGET_PACKAGE_SPEC" - TARGET_EXPECT_VERSION="$(npm view "$TARGET_PACKAGE_SPEC" version --userconfig "$(mktemp)")" - say "Target package version: $TARGET_EXPECT_VERSION" - return - fi - if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then - say "Pack target package tgz: $TARGET_PACKAGE_SPEC" - pkg="$( - npm pack "$TARGET_PACKAGE_SPEC" --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/$(basename "$pkg")" - TARGET_EXPECT_VERSION="$(extract_package_version_from_tgz "$MAIN_TGZ_PATH")" - say "Packed $MAIN_TGZ_PATH" - say "Target package version: $TARGET_EXPECT_VERSION" - return - fi - say "Pack current main tgz" - acquire_build_lock - set +e - { - OPENCLAW_PARALLELS_BUILD_LOCK_HELD=1 ensure_current_build && - write_package_dist_inventory && - stage_pack_runtime_deps && - short_head="$(git rev-parse --short HEAD)" && - 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"])' - )" - } - rc=$? - set -e - release_build_lock - [[ $rc -eq 0 ]] || return "$rc" - MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$short_head.tgz" - cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH" - packed_commit="$(extract_package_build_commit_from_tgz "$MAIN_TGZ_PATH")" - [[ -n "$packed_commit" ]] || die "failed to read packed build commit from $MAIN_TGZ_PATH" - PACKED_MAIN_COMMIT_SHORT="${packed_commit:0:7}" - say "Packed $MAIN_TGZ_PATH" - tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json -} - -verify_target_version() { - if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then - verify_version_contains "$TARGET_EXPECT_VERSION" - return - fi - [[ -n "$PACKED_MAIN_COMMIT_SHORT" ]] || die "packed main commit not captured" - verify_version_contains "$PACKED_MAIN_COMMIT_SHORT" -} - -current_build_commit() { - parallels_package_current_build_commit -} - -current_control_ui_ready() { - [[ -f "dist/control-ui/index.html" ]] -} - -acquire_build_lock() { - parallels_package_acquire_build_lock "$BUILD_LOCK_DIR" -} - -release_build_lock() { - parallels_package_release_build_lock "$BUILD_LOCK_DIR" -} - -ensure_current_build() { - local head build_commit rc lock_owned - lock_owned=0 - if [[ "${OPENCLAW_PARALLELS_BUILD_LOCK_HELD:-0}" != "1" ]]; then - acquire_build_lock - lock_owned=1 - fi - head="$(git rev-parse HEAD)" - build_commit="$(current_build_commit)" - if [[ "$build_commit" == "$head" ]] && current_control_ui_ready; then - if [[ "$lock_owned" -eq 1 ]]; then - release_build_lock - fi - return - fi - say "Build dist for current head" - set +e - pnpm build - rc=$? - if [[ $rc -eq 0 ]]; then - parallels_package_assert_no_generated_drift - rc=$? - fi - if [[ $rc -eq 0 ]]; then - say "Build Control UI for current head" - pnpm ui:build - rc=$? - fi - build_commit="$(current_build_commit)" - set -e - if [[ "$lock_owned" -eq 1 ]]; then - release_build_lock - fi - [[ $rc -eq 0 ]] || return "$rc" - if [[ "$build_commit" != "$head" ]]; then - warn "dist/build-info.json still does not match HEAD after build" - return 1 - fi - if ! current_control_ui_ready; then - warn "dist/control-ui/index.html missing after ui build" - return 1 - fi -} - -write_package_dist_inventory() { - parallels_package_write_dist_inventory -} - -stage_pack_runtime_deps() { - node scripts/stage-bundled-plugin-runtime-deps.mjs -} - -start_server() { - local host_ip="$1" - say "Serve $(artifact_label) on $host_ip:$HOST_PORT" - ( - cd "$MAIN_TGZ_DIR" - exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 - ) >/tmp/openclaw-parallels-http.log 2>&1 & - SERVER_PID=$! - sleep 1 - kill -0 "$SERVER_PID" >/dev/null 2>&1 || die "failed to start host HTTP server" -} - -install_main_timeout() { - if target_package_installs_directly; then - printf '%s\n' "$TIMEOUT_INSTALL_REGISTRY_S" - return - fi - printf '%s\n' "$TIMEOUT_INSTALL_TGZ_S" -} - -install_main_tgz() { - local host_ip="$1" - local temp_name="$2" - local tgz_url_q - if target_package_installs_directly; then - guest_current_user_sh "$(cat <&2 - exit 1 - fi -} -check_path "\$root/openclaw" -check_path "\$root/openclaw/extensions" -if [ -d "\$root/openclaw/extensions" ]; then - while IFS= read -r -d '' extension_dir; do - check_path "\$extension_dir" - done < <(/usr/bin/find "\$root/openclaw/extensions" -mindepth 1 -maxdepth 1 -type d -print0) -fi -EOF -)" - guest_current_user_exec /bin/bash -lc "$cmd" -} - -reset_openclaw_user_state() { - guest_current_user_sh "$(cat <<'EOF' -/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true -/usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true -/usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true -rm -rf "$HOME/.openclaw" -rm -f /tmp/openclaw-parallels-macos-gateway.log -EOF - )" -} - -run_ref_onboard() { - local daemon_args=("--install-daemon") - if headless_guest_fallback; then - daemon_args=("--skip-health") - fi - guest_current_user_cli \ - /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" \ - "$GUEST_OPENCLAW_BIN" onboard \ - --non-interactive \ - --mode local \ - --auth-choice "$AUTH_CHOICE" \ - --secret-input-mode ref \ - --gateway-port 18789 \ - --gateway-bind loopback \ - "${daemon_args[@]}" \ - --skip-skills \ - --accept-risk \ - --json -} - -start_manual_gateway_if_needed() { - if ! headless_guest_fallback; then - return 0 - fi - local gateway_log guest_gateway_log guest_home launch_cmd runner_log done_path runner_path - guest_home="$(parallels_macos_resolve_desktop_home "$VM_NAME" "$GUEST_CURRENT_USER")" - gateway_log="$RUN_DIR/macos-gateway-prlctl.log" - guest_gateway_log="/tmp/openclaw-parallels-macos-gateway.log" - runner_log="/tmp/openclaw-parallels-gateway-start.log" - done_path="/tmp/openclaw-parallels-gateway-start.done" - runner_path="/tmp/openclaw-parallels-gateway-start.sh" - printf 'manual gateway launch transport=%s user=%s\n' "$GUEST_CURRENT_USER_TRANSPORT" "$GUEST_CURRENT_USER" - launch_cmd="$(cat </dev/null 2>&1 || true -/usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true -/usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true -/usr/bin/env \\ - HOME=$(shell_quote "$guest_home") \\ - USER=$(shell_quote "$GUEST_CURRENT_USER") \\ - LOGNAME=$(shell_quote "$GUEST_CURRENT_USER") \\ - PATH=$(shell_quote "$GUEST_EXEC_PATH") \\ - $(shell_quote "$API_KEY_ENV=$API_KEY_VALUE") \\ - OPENCLAW_HOME=$(shell_quote "$guest_home") \\ - OPENCLAW_STATE_DIR=$(shell_quote "$guest_home/.openclaw") \\ - OPENCLAW_CONFIG_PATH=$(shell_quote "$guest_home/.openclaw/openclaw.json") \\ - $(shell_quote "$GUEST_NODE_BIN") $(shell_quote "$GUEST_OPENCLAW_ENTRY") gateway run --bind loopback --port 18789 --force \\ - < /dev/null >$(shell_quote "$guest_gateway_log") 2>&1 & -gateway_pid="\$!" -printf 'guest gateway pid %s\n' "\$gateway_pid" -printf 'guest gateway log %s\n' $(shell_quote "$guest_gateway_log") -sleep 1 -if ! kill -0 "\$gateway_pid" >/dev/null 2>&1; then - tail -n 120 $(shell_quote "$guest_gateway_log") >&2 || true - exit 1 -fi -EOF -)" - if ! run_logged_guest_current_user_sh "$launch_cmd" "$runner_log" "$done_path" "$TIMEOUT_GATEWAY_S" "$runner_path" >"$gateway_log" 2>&1; then - cat "$gateway_log" >&2 || true - return 1 - fi - cat "$gateway_log" -} - -verify_gateway() { - local attempt - for attempt in 1 2 3 4 5 6 7 8; do - if guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway status --deep --require-rpc --timeout 15000; then - return 0 - fi - if (( attempt < 8 )); then - printf 'gateway-status retry %s\n' "$attempt" >&2 - sleep 5 - fi - done - return 1 -} - -show_gateway_status_compat() { - if guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway status --help | grep -Fq -- "--require-rpc"; then - guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway status --deep --require-rpc - return - fi - guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway status --deep -} - -verify_turn() { - guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" models set "$MODEL_ID" - guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" config set agents.defaults.skipBootstrap true --strict-json -guest_current_user_sh "$(cat <&2 - return 1 - } - printf '%s\n' "$dashboard_url" -} - -verify_dashboard_load() { - local dashboard_url dashboard_http_url dashboard_url_q dashboard_http_url_q cmd headless_flag - # `openclaw dashboard --no-open` can hang under the Tahoe Parallels transport - # even when the dashboard itself is healthy. Probe the local dashboard URL - # directly so the smoke still validates HTML readiness and browser reachability. - dashboard_url="http://127.0.0.1:18789/" - dashboard_http_url="$dashboard_url" - dashboard_url_q="$(shell_quote "$dashboard_url")" - dashboard_http_url_q="$(shell_quote "$dashboard_http_url")" - headless_flag=0 - if headless_guest_fallback; then - headless_flag=1 - fi - cmd="$(cat <&2 - exit 1 -fi -deadline=\$((SECONDS + 120)) -dashboard_ready=0 -while [ \$SECONDS -lt \$deadline ]; do - if curl -fsSL --connect-timeout 2 --max-time 5 "\$dashboard_http_url" >/tmp/openclaw-dashboard-smoke.html 2>/dev/null; then - if grep -F 'OpenClaw Control' /tmp/openclaw-dashboard-smoke.html >/dev/null; then - if grep -F '' /tmp/openclaw-dashboard-smoke.html >/dev/null; then - dashboard_ready=1 - break - fi - fi - fi - sleep 1 -done -[ "\$dashboard_ready" = "1" ] || { - echo "dashboard HTML did not become ready at \$dashboard_http_url" >&2 - exit 1 -} -grep -F 'OpenClaw Control' /tmp/openclaw-dashboard-smoke.html >/dev/null -grep -F '' /tmp/openclaw-dashboard-smoke.html >/dev/null -echo "dashboard HTML ready at \$dashboard_http_url" -if [ "\$headless_flag" = "1" ]; then - exit 0 -fi -pkill -x Safari >/dev/null 2>&1 || true -open -a Safari "\$dashboard_url" -deadline=\$((SECONDS + 20)) -while [ \$SECONDS -lt \$deadline ]; do - # Tahoe can hand dashboard sockets to WebKit helpers even after the Safari - # app process exits. Avoid lsof here because it can stall under Parallels; - # an established localhost client socket proves the browser reached the UI. - if netstat -anv -p tcp 2>/dev/null \ - | awk -v port=".\$dashboard_port" '\$4 ~ port "\$" && \$6 == "ESTABLISHED" { found = 1 } END { exit found ? 0 : 1 }'; then - echo "dashboard browser connection ready on port \$dashboard_port" - exit 0 - fi - sleep 1 -done -echo "Safari did not establish a dashboard client connection on port \$dashboard_port" >&2 -exit 1 -EOF -)" - guest_current_user_sh "$cmd" -} - -configure_discord_smoke() { - local guilds_json script - guilds_json="$( - DISCORD_GUILD_ID="$DISCORD_GUILD_ID" DISCORD_CHANNEL_ID="$DISCORD_CHANNEL_ID" python3 - <<'PY' -import json -import os - -print( - json.dumps( - { - os.environ["DISCORD_GUILD_ID"]: { - "channels": { - os.environ["DISCORD_CHANNEL_ID"]: { - "enabled": True, - "requireMention": False, - } - } - } - } - ) -) -PY - )" - script="$(cat </tmp/openclaw-discord-token <<'__OPENCLAW_TOKEN__' -$DISCORD_TOKEN_VALUE -__OPENCLAW_TOKEN__ -cat >/tmp/openclaw-discord-guilds.json <<'__OPENCLAW_GUILDS__' -$guilds_json -__OPENCLAW_GUILDS__ -token="\$(tr -d '\n' /dev/null 2>&1; then - break - fi - sleep 2 -done -$GUEST_NODE_BIN $GUEST_OPENCLAW_ENTRY channels status --probe --json -rm -f /tmp/openclaw-discord-token /tmp/openclaw-discord-guilds.json -EOF -)" - guest_current_user_sh "$script" -} - -discord_message_id_from_send_log() { - local path="$1" - python3 - "$path" <<'PY' -import json -import pathlib -import sys - -payload = json.loads(pathlib.Path(sys.argv[1]).read_text()) -message_id = payload.get("payload", {}).get("messageId") -if not message_id: - message_id = payload.get("payload", {}).get("result", {}).get("messageId") -if not message_id: - raise SystemExit("messageId missing from send output") -print(message_id) -PY -} - -wait_for_discord_host_visibility() { - local nonce="$1" - local message_id="${2:-}" - local response - local deadline=$((SECONDS + TIMEOUT_DISCORD_S)) - while (( SECONDS < deadline )); do - set +e - if [[ -n "$message_id" ]]; then - response="$(discord_api_request GET "/channels/$DISCORD_CHANNEL_ID/messages/$message_id")" - local direct_rc=$? - if [[ $direct_rc -eq 0 ]] && [[ -n "$response" ]] && { [[ "$response" == *"$nonce"* ]] || printf '%s' "$response" | json_contains_string "$nonce"; }; then - set -e - return 0 - fi - fi - response="$(discord_api_request GET "/channels/$DISCORD_CHANNEL_ID/messages?limit=20")" - local rc=$? - set -e - if [[ $rc -eq 0 ]] && [[ -n "$response" ]] && { [[ "$response" == *"$nonce"* ]] || printf '%s' "$response" | json_contains_string "$nonce"; }; then - return 0 - fi - sleep 2 - done - return 1 -} - -post_host_discord_message() { - local nonce="$1" - local id_file="$2" - local payload response - payload="$( - NONCE="$nonce" python3 - <<'PY' -import json -import os - -print( - json.dumps( - { - "content": f"parallels-macos-smoke-inbound-{os.environ['NONCE']}", - "flags": 4096, - } - ) -) -PY - )" - response="$(discord_api_request POST "/channels/$DISCORD_CHANNEL_ID/messages" "$payload")" - RESPONSE="$response" python3 - "$id_file" <<'PY' -import json -import os -import pathlib -import sys - -payload = json.loads(os.environ["RESPONSE"]) -message_id = payload.get("id") -if not isinstance(message_id, str) or not message_id: - raise SystemExit("host Discord post missing message id") -pathlib.Path(sys.argv[1]).write_text(f"{message_id}\n", encoding="utf-8") -PY -} - -wait_for_guest_discord_readback() { - local nonce="$1" - local response rc - local last_response_path="$RUN_DIR/discord-last-readback.json" - local deadline=$((SECONDS + TIMEOUT_DISCORD_S)) - while (( SECONDS < deadline )); do - set +e - response="$( - guest_current_user_exec \ - "$GUEST_OPENCLAW_BIN" \ - message read \ - --channel discord \ - --target "channel:$DISCORD_CHANNEL_ID" \ - --limit 20 \ - --json - )" - rc=$? - set -e - if [[ -n "$response" ]]; then - printf '%s' "$response" >"$last_response_path" - fi - if [[ $rc -eq 0 ]] && [[ -n "$response" ]] && { [[ "$response" == *"$nonce"* ]] || printf '%s' "$response" | json_contains_string "$nonce"; }; then - return 0 - fi - sleep 3 - done - return 1 -} - -run_discord_roundtrip_smoke() { - local phase="$1" - local nonce outbound_nonce inbound_nonce outbound_message outbound_log sent_id_file host_id_file sent_message_id - nonce="$(date +%s)-$RANDOM" - outbound_nonce="$phase-out-$nonce" - inbound_nonce="$phase-in-$nonce" - outbound_message="parallels-macos-smoke-outbound-$outbound_nonce" - outbound_log="$RUN_DIR/$phase.discord-send.json" - sent_id_file="$RUN_DIR/$phase.discord-sent-message-id" - host_id_file="$RUN_DIR/$phase.discord-host-message-id" - - printf 'discord: guest-send\n' - guest_current_user_exec \ - "$GUEST_OPENCLAW_BIN" \ - message send \ - --channel discord \ - --target "channel:$DISCORD_CHANNEL_ID" \ - --message "$outbound_message" \ - --silent \ - --json >"$outbound_log" - - sent_message_id="$(discord_message_id_from_send_log "$outbound_log")" - printf '%s\n' "$sent_message_id" >"$sent_id_file" - printf 'discord: host-visibility %s\n' "$sent_message_id" - wait_for_discord_host_visibility "$outbound_nonce" "$sent_message_id" - printf 'discord: host-reply\n' - post_host_discord_message "$inbound_nonce" "$host_id_file" - printf 'discord: guest-readback\n' - wait_for_guest_discord_readback "$inbound_nonce" -} - -phase_log_path() { - printf '%s/%s.log\n' "$RUN_DIR" "$1" -} - -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" == "$$" ]] -} - -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(errors="replace") -matches = re.findall(r"OpenClaw [^\r\n]+ \([0-9a-f]{7,}\)", text) -print(matches[-1] if matches else "") -PY -} - -show_log_excerpt() { - local log_path="$1" - warn "log tail: $log_path" - tail -n 80 "$log_path" >&2 || true -} - -show_restore_timeout_diagnostics() { - warn "restore diagnostics for $VM_NAME" - prlctl status "$VM_NAME" >&2 || true - warn "snapshot list for $VM_NAME" - prlctl snapshot-list "$VM_NAME" >&2 || true -} - -phase_run() { - local phase_id="$1" - local timeout_s="$2" - shift 2 - - local log_path pid start rc timed_out next_warn summary - log_path="$(phase_log_path "$phase_id")" - say "$phase_id" - start=$SECONDS - next_warn=$((start + PHASE_STALE_WARN_S)) - timed_out=0 - - ( - "$@" - ) >"$log_path" 2>&1 & - pid=$! - - while child_job_running "$pid"; do - if (( SECONDS >= next_warn )); then - summary="$(parallels_log_progress_extract python3 "$log_path")" - [[ -n "$summary" ]] || summary="waiting for first log line" - warn "$phase_id still running after $((SECONDS - start))s: $summary" - next_warn=$((SECONDS + PHASE_STALE_WARN_S)) - fi - if (( SECONDS - start >= timeout_s )); then - timed_out=1 - kill "$pid" >/dev/null 2>&1 || true - sleep 2 - kill -9 "$pid" >/dev/null 2>&1 || true - break - fi - sleep 1 - done - - set +e - wait "$pid" - rc=$? - set -e - - if (( timed_out )); then - warn "$phase_id timed out after ${timeout_s}s" - printf 'timeout after %ss\n' "$timeout_s" >>"$log_path" - if [[ "$phase_id" == *.restore-snapshot ]]; then - show_restore_timeout_diagnostics - fi - show_log_excerpt "$log_path" - return 124 - fi - - if [[ $rc -ne 0 ]]; then - warn "$phase_id failed (rc=$rc)" - show_log_excerpt "$log_path" - return "$rc" - fi - - return 0 -} - -write_summary_json() { - local summary_path="$RUN_DIR/summary.json" - python3 - "$summary_path" <<'PY' -import json -import os -import sys - -summary = { - "vm": os.environ["SUMMARY_VM"], - "provider": os.environ["SUMMARY_PROVIDER"], - "snapshotHint": os.environ["SUMMARY_SNAPSHOT_HINT"], - "snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"], - "mode": os.environ["SUMMARY_MODE"], - "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], - "installVersion": os.environ["SUMMARY_INSTALL_VERSION"], - "targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"], - "currentHead": os.environ["SUMMARY_CURRENT_HEAD"], - "runDir": os.environ["SUMMARY_RUN_DIR"], - "freshMain": { - "status": os.environ["SUMMARY_FRESH_MAIN_STATUS"], - "version": os.environ["SUMMARY_FRESH_MAIN_VERSION"], - "gateway": os.environ["SUMMARY_FRESH_GATEWAY_STATUS"], - "agent": os.environ["SUMMARY_FRESH_AGENT_STATUS"], - "dashboard": os.environ["SUMMARY_FRESH_DASHBOARD_STATUS"], - "discord": os.environ["SUMMARY_FRESH_DISCORD_STATUS"], - }, - "upgrade": { - "path": os.environ["SUMMARY_UPGRADE_PATH_LABEL"], - "precheck": os.environ["SUMMARY_UPGRADE_PRECHECK_STATUS"], - "status": os.environ["SUMMARY_UPGRADE_STATUS"], - "latestVersionInstalled": os.environ["SUMMARY_LATEST_INSTALLED_VERSION"], - "devVersion": os.environ["SUMMARY_UPGRADE_MAIN_VERSION"], - "mainVersion": os.environ["SUMMARY_UPGRADE_MAIN_VERSION"], - "gateway": os.environ["SUMMARY_UPGRADE_GATEWAY_STATUS"], - "agent": os.environ["SUMMARY_UPGRADE_AGENT_STATUS"], - "dashboard": os.environ["SUMMARY_UPGRADE_DASHBOARD_STATUS"], - "discord": os.environ["SUMMARY_UPGRADE_DISCORD_STATUS"], - }, -} -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 -} - -capture_latest_ref_failure() { - set +e - run_ref_onboard - local rc=$? - set -e - if [[ $rc -eq 0 ]]; then - say "Latest release ref-mode onboard passed" - return 0 - fi - warn "Latest release ref-mode onboard failed pre-upgrade" - set +e - show_gateway_status_compat || true - set -e - return 1 -} - -run_fresh_main_lane() { - local snapshot_id="$1" - local host_ip="$2" - phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" - phase_run "fresh.reset-state" "$TIMEOUT_ONBOARD_S" reset_openclaw_user_state - phase_run "fresh.install-main" "$(install_main_timeout)" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz" - FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")" - phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version - if [[ -z "$FRESH_MAIN_VERSION" ]]; then - FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.verify-main-version)")" - fi - phase_run "fresh.verify-bundle-permissions" "$TIMEOUT_PERMISSION_S" verify_bundle_permissions - phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard - phase_run "fresh.gateway-start" "$TIMEOUT_GATEWAY_S" start_manual_gateway_if_needed - phase_run "fresh.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway - FRESH_GATEWAY_STATUS="pass" - phase_run "fresh.dashboard-load" "$TIMEOUT_DASHBOARD_S" verify_dashboard_load - FRESH_DASHBOARD_STATUS="pass" - phase_run "fresh.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn - FRESH_AGENT_STATUS="pass" - if discord_smoke_enabled; then - FRESH_DISCORD_STATUS="fail" - phase_run "fresh.discord-config" "$TIMEOUT_GATEWAY_S" configure_discord_smoke - phase_run "fresh.discord-roundtrip" "$TIMEOUT_DISCORD_S" run_discord_roundtrip_smoke "fresh" - FRESH_DISCORD_STATUS="pass" - fi -} - -run_upgrade_lane() { - local snapshot_id="$1" - local host_ip="$2" - phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" - phase_run "upgrade.reset-state" "$TIMEOUT_ONBOARD_S" reset_openclaw_user_state - phase_run "upgrade.install-latest" "$TIMEOUT_INSTALL_SITE_S" install_latest_release - LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-latest)")" - phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$INSTALL_VERSION" - if [[ "$CHECK_LATEST_REF" -eq 1 ]]; then - if phase_run "upgrade.latest-ref-precheck" "$TIMEOUT_ONBOARD_S" capture_latest_ref_failure; then - UPGRADE_PRECHECK_STATUS="latest-ref-pass" - else - UPGRADE_PRECHECK_STATUS="latest-ref-fail" - fi - else - UPGRADE_PRECHECK_STATUS="skipped" - fi - if upgrade_uses_host_tgz; then - phase_run "upgrade.install-main" "$(install_main_timeout)" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz" - UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")" - phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version - if [[ -z "$UPGRADE_MAIN_VERSION" ]]; then - UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.verify-main-version)")" - fi - phase_run "upgrade.verify-bundle-permissions" "$TIMEOUT_PERMISSION_S" verify_bundle_permissions - else - phase_run "upgrade.update-dev" "$TIMEOUT_UPDATE_DEV_S" run_dev_channel_update - UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.update-dev)")" - phase_run "upgrade.verify-dev-channel" "$TIMEOUT_VERIFY_S" verify_dev_channel_update - fi - phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard - phase_run "upgrade.gateway-start" "$TIMEOUT_GATEWAY_S" start_manual_gateway_if_needed - phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway - UPGRADE_GATEWAY_STATUS="pass" - phase_run "upgrade.dashboard-load" "$TIMEOUT_DASHBOARD_S" verify_dashboard_load - UPGRADE_DASHBOARD_STATUS="pass" - phase_run "upgrade.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn - UPGRADE_AGENT_STATUS="pass" - if discord_smoke_enabled; then - UPGRADE_DISCORD_STATUS="fail" - phase_run "upgrade.discord-config" "$TIMEOUT_GATEWAY_S" configure_discord_smoke - phase_run "upgrade.discord-roundtrip" "$TIMEOUT_DISCORD_S" run_discord_roundtrip_smoke "upgrade" - UPGRADE_DISCORD_STATUS="pass" - fi -} - -FRESH_MAIN_STATUS="skip" -UPGRADE_STATUS="skip" -UPGRADE_PRECHECK_STATUS="skip" - -IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)" -[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id" -[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT" -LATEST_VERSION="$(resolve_latest_version)" -if [[ -z "$INSTALL_VERSION" ]]; then - INSTALL_VERSION="$LATEST_VERSION" -fi -HOST_IP="$(resolve_host_ip)" -HOST_PORT="$(resolve_host_port)" - -say "VM: $VM_NAME" -say "Snapshot hint: $SNAPSHOT_HINT" -say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]" -say "Latest npm version: $LATEST_VERSION" -say "Current head: $(git rev-parse --short HEAD)" -if discord_smoke_enabled; then - say "Discord smoke: guild=$DISCORD_GUILD_ID channel=$DISCORD_CHANNEL_ID" -else - say "Discord smoke: disabled" -fi -say "Run logs: $RUN_DIR" - -if needs_host_tgz; then - pack_main_tgz - start_server "$HOST_IP" -fi - -if [[ "$MODE" == "fresh" || "$MODE" == "both" ]]; then - set +e - run_fresh_main_lane "$SNAPSHOT_ID" "$HOST_IP" - fresh_rc=$? - set -e - if [[ $fresh_rc -eq 0 ]]; then - FRESH_MAIN_STATUS="pass" - else - FRESH_MAIN_STATUS="fail" - fi -fi - -if [[ "$MODE" == "upgrade" || "$MODE" == "both" ]]; then - set +e - run_upgrade_lane "$SNAPSHOT_ID" "$HOST_IP" - upgrade_rc=$? - set -e - if [[ $upgrade_rc -eq 0 ]]; then - UPGRADE_STATUS="pass" - else - UPGRADE_STATUS="fail" - fi -fi - -if [[ "$KEEP_SERVER" -eq 0 && -n "${SERVER_PID:-}" ]]; then - kill "$SERVER_PID" >/dev/null 2>&1 || true - SERVER_PID="" -fi - -stop_vm_after_successful_discord_smoke - -SUMMARY_JSON_PATH="$( - SUMMARY_VM="$VM_NAME" \ - SUMMARY_PROVIDER="$PROVIDER" \ - SUMMARY_SNAPSHOT_HINT="$SNAPSHOT_HINT" \ - SUMMARY_SNAPSHOT_ID="$SNAPSHOT_ID" \ - SUMMARY_MODE="$MODE" \ - SUMMARY_LATEST_VERSION="$LATEST_VERSION" \ - SUMMARY_INSTALL_VERSION="$INSTALL_VERSION" \ - SUMMARY_TARGET_PACKAGE_SPEC="$TARGET_PACKAGE_SPEC" \ - SUMMARY_CURRENT_HEAD="${PACKED_MAIN_COMMIT_SHORT:-$(git rev-parse --short HEAD)}" \ - SUMMARY_RUN_DIR="$RUN_DIR" \ - SUMMARY_FRESH_MAIN_STATUS="$FRESH_MAIN_STATUS" \ - SUMMARY_FRESH_MAIN_VERSION="$FRESH_MAIN_VERSION" \ - SUMMARY_FRESH_GATEWAY_STATUS="$FRESH_GATEWAY_STATUS" \ - SUMMARY_FRESH_AGENT_STATUS="$FRESH_AGENT_STATUS" \ - SUMMARY_FRESH_DASHBOARD_STATUS="$FRESH_DASHBOARD_STATUS" \ - SUMMARY_FRESH_DISCORD_STATUS="$FRESH_DISCORD_STATUS" \ - SUMMARY_UPGRADE_PRECHECK_STATUS="$UPGRADE_PRECHECK_STATUS" \ - SUMMARY_UPGRADE_STATUS="$UPGRADE_STATUS" \ - SUMMARY_LATEST_INSTALLED_VERSION="$LATEST_INSTALLED_VERSION" \ - SUMMARY_UPGRADE_MAIN_VERSION="$UPGRADE_MAIN_VERSION" \ - SUMMARY_UPGRADE_GATEWAY_STATUS="$UPGRADE_GATEWAY_STATUS" \ - SUMMARY_UPGRADE_AGENT_STATUS="$UPGRADE_AGENT_STATUS" \ - SUMMARY_UPGRADE_DASHBOARD_STATUS="$UPGRADE_DASHBOARD_STATUS" \ - SUMMARY_UPGRADE_DISCORD_STATUS="$UPGRADE_DISCORD_STATUS" \ - SUMMARY_UPGRADE_PATH_LABEL="$(upgrade_summary_label)" \ - write_summary_json -)" - -if [[ "$JSON_OUTPUT" -eq 1 ]]; then - cat "$SUMMARY_JSON_PATH" -else - printf '\nSummary:\n' - if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then - printf ' target-package: %s\n' "$TARGET_PACKAGE_SPEC" - fi - if [[ -n "$INSTALL_VERSION" ]]; then - printf ' baseline-install-version: %s\n' "$INSTALL_VERSION" - fi - printf ' fresh-main: %s (%s) discord=%s\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION" "$FRESH_DISCORD_STATUS" - printf ' latest precheck: %s (%s)\n' "$UPGRADE_PRECHECK_STATUS" "$LATEST_INSTALLED_VERSION" - printf ' %s: %s (%s) discord=%s\n' "$(upgrade_summary_label)" "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION" "$UPGRADE_DISCORD_STATUS" - printf ' logs: %s\n' "$RUN_DIR" - printf ' summary: %s\n' "$SUMMARY_JSON_PATH" -fi - -if [[ "$FRESH_MAIN_STATUS" == "fail" || "$UPGRADE_STATUS" == "fail" ]]; then - exit 1 -fi +exec pnpm --dir "$ROOT_DIR" exec tsx scripts/e2e/parallels/macos-smoke.ts "$@" diff --git a/scripts/e2e/parallels-npm-update-smoke.sh b/scripts/e2e/parallels-npm-update-smoke.sh index c17d648f0fe..185ab38bcc8 100755 --- a/scripts/e2e/parallels-npm-update-smoke.sh +++ b/scripts/e2e/parallels-npm-update-smoke.sh @@ -2,2025 +2,4 @@ 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="" -MODEL_ID_EXPLICIT=0 -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 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 - --platform Comma-separated platforms to run: all, macos, windows, linux. - Default: all - --provider - Provider auth/model lane. Default: openai - --model Override the model used for agent-turn smoke checks. - Default: openai/gpt-5.5 for the OpenAI lane - --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 - ;; - --platform|--only) - RUN_PLATFORMS="$2" - shift 2 - ;; - --provider) - PROVIDER="$2" - shift 2 - ;; - --model) - MODEL_ID="$2" - MODEL_ID_EXPLICIT=1 - 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_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" - [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" - ;; - anthropic) - AUTH_CHOICE="apiKey" - AUTH_KEY_FLAG="anthropic-api-key" - [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-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_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-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 | tail -n 1 | tr -d '\r' || true -} - -is_explicit_package_target() { - local target="$1" - [[ "$target" == *"://"* || "$target" == *"#"* || "$target" =~ ^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm): ]] -} - -preflight_registry_update_target() { - local baseline_version target_version - [[ -n "$UPDATE_TARGET" && "$UPDATE_TARGET" != "local-main" ]] || return 0 - is_explicit_package_target "$UPDATE_TARGET" && return 0 - - baseline_version="$(resolve_registry_target_version "$PACKAGE_SPEC")" - target_version="$(resolve_registry_target_version "$UPDATE_TARGET")" - [[ -n "$baseline_version" && -n "$target_version" ]] || return 0 - if [[ "$baseline_version" == "$target_version" ]]; then - die "--update-target $UPDATE_TARGET resolves to openclaw@$target_version, same as baseline $PACKAGE_SPEC; publish or choose a newer --update-target before running VM update coverage" - fi -} - -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) - $previousDisableBundledPlugins = $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS - $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1' - try { - $output = & $Path update --tag $Target --yes --json *>&1 - } finally { - if ($null -eq $previousDisableBundledPlugins) { - Remove-Item Env:OPENCLAW_DISABLE_BUNDLED_PLUGINS -ErrorAction SilentlyContinue - } else { - $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = $previousDisableBundledPlugins - } - } - [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 </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 -$(parallels_bash_seed_workspace_snippet "Parallels npm update smoke test assistant.") - /opt/homebrew/bin/openclaw models set "$MODEL_ID" - /opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json -/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 <&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' - & \$openclaw config set agents.defaults.skipBootstrap true --strict-json -\$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 <