#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$ROOT_DIR/scripts/e2e/lib/parallels-macos-common.sh" source "$ROOT_DIR/scripts/e2e/lib/parallels-package-common.sh" 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:-1200}" 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 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" } 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 < "\$workspace/IDENTITY.md" <<'IDENTITY_EOF' # Identity - Name: OpenClaw - Purpose: Parallels macOS smoke test assistant. IDENTITY_EOF cat > "\$workspace/.openclaw/workspace-state.json" <<'STATE_EOF' { "version": 1, "setupCompletedAt": "2026-01-01T00:00:00.000Z" } STATE_EOF rm -f "\$workspace/BOOTSTRAP.md" exec /usr/bin/env $(shell_quote "$API_KEY_ENV=$API_KEY_VALUE") \ $(shell_quote "$GUEST_NODE_BIN") $(shell_quote "$GUEST_OPENCLAW_ENTRY") agent \ --agent main \ --session-id parallels-macos-smoke \ --message $(shell_quote "Reply with exact ASCII text OK only.") \ --json EOF )" } resolve_dashboard_url() { local dashboard_url dashboard_url="$( guest_current_user_cli "$GUEST_OPENCLAW_BIN" dashboard --no-open \ | awk '/^Dashboard URL: / { sub(/^Dashboard URL: /, ""); print; exit }' )" dashboard_url="${dashboard_url//$'\r'/}" dashboard_url="${dashboard_url//$'\n'/}" [[ -n "$dashboard_url" ]] || { echo "failed to resolve dashboard URL from openclaw dashboard --no-open" >&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.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.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