diff --git a/AGENTS.md b/AGENTS.md index f7c2f34ce39..5112a8241df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,6 +205,8 @@ - Parallels macOS smoke playbook: - `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`. - Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed. + - Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. + - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`. - Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`. - For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green. - Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially. diff --git a/package.json b/package.json index 54d897eb66f..61cbaae5c57 100644 --- a/package.json +++ b/package.json @@ -325,6 +325,7 @@ "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", + "test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh", "test:perf:budget": "node scripts/test-perf-budget.mjs", "test:perf:hotspots": "node scripts/test-hotspots.mjs", "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh new file mode 100644 index 00000000000..8fcb0d05eae --- /dev/null +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -0,0 +1,691 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +VM_NAME="macOS Tahoe" +SNAPSHOT_HINT="macOS 26.3.1 fresh" +MODE="both" +OPENAI_API_KEY_ENV="OPENAI_API_KEY" +INSTALL_URL="https://openclaw.ai/install.sh" +HOST_PORT="18425" +HOST_PORT_EXPLICIT=0 +HOST_IP="" +LATEST_VERSION="" +KEEP_SERVER=0 +CHECK_LATEST_REF=1 +JSON_OUTPUT=0 +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" + +MAIN_TGZ_DIR="$(mktemp -d)" +MAIN_TGZ_PATH="" +SERVER_PID="" +RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-smoke.XXXXXX)" + +TIMEOUT_INSTALL_S=900 +TIMEOUT_VERIFY_S=60 +TIMEOUT_ONBOARD_S=180 +TIMEOUT_GATEWAY_S=60 +TIMEOUT_AGENT_S=120 +TIMEOUT_PERMISSION_S=60 +TIMEOUT_SNAPSHOT_S=180 + +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" + +say() { + printf '==> %s\n' "$*" +} + +warn() { + printf 'warn: %s\n' "$*" >&2 +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "${SERVER_PID:-}" ]]; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + fi + rm -rf "$MAIN_TGZ_DIR" + 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 fresh" + --mode + fresh = fresh snapshot -> current main tgz -> onboard smoke + upgrade = fresh snapshot -> latest release -> current main tgz -> onboard smoke + both = run both lanes + --openai-api-key-env Host env var name for OpenAI API key. + Default: OPENAI_API_KEY + --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. + --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. + --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 + --vm) + VM_NAME="$2" + shift 2 + ;; + --snapshot-hint) + SNAPSHOT_HINT="$2" + shift 2 + ;; + --mode) + MODE="$2" + shift 2 + ;; + --openai-api-key-env) + OPENAI_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 + ;; + --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 + +OPENAI_API_KEY_VALUE="${!OPENAI_API_KEY_ENV:-}" +[[ -n "$OPENAI_API_KEY_VALUE" ]] || die "$OPENAI_API_KEY_ENV is required" + +resolve_snapshot_id() { + 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 sys + +payload = json.loads(os.environ["SNAPSHOT_JSON"]) +hint = os.environ["SNAPSHOT_HINT"].strip().lower() +best_id = None +best_score = -1.0 +for snapshot_id, meta in payload.items(): + name = str(meta.get("name", "")).strip() + lowered = name.lower() + score = 0.0 + if lowered == hint: + score = 10.0 + elif hint and hint in lowered: + score = 5.0 + len(hint) / max(len(lowered), 1) + else: + score = difflib.SequenceMatcher(None, hint, lowered).ratio() + if score > best_score: + best_score = score + best_id = snapshot_id +if not best_id: + sys.exit("no snapshot matched") +print(best_id) +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_current_user() { + local deadline + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + if prlctl exec "$VM_NAME" --current-user whoami >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +guest_current_user_exec() { + prlctl exec "$VM_NAME" --current-user /usr/bin/env \ + PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin \ + "$@" +} + +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 +expect { + -re {__OPENCLAW_RC__:(-?[0-9]+)} { + set rc $expect_out(1,string) + exp_continue + } + eof {} +} +catch wait result +exit $rc +EOF +} + +guest_current_user_sh() { + local script + 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/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" + guest_script current-user "$script" +} + +restore_snapshot() { + local snapshot_id="$1" + say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" + prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null + 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 + install_url_q="$(shell_quote "$INSTALL_URL")" + guest_current_user_sh "$(cat <&2 + return 1 + ;; + esac +} + +pack_main_tgz() { + say "Pack current main tgz" + local short_head pkg + 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"])' + )" + MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$short_head.tgz" + cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH" + say "Packed $MAIN_TGZ_PATH" + tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json +} + +start_server() { + local host_ip="$1" + say "Serve current main tgz on $host_ip:$HOST_PORT" + ( + cd "$MAIN_TGZ_DIR" + exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0 + ) >/tmp/openclaw-parallels-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_tgz() { + local host_ip="$1" + local temp_name="$2" + local tgz_url_q + tgz_url_q="$(shell_quote "http://$host_ip:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")")" + 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() { + guest_current_user_exec \ + /usr/bin/env "OPENAI_API_KEY=$OPENAI_API_KEY_VALUE" \ + "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" onboard \ + --non-interactive \ + --mode local \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --gateway-port 18789 \ + --gateway-bind loopback \ + --install-daemon \ + --skip-skills \ + --accept-risk \ + --json +} + +verify_gateway() { + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --deep +} + +verify_turn() { + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" agent --agent main --message ping --json +} + +phase_log_path() { + printf '%s/%s.log\n' "$RUN_DIR" "$1" +} + +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 +} + +phase_run() { + local phase_id="$1" + local timeout_s="$2" + shift 2 + + local log_path pid start rc timed_out + log_path="$(phase_log_path "$phase_id")" + say "$phase_id" + start=$SECONDS + timed_out=0 + + ( + "$@" + ) >"$log_path" 2>&1 & + pid=$! + + while kill -0 "$pid" >/dev/null 2>&1; do + 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"], + "latestVersion": os.environ["SUMMARY_LATEST_VERSION"], + "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"], + }, + "upgrade": { + "precheck": os.environ["SUMMARY_UPGRADE_PRECHECK_STATUS"], + "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 +} + +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 + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" gateway status --deep || 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" "$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_version_contains "$(git rev-parse --short=7 HEAD)" + 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-status" "$TIMEOUT_GATEWAY_S" verify_gateway + FRESH_GATEWAY_STATUS="pass" + phase_run "fresh.first-agent-turn" "$TIMEOUT_AGENT_S" verify_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.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" + 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 + 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_version_contains "$(git rev-parse --short=7 HEAD)" + phase_run "upgrade.verify-bundle-permissions" "$TIMEOUT_PERMISSION_S" verify_bundle_permissions + phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard + phase_run "upgrade.gateway-status" "$TIMEOUT_GATEWAY_S" verify_gateway + UPGRADE_GATEWAY_STATUS="pass" + phase_run "upgrade.first-agent-turn" "$TIMEOUT_AGENT_S" verify_turn + UPGRADE_AGENT_STATUS="pass" +} + +FRESH_MAIN_STATUS="skip" +UPGRADE_STATUS="skip" +UPGRADE_PRECHECK_STATUS="skip" + +SNAPSHOT_ID="$(resolve_snapshot_id)" +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 "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_LATEST_VERSION="$LATEST_VERSION" \ + SUMMARY_CURRENT_HEAD="$(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_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" \ + write_summary_json +)" + +if [[ "$JSON_OUTPUT" -eq 1 ]]; then + cat "$SUMMARY_JSON_PATH" +else + printf '\nSummary:\n' + printf ' fresh-main: %s (%s)\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION" + printf ' latest->main precheck: %s (%s)\n' "$UPGRADE_PRECHECK_STATUS" "$LATEST_INSTALLED_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 diff --git a/scripts/install.sh b/scripts/install.sh index ea02c48b6db..2abfbad9935 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -995,6 +995,7 @@ SHARP_IGNORE_GLOBAL_LIBVIPS="${SHARP_IGNORE_GLOBAL_LIBVIPS:-1}" NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}" NPM_SILENT_FLAG="--silent" VERBOSE="${OPENCLAW_VERBOSE:-0}" +VERIFY_INSTALL="${OPENCLAW_VERIFY_INSTALL:-0}" OPENCLAW_BIN="" PNPM_CMD=() HELP=0 @@ -1016,6 +1017,7 @@ Options: --no-git-update Skip git pull for existing checkout --no-onboard Skip onboarding (non-interactive) --no-prompt Disable prompts (required in CI/automation) + --verify Run a post-install smoke verify --dry-run Print what would happen (no changes) --verbose Print debug output (set -x, npm verbose) --help, -h Show this help @@ -1027,6 +1029,7 @@ Environment variables: OPENCLAW_GIT_DIR=... OPENCLAW_GIT_UPDATE=0|1 OPENCLAW_NO_PROMPT=1 + OPENCLAW_VERIFY_INSTALL=1 OPENCLAW_DRY_RUN=1 OPENCLAW_NO_ONBOARD=1 OPENCLAW_VERBOSE=1 @@ -1036,6 +1039,7 @@ Environment variables: Examples: curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard + curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --no-onboard --verify curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git --no-onboard EOF } @@ -1059,6 +1063,10 @@ parse_args() { VERBOSE=1 shift ;; + --verify) + VERIFY_INSTALL=1 + shift + ;; --no-prompt) NO_PROMPT=1 shift @@ -2196,7 +2204,38 @@ refresh_gateway_service_if_loaded() { return 0 fi - run_quiet_step "Probing gateway service" "$claw" gateway status --probe --deep || true + run_quiet_step "Probing gateway service" "$claw" gateway status --deep || true +} + +verify_installation() { + if [[ "${VERIFY_INSTALL}" != "1" ]]; then + return 0 + fi + + ui_stage "Verifying installation" + local claw="${OPENCLAW_BIN:-}" + if [[ -z "$claw" ]]; then + claw="$(resolve_openclaw_bin || true)" + fi + if [[ -z "$claw" ]]; then + ui_error "Install verify failed: openclaw not on PATH yet" + warn_openclaw_not_found + return 1 + fi + + run_quiet_step "Checking OpenClaw version" "$claw" --version || return 1 + + if is_gateway_daemon_loaded "$claw"; then + run_quiet_step "Checking gateway service" "$claw" gateway status --deep || { + ui_error "Install verify failed: gateway service unhealthy" + ui_info "Run: openclaw gateway status --deep" + return 1 + } + else + ui_info "Gateway service not loaded; skipping gateway deep probe" + fi + + ui_success "Install verify complete" } # Main installation flow @@ -2485,6 +2524,10 @@ main() { fi fi + if ! verify_installation; then + exit 1 + fi + if [[ "$should_open_dashboard" == "true" ]]; then maybe_open_dashboard fi diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index f2e0724b53b..23684eb5f5a 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -14,6 +14,19 @@ const gatewayClientCalls: Array<{ }> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => vi.fn(async () => {})); +const gatewayServiceMock = vi.hoisted(() => ({ + label: "LaunchAgent", + loadedText: "loaded", + isLoaded: vi.fn(async () => true), + readRuntime: vi.fn(async () => ({ + status: "running", + state: "active", + pid: 4242, + })), +})); +const readLastGatewayErrorLineMock = vi.hoisted(() => + vi.fn(async () => "Gateway failed to start: required secrets are unavailable."), +); let waitForGatewayReachableMock: | ((params: { url: string; token?: string; password?: string; deadlineMs?: number }) => Promise<{ ok: boolean; @@ -64,6 +77,14 @@ vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({ installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock, })); +vi.mock("../daemon/service.js", () => ({ + resolveGatewayService: () => gatewayServiceMock, +})); + +vi.mock("../daemon/diagnostics.js", () => ({ + readLastGatewayErrorLine: readLastGatewayErrorLineMock, +})); + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); const { resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js"); const { resolveConfigPath } = await import("../config/config.js"); @@ -134,6 +155,9 @@ describe("onboard (non-interactive): gateway and remote auth", () => { afterEach(() => { waitForGatewayReachableMock = undefined; installGatewayDaemonNonInteractiveMock.mockClear(); + gatewayServiceMock.isLoaded.mockClear(); + gatewayServiceMock.readRuntime.mockClear(); + readLastGatewayErrorLineMock.mockClear(); }); it("writes gateway token auth into config", async () => { @@ -376,6 +400,73 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("emits structured JSON diagnostics when daemon health fails", async () => { + await withStateDir("state-local-daemon-health-json-fail-", async (stateDir) => { + waitForGatewayReachableMock = vi.fn(async () => ({ + ok: false, + detail: "gateway closed (1006 abnormal closure (no close frame)): no close reason", + })); + + let capturedError = ""; + const runtimeWithCapture = { + log: () => {}, + error: (message: string) => { + capturedError = message; + throw new Error(message); + }, + exit: (_code: number) => { + throw new Error("exit should not be reached after runtime.error"); + }, + }; + + await expect( + runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: false, + installDaemon: true, + gatewayBind: "loopback", + json: true, + }, + runtimeWithCapture, + ), + ).rejects.toThrow(/"phase": "gateway-health"/); + + const parsed = JSON.parse(capturedError) as { + ok: boolean; + phase: string; + installDaemon: boolean; + detail?: string; + gateway?: { wsUrl?: string }; + hints?: string[]; + diagnostics?: { + service?: { + label?: string; + loaded?: boolean; + runtimeStatus?: string; + pid?: number; + }; + lastGatewayError?: string; + }; + }; + expect(parsed.ok).toBe(false); + expect(parsed.phase).toBe("gateway-health"); + expect(parsed.installDaemon).toBe(true); + expect(parsed.detail).toContain("1006 abnormal closure"); + expect(parsed.gateway?.wsUrl).toContain("ws://127.0.0.1:"); + expect(parsed.hints).toContain("Run `openclaw gateway status --deep` for more detail."); + expect(parsed.diagnostics?.service?.label).toBe("LaunchAgent"); + expect(parsed.diagnostics?.service?.loaded).toBe(true); + expect(parsed.diagnostics?.service?.runtimeStatus).toBe("running"); + expect(parsed.diagnostics?.service?.pid).toBe(4242); + expect(parsed.diagnostics?.lastGatewayError).toContain("required secrets are unavailable"); + }); + }, 60_000); + it("auto-generates token auth when binding LAN and persists the token", async () => { if (process.platform === "win32") { // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 0765eb1a513..e573c385166 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -15,13 +15,84 @@ import { import type { OnboardOptions } from "../onboard-types.js"; import { inferAuthChoiceFromFlags } from "./local/auth-choice-inference.js"; import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js"; -import { logNonInteractiveOnboardingJson } from "./local/output.js"; +import { + logNonInteractiveOnboardingFailure, + logNonInteractiveOnboardingJson, +} from "./local/output.js"; import { applyNonInteractiveSkillsConfig } from "./local/skills-config.js"; import { resolveNonInteractiveWorkspaceDir } from "./local/workspace.js"; const INSTALL_DAEMON_HEALTH_DEADLINE_MS = 45_000; const ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS = 15_000; +async function collectGatewayHealthFailureDiagnostics(): Promise< + | { + service?: { + label: string; + loaded: boolean; + loadedText: string; + runtimeStatus?: string; + state?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; + }; + lastGatewayError?: string; + inspectError?: string; + } + | undefined +> { + const diagnostics: { + service?: { + label: string; + loaded: boolean; + loadedText: string; + runtimeStatus?: string; + state?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; + }; + lastGatewayError?: string; + inspectError?: string; + } = {}; + + try { + const { resolveGatewayService } = await import("../../daemon/service.js"); + const service = resolveGatewayService(); + const env = process.env as Record; + const [loaded, runtime] = await Promise.all([ + service.isLoaded({ env }).catch(() => false), + service.readRuntime(env).catch(() => undefined), + ]); + diagnostics.service = { + label: service.label, + loaded, + loadedText: service.loadedText, + runtimeStatus: runtime?.status, + state: runtime?.state, + pid: runtime?.pid, + lastExitStatus: runtime?.lastExitStatus, + lastExitReason: runtime?.lastExitReason, + }; + } catch (err) { + diagnostics.inspectError = `service diagnostics failed: ${String(err)}`; + } + + try { + const { readLastGatewayErrorLine } = await import("../../daemon/diagnostics.js"); + diagnostics.lastGatewayError = (await readLastGatewayErrorLine(process.env)) ?? undefined; + } catch (err) { + diagnostics.inspectError = diagnostics.inspectError + ? `${diagnostics.inspectError}; log diagnostics failed: ${String(err)}` + : `log diagnostics failed: ${String(err)}`; + } + + return diagnostics.service || diagnostics.lastGatewayError || diagnostics.inspectError + ? diagnostics + : undefined; +} + export async function runNonInteractiveOnboardingLocal(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -115,24 +186,33 @@ export async function runNonInteractiveOnboardingLocal(params: { : ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS, }); if (!probe.ok) { - const message = [ - `Gateway did not become reachable at ${links.wsUrl}.`, - probe.detail ? `Last probe: ${probe.detail}` : undefined, - !opts.installDaemon + const diagnostics = opts.installDaemon + ? await collectGatewayHealthFailureDiagnostics() + : undefined; + logNonInteractiveOnboardingFailure({ + opts, + runtime, + mode, + phase: "gateway-health", + message: `Gateway did not become reachable at ${links.wsUrl}.`, + detail: probe.detail, + gateway: { + wsUrl: links.wsUrl, + httpUrl: links.httpUrl, + }, + installDaemon: Boolean(opts.installDaemon), + daemonRuntime: opts.installDaemon ? daemonRuntimeRaw : undefined, + diagnostics, + hints: !opts.installDaemon ? [ "Non-interactive local onboarding only waits for an already-running gateway unless you pass --install-daemon.", `Fix: start \`${formatCliCommand("openclaw gateway run")}\`, re-run with \`--install-daemon\`, or use \`--skip-health\`.`, process.platform === "win32" ? "Native Windows managed gateway install tries Scheduled Tasks first and falls back to a per-user Startup-folder login item when task creation is denied." : undefined, - ] - .filter(Boolean) - .join("\n") - : undefined, - ] - .filter(Boolean) - .join("\n"); - runtime.error(message); + ].filter((value): value is string => Boolean(value)) + : [`Run \`${formatCliCommand("openclaw gateway status --deep")}\` for more detail.`], + }); runtime.exit(1); return; } diff --git a/src/commands/onboard-non-interactive/local/output.ts b/src/commands/onboard-non-interactive/local/output.ts index d4296e3500c..100956ae979 100644 --- a/src/commands/onboard-non-interactive/local/output.ts +++ b/src/commands/onboard-non-interactive/local/output.ts @@ -1,6 +1,21 @@ import type { RuntimeEnv } from "../../../runtime.js"; import type { OnboardOptions } from "../../onboard-types.js"; +type GatewayHealthFailureDiagnostics = { + service?: { + label: string; + loaded: boolean; + loadedText: string; + runtimeStatus?: string; + state?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; + }; + lastGatewayError?: string; + inspectError?: string; +}; + export function logNonInteractiveOnboardingJson(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -24,6 +39,7 @@ export function logNonInteractiveOnboardingJson(params: { params.runtime.log( JSON.stringify( { + ok: true, mode: params.mode, workspace: params.workspaceDir, authChoice: params.authChoice, @@ -38,3 +54,88 @@ export function logNonInteractiveOnboardingJson(params: { ), ); } + +function formatGatewayRuntimeSummary( + diagnostics: GatewayHealthFailureDiagnostics | undefined, +): string | undefined { + const service = diagnostics?.service; + if (!service?.runtimeStatus) { + return undefined; + } + const parts = [service.runtimeStatus]; + if (typeof service.pid === "number") { + parts.push(`pid ${service.pid}`); + } + if (service.state) { + parts.push(`state ${service.state}`); + } + if (typeof service.lastExitStatus === "number") { + parts.push(`last exit ${service.lastExitStatus}`); + } + if (service.lastExitReason) { + parts.push(`reason ${service.lastExitReason}`); + } + return parts.join(", "); +} + +export function logNonInteractiveOnboardingFailure(params: { + opts: OnboardOptions; + runtime: RuntimeEnv; + mode: "local" | "remote"; + phase: string; + message: string; + detail?: string; + hints?: string[]; + gateway?: { + wsUrl?: string; + httpUrl?: string; + }; + installDaemon?: boolean; + daemonRuntime?: string; + diagnostics?: GatewayHealthFailureDiagnostics; +}) { + const hints = params.hints?.filter(Boolean) ?? []; + const gatewayRuntime = formatGatewayRuntimeSummary(params.diagnostics); + + if (params.opts.json) { + params.runtime.error( + JSON.stringify( + { + ok: false, + mode: params.mode, + phase: params.phase, + message: params.message, + detail: params.detail, + gateway: params.gateway, + installDaemon: Boolean(params.installDaemon), + daemonRuntime: params.daemonRuntime, + diagnostics: params.diagnostics, + hints: hints.length > 0 ? hints : undefined, + }, + null, + 2, + ), + ); + return; + } + + const lines = [ + params.message, + params.detail ? `Last probe: ${params.detail}` : undefined, + params.diagnostics?.service + ? `Service: ${params.diagnostics.service.label} (${params.diagnostics.service.loaded ? params.diagnostics.service.loadedText : "not loaded"})` + : undefined, + gatewayRuntime ? `Runtime: ${gatewayRuntime}` : undefined, + params.diagnostics?.lastGatewayError + ? `Last gateway error: ${params.diagnostics.lastGatewayError}` + : undefined, + params.diagnostics?.inspectError + ? `Diagnostics warning: ${params.diagnostics.inspectError}` + : undefined, + hints.length > 0 ? hints.join("\n") : undefined, + ] + .filter(Boolean) + .join("\n"); + + params.runtime.error(lines); +} diff --git a/src/shared/node-match.test.ts b/src/shared/node-match.test.ts index 2ddc3663d3f..9db461b17e5 100644 --- a/src/shared/node-match.test.ts +++ b/src/shared/node-match.test.ts @@ -5,6 +5,7 @@ describe("shared/node-match", () => { it("normalizes node keys by lowercasing and collapsing separators", () => { expect(normalizeNodeKey(" Mac Studio! ")).toBe("mac-studio"); expect(normalizeNodeKey("---PI__Node---")).toBe("pi-node"); + expect(normalizeNodeKey("###")).toBe(""); }); it("matches candidates by node id, remote ip, normalized name, and long prefix", () => { @@ -16,6 +17,7 @@ describe("shared/node-match", () => { expect(resolveNodeMatches(nodes, "mac-abcdef")).toEqual([nodes[0]]); expect(resolveNodeMatches(nodes, "100.0.0.2")).toEqual([nodes[1]]); expect(resolveNodeMatches(nodes, "mac studio")).toEqual([nodes[0]]); + expect(resolveNodeMatches(nodes, " Mac---Studio!! ")).toEqual([nodes[0]]); expect(resolveNodeMatches(nodes, "pi-456")).toEqual([nodes[1]]); expect(resolveNodeMatches(nodes, "pi")).toEqual([]); expect(resolveNodeMatches(nodes, " ")).toEqual([]); @@ -33,6 +35,18 @@ describe("shared/node-match", () => { ).toBe("ios-live"); }); + it("falls back to raw ambiguous matches when none of them are connected", () => { + expect(() => + resolveNodeIdFromCandidates( + [ + { nodeId: "ios-a", displayName: "iPhone", connected: false }, + { nodeId: "ios-b", displayName: "iPhone", connected: false }, + ], + "iphone", + ), + ).toThrow(/ambiguous node: iphone.*matches: iPhone, iPhone/); + }); + it("throws clear unknown and ambiguous node errors", () => { expect(() => resolveNodeIdFromCandidates( @@ -56,4 +70,13 @@ describe("shared/node-match", () => { expect(() => resolveNodeIdFromCandidates([], "")).toThrow(/node required/); }); + + it("lists remote ips in unknown-node errors when display names are missing", () => { + expect(() => + resolveNodeIdFromCandidates( + [{ nodeId: "mac-123", remoteIp: "100.0.0.1" }, { nodeId: "pi-456" }], + "nope", + ), + ).toThrow(/unknown node: nope.*known: 100.0.0.1, pi-456/); + }); }); diff --git a/src/shared/subagents-format.test.ts b/src/shared/subagents-format.test.ts index 34d1f9a8d5d..c058c19ccd1 100644 --- a/src/shared/subagents-format.test.ts +++ b/src/shared/subagents-format.test.ts @@ -12,7 +12,9 @@ describe("shared/subagents-format", () => { it("formats compact durations across minute, hour, and day buckets", () => { expect(formatDurationCompact()).toBe("n/a"); expect(formatDurationCompact(30_000)).toBe("1m"); + expect(formatDurationCompact(60 * 60_000)).toBe("1h"); expect(formatDurationCompact(61 * 60_000)).toBe("1h1m"); + expect(formatDurationCompact(24 * 60 * 60_000)).toBe("1d"); expect(formatDurationCompact(25 * 60 * 60_000)).toBe("1d1h"); }); @@ -20,7 +22,9 @@ describe("shared/subagents-format", () => { expect(formatTokenShort()).toBeUndefined(); expect(formatTokenShort(999.9)).toBe("999"); expect(formatTokenShort(1_500)).toBe("1.5k"); + expect(formatTokenShort(10_000)).toBe("10k"); expect(formatTokenShort(15_400)).toBe("15k"); + expect(formatTokenShort(1_000_000)).toBe("1m"); expect(formatTokenShort(1_250_000)).toBe("1.3m"); }); @@ -40,6 +44,11 @@ describe("shared/subagents-format", () => { output: 5, total: 15, }); + expect(resolveIoTokens({ outputTokens: 5 })).toEqual({ + input: 0, + output: 5, + total: 5, + }); expect(resolveIoTokens({ inputTokens: Number.NaN, outputTokens: 0 })).toBeUndefined(); }); @@ -53,6 +62,13 @@ describe("shared/subagents-format", () => { ).toBe("tokens 1.5k (in 1.2k / out 300), prompt/cache 2.1k"); expect(formatTokenUsageDisplay({ totalTokens: 500 })).toBe("tokens 500 prompt/cache"); + expect( + formatTokenUsageDisplay({ + inputTokens: 1_200, + outputTokens: 300, + totalTokens: 1_500, + }), + ).toBe("tokens 1.5k (in 1.2k / out 300)"); expect(formatTokenUsageDisplay({ inputTokens: 0, outputTokens: 0, totalTokens: 0 })).toBe(""); }); }); diff --git a/src/shared/subagents-format.ts b/src/shared/subagents-format.ts index f31ec9e9d4e..643c4b58ca5 100644 --- a/src/shared/subagents-format.ts +++ b/src/shared/subagents-format.ts @@ -25,12 +25,12 @@ export function formatTokenShort(value?: number) { return `${n}`; } if (n < 10_000) { - return `${(n / 1_000).toFixed(1).replace(/\\.0$/, "")}k`; + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; } if (n < 1_000_000) { return `${Math.round(n / 1_000)}k`; } - return `${(n / 1_000_000).toFixed(1).replace(/\\.0$/, "")}m`; + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; } export function truncateLine(value: string, maxLength: number) {