From b4faa5aa5eee35705e4b98736acfe83dea2aecdb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 09:15:55 +0800 Subject: [PATCH] chore(gateway): cover update and setup RPCs * test(gateway): cover update and setup RPCs * test(gateway): cover target wizard lifecycle * test(gateway): normalize QA fixture ownership * test(gateway): include historical dist closure * test(gateway): isolate historical update fixture * test(qa): align target wizard lifecycle proof * test(qa): read structured gateway call errors --- .../update-run-package-self-upgrade.yaml | 11 +- .../update-run-package-self-upgrade.sh | 564 ++++++++++++++++++ .../update-run-package-self-upgrade-docker.sh | 48 +- .../update-run-package-self-upgrade.test.ts | 130 +++- .../update-run-package-self-upgrade.ts | 31 +- 5 files changed, 777 insertions(+), 7 deletions(-) diff --git a/qa/scenarios/runtime/update-run-package-self-upgrade.yaml b/qa/scenarios/runtime/update-run-package-self-upgrade.yaml index b4d709734a70..cc8cb54b418c 100644 --- a/qa/scenarios/runtime/update-run-package-self-upgrade.yaml +++ b/qa/scenarios/runtime/update-run-package-self-upgrade.yaml @@ -6,11 +6,15 @@ scenario: coverage: primary: - cli.update-status-and-rpc + - gateway.update-and-setup-apis secondary: - cli.managed-gateway-restart - objective: Verify an installed OpenClaw 2026.4.26 package upgrades to the resolved latest package through the authenticated Gateway update.run RPC and returns healthy after restart. + objective: Verify authenticated setup RPC compatibility before and after an installed OpenClaw 2026.4.26 package upgrades to the resolved latest package through update.run and returns healthy after restart. successCriteria: - - The package-backed Docker lane installs and verifies openclaw@2026.4.26 before invoking the authenticated Gateway update.run RPC directly. + - The package-backed Docker lane builds an isolated production install of openclaw@2026.4.26, overlays its exact-tag private dist, preflight-imports the linked historical QA channel, and separately installs the published source Gateway before invoking update.run. + - Authenticated wizard.start and wizard.status return valid sanitized projections, the running historical session remains usable by wizard.next, and wizard.cancel terminates the setup flow. + - A second wizard.start is rejected while setup is active, and cancellation purges the session so a replacement setup can start and cancel cleanly. + - After restart, one target-package wizard session proves repeated wizard.status retains the running session before explicit cancellation and settlement-polled purge, while its replacement proves wizard.next, duplicate-start rejection, cancellation, settlement-polled purge, and another replacement setup. - The lane resolves openclaw@latest before the RPC and rejects a no-op or any installed version that differs from the resolved target. - The update RPC succeeds with source and target versions and writes the exact requested restart note into the update sentinel. - The restarted Gateway reports the resolved target version, passes health, readiness, and RPC status probes, and reports the QA channel account running. @@ -22,6 +26,7 @@ scenario: - docs/help/testing-updates-plugins.md codeRefs: - src/gateway/server-methods/update.ts + - src/gateway/server-methods/wizard.ts - src/infra/restart.ts - scripts/e2e/update-run-package-self-upgrade-docker.sh - scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh @@ -29,7 +34,7 @@ scenario: execution: kind: script path: test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts - summary: Runs the opt-in package-backed Docker lane for authenticated Gateway update.run self-upgrade and emits source/target, restart sentinel, Gateway health, and QA channel evidence. + summary: Runs the opt-in installed-package Docker lane for authenticated historical and current wizard lifecycles plus update RPCs, then emits sanitized setup, source/target, restart sentinel, Gateway health, and QA channel evidence. allowBlockedEvidence: true timeoutMs: 3600000 args: diff --git a/scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh b/scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh index 197cbad12b03..2742d5a41247 100755 --- a/scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh +++ b/scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh @@ -57,6 +57,50 @@ GATEWAY_STATUS_JSON="$ARTIFACT_DIR/gateway-status.json" GATEWAY_STATUS_ERR="$ARTIFACT_DIR/gateway-status.err" CHANNELS_STATUS_JSON="$ARTIFACT_DIR/channels-status.json" CHANNELS_STATUS_ERR="$ARTIFACT_DIR/channels-status.err" +WIZARD_START_JSON="$ARTIFACT_DIR/wizard-start.json" +WIZARD_START_ERR="$ARTIFACT_DIR/wizard-start.err" +WIZARD_STATUS_JSON="$ARTIFACT_DIR/wizard-status.json" +WIZARD_STATUS_ERR="$ARTIFACT_DIR/wizard-status.err" +WIZARD_NEXT_JSON="$ARTIFACT_DIR/wizard-next.json" +WIZARD_NEXT_ERR="$ARTIFACT_DIR/wizard-next.err" +WIZARD_DUPLICATE_JSON="$ARTIFACT_DIR/wizard-duplicate-start.json" +WIZARD_DUPLICATE_ERR="$ARTIFACT_DIR/wizard-duplicate-start.err" +WIZARD_CANCEL_JSON="$ARTIFACT_DIR/wizard-cancel.json" +WIZARD_CANCEL_ERR="$ARTIFACT_DIR/wizard-cancel.err" +WIZARD_CANCELLED_STATUS_JSON="$ARTIFACT_DIR/wizard-cancelled-status.json" +WIZARD_CANCELLED_STATUS_ERR="$ARTIFACT_DIR/wizard-cancelled-status.err" +WIZARD_REPLACEMENT_START_JSON="$ARTIFACT_DIR/wizard-replacement-start.json" +WIZARD_REPLACEMENT_START_ERR="$ARTIFACT_DIR/wizard-replacement-start.err" +WIZARD_REPLACEMENT_CANCEL_JSON="$ARTIFACT_DIR/wizard-replacement-cancel.json" +WIZARD_REPLACEMENT_CANCEL_ERR="$ARTIFACT_DIR/wizard-replacement-cancel.err" +WIZARD_REPLACEMENT_STATUS_JSON="$ARTIFACT_DIR/wizard-replacement-status.json" +WIZARD_REPLACEMENT_STATUS_ERR="$ARTIFACT_DIR/wizard-replacement-status.err" +WIZARD_FLOW_JSON="$ARTIFACT_DIR/wizard-flow.json" +TARGET_WIZARD_STATUS_START_JSON="$ARTIFACT_DIR/target-wizard-status-start.json" +TARGET_WIZARD_STATUS_START_ERR="$ARTIFACT_DIR/target-wizard-status-start.err" +TARGET_WIZARD_STATUS_JSON="$ARTIFACT_DIR/target-wizard-status.json" +TARGET_WIZARD_STATUS_ERR="$ARTIFACT_DIR/target-wizard-status.err" +TARGET_WIZARD_STATUS_RETAINED_JSON="$ARTIFACT_DIR/target-wizard-status-retained.json" +TARGET_WIZARD_STATUS_RETAINED_ERR="$ARTIFACT_DIR/target-wizard-status-retained.err" +TARGET_WIZARD_STATUS_CANCEL_JSON="$ARTIFACT_DIR/target-wizard-status-cancel.json" +TARGET_WIZARD_STATUS_CANCEL_ERR="$ARTIFACT_DIR/target-wizard-status-cancel.err" +TARGET_WIZARD_STATUS_PURGED_JSON="$ARTIFACT_DIR/target-wizard-status-purged.json" +TARGET_WIZARD_STATUS_PURGED_ERR="$ARTIFACT_DIR/target-wizard-status-purged.err" +TARGET_WIZARD_ACTIVE_START_JSON="$ARTIFACT_DIR/target-wizard-active-start.json" +TARGET_WIZARD_ACTIVE_START_ERR="$ARTIFACT_DIR/target-wizard-active-start.err" +TARGET_WIZARD_NEXT_JSON="$ARTIFACT_DIR/target-wizard-next.json" +TARGET_WIZARD_NEXT_ERR="$ARTIFACT_DIR/target-wizard-next.err" +TARGET_WIZARD_DUPLICATE_JSON="$ARTIFACT_DIR/target-wizard-duplicate-start.json" +TARGET_WIZARD_DUPLICATE_ERR="$ARTIFACT_DIR/target-wizard-duplicate-start.err" +TARGET_WIZARD_CANCEL_JSON="$ARTIFACT_DIR/target-wizard-cancel.json" +TARGET_WIZARD_CANCEL_ERR="$ARTIFACT_DIR/target-wizard-cancel.err" +TARGET_WIZARD_REPLACEMENT_START_JSON="$ARTIFACT_DIR/target-wizard-replacement-start.json" +TARGET_WIZARD_REPLACEMENT_START_ERR="$ARTIFACT_DIR/target-wizard-replacement-start.err" +TARGET_WIZARD_REPLACEMENT_CANCEL_JSON="$ARTIFACT_DIR/target-wizard-replacement-cancel.json" +TARGET_WIZARD_REPLACEMENT_CANCEL_ERR="$ARTIFACT_DIR/target-wizard-replacement-cancel.err" +TARGET_WIZARD_PURGED_STATUS_JSON="$ARTIFACT_DIR/target-wizard-purged-status.json" +TARGET_WIZARD_PURGED_STATUS_ERR="$ARTIFACT_DIR/target-wizard-purged-status.err" +TARGET_WIZARD_FLOW_JSON="$ARTIFACT_DIR/target-wizard-flow.json" SUMMARY_JSON="$ARTIFACT_DIR/summary.json" SYSTEMCTL_SHIM_LOG="$ARTIFACT_DIR/systemctl-shim.log" SYSTEMCTL_SHIM_SETUP_LOG="$ARTIFACT_DIR/systemctl-shim-setup.log" @@ -86,6 +130,8 @@ rm -f \ "$UPDATE_STATUS_JSON" \ "$UPDATE_STATUS_ERR" \ "$ARTIFACT_DIR/update-status.candidate.json" \ + "$WIZARD_FLOW_JSON" \ + "$TARGET_WIZARD_FLOW_JSON" \ "$SUMMARY_JSON" : >"$SYSTEMCTL_SHIM_DAEMON_LOG" @@ -330,10 +376,242 @@ gateway_call() { >"$output" 2>"$error_output" } +assert_gateway_call_error_message() { + local output="$1" + local error_output="$2" + local expected="$3" + local label="$4" + local allow_stderr_fallback="${5:-0}" + if EXPECTED_GATEWAY_ERROR="$expected" node -e ' + const fs = require("node:fs"); + const file = process.argv[1]; + const raw = fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : ""; + if (!raw) { + process.exit(1); + } + try { + const payload = JSON.parse(raw); + process.exit( + payload?.ok === false && + payload?.error?.type === "gateway_request_error" && + payload.error.message === process.env.EXPECTED_GATEWAY_ERROR + ? 0 + : 1, + ); + } catch { + process.exit(1); + } + ' "$output"; then + return 0 + fi + if [ "$allow_stderr_fallback" = "1" ] && grep -Fq "$expected" "$error_output"; then + return 0 + fi + echo "$label failed without the expected '$expected' result" >&2 + openclaw_e2e_print_log "$output" >&2 + openclaw_e2e_print_log "$error_output" >&2 + exit 1 +} + gateway_call channels.status '{"probe":false,"timeoutMs":2000}' \ "$ARTIFACT_DIR/channels-status-before.json" \ "$ARTIFACT_DIR/channels-status-before.err" +echo "Exercising authenticated Gateway wizard RPC lifecycle" +gateway_call wizard.start '{"mode":"local"}' "$WIZARD_START_JSON" "$WIZARD_START_ERR" +wizard_session_id="$( + node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if ( + typeof payload?.sessionId !== "string" || + !payload.sessionId || + payload.done !== false || + payload.status !== "running" || + typeof payload.step?.id !== "string" + ) { + throw new Error(`unexpected wizard.start result: ${JSON.stringify(payload)}`); + } + process.stdout.write(payload.sessionId); + ' "$WIZARD_START_JSON" +)" +wizard_step_id="$( + node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + process.stdout.write(payload.step.id); + ' "$WIZARD_START_JSON" +)" +wizard_session_params="$( + WIZARD_SESSION_ID="$wizard_session_id" node -e ' + process.stdout.write(JSON.stringify({ sessionId: process.env.WIZARD_SESSION_ID })); + ' +)" +gateway_call wizard.status "$wizard_session_params" "$WIZARD_STATUS_JSON" "$WIZARD_STATUS_ERR" +wizard_next_params="$( + WIZARD_SESSION_ID="$wizard_session_id" WIZARD_STEP_ID="$wizard_step_id" node -e ' + process.stdout.write( + JSON.stringify({ + sessionId: process.env.WIZARD_SESSION_ID, + answer: { stepId: process.env.WIZARD_STEP_ID, value: null }, + }), + ); + ' +)" +gateway_call wizard.next "$wizard_next_params" "$WIZARD_NEXT_JSON" "$WIZARD_NEXT_ERR" + +if gateway_call wizard.start '{"mode":"local"}' \ + "$WIZARD_DUPLICATE_JSON" "$WIZARD_DUPLICATE_ERR"; then + echo "wizard.start unexpectedly allowed an overlapping setup session" >&2 + exit 1 +fi +assert_gateway_call_error_message \ + "$WIZARD_DUPLICATE_JSON" \ + "$WIZARD_DUPLICATE_ERR" \ + "wizard already running" \ + "overlapping wizard.start" \ + 1 + +gateway_call wizard.cancel "$wizard_session_params" "$WIZARD_CANCEL_JSON" "$WIZARD_CANCEL_ERR" +if gateway_call wizard.status "$wizard_session_params" \ + "$WIZARD_CANCELLED_STATUS_JSON" "$WIZARD_CANCELLED_STATUS_ERR"; then + echo "cancelled wizard session remained reachable" >&2 + exit 1 +fi +assert_gateway_call_error_message \ + "$WIZARD_CANCELLED_STATUS_JSON" \ + "$WIZARD_CANCELLED_STATUS_ERR" \ + "wizard not found" \ + "cancelled wizard cleanup" \ + 1 + +gateway_call wizard.start '{"mode":"local"}' \ + "$WIZARD_REPLACEMENT_START_JSON" "$WIZARD_REPLACEMENT_START_ERR" +replacement_session_id="$( + node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if ( + typeof payload?.sessionId !== "string" || + !payload.sessionId || + payload.done !== false || + payload.status !== "running" + ) { + throw new Error(`unexpected replacement wizard.start result: ${JSON.stringify(payload)}`); + } + process.stdout.write(payload.sessionId); + ' "$WIZARD_REPLACEMENT_START_JSON" +)" +replacement_session_params="$( + WIZARD_SESSION_ID="$replacement_session_id" node -e ' + process.stdout.write(JSON.stringify({ sessionId: process.env.WIZARD_SESSION_ID })); + ' +)" +gateway_call wizard.cancel "$replacement_session_params" \ + "$WIZARD_REPLACEMENT_CANCEL_JSON" "$WIZARD_REPLACEMENT_CANCEL_ERR" +if gateway_call wizard.status "$replacement_session_params" \ + "$WIZARD_REPLACEMENT_STATUS_JSON" "$WIZARD_REPLACEMENT_STATUS_ERR"; then + echo "replacement wizard session remained reachable after cancellation" >&2 + exit 1 +fi +assert_gateway_call_error_message \ + "$WIZARD_REPLACEMENT_STATUS_JSON" \ + "$WIZARD_REPLACEMENT_STATUS_ERR" \ + "wizard not found" \ + "replacement wizard cleanup" \ + 1 + +WIZARD_START_JSON="$WIZARD_START_JSON" \ + WIZARD_STATUS_JSON="$WIZARD_STATUS_JSON" \ + WIZARD_NEXT_JSON="$WIZARD_NEXT_JSON" \ + WIZARD_CANCEL_JSON="$WIZARD_CANCEL_JSON" \ + WIZARD_REPLACEMENT_START_JSON="$WIZARD_REPLACEMENT_START_JSON" \ + WIZARD_REPLACEMENT_CANCEL_JSON="$WIZARD_REPLACEMENT_CANCEL_JSON" \ + WIZARD_FLOW_JSON="$WIZARD_FLOW_JSON" \ + node -e ' + const fs = require("node:fs"); + const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + const sanitizeStep = (step, label) => { + if (!step || typeof step.id !== "string" || !step.id) { + throw new Error(`${label} omitted its wizard step`); + } + const allowedTypes = new Set([ + "note", + "select", + "text", + "confirm", + "multiselect", + "progress", + "action", + ]); + if (!allowedTypes.has(step.type)) { + throw new Error(`${label} returned unsupported step type ${String(step.type)}`); + } + if (step.sensitive === true && Object.hasOwn(step, "initialValue")) { + throw new Error(`${label} exposed a sensitive initial value`); + } + for (const field of ["title", "message", "placeholder"]) { + if (step[field] !== undefined && typeof step[field] !== "string") { + throw new Error(`${label} returned non-string ${field}`); + } + if (step[field]?.length > 8192) { + throw new Error(`${label} returned oversized ${field}`); + } + } + if (step.options !== undefined && !Array.isArray(step.options)) { + throw new Error(`${label} returned malformed options`); + } + return { + type: step.type, + executor: step.executor, + sensitive: step.sensitive === true, + initialValuePresent: Object.hasOwn(step, "initialValue"), + titleLength: step.title?.length ?? 0, + messageLength: step.message?.length ?? 0, + optionCount: step.options?.length ?? 0, + }; + }; + const start = readJson(process.env.WIZARD_START_JSON); + const status = readJson(process.env.WIZARD_STATUS_JSON); + const next = readJson(process.env.WIZARD_NEXT_JSON); + const cancel = readJson(process.env.WIZARD_CANCEL_JSON); + const replacementStart = readJson(process.env.WIZARD_REPLACEMENT_START_JSON); + const replacementCancel = readJson(process.env.WIZARD_REPLACEMENT_CANCEL_JSON); + if (status.status !== "running") { + throw new Error(`wizard.status was not running: ${JSON.stringify(status)}`); + } + if (next.done !== false || next.status !== "running") { + throw new Error(`wizard.next did not advance a running session: ${JSON.stringify(next)}`); + } + if (cancel.status !== "cancelled" || replacementCancel.status !== "cancelled") { + throw new Error("wizard.cancel did not cancel both sessions"); + } + fs.writeFileSync( + process.env.WIZARD_FLOW_JSON, + `${JSON.stringify( + { + status: "passed", + authenticated: true, + start: { status: start.status, step: sanitizeStep(start.step, "wizard.start") }, + statusPoll: status.status, + runningStatusRetained: true, + next: { status: next.status, step: sanitizeStep(next.step, "wizard.next") }, + duplicateStartRejected: true, + cancelStatus: cancel.status, + cancelledSessionPurged: true, + replacement: { + status: replacementStart.status, + step: sanitizeStep(replacementStart.step, "replacement wizard.start"), + cancelStatus: replacementCancel.status, + purged: true, + }, + }, + null, + 2, + )}\n`, + ); + ' + update_params="$( RESTART_NOTE="$RESTART_NOTE" node -e ' process.stdout.write( @@ -426,6 +704,288 @@ if [ ! -f "$UPDATE_STATUS_JSON" ]; then exit 1 fi +echo "Exercising current target Gateway wizard RPC lifecycle" +wait_for_target_wizard_start() { + local output="$1" + local error_output="$2" + local label="$3" + local deadline=$((SECONDS + 30)) + local polls=0 + while [ "$SECONDS" -lt "$deadline" ]; do + polls=$((polls + 1)) + if gateway_call wizard.start '{"mode":"local"}' "$output" "$error_output"; then + local session_id + session_id="$( + TARGET_START_LABEL="$label" node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if ( + typeof payload?.sessionId !== "string" || + !payload.sessionId || + payload.done !== false || + payload.status !== "running" || + typeof payload.step?.id !== "string" + ) { + throw new Error( + `unexpected ${process.env.TARGET_START_LABEL}: ${JSON.stringify(payload)}`, + ); + } + process.stdout.write(payload.sessionId); + ' "$output" + )" + printf '%s\t%s\n' "$session_id" "$polls" + return 0 + fi + assert_gateway_call_error_message \ + "$output" "$error_output" "wizard already running" "$label" + sleep 0.2 + done + echo "timed out waiting for $label" >&2 + openclaw_e2e_print_log "$error_output" >&2 + return 1 +} + +gateway_call wizard.start '{"mode":"local"}' \ + "$TARGET_WIZARD_STATUS_START_JSON" "$TARGET_WIZARD_STATUS_START_ERR" +target_status_session_id="$( + node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if ( + typeof payload?.sessionId !== "string" || + !payload.sessionId || + payload.done !== false || + payload.status !== "running" || + typeof payload.step?.id !== "string" + ) { + throw new Error(`unexpected target wizard.start result: ${JSON.stringify(payload)}`); + } + process.stdout.write(payload.sessionId); + ' "$TARGET_WIZARD_STATUS_START_JSON" +)" +target_status_session_params="$( + WIZARD_SESSION_ID="$target_status_session_id" node -e ' + process.stdout.write(JSON.stringify({ sessionId: process.env.WIZARD_SESSION_ID })); + ' +)" +gateway_call wizard.status "$target_status_session_params" \ + "$TARGET_WIZARD_STATUS_JSON" "$TARGET_WIZARD_STATUS_ERR" +gateway_call wizard.status "$target_status_session_params" \ + "$TARGET_WIZARD_STATUS_RETAINED_JSON" "$TARGET_WIZARD_STATUS_RETAINED_ERR" +gateway_call wizard.cancel "$target_status_session_params" \ + "$TARGET_WIZARD_STATUS_CANCEL_JSON" "$TARGET_WIZARD_STATUS_CANCEL_ERR" + +target_active_start_result="$( + wait_for_target_wizard_start \ + "$TARGET_WIZARD_ACTIVE_START_JSON" \ + "$TARGET_WIZARD_ACTIVE_START_ERR" \ + "target status cancellation settlement" +)" +IFS=$'\t' read -r target_active_session_id target_status_settlement_polls \ + <<<"$target_active_start_result" + +if gateway_call wizard.status "$target_status_session_params" \ + "$TARGET_WIZARD_STATUS_PURGED_JSON" "$TARGET_WIZARD_STATUS_PURGED_ERR"; then + echo "target cancelled status-session wizard remained reachable after settlement" >&2 + exit 1 +fi +assert_gateway_call_error_message \ + "$TARGET_WIZARD_STATUS_PURGED_JSON" \ + "$TARGET_WIZARD_STATUS_PURGED_ERR" \ + "wizard not found" \ + "target cancelled status-session cleanup" + +target_active_step_id="$( + node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + process.stdout.write(payload.step.id); + ' "$TARGET_WIZARD_ACTIVE_START_JSON" +)" +target_active_session_params="$( + WIZARD_SESSION_ID="$target_active_session_id" node -e ' + process.stdout.write(JSON.stringify({ sessionId: process.env.WIZARD_SESSION_ID })); + ' +)" +target_active_next_params="$( + WIZARD_SESSION_ID="$target_active_session_id" WIZARD_STEP_ID="$target_active_step_id" node -e ' + process.stdout.write( + JSON.stringify({ + sessionId: process.env.WIZARD_SESSION_ID, + answer: { stepId: process.env.WIZARD_STEP_ID, value: null }, + }), + ); + ' +)" +gateway_call wizard.next "$target_active_next_params" \ + "$TARGET_WIZARD_NEXT_JSON" "$TARGET_WIZARD_NEXT_ERR" +if gateway_call wizard.start '{"mode":"local"}' \ + "$TARGET_WIZARD_DUPLICATE_JSON" "$TARGET_WIZARD_DUPLICATE_ERR"; then + echo "target wizard.start unexpectedly allowed an overlapping setup session" >&2 + exit 1 +fi +assert_gateway_call_error_message \ + "$TARGET_WIZARD_DUPLICATE_JSON" \ + "$TARGET_WIZARD_DUPLICATE_ERR" \ + "wizard already running" \ + "target overlapping wizard.start" + +gateway_call wizard.cancel "$target_active_session_params" \ + "$TARGET_WIZARD_CANCEL_JSON" "$TARGET_WIZARD_CANCEL_ERR" +target_replacement_start_result="$( + wait_for_target_wizard_start \ + "$TARGET_WIZARD_REPLACEMENT_START_JSON" \ + "$TARGET_WIZARD_REPLACEMENT_START_ERR" \ + "target active cancellation settlement" +)" +IFS=$'\t' read -r target_replacement_session_id target_cancel_settlement_polls \ + <<<"$target_replacement_start_result" + +if gateway_call wizard.status "$target_active_session_params" \ + "$TARGET_WIZARD_PURGED_STATUS_JSON" "$TARGET_WIZARD_PURGED_STATUS_ERR"; then + echo "target cancelled wizard session remained reachable" >&2 + exit 1 +fi +assert_gateway_call_error_message \ + "$TARGET_WIZARD_PURGED_STATUS_JSON" \ + "$TARGET_WIZARD_PURGED_STATUS_ERR" \ + "wizard not found" \ + "target cancelled wizard cleanup" + +target_replacement_session_params="$( + WIZARD_SESSION_ID="$target_replacement_session_id" node -e ' + process.stdout.write(JSON.stringify({ sessionId: process.env.WIZARD_SESSION_ID })); + ' +)" +gateway_call wizard.cancel "$target_replacement_session_params" \ + "$TARGET_WIZARD_REPLACEMENT_CANCEL_JSON" "$TARGET_WIZARD_REPLACEMENT_CANCEL_ERR" + +TARGET_WIZARD_STATUS_START_JSON="$TARGET_WIZARD_STATUS_START_JSON" \ + TARGET_WIZARD_STATUS_JSON="$TARGET_WIZARD_STATUS_JSON" \ + TARGET_WIZARD_STATUS_RETAINED_JSON="$TARGET_WIZARD_STATUS_RETAINED_JSON" \ + TARGET_WIZARD_STATUS_CANCEL_JSON="$TARGET_WIZARD_STATUS_CANCEL_JSON" \ + TARGET_WIZARD_ACTIVE_START_JSON="$TARGET_WIZARD_ACTIVE_START_JSON" \ + TARGET_WIZARD_NEXT_JSON="$TARGET_WIZARD_NEXT_JSON" \ + TARGET_WIZARD_CANCEL_JSON="$TARGET_WIZARD_CANCEL_JSON" \ + TARGET_WIZARD_REPLACEMENT_START_JSON="$TARGET_WIZARD_REPLACEMENT_START_JSON" \ + TARGET_WIZARD_REPLACEMENT_CANCEL_JSON="$TARGET_WIZARD_REPLACEMENT_CANCEL_JSON" \ + TARGET_STATUS_SETTLEMENT_POLLS="$target_status_settlement_polls" \ + TARGET_CANCEL_SETTLEMENT_POLLS="$target_cancel_settlement_polls" \ + TARGET_WIZARD_FLOW_JSON="$TARGET_WIZARD_FLOW_JSON" \ + node -e ' + const fs = require("node:fs"); + const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + const sanitizeStep = (step, label) => { + if (!step || typeof step.id !== "string" || !step.id) { + throw new Error(`${label} omitted its wizard step`); + } + const allowedTypes = new Set([ + "note", + "select", + "text", + "confirm", + "multiselect", + "progress", + "action", + ]); + if (!allowedTypes.has(step.type)) { + throw new Error(`${label} returned unsupported step type ${String(step.type)}`); + } + if (step.sensitive === true && Object.hasOwn(step, "initialValue")) { + throw new Error(`${label} exposed a sensitive initial value`); + } + for (const field of ["title", "message", "placeholder"]) { + if (step[field] !== undefined && typeof step[field] !== "string") { + throw new Error(`${label} returned non-string ${field}`); + } + if (step[field]?.length > 8192) { + throw new Error(`${label} returned oversized ${field}`); + } + } + if (step.options !== undefined && !Array.isArray(step.options)) { + throw new Error(`${label} returned malformed options`); + } + return { + type: step.type, + executor: step.executor, + sensitive: step.sensitive === true, + initialValuePresent: Object.hasOwn(step, "initialValue"), + titleLength: step.title?.length ?? 0, + messageLength: step.message?.length ?? 0, + optionCount: step.options?.length ?? 0, + }; + }; + const statusStart = readJson(process.env.TARGET_WIZARD_STATUS_START_JSON); + const status = readJson(process.env.TARGET_WIZARD_STATUS_JSON); + const retainedStatus = readJson(process.env.TARGET_WIZARD_STATUS_RETAINED_JSON); + const statusCancel = readJson(process.env.TARGET_WIZARD_STATUS_CANCEL_JSON); + const activeStart = readJson(process.env.TARGET_WIZARD_ACTIVE_START_JSON); + const next = readJson(process.env.TARGET_WIZARD_NEXT_JSON); + const cancel = readJson(process.env.TARGET_WIZARD_CANCEL_JSON); + const replacementStart = readJson(process.env.TARGET_WIZARD_REPLACEMENT_START_JSON); + const replacementCancel = readJson(process.env.TARGET_WIZARD_REPLACEMENT_CANCEL_JSON); + if (status.status !== "running" || retainedStatus.status !== "running") { + throw new Error( + `target wizard.status did not retain its running session: ${JSON.stringify({ + status, + retainedStatus, + })}`, + ); + } + if (next.done !== false || next.status !== "running") { + throw new Error(`target wizard.next did not advance a running session: ${JSON.stringify(next)}`); + } + if ( + statusCancel.status !== "cancelled" || + cancel.status !== "cancelled" || + replacementCancel.status !== "cancelled" + ) { + throw new Error("target wizard.cancel did not cancel every target session"); + } + fs.writeFileSync( + process.env.TARGET_WIZARD_FLOW_JSON, + `${JSON.stringify( + { + status: "passed", + authenticated: true, + packagePhase: "target", + statusSession: { + start: { + status: statusStart.status, + step: sanitizeStep(statusStart.step, "target status wizard.start"), + }, + observedStatuses: [status.status, retainedStatus.status], + runningStatusRetained: true, + cancelStatus: statusCancel.status, + settlementPolls: Number(process.env.TARGET_STATUS_SETTLEMENT_POLLS), + purged: true, + }, + activeSession: { + start: { + status: activeStart.status, + step: sanitizeStep(activeStart.step, "target active wizard.start"), + }, + next: { + status: next.status, + step: sanitizeStep(next.step, "target wizard.next"), + }, + duplicateStartRejected: true, + cancelStatus: cancel.status, + settlementPolls: Number(process.env.TARGET_CANCEL_SETTLEMENT_POLLS), + purged: true, + replacement: { + status: replacementStart.status, + step: sanitizeStep(replacementStart.step, "target replacement wizard.start"), + cancelStatus: replacementCancel.status, + }, + }, + }, + null, + 2, + )}\n`, + ); + ' + post_restart_observed_at_ms="$(node -e 'process.stdout.write(String(Date.now()))')" deadline=$((SECONDS + 60)) while [ "$SECONDS" -lt "$deadline" ]; do @@ -491,6 +1051,8 @@ SOURCE_VERSION="$SOURCE_VERSION" \ POST_RESTART_OBSERVED_AT_MS="$post_restart_observed_at_ms" \ UPDATE_RPC_JSON="$UPDATE_RPC_JSON" \ UPDATE_STATUS_JSON="$UPDATE_STATUS_JSON" \ + WIZARD_FLOW_JSON="$WIZARD_FLOW_JSON" \ + TARGET_WIZARD_FLOW_JSON="$TARGET_WIZARD_FLOW_JSON" \ QA_CHANNEL_INSTALL_RECORD_JSON="$QA_CHANNEL_INSTALL_RECORD_JSON" \ TARGET_PLUGIN_INDEX_JSON="$TARGET_PLUGIN_INDEX_JSON" \ SOURCE_PLUGIN_INSPECT_JSON="$SOURCE_PLUGIN_INSPECT_JSON" \ @@ -533,6 +1095,8 @@ SOURCE_VERSION="$SOURCE_VERSION" \ expectedRestartNote: process.env.RESTART_NOTE, updateRpcCompletedAtMs: Number(process.env.UPDATE_RPC_COMPLETED_AT_MS), postRestartObservedAtMs: postRestartAtMs, + wizardFlow: readJson(process.env.WIZARD_FLOW_JSON), + targetWizardFlow: readJson(process.env.TARGET_WIZARD_FLOW_JSON), updateRpcResult: readJson(process.env.UPDATE_RPC_JSON), restartSentinel: updateStatus.sentinel, qaChannelInstallRecord: readJson(process.env.QA_CHANNEL_INSTALL_RECORD_JSON), diff --git a/scripts/e2e/update-run-package-self-upgrade-docker.sh b/scripts/e2e/update-run-package-self-upgrade-docker.sh index 471edb8db443..cdc317a5dd11 100755 --- a/scripts/e2e/update-run-package-self-upgrade-docker.sh +++ b/scripts/e2e/update-run-package-self-upgrade-docker.sh @@ -25,6 +25,7 @@ SKIP_BUILD="${OPENCLAW_UPDATE_RUN_SELF_UPGRADE_E2E_SKIP_BUILD:-0}" DOCKER_RUN_TIMEOUT="${OPENCLAW_UPDATE_RUN_SELF_UPGRADE_DOCKER_RUN_TIMEOUT:-1800s}" ARTIFACT_DIR="${OPENCLAW_UPDATE_RUN_SELF_UPGRADE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/update-run-package-self-upgrade}" QA_CHANNEL_FIXTURE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-update-run-qa-channel.XXXXXX")" +HISTORICAL_DIST_ARCHIVE="$QA_CHANNEL_FIXTURE_ROOT/historical-dist.tar" cleanup() { rm -rf "$QA_CHANNEL_FIXTURE_ROOT" @@ -72,6 +73,8 @@ prepare_qa_channel_fixture() { tagObject: process.env.TAG_OBJECT, commit: process.env.SOURCE_COMMIT, buildCommand: "OPENCLAW_BUILD_PRIVATE_QA=1 corepack pnpm build:docker", + runtimeInstall: `npm install --prefix --omit=dev openclaw@${process.env.SOURCE_TAG.slice(1)}`, + privateDistOverlay: true, }, null, 2)}\n`, ); ' "$ARTIFACT_DIR/qa-channel-fixture-provenance.json" @@ -102,6 +105,7 @@ prepare_qa_channel_fixture() { return 1 fi done + tar -C "$checkout_root" -cf "$HISTORICAL_DIST_ARCHIVE" dist } mkdir -p "$ARTIFACT_DIR" @@ -123,7 +127,47 @@ docker_e2e_run_with_harness \ -e OPENCLAW_UPDATE_RUN_SELF_UPGRADE_ARTIFACT_DIR=/tmp/openclaw-update-run-artifacts \ -e OPENCLAW_UPDATE_RUN_SELF_UPGRADE_SOURCE_VERSION="$SOURCE_VERSION" \ -v "$ARTIFACT_DIR:/tmp/openclaw-update-run-artifacts" \ - -v "$QA_CHANNEL_FIXTURE_ROOT/checkout:/tmp/openclaw-update-run-build:ro" \ + -v "$HISTORICAL_DIST_ARCHIVE:/tmp/openclaw-update-run-historical-dist.tar:ro" \ "$IMAGE_NAME" \ timeout --kill-after=30s "$DOCKER_RUN_TIMEOUT" \ - bash scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh + bash -lc 'set -euo pipefail +historical_install_root=/tmp/openclaw-update-run-historical-install +historical_package_root="$historical_install_root/node_modules/openclaw" +qa_plugin_link=/tmp/openclaw-update-run-build/dist/extensions/qa-channel +npm install \ + --prefix "$historical_install_root" \ + --omit=dev \ + --no-fund \ + --no-audit \ + "openclaw@$OPENCLAW_UPDATE_RUN_SELF_UPGRADE_SOURCE_VERSION" \ + 2>&1 | tee /tmp/openclaw-update-run-artifacts/historical-package-install.log +tar --no-same-owner \ + -xf /tmp/openclaw-update-run-historical-dist.tar \ + -C "$historical_package_root" +mkdir -p "$(dirname "$qa_plugin_link")" +ln -s "$historical_package_root/dist/extensions/qa-channel" "$qa_plugin_link" +HISTORICAL_PACKAGE_ROOT="$historical_package_root" \ + HISTORICAL_PLUGIN_ENTRY="$historical_package_root/dist/extensions/qa-channel/index.js" \ + HISTORICAL_PREFLIGHT_OUT=/tmp/openclaw-update-run-artifacts/historical-package-preflight.json \ + node --input-type=module < { ); }); + it("proves the authenticated setup lifecycle before updating", async () => { + const script = await fs.readFile( + path.join( + process.cwd(), + "scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh", + ), + "utf8", + ); + + expect(script).toContain( + 'gateway_call wizard.start \'{"mode":"local"}\' "$WIZARD_START_JSON" "$WIZARD_START_ERR"', + ); + expect(script).toContain( + 'gateway_call wizard.status "$wizard_session_params" "$WIZARD_STATUS_JSON" "$WIZARD_STATUS_ERR"', + ); + expect(script).toContain( + 'gateway_call wizard.next "$wizard_next_params" "$WIZARD_NEXT_JSON" "$WIZARD_NEXT_ERR"', + ); + expect(script).toContain("runningStatusRetained: true"); + expect(script).toContain("assert_gateway_call_error_message()"); + expect(script).toContain( + '"$WIZARD_DUPLICATE_JSON" \\\n "$WIZARD_DUPLICATE_ERR" \\\n "wizard already running"', + ); + expect(script).toContain('local allow_stderr_fallback="${5:-0}"'); + expect(script).toContain( + '[ "$allow_stderr_fallback" = "1" ] && grep -Fq "$expected" "$error_output"', + ); + expect(script).toContain( + 'gateway_call wizard.cancel "$wizard_session_params" "$WIZARD_CANCEL_JSON" "$WIZARD_CANCEL_ERR"', + ); + expect(script).toContain( + '"$WIZARD_CANCELLED_STATUS_JSON" \\\n "$WIZARD_CANCELLED_STATUS_ERR" \\\n "wizard not found"', + ); + expect(script).toContain('step.sensitive === true && Object.hasOwn(step, "initialValue")'); + expect(script.indexOf("Exercising authenticated Gateway wizard RPC lifecycle")).toBeLessThan( + script.indexOf("Invoking authenticated Gateway RPC update.run"), + ); + }); + + it("proves the current target setup lifecycle after updating", async () => { + const script = await fs.readFile( + path.join( + process.cwd(), + "scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh", + ), + "utf8", + ); + + expect(script).toContain("Exercising current target Gateway wizard RPC lifecycle"); + expect(script).toContain( + 'gateway_call wizard.status "$target_status_session_params" \\\n "$TARGET_WIZARD_STATUS_JSON" "$TARGET_WIZARD_STATUS_ERR"', + ); + expect(script).toContain( + 'gateway_call wizard.status "$target_status_session_params" \\\n "$TARGET_WIZARD_STATUS_RETAINED_JSON" "$TARGET_WIZARD_STATUS_RETAINED_ERR"', + ); + expect(script).toContain( + 'gateway_call wizard.cancel "$target_status_session_params" \\\n "$TARGET_WIZARD_STATUS_CANCEL_JSON" "$TARGET_WIZARD_STATUS_CANCEL_ERR"', + ); + expect(script).toContain( + 'gateway_call wizard.next "$target_active_next_params" \\\n "$TARGET_WIZARD_NEXT_JSON" "$TARGET_WIZARD_NEXT_ERR"', + ); + expect(script).toContain( + '"$TARGET_WIZARD_DUPLICATE_JSON" \\\n "$TARGET_WIZARD_DUPLICATE_ERR" \\\n "wizard already running"', + ); + expect(script).toContain( + 'gateway_call wizard.cancel "$target_active_session_params" \\\n "$TARGET_WIZARD_CANCEL_JSON" "$TARGET_WIZARD_CANCEL_ERR"', + ); + expect(script).toContain("wait_for_target_wizard_start()"); + expect(script).toContain("target_status_settlement_polls"); + expect(script).toContain("target_cancel_settlement_polls"); + expect(script).toContain( + '"$TARGET_WIZARD_REPLACEMENT_START_JSON" \\\n "$TARGET_WIZARD_REPLACEMENT_START_ERR"', + ); + expect(script.indexOf("target_active_start_result=")).toBeLessThan( + script.indexOf( + 'gateway_call wizard.status "$target_status_session_params" \\\n "$TARGET_WIZARD_STATUS_PURGED_JSON"', + ), + ); + expect(script).toContain( + '"$TARGET_WIZARD_PURGED_STATUS_JSON" \\\n "$TARGET_WIZARD_PURGED_STATUS_ERR" \\\n "wizard not found"', + ); + expect(script.indexOf("target_replacement_start_result=")).toBeLessThan( + script.indexOf( + 'gateway_call wizard.status "$target_active_session_params" \\\n "$TARGET_WIZARD_PURGED_STATUS_JSON"', + ), + ); + expect(script.indexOf("Invoking authenticated Gateway RPC update.run")).toBeLessThan( + script.indexOf("Exercising current target Gateway wizard RPC lifecycle"), + ); + expect(script.indexOf("Exercising current target Gateway wizard RPC lifecycle")).toBeLessThan( + script.indexOf("post_restart_observed_at_ms="), + ); + }); + it("falls back to the exact tag clone when commit or tag objects are missing", async () => { const script = await fs.readFile( path.join(process.cwd(), "scripts/e2e/update-run-package-self-upgrade-docker.sh"), @@ -84,6 +178,21 @@ describe("update.run package self-upgrade producer", () => { expect(script).toContain( '! git -C "$source_repo" cat-file -e "$SOURCE_TAG^{commit}" 2>/dev/null', ); + expect(script).toContain('tar -C "$checkout_root" -cf "$HISTORICAL_DIST_ARCHIVE" dist'); + expect(script).toContain( + 'npm install \\\n --prefix "$historical_install_root" \\\n --omit=dev', + ); + expect(script).toContain( + 'tar --no-same-owner \\\n -xf /tmp/openclaw-update-run-historical-dist.tar \\\n -C "$historical_package_root"', + ); + expect(script).toContain( + 'ln -s "$historical_package_root/dist/extensions/qa-channel" "$qa_plugin_link"', + ); + expect(script).toContain("await import(pathToFileURL(pluginEntry).href)"); + expect(script).not.toContain( + '-v "$QA_CHANNEL_FIXTURE_ROOT/checkout:/tmp/openclaw-update-run-build:ro"', + ); + expect(script).not.toContain("NODE_PATH"); }); it("formats the proven version transition and sentinel", () => { @@ -92,13 +201,32 @@ describe("update.run package self-upgrade producer", () => { installedVersion: "2026.7.2", source: { version: "2026.4.26" }, target: { resolvedVersion: "2026.7.2", tag: "latest" }, + wizardFlow: { + authenticated: true, + cancelledSessionPurged: true, + duplicateStartRejected: true, + runningStatusRetained: true, + status: "passed", + }, + targetWizardFlow: { + activeSession: { + duplicateStartRejected: true, + purged: true, + }, + authenticated: true, + status: "passed", + statusSession: { + purged: true, + runningStatusRetained: true, + }, + }, restartSentinel: { message: "QA-UPDATE-RUN-PACKAGE-SELF-UPGRADE", status: "ok", }, }), ).toBe( - "source=2026.4.26; target=latest:2026.7.2; installed=2026.7.2; sentinel=ok:QA-UPDATE-RUN-PACKAGE-SELF-UPGRADE", + "wizard=passed:authenticated:status-retained:exclusive:purged; target-wizard=passed:authenticated:status-retained:status-purged:exclusive:purged; source=2026.4.26; target=latest:2026.7.2; installed=2026.7.2; sentinel=ok:QA-UPDATE-RUN-PACKAGE-SELF-UPGRADE", ); }); }); diff --git a/test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts b/test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts index e0f90d5081d2..f5183ed8df49 100644 --- a/test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts +++ b/test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts @@ -23,6 +23,25 @@ type UpdateRunSelfUpgradeSummary = { source?: { version?: string }; target?: { resolvedVersion?: string; tag?: string }; restartSentinel?: { message?: string; status?: string }; + wizardFlow?: { + authenticated?: boolean; + duplicateStartRejected?: boolean; + cancelledSessionPurged?: boolean; + runningStatusRetained?: boolean; + status?: string; + }; + targetWizardFlow?: { + activeSession?: { + duplicateStartRejected?: boolean; + purged?: boolean; + }; + authenticated?: boolean; + status?: string; + statusSession?: { + purged?: boolean; + runningStatusRetained?: boolean; + }; + }; }; function formatErrorMessage(error: unknown) { @@ -63,6 +82,8 @@ export function resolveUpdateRunSelfUpgradePermission( export function formatUpdateRunSelfUpgradeDetails(summary: UpdateRunSelfUpgradeSummary) { return [ + `wizard=${summary.wizardFlow?.status ?? "unknown"}:${summary.wizardFlow?.authenticated === true ? "authenticated" : "unauthenticated"}:${summary.wizardFlow?.runningStatusRetained === true ? "status-retained" : "status-unknown"}:${summary.wizardFlow?.duplicateStartRejected === true ? "exclusive" : "overlap-unknown"}:${summary.wizardFlow?.cancelledSessionPurged === true ? "purged" : "cleanup-unknown"}`, + `target-wizard=${summary.targetWizardFlow?.status ?? "unknown"}:${summary.targetWizardFlow?.authenticated === true ? "authenticated" : "unauthenticated"}:${summary.targetWizardFlow?.statusSession?.runningStatusRetained === true ? "status-retained" : "status-unknown"}:${summary.targetWizardFlow?.statusSession?.purged === true ? "status-purged" : "status-cleanup-unknown"}:${summary.targetWizardFlow?.activeSession?.duplicateStartRejected === true ? "exclusive" : "overlap-unknown"}:${summary.targetWizardFlow?.activeSession?.purged === true ? "purged" : "cleanup-unknown"}`, `source=${summary.source?.version ?? "unknown"}`, `target=${summary.target?.tag ?? "unknown"}:${summary.target?.resolvedVersion ?? "unknown"}`, `installed=${summary.installedVersion ?? "unknown"}`, @@ -111,7 +132,7 @@ async function runProducer(options: ProducerOptions): Promise