Merge branch 'main' into ui/dashboard-v2.1
This commit is contained in:
commit
007b136b8b
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
691
scripts/e2e/parallels-macos-smoke.sh
Normal file
691
scripts/e2e/parallels-macos-smoke.sh
Normal file
@ -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 <name> Parallels VM name. Default: "macOS Tahoe"
|
||||
--snapshot-hint <name> Snapshot name substring/fuzzy match.
|
||||
Default: "macOS 26.3.1 fresh"
|
||||
--mode <fresh|upgrade|both>
|
||||
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 <var> Host env var name for OpenAI API key.
|
||||
Default: OPENAI_API_KEY
|
||||
--install-url <url> Installer URL for latest release. Default: https://openclaw.ai/install.sh
|
||||
--host-port <port> Host HTTP port for current-main tgz. Default: 18425
|
||||
--host-ip <ip> Override Parallels host IP.
|
||||
--latest-version <ver> 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 <<EOF
|
||||
export OPENCLAW_NO_ONBOARD=1
|
||||
curl -fsSL $install_url_q -o /tmp/openclaw-install.sh
|
||||
bash /tmp/openclaw-install.sh
|
||||
$GUEST_OPENCLAW_BIN --version
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
verify_version_contains() {
|
||||
local needle="$1"
|
||||
local version
|
||||
version="$(
|
||||
guest_current_user_exec "$GUEST_OPENCLAW_BIN" --version
|
||||
)"
|
||||
printf '%s\n' "$version"
|
||||
case "$version" in
|
||||
*"$needle"*) ;;
|
||||
*)
|
||||
echo "version mismatch: expected substring $needle" >&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 <<EOF
|
||||
curl -fsSL $tgz_url_q -o /tmp/$temp_name
|
||||
$GUEST_NPM_BIN install -g /tmp/$temp_name
|
||||
$GUEST_OPENCLAW_BIN --version
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
verify_bundle_permissions() {
|
||||
local npm_q cmd
|
||||
npm_q="$(shell_quote "$GUEST_NPM_BIN")"
|
||||
cmd="$(cat <<EOF
|
||||
root=\$($npm_q root -g); check_path() { local path="\$1"; [ -e "\$path" ] || return 0; local perm perm_oct; perm=\$(/usr/bin/stat -f '%OLp' "\$path"); perm_oct=\$((8#\$perm)); if (( perm_oct & 0002 )); then echo "world-writable install artifact: \$path (\$perm)" >&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
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<string, string | undefined>;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user