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
This commit is contained in:
Vincent Koc
2026-08-04 09:15:55 +08:00
committed by GitHub
parent ee397f0c59
commit b4faa5aa5e
5 changed files with 777 additions and 7 deletions

View File

@@ -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:

View File

@@ -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),

View File

@@ -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 <isolated-root> --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 <<NODE
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const packageRoot = process.env.HISTORICAL_PACKAGE_ROOT;
const pluginEntry = process.env.HISTORICAL_PLUGIN_ENTRY;
const packageVersion = JSON.parse(
fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"),
).version;
const pluginVersion = JSON.parse(
fs.readFileSync(path.join(path.dirname(pluginEntry), "package.json"), "utf8"),
).version;
if (packageVersion !== process.env.OPENCLAW_UPDATE_RUN_SELF_UPGRADE_SOURCE_VERSION) {
throw new Error("isolated historical package version mismatch: " + packageVersion);
}
await import(pathToFileURL(pluginEntry).href);
fs.writeFileSync(
process.env.HISTORICAL_PREFLIGHT_OUT,
JSON.stringify({ packageVersion, pluginVersion, pluginEntryImported: true }, null, 2) + "\n",
);
NODE
exec bash scripts/e2e/lib/upgrade-survivor/update-run-package-self-upgrade.sh'

View File

@@ -72,6 +72,100 @@ describe("update.run package self-upgrade producer", () => {
);
});
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",
);
});
});

View File

@@ -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<QaEvidenceSummaryJ
const writer = createQaScriptEvidenceWriter({
artifactBase: options.artifactBase,
logFileName: "update-run-package-self-upgrade.log",
primaryModel: "gateway/update.run",
primaryModel: "gateway/update-and-setup",
providerMode: "mock-openai",
repoRoot: options.repoRoot,
target: {
@@ -122,6 +143,7 @@ async function runProducer(options: ProducerOptions): Promise<QaEvidenceSummaryJ
"scripts/e2e/lib/upgrade-survivor/assertions.mjs",
"scripts/lib/docker-e2e-scenarios.mjs",
"src/gateway/server-methods/update.ts",
"src/gateway/server-methods/wizard.ts",
],
docsRefs: [
"docs/cli/update.md",
@@ -159,6 +181,8 @@ async function runProducer(options: ProducerOptions): Promise<QaEvidenceSummaryJ
return await writer.write({
artifacts: [
{ kind: "summary", filePath: path.join("lane", "summary.json") },
{ kind: "rpc", filePath: path.join("lane", "wizard-flow.json") },
{ kind: "rpc", filePath: path.join("lane", "target-wizard-flow.json") },
{ kind: "rpc", filePath: path.join("lane", "update-rpc.json") },
{ kind: "sentinel", filePath: path.join("lane", "update-status.json") },
{
@@ -169,6 +193,11 @@ async function runProducer(options: ProducerOptions): Promise<QaEvidenceSummaryJ
{ kind: "summary", filePath: path.join("lane", "source-plugin-inspect.json") },
{ kind: "summary", filePath: path.join("lane", "target-plugin-index.json") },
{ kind: "log", filePath: path.join("lane", "historical-qa-channel-build.log") },
{ kind: "log", filePath: path.join("lane", "historical-package-install.log") },
{
kind: "summary",
filePath: path.join("lane", "historical-package-preflight.json"),
},
{
kind: "summary",
filePath: path.join("lane", "qa-channel-fixture-provenance.json"),